fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던 결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을 내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고 같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다. - 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는 DB 쓰기에 선행하여 거부 시 상태 불변. - 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체. 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형. - secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙 소유권·비밀·발행 상태 검사 통일. - 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를 런타임·저장측·정적검사 3층 동형화. - 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
This commit is contained in:
@@ -385,6 +385,33 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
|||||||
|
|
||||||
> 상세: [validation.md "계층 리소스 순환 참조" / "배열 항목의 상위 스코프"](docs/backend/validation.md), [service-repository.md "중첩 리소스 스코프" / "설정 기반 한계값"](docs/backend/service-repository.md)
|
> 상세: [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건).
|
목록은 화면이 그 행에서 **실제로 그리는 것**만 싣는다. 행마다 하위 컬렉션을 통째로 직렬화하면 한 페이지를 여는 것만으로 수백~수천 행이 응답에 실린다 (공개 #76 — 상품 100건 × 옵션 20건).
|
||||||
|
|||||||
@@ -6,6 +6,20 @@
|
|||||||
|
|
||||||
## [7.0.7] - 2026-08-11
|
## [7.0.7] - 2026-08-11
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 레이아웃 편집기 첨부 파일 업로드에 업로드 전/후 액션 훅과 파일 가공 필터 훅 제공 — 확장에서 다른 업로드 경로와 동일하게 개입할 수 있습니다.
|
- 레이아웃 편집기 첨부 파일 업로드에 업로드 전/후 액션 훅과 파일 가공 필터 훅 제공 — 확장에서 다른 업로드 경로와 동일하게 개입할 수 있습니다.
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ interface MenuRepositoryInterface
|
|||||||
*/
|
*/
|
||||||
public function findById(int $id): ?Menu;
|
public function findById(int $id): ?Menu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 ID로 메뉴를 한 번에 조회합니다.
|
||||||
|
*
|
||||||
|
* 정적 라우트(`PUT menus/order`)에서 스코프 게이트를 재적용할 때 대상 전체를 한 번에
|
||||||
|
* 확인하기 위한 조회입니다. 관계는 로드하지 않습니다(소유자 판정에 불필요).
|
||||||
|
*
|
||||||
|
* @param array<int, int> $ids 메뉴 ID 목록
|
||||||
|
* @return Collection 메뉴 컬렉션
|
||||||
|
*/
|
||||||
|
public function findByIds(array $ids): Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 슬러그로 메뉴를 찾습니다.
|
* 슬러그로 메뉴를 찾습니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ interface PermissionRepositoryInterface
|
|||||||
*/
|
*/
|
||||||
public function findByIdentifier(string $identifier): ?Permission;
|
public function findByIdentifier(string $identifier): ?Permission;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 ID로 권한을 일괄 조회합니다.
|
||||||
|
*
|
||||||
|
* @param array<int> $ids 권한 ID 배열
|
||||||
|
* @return Collection 권한 컬렉션 (ID 기준)
|
||||||
|
*/
|
||||||
|
public function getByIds(array $ids): Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 새로운 권한을 생성합니다.
|
* 새로운 권한을 생성합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보호된 역할(코어/확장 소유) 수정 시도 시 발생하는 예외
|
||||||
|
*
|
||||||
|
* 삭제 경로에만 있던 코어/확장 소유 역할 보호를 수정·상태변경 경로까지 대칭
|
||||||
|
* 적용하기 위한 예외입니다. 비-슈퍼관리자 액터는 `admin` 등 코어/확장 소유
|
||||||
|
* 역할의 권한·활성상태를 변경할 수 없습니다(KVE-2026-1919).
|
||||||
|
*/
|
||||||
|
class CannotModifyProtectedRoleException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 보호된 역할 수정 시도 시 예외를 생성합니다.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(__('exceptions.cannot_modify_protected_role'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비-슈퍼관리자 액터가 슈퍼 관리자 계정/역할을 수정하려 할 때 발생하는 예외
|
||||||
|
*
|
||||||
|
* 삭제 경로에만 있던 슈퍼 관리자 보호 가드를 수정·상태변경·권한부여 경로까지
|
||||||
|
* 대칭 적용하기 위한 등급 상한(rank ceiling) 위반 예외입니다.
|
||||||
|
*/
|
||||||
|
class CannotModifySuperAdminException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 슈퍼 관리자 수정 시도 시 예외를 생성합니다.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(__('exceptions.cannot_modify_super_admin'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한 상승(escalation) 시도 시 발생하는 예외
|
||||||
|
*
|
||||||
|
* 비-슈퍼관리자 액터가 역할에 자신이 보유하지 않은 권한, 또는 자신의 범위(scope)보다
|
||||||
|
* 넓은 범위의 권한을 부여하려 할 때 발생합니다(KVE-2026-1919 권한 상승 차단).
|
||||||
|
*/
|
||||||
|
class PermissionEscalationException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 권한 상승 시도 시 예외를 생성합니다.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(__('exceptions.cannot_grant_unheld_permission'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -927,6 +927,31 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
|
||||||
|
*
|
||||||
|
* module.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 모듈이 레이아웃
|
||||||
|
* `scripts[].src` 로 로드하는 외부 CDN 호스트를 선언합니다. 코어는 이 목록을
|
||||||
|
* 집계(AbstractModule/AbstractPlugin → TrustedScriptHosts)해 런타임 스크립트 로더·
|
||||||
|
* 저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된 호스트만** 허용하도록
|
||||||
|
* 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
|
||||||
|
*
|
||||||
|
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.example.com'])
|
||||||
|
*/
|
||||||
|
public function getTrustedScriptHosts(): array
|
||||||
|
{
|
||||||
|
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
|
||||||
|
|
||||||
|
if (! is_array($hosts)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
|
||||||
|
fn ($host) => $host !== ''
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 레이아웃 확장 파일 경로 반환
|
* 레이아웃 확장 파일 경로 반환
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -771,6 +771,31 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
|
||||||
|
*
|
||||||
|
* plugin.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 플러그인이 레이아웃
|
||||||
|
* `scripts[].src` 로 로드하는 외부 CDN 호스트(예: `cdn.ckeditor.com`)를 선언합니다.
|
||||||
|
* 코어는 이 목록을 집계(AbstractPlugin/AbstractModule → TrustedScriptHosts)해 런타임
|
||||||
|
* 스크립트 로더·저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된
|
||||||
|
* 호스트만** 허용하도록 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
|
||||||
|
*
|
||||||
|
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.ckeditor.com'])
|
||||||
|
*/
|
||||||
|
public function getTrustedScriptHosts(): array
|
||||||
|
{
|
||||||
|
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
|
||||||
|
|
||||||
|
if (! is_array($hosts)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
|
||||||
|
fn ($host) => $host !== ''
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 레이아웃 확장 파일 경로 반환
|
* 레이아웃 확장 파일 경로 반환
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -204,6 +204,38 @@ class PermissionHelper
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스코프 접근이 허용되는 모델만 남긴 배열을 반환합니다 (정적 일괄 라우트용).
|
||||||
|
*
|
||||||
|
* `PermissionMiddleware` 의 스코프 검사는 라우트에서 모델이 resolve 될 때만
|
||||||
|
* 동작합니다 — 모델이 없으면 목록 엔드포인트로 보아 건너뜁니다. 따라서
|
||||||
|
* `{user}` 같은 파라미터가 없는 **정적 일괄 라우트**(예: `PATCH users/bulk-status`)
|
||||||
|
* 에서는 스코프 검사가 통째로 우회됩니다. 상세 경로가 403 으로 막는 대상을
|
||||||
|
* 일괄 경로로는 바꿀 수 있으면 그 경로가 우회로이므로, 서비스 계층에서
|
||||||
|
* 같은 판정(`checkScopeAccess`)을 재적용해야 합니다.
|
||||||
|
*
|
||||||
|
* 판정은 대상별로 이뤄집니다 — 액터의 유효 스코프가 self 면 자기 소유만,
|
||||||
|
* role 이면 같은 역할 범위까지, 미지정(글로벌)이면 전체가 통과합니다.
|
||||||
|
*
|
||||||
|
* @param iterable<Model> $models 검사 대상 모델 목록
|
||||||
|
* @param string $permission 권한 식별자
|
||||||
|
* @param User|null $user 사용자 (null이면 현재 인증 사용자)
|
||||||
|
* @return array<Model> 스코프 접근이 허용된 모델 목록
|
||||||
|
*/
|
||||||
|
public static function filterByScope(iterable $models, string $permission, ?User $user = null): array
|
||||||
|
{
|
||||||
|
$user = $user ?? Auth::user();
|
||||||
|
|
||||||
|
$allowed = [];
|
||||||
|
foreach ($models as $model) {
|
||||||
|
if (self::checkScopeAccess($model, $permission, $user)) {
|
||||||
|
$allowed[] = $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission 스코프 데이터를 static 캐시와 함께 조회합니다.
|
* Permission 스코프 데이터를 static 캐시와 함께 조회합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Http\Resources\AttachmentResource;
|
|||||||
use App\Models\Attachment;
|
use App\Models\Attachment;
|
||||||
use App\Services\AttachmentService;
|
use App\Services\AttachmentService;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +22,7 @@ class AttachmentController extends AdminBaseController
|
|||||||
/**
|
/**
|
||||||
* AttachmentController 생성자
|
* AttachmentController 생성자
|
||||||
*
|
*
|
||||||
* @param AttachmentService $attachmentService 첨부파일 서비스
|
* @param AttachmentService $attachmentService 첨부파일 서비스
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private AttachmentService $attachmentService
|
private AttachmentService $attachmentService
|
||||||
@@ -32,7 +33,7 @@ class AttachmentController extends AdminBaseController
|
|||||||
/**
|
/**
|
||||||
* 단일 파일 업로드
|
* 단일 파일 업로드
|
||||||
*
|
*
|
||||||
* @param UploadAttachmentRequest $request 업로드 요청
|
* @param UploadAttachmentRequest $request 업로드 요청
|
||||||
* @return JsonResponse
|
* @return JsonResponse
|
||||||
*/
|
*/
|
||||||
public function upload(UploadAttachmentRequest $request): JsonResponse
|
public function upload(UploadAttachmentRequest $request): JsonResponse
|
||||||
@@ -64,7 +65,7 @@ class AttachmentController extends AdminBaseController
|
|||||||
/**
|
/**
|
||||||
* 여러 파일 일괄 업로드
|
* 여러 파일 일괄 업로드
|
||||||
*
|
*
|
||||||
* @param UploadBatchAttachmentRequest $request 일괄 업로드 요청
|
* @param UploadBatchAttachmentRequest $request 일괄 업로드 요청
|
||||||
* @return JsonResponse
|
* @return JsonResponse
|
||||||
*/
|
*/
|
||||||
public function uploadBatch(UploadBatchAttachmentRequest $request): JsonResponse
|
public function uploadBatch(UploadBatchAttachmentRequest $request): JsonResponse
|
||||||
@@ -117,7 +118,7 @@ class AttachmentController extends AdminBaseController
|
|||||||
/**
|
/**
|
||||||
* 순서 변경
|
* 순서 변경
|
||||||
*
|
*
|
||||||
* @param ReorderAttachmentsRequest $request 순서 변경 요청
|
* @param ReorderAttachmentsRequest $request 순서 변경 요청
|
||||||
* @return JsonResponse
|
* @return JsonResponse
|
||||||
*/
|
*/
|
||||||
public function reorder(ReorderAttachmentsRequest $request): JsonResponse
|
public function reorder(ReorderAttachmentsRequest $request): JsonResponse
|
||||||
@@ -126,9 +127,12 @@ class AttachmentController extends AdminBaseController
|
|||||||
$this->attachmentService->reorder($request->input('order'));
|
$this->attachmentService->reorder($request->input('order'));
|
||||||
|
|
||||||
return $this->success('attachment.reorder_success');
|
return $this->success('attachment.reorder_success');
|
||||||
|
} catch (AuthorizationException $e) {
|
||||||
|
// 스코프 밖 첨부가 포함된 경우. 아래 제네릭 catch 보다 앞에 둬야 한다 —
|
||||||
|
// 뒤에 두면 인가 거부가 500 으로 뭉개져 상세 경로(403)와 응답이 갈린다.
|
||||||
|
return $this->error('auth.scope_denied', 403, $e->getMessage());
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return $this->error('attachment.reorder_failed', 500, $e->getMessage());
|
return $this->error('attachment.reorder_failed', 500, $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Http\Resources\MenuCollection;
|
|||||||
use App\Http\Resources\MenuResource;
|
use App\Http\Resources\MenuResource;
|
||||||
use App\Models\Menu;
|
use App\Models\Menu;
|
||||||
use App\Services\MenuService;
|
use App\Services\MenuService;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -222,6 +223,10 @@ class MenuController extends AdminBaseController
|
|||||||
}
|
}
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
return $this->error('menu.order_update_failed', 422, $e->errors());
|
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) {
|
} catch (\Exception $e) {
|
||||||
return $this->error('menu.update_error', 500, $e->getMessage());
|
return $this->error('menu.update_error', 500, $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Admin;
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Exceptions\CannotModifyProtectedRoleException;
|
||||||
use App\Exceptions\ExtensionOwnedRoleDeleteException;
|
use App\Exceptions\ExtensionOwnedRoleDeleteException;
|
||||||
|
use App\Exceptions\PermissionEscalationException;
|
||||||
use App\Exceptions\SystemRoleDeleteException;
|
use App\Exceptions\SystemRoleDeleteException;
|
||||||
use App\Helpers\PermissionHelper;
|
use App\Helpers\PermissionHelper;
|
||||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||||
|
use App\Http\Requests\Role\ActiveRolesRequest;
|
||||||
use App\Http\Requests\Role\RoleListRequest;
|
use App\Http\Requests\Role\RoleListRequest;
|
||||||
use App\Http\Requests\Role\StoreRoleRequest;
|
use App\Http\Requests\Role\StoreRoleRequest;
|
||||||
use App\Http\Requests\Role\UpdateRoleRequest;
|
use App\Http\Requests\Role\UpdateRoleRequest;
|
||||||
use App\Http\Resources\RoleCollection;
|
use App\Http\Resources\RoleCollection;
|
||||||
use App\Http\Resources\RoleResource;
|
use App\Http\Resources\RoleResource;
|
||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\RoleService;
|
use App\Services\RoleService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,13 +63,13 @@ class RoleController extends AdminBaseController
|
|||||||
* core.permissions.read 권한 보유 시 전체 활성 역할을 반환하고,
|
* core.permissions.read 권한 보유 시 전체 활성 역할을 반환하고,
|
||||||
* 미보유 시 현재 사용자에게 부여된 역할만 반환합니다.
|
* 미보유 시 현재 사용자에게 부여된 역할만 반환합니다.
|
||||||
*
|
*
|
||||||
* @param Request $request HTTP 요청 객체
|
* @param ActiveRolesRequest $request 활성 역할 조회 요청
|
||||||
* @return JsonResponse 활성화된 역할 목록을 포함한 JSON 응답
|
* @return JsonResponse 활성화된 역할 목록을 포함한 JSON 응답
|
||||||
*/
|
*/
|
||||||
public function active(Request $request): JsonResponse
|
public function active(ActiveRolesRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
/** @var \App\Models\User $user */
|
/** @var User $user */
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
// 역할 관리 권한(core.permissions.read) 보유 → 전체 활성 역할 (사용자 관리용)
|
// 역할 관리 권한(core.permissions.read) 보유 → 전체 활성 역할 (사용자 관리용)
|
||||||
@@ -78,7 +81,10 @@ class RoleController extends AdminBaseController
|
|||||||
return $this->success('role.fetch_success', [
|
return $this->success('role.fetch_success', [
|
||||||
'data' => RoleResource::collection($roles),
|
'data' => RoleResource::collection($roles),
|
||||||
'abilities' => [
|
'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) {
|
} catch (Exception $e) {
|
||||||
@@ -122,6 +128,8 @@ class RoleController extends AdminBaseController
|
|||||||
new RoleResource($role),
|
new RoleResource($role),
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
|
} catch (PermissionEscalationException $e) {
|
||||||
|
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
return $this->error('role.create_failed', 422, $e->errors());
|
return $this->error('role.create_failed', 422, $e->errors());
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -145,6 +153,10 @@ class RoleController extends AdminBaseController
|
|||||||
'role.update_success',
|
'role.update_success',
|
||||||
new RoleResource($updatedRole)
|
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) {
|
} catch (ValidationException $e) {
|
||||||
return $this->error('role.update_failed', 422, $e->errors());
|
return $this->error('role.update_failed', 422, $e->errors());
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -174,6 +186,8 @@ class RoleController extends AdminBaseController
|
|||||||
} else {
|
} else {
|
||||||
return $this->error('role.update_failed');
|
return $this->error('role.update_failed');
|
||||||
}
|
}
|
||||||
|
} catch (CannotModifyProtectedRoleException $e) {
|
||||||
|
return $this->error('exceptions.cannot_modify_protected_role', 403);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return $this->error('role.update_failed', 500, $e->getMessage());
|
return $this->error('role.update_failed', 500, $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Api\Admin;
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
use App\Exceptions\CannotDeleteSuperAdminException;
|
use App\Exceptions\CannotDeleteSuperAdminException;
|
||||||
|
use App\Exceptions\CannotModifySuperAdminException;
|
||||||
|
use App\Exceptions\PermissionEscalationException;
|
||||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||||
use App\Http\Requests\User\BulkUpdateUserStatusRequest;
|
use App\Http\Requests\User\BulkUpdateUserStatusRequest;
|
||||||
use App\Http\Requests\User\CheckEmailRequest;
|
use App\Http\Requests\User\CheckEmailRequest;
|
||||||
@@ -74,6 +76,8 @@ class UserController extends AdminBaseController
|
|||||||
new UserResource($user),
|
new UserResource($user),
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
|
} catch (PermissionEscalationException $e) {
|
||||||
|
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
return $this->error('user.create_failed', 422, $e->errors());
|
return $this->error('user.create_failed', 422, $e->errors());
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -122,6 +126,10 @@ class UserController extends AdminBaseController
|
|||||||
'user.update_success',
|
'user.update_success',
|
||||||
new UserResource($updatedUser)
|
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) {
|
} catch (ValidationException $e) {
|
||||||
return $this->error('user.update_failed', 422, $e->errors());
|
return $this->error('user.update_failed', 422, $e->errors());
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -148,6 +156,8 @@ class UserController extends AdminBaseController
|
|||||||
'auth.account_unlocked',
|
'auth.account_unlocked',
|
||||||
new UserResource($unlocked)
|
new UserResource($unlocked)
|
||||||
);
|
);
|
||||||
|
} catch (CannotModifySuperAdminException $e) {
|
||||||
|
return $this->error('exceptions.cannot_modify_super_admin', 403);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
|
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ use App\Models\Template;
|
|||||||
use App\Models\TemplateLayout;
|
use App\Models\TemplateLayout;
|
||||||
use App\Rules\ComponentExists;
|
use App\Rules\ComponentExists;
|
||||||
use App\Rules\NoExternalUrls;
|
use App\Rules\NoExternalUrls;
|
||||||
|
use App\Rules\SafeLayoutExpressions;
|
||||||
use App\Rules\ValidLayoutStructure;
|
use App\Rules\ValidLayoutStructure;
|
||||||
use App\Rules\WhitelistedEndpoint;
|
use App\Rules\WhitelistedEndpoint;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ class StoreLayoutRequest extends FormRequest
|
|||||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인
|
* 사용자가 이 요청을 수행할 권한이 있는지 확인
|
||||||
*
|
*
|
||||||
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
|
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
|
||||||
|
*
|
||||||
|
* @return bool 항상 true (권한은 미들웨어가 담당)
|
||||||
*/
|
*/
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
@@ -33,7 +37,7 @@ class StoreLayoutRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* 요청에 적용할 검증 규칙
|
* 요청에 적용할 검증 규칙
|
||||||
*
|
*
|
||||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -67,6 +71,8 @@ class StoreLayoutRequest extends FormRequest
|
|||||||
new WhitelistedEndpoint,
|
new WhitelistedEndpoint,
|
||||||
// 4. 외부 URL 차단
|
// 4. 외부 URL 차단
|
||||||
new NoExternalUrls,
|
new NoExternalUrls,
|
||||||
|
// 5. 표현식 샌드박스 우회/원격 스크립트 저장측 차단
|
||||||
|
new SafeLayoutExpressions,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Requests\Layout;
|
|||||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||||
use App\Extension\HookManager;
|
use App\Extension\HookManager;
|
||||||
use App\Rules\NoExternalUrls;
|
use App\Rules\NoExternalUrls;
|
||||||
|
use App\Rules\SafeLayoutExpressions;
|
||||||
use App\Rules\ValidDataSourceMerge;
|
use App\Rules\ValidDataSourceMerge;
|
||||||
use App\Rules\ValidLayoutStructure;
|
use App\Rules\ValidLayoutStructure;
|
||||||
use App\Rules\ValidParentLayout;
|
use App\Rules\ValidParentLayout;
|
||||||
@@ -265,6 +266,14 @@ class UpdateLayoutContentRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'array',
|
'array',
|
||||||
new ValidLayoutStructure,
|
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',
|
'string',
|
||||||
new WhitelistedEndpoint,
|
new WhitelistedEndpoint,
|
||||||
new NoExternalUrls,
|
new NoExternalUrls,
|
||||||
|
// SafeLayoutExpressions 는 content 배열 규칙에서 트리 전체를 순회하므로 여기(문자열
|
||||||
|
// endpoint)에는 부착하지 않는다 — 문자열에 부착 시 is_array 가드로 no-op 이 된다.
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! $isExtending && ! $isBaseLayout) {
|
if (! $isExtending && ! $isBaseLayout) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Layout;
|
|||||||
|
|
||||||
use App\Extension\HookManager;
|
use App\Extension\HookManager;
|
||||||
use App\Rules\NoExternalUrls;
|
use App\Rules\NoExternalUrls;
|
||||||
|
use App\Rules\SafeLayoutExpressions;
|
||||||
use App\Rules\ValidDataSourceMerge;
|
use App\Rules\ValidDataSourceMerge;
|
||||||
use App\Rules\ValidLayoutExtensionStructure;
|
use App\Rules\ValidLayoutExtensionStructure;
|
||||||
use App\Rules\WhitelistedEndpoint;
|
use App\Rules\WhitelistedEndpoint;
|
||||||
@@ -62,6 +63,11 @@ class UpdateLayoutExtensionContentRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'array',
|
'array',
|
||||||
new ValidLayoutExtensionStructure,
|
new ValidLayoutExtensionStructure,
|
||||||
|
// 표현식 샌드박스 우회/원격 스크립트 저장측 차단 (KVE-2026-1915).
|
||||||
|
// content 트리 전체(data_sources·scripts·표현식 문자열)를 재귀 순회한다.
|
||||||
|
new SafeLayoutExpressions,
|
||||||
|
// props·actions·init_actions 의 외부 URL 차단 (Store/UpdateLayoutRequest 와 동일 강도)
|
||||||
|
new NoExternalUrls,
|
||||||
],
|
],
|
||||||
|
|
||||||
// 우선순위 (선택 — content.priority 와 별개로 직접 지정 가능)
|
// 우선순위 (선택 — content.priority 와 별개로 직접 지정 가능)
|
||||||
@@ -74,6 +80,8 @@ class UpdateLayoutExtensionContentRequest extends FormRequest
|
|||||||
'content.data_sources' => ['nullable', 'array', new ValidDataSourceMerge],
|
'content.data_sources' => ['nullable', 'array', new ValidDataSourceMerge],
|
||||||
|
|
||||||
// 데이터소스 endpoint 검증
|
// 데이터소스 endpoint 검증
|
||||||
|
// SafeLayoutExpressions 는 content 배열 규칙이 트리 전체를 순회하며 data_sources[].endpoint
|
||||||
|
// same-origin 까지 검사하므로 여기(문자열)에는 부착하지 않는다 (문자열 부착 시 no-op).
|
||||||
'content.data_sources.*.endpoint' => [
|
'content.data_sources.*.endpoint' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ use App\Models\Template;
|
|||||||
use App\Models\TemplateLayout;
|
use App\Models\TemplateLayout;
|
||||||
use App\Rules\ComponentExists;
|
use App\Rules\ComponentExists;
|
||||||
use App\Rules\NoExternalUrls;
|
use App\Rules\NoExternalUrls;
|
||||||
|
use App\Rules\SafeLayoutExpressions;
|
||||||
use App\Rules\ValidLayoutStructure;
|
use App\Rules\ValidLayoutStructure;
|
||||||
use App\Rules\WhitelistedEndpoint;
|
use App\Rules\WhitelistedEndpoint;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ class UpdateLayoutRequest extends FormRequest
|
|||||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인
|
* 사용자가 이 요청을 수행할 권한이 있는지 확인
|
||||||
*
|
*
|
||||||
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
|
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
|
||||||
|
*
|
||||||
|
* @return bool 항상 true (권한은 미들웨어가 담당)
|
||||||
*/
|
*/
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
@@ -33,7 +37,7 @@ class UpdateLayoutRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* 요청에 적용할 검증 규칙
|
* 요청에 적용할 검증 규칙
|
||||||
*
|
*
|
||||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -71,6 +75,8 @@ class UpdateLayoutRequest extends FormRequest
|
|||||||
new WhitelistedEndpoint,
|
new WhitelistedEndpoint,
|
||||||
// 4. 외부 URL 차단
|
// 4. 외부 URL 차단
|
||||||
new NoExternalUrls,
|
new NoExternalUrls,
|
||||||
|
// 5. 표현식 샌드박스 우회/원격 스크립트 저장측 차단
|
||||||
|
new SafeLayoutExpressions,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Role;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 역할 목록(선택 옵션용) 조회 요청
|
||||||
|
*
|
||||||
|
* 별도 검증 규칙 없이 인증 컨텍스트만 전달하는 엔드포인트지만, 컨트롤러가 base
|
||||||
|
* Illuminate\Http\Request 를 직접 주입받지 않도록 전용 FormRequest 서브클래스를 둔다.
|
||||||
|
* 인증/권한은 permission 미들웨어 체인이 담당하므로 authorize() 는 true 고정.
|
||||||
|
*/
|
||||||
|
class ActiveRolesRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*
|
||||||
|
* @return bool 항상 true (권한은 미들웨어 체인이 검증)
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Http\Resources;
|
|||||||
|
|
||||||
use App\Http\Resources\Traits\HasAbilityCheck;
|
use App\Http\Resources\Traits\HasAbilityCheck;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
class UserCollection extends BaseApiCollection
|
class UserCollection extends BaseApiCollection
|
||||||
{
|
{
|
||||||
@@ -20,7 +21,10 @@ class UserCollection extends BaseApiCollection
|
|||||||
'can_create' => 'core.users.create',
|
'can_create' => 'core.users.create',
|
||||||
'can_update' => 'core.users.update',
|
'can_update' => 'core.users.update',
|
||||||
'can_delete' => 'core.users.delete',
|
'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) {
|
'data' => $this->mapWithRowNumber(function ($user) {
|
||||||
return (new UserResource($user))->toListArray(request());
|
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(),
|
'current_page' => $this->resource->currentPage(),
|
||||||
'last_page' => $this->resource->lastPage(),
|
'last_page' => $this->resource->lastPage(),
|
||||||
'per_page' => $this->resource->perPage(),
|
'per_page' => $this->resource->perPage(),
|
||||||
@@ -56,7 +60,7 @@ class UserCollection extends BaseApiCollection
|
|||||||
*/
|
*/
|
||||||
public function withStatistics(array $statistics = []): array
|
public function withStatistics(array $statistics = []): array
|
||||||
{
|
{
|
||||||
$isPaginator = $this->resource instanceof \Illuminate\Pagination\LengthAwarePaginator;
|
$isPaginator = $this->resource instanceof LengthAwarePaginator;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'data' => $this->mapWithRowNumber(function ($user) {
|
'data' => $this->mapWithRowNumber(function ($user) {
|
||||||
@@ -87,7 +91,7 @@ class UserCollection extends BaseApiCollection
|
|||||||
'data' => $this->mapWithRowNumber(function ($user) {
|
'data' => $this->mapWithRowNumber(function ($user) {
|
||||||
return (new UserResource($user))->withAdminInfo();
|
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(),
|
'current_page' => $this->resource->currentPage(),
|
||||||
'last_page' => $this->resource->lastPage(),
|
'last_page' => $this->resource->lastPage(),
|
||||||
'per_page' => $this->resource->perPage(),
|
'per_page' => $this->resource->perPage(),
|
||||||
|
|||||||
@@ -204,7 +204,10 @@ class UserResource extends BaseApiResource
|
|||||||
'can_create' => 'core.users.create',
|
'can_create' => 'core.users.create',
|
||||||
'can_update' => 'core.users.update',
|
'can_update' => 'core.users.update',
|
||||||
'can_delete' => 'core.users.delete',
|
'can_delete' => 'core.users.delete',
|
||||||
'can_assign_roles' => 'core.permissions.update',
|
// 역할 부여는 "사용자 관리"(core.users.update)의 일부다 — "역할 정의 수정"
|
||||||
|
// (core.permissions.update: 역할에 권한을 가감)이 아니다. 부여 가능한 개별 역할의
|
||||||
|
// 범위는 서버 상한(PermissionEscalationGuard)이 역할별로 강제한다.
|
||||||
|
'can_assign_roles' => 'core.users.update',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Services\ModuleSettingsService;
|
|||||||
use App\Services\PluginSettingsService;
|
use App\Services\PluginSettingsService;
|
||||||
use App\Services\SettingsService;
|
use App\Services\SettingsService;
|
||||||
use App\Services\TemplateService;
|
use App\Services\TemplateService;
|
||||||
|
use App\Support\TrustedScriptHosts;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class TemplateComposer
|
class TemplateComposer
|
||||||
@@ -103,6 +104,10 @@ class TemplateComposer
|
|||||||
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
|
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
|
||||||
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
|
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
|
||||||
|
|
||||||
|
// 신뢰 외부 스크립트 호스트 — 레이아웃 scripts[].src same-origin 예외 허용목록
|
||||||
|
// (KVE-2026-1915: 확장이 manifest 로 선언한 CDN 호스트만 런타임 로더가 허용)
|
||||||
|
$trustedScriptHosts = TrustedScriptHosts::hosts();
|
||||||
|
|
||||||
$view->with('activeAdminTemplate', $activeTemplate);
|
$view->with('activeAdminTemplate', $activeTemplate);
|
||||||
$view->with('extensionCacheVersion', $extensionCacheVersion);
|
$view->with('extensionCacheVersion', $extensionCacheVersion);
|
||||||
$view->with('frontendSettings', $frontendSettings);
|
$view->with('frontendSettings', $frontendSettings);
|
||||||
@@ -115,5 +120,6 @@ class TemplateComposer
|
|||||||
$view->with('activePluginsMeta', $activePluginsMeta);
|
$view->with('activePluginsMeta', $activePluginsMeta);
|
||||||
$view->with('appConfig', $appConfig);
|
$view->with('appConfig', $appConfig);
|
||||||
$view->with('templateExternals', $templateExternals);
|
$view->with('templateExternals', $templateExternals);
|
||||||
|
$view->with('trustedScriptHosts', $trustedScriptHosts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Services\ModuleSettingsService;
|
|||||||
use App\Services\PluginSettingsService;
|
use App\Services\PluginSettingsService;
|
||||||
use App\Services\SettingsService;
|
use App\Services\SettingsService;
|
||||||
use App\Services\TemplateService;
|
use App\Services\TemplateService;
|
||||||
|
use App\Support\TrustedScriptHosts;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,6 +110,10 @@ class UserTemplateComposer
|
|||||||
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
|
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
|
||||||
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
|
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
|
||||||
|
|
||||||
|
// 신뢰 외부 스크립트 호스트 — 레이아웃 scripts[].src same-origin 예외 허용목록
|
||||||
|
// (KVE-2026-1915: 확장이 manifest 로 선언한 CDN 호스트만 런타임 로더가 허용)
|
||||||
|
$trustedScriptHosts = TrustedScriptHosts::hosts();
|
||||||
|
|
||||||
$view->with('activeUserTemplate', $activeTemplate);
|
$view->with('activeUserTemplate', $activeTemplate);
|
||||||
$view->with('extensionCacheVersion', $extensionCacheVersion);
|
$view->with('extensionCacheVersion', $extensionCacheVersion);
|
||||||
$view->with('frontendSettings', $frontendSettings);
|
$view->with('frontendSettings', $frontendSettings);
|
||||||
@@ -121,5 +126,6 @@ class UserTemplateComposer
|
|||||||
$view->with('activePluginsMeta', $activePluginsMeta);
|
$view->with('activePluginsMeta', $activePluginsMeta);
|
||||||
$view->with('appConfig', $appConfig);
|
$view->with('appConfig', $appConfig);
|
||||||
$view->with('templateExternals', $templateExternals);
|
$view->with('templateExternals', $templateExternals);
|
||||||
|
$view->with('trustedScriptHosts', $trustedScriptHosts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,21 @@ class MenuRepository implements MenuRepositoryInterface
|
|||||||
return Menu::with(['creator', 'parent', 'children'])->find($id);
|
return Menu::with(['creator', 'parent', 'children'])->find($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 ID로 메뉴를 한 번에 조회합니다.
|
||||||
|
*
|
||||||
|
* @param array<int, int> $ids 메뉴 ID 목록
|
||||||
|
* @return Collection 메뉴 컬렉션
|
||||||
|
*/
|
||||||
|
public function findByIds(array $ids): Collection
|
||||||
|
{
|
||||||
|
if (empty($ids)) {
|
||||||
|
return Menu::query()->whereRaw('1 = 0')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Menu::whereIn('id', $ids)->get();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 슬러그로 메뉴를 찾습니다.
|
* 슬러그로 메뉴를 찾습니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ class PermissionRepository implements PermissionRepositoryInterface
|
|||||||
return Permission::find($id);
|
return Permission::find($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 ID로 권한을 일괄 조회합니다.
|
||||||
|
*
|
||||||
|
* @param array<int> $ids 권한 ID 배열
|
||||||
|
* @return Collection 권한 컬렉션 (ID 기준)
|
||||||
|
*/
|
||||||
|
public function getByIds(array $ids): Collection
|
||||||
|
{
|
||||||
|
if (empty($ids)) {
|
||||||
|
return new Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Permission::whereIn('id', $ids)->get();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 식별자로 권한을 찾습니다.
|
* 식별자로 권한을 찾습니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ use Illuminate\Contracts\Validation\ValidationRule;
|
|||||||
/**
|
/**
|
||||||
* 레이아웃 JSON에서 외부 URL을 차단하는 Custom Rule
|
* 레이아웃 JSON에서 외부 URL을 차단하는 Custom Rule
|
||||||
*
|
*
|
||||||
* props와 actions 내의 http://, https://, data:, javascript: 등
|
* 컴포넌트 props·actions 와 최상위 init_actions 내의 http://, https://, data:,
|
||||||
* 위험한 URI 스킴을 감지하여 차단합니다.
|
* javascript: 등 위험한 URI 스킴을 감지하여 차단합니다.
|
||||||
|
*
|
||||||
|
* 검사 대상 구분(신뢰 경계): init_actions 는 로드 시 자동 실행되는 액션이라 외부
|
||||||
|
* navigate/apiCall URL 이 곧 자동 리다이렉트·데이터 유출 경로가 되므로 실행 지점에서
|
||||||
|
* 차단합니다. 반면 state/computed 는 데이터 값이며, 실제 위험은 그 값이 바인딩되는
|
||||||
|
* sink(컴포넌트 prop = img src 등)에서 발생하고 그 sink 는 이미 여기서 검사됩니다 —
|
||||||
|
* 예시/안내용 URL 을 담는 정당한 용례를 깨지 않기 위해 데이터 계층은 재차단하지 않습니다.
|
||||||
*/
|
*/
|
||||||
class NoExternalUrls implements ValidationRule
|
class NoExternalUrls implements ValidationRule
|
||||||
{
|
{
|
||||||
@@ -31,6 +37,15 @@ class NoExternalUrls implements ValidationRule
|
|||||||
*/
|
*/
|
||||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
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)) {
|
if (! is_array($value)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -39,6 +54,16 @@ class NoExternalUrls implements ValidationRule
|
|||||||
if (isset($value['components']) && is_array($value['components'])) {
|
if (isset($value['components']) && is_array($value['components'])) {
|
||||||
$this->validateComponents($value['components'], $fail);
|
$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)
|
// 추가 패턴 검사: //로 시작 (프로토콜 상대 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]));
|
$fail(__('validation.external_url.detected_in_props', ['url' => $value]));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use App\Support\TrustedScriptHosts;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 JSON 표현식 저장측 심층 방어 규칙 (KVE-2026-1915)
|
||||||
|
*
|
||||||
|
* 클라이언트의 화이트리스트 AST 평가기(SafeExpressionEvaluator)가 표현식 실행의
|
||||||
|
* 1차 방어입니다. 이 규칙은 저장 시점에 위험 토큰을 거부하는 서버측 보조 방어로,
|
||||||
|
* 편집자가 저장한 레이아웃이 애초에 샌드박스 우회 표현식/원격 스크립트를 담지
|
||||||
|
* 못하게 합니다.
|
||||||
|
*
|
||||||
|
* 차단 대상(레이아웃 JSON 전체를 재귀 순회하며 모든 문자열 값 검사):
|
||||||
|
* - 프로토타입 체인 접근: `.constructor` / `.__proto__` / `.prototype`,
|
||||||
|
* `['constructor']` 등 문자열 리터럴 computed 접근 → Function 도달 경로
|
||||||
|
* - 함수 생성/코드 실행: `Function(`, `eval(`, 동적 `import(`
|
||||||
|
* - 원격 스크립트: `scripts[].src` 는 same-origin path-only(`/` 시작, `//`·scheme 금지),
|
||||||
|
* 단 확장이 manifest(`trusted_script_hosts`)로 선언한 신뢰 호스트는 예외로 허용
|
||||||
|
* - 외부 데이터 소스: `data_sources[].endpoint` 도 동일 (same-origin 또는 신뢰 호스트)
|
||||||
|
*
|
||||||
|
* 화살표 함수(`=>`)는 정상 표현식에서 광범위하게 사용되므로 차단하지 않습니다
|
||||||
|
* (클라이언트 평가기가 인터프리터로 안전하게 실행).
|
||||||
|
*/
|
||||||
|
class SafeLayoutExpressions implements ValidationRule
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 위험 표현식 토큰 패턴 (문자열 값 대상)
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
private const DANGEROUS_PATTERNS = [
|
||||||
|
// 프로토타입/생성자 체인 접근 (dot)
|
||||||
|
'/\.\s*(constructor|__proto__|prototype)\b/i',
|
||||||
|
// 프로토타입/생성자 체인 접근 (문자열 리터럴 computed 키) — 중첩 배열 키
|
||||||
|
// `[['constructor']]` 도 내부 `['constructor']` 가 매칭된다.
|
||||||
|
'/\[\s*[\'"](constructor|__proto__|prototype)[\'"]\s*\]/i',
|
||||||
|
// Object 리플렉션 static 호출 — 프로토타입/디스크립터를 읽어 Function 도달·프로토타입
|
||||||
|
// 오염 경로. **호출 위치(뒤에 `(`)만** 매칭해 안내 문구의 단순 단어 언급은 오탐하지 않는다.
|
||||||
|
// 리플렉션 인자 `getOwnPropertyDescriptor(x, 'constructor')` 는 이 메서드명이 반드시
|
||||||
|
// 동반되므로 여기서 잡히고, 금지 프로퍼티를 그냥 문자열로 비교하는 정상 표현식
|
||||||
|
// (`{{ mode === 'prototype' }}` — 런타임 평가기가 허용)은 차단하지 않는다.
|
||||||
|
'/\b(getPrototypeOf|setPrototypeOf|getOwnPropertyDescriptors?|defineProperty|defineProperties)\s*\(/i',
|
||||||
|
// 함수 생성자 / eval 호출
|
||||||
|
'/\bFunction\s*\(/',
|
||||||
|
'/\beval\s*\(/',
|
||||||
|
// 동적 import() — 원격 ES 모듈 로드/코드 실행 경로 (런타임 AST 평가기와 저장측 패리티)
|
||||||
|
'/\bimport\s*\(/',
|
||||||
|
// 원시 __proto__ 식별자
|
||||||
|
'/\b__proto__\b/',
|
||||||
|
// legacy 접근자 4종 — 프로퍼티를 **문자열 인자**로 지목해 프로토타입을 읽고 쓴다.
|
||||||
|
// Object 리플렉션 static 과 같은 능력을 모든 객체가 상속으로 제공하므로 같은 강도로
|
||||||
|
// 막는다. 배포 레이아웃 전수에서 사용 0건이라 정상 표현식 회귀가 없다.
|
||||||
|
'/\b__(lookup|define)(Getter|Setter)__\b/',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 주의: 문자열 조립 난독화(`['const' + 'ructor']`)는 정적 토큰 매칭으로 잡을 수 없다.
|
||||||
|
// 그 형태의 최종 방어는 런타임 화이트리스트 인터프리터(SafeExpressionEvaluator)가
|
||||||
|
// 담당한다 — 키를 1회 정규화(String 강제변환)한 뒤 금지 프로퍼티를 차단하므로,
|
||||||
|
// 조립·배열·toString 강제변환 등 모든 우회 형태가 접근 시점에 거부된다. 이 저장측
|
||||||
|
// 규칙은 리터럴·리플렉션 형태를 저장 단계에서 조기 차단하는 심층 방어 계층이다.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신뢰 외부 스크립트 호스트 캐시 (검증 1회당 집계 1회).
|
||||||
|
*
|
||||||
|
* @var array<int, string>|null
|
||||||
|
*/
|
||||||
|
private ?array $trustedHosts = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증 수행
|
||||||
|
*/
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->walk($value, $fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 JSON 트리를 재귀 순회하며 문자열 값을 검사합니다.
|
||||||
|
*
|
||||||
|
* @param array<mixed> $node 현재 노드
|
||||||
|
* @param Closure $fail 검증 실패 콜백
|
||||||
|
*/
|
||||||
|
private function walk(array $node, Closure $fail): void
|
||||||
|
{
|
||||||
|
foreach ($node as $key => $item) {
|
||||||
|
// 백틱 템플릿 리터럴을 포함한 위험 표현식 토큰 검사
|
||||||
|
if (is_string($item)) {
|
||||||
|
$this->assertSafeExpression($item, $fail);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// scripts[].src same-origin 검증
|
||||||
|
if ($key === 'scripts') {
|
||||||
|
$this->assertSameOriginList($item, 'src', $fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// data_sources[].endpoint same-origin 검증
|
||||||
|
if ($key === 'data_sources') {
|
||||||
|
$this->assertSameOriginList($item, 'endpoint', $fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->walk($item, $fail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자열 표현식에 위험 토큰이 포함되어 있으면 검증을 실패시킵니다.
|
||||||
|
*
|
||||||
|
* @param string $value 검사 대상 문자열
|
||||||
|
* @param Closure $fail 검증 실패 콜백
|
||||||
|
*/
|
||||||
|
private function assertSafeExpression(string $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
// 백틱 템플릿 리터럴은 차단하지 않는다 — 레이아웃이 내비게이션 경로 조립 등에
|
||||||
|
// 정상적으로 사용하며(예: `/mypage/${$args[0]}`), `${...}` 는 클라이언트
|
||||||
|
// 평가기가 인터프리터로 안전하게 해석한다. constructor/Function 등 실제 위험
|
||||||
|
// 토큰만 아래에서 거부한다.
|
||||||
|
foreach (self::DANGEROUS_PATTERNS as $pattern) {
|
||||||
|
if (preg_match($pattern, $value) === 1) {
|
||||||
|
$fail(__('validation.layout.dangerous_expression', ['snippet' => mb_substr($value, 0, 80)]));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scripts/data_sources 배열의 지정 필드가 same-origin path-only 인지 검증합니다.
|
||||||
|
*
|
||||||
|
* @param array<mixed> $list scripts 또는 data_sources 배열
|
||||||
|
* @param string $field 검사할 필드명 (src | endpoint)
|
||||||
|
* @param Closure $fail 검증 실패 콜백
|
||||||
|
*/
|
||||||
|
private function assertSameOriginList(array $list, string $field, Closure $fail): void
|
||||||
|
{
|
||||||
|
foreach ($list as $entry) {
|
||||||
|
if (! is_array($entry) || ! isset($entry[$field]) || ! is_string($entry[$field])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = trim($entry[$field]);
|
||||||
|
|
||||||
|
if ($url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 표현식 바인딩(`{{...}}`)은 런타임 해석 대상이라 여기서 판정하지 않는다.
|
||||||
|
if (str_starts_with($url, '{{')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// same-origin path 이거나, 확장이 선언한 신뢰 호스트면 허용. 그 외 외부 origin 차단.
|
||||||
|
if (! $this->isSameOriginPath($url)
|
||||||
|
&& ! TrustedScriptHosts::isTrustedUrl($url, $this->trustedHosts())) {
|
||||||
|
$fail(__('validation.layout.external_resource_url', ['url' => $url]));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신뢰 외부 스크립트 호스트 목록을 반환합니다 (검증 1회당 1회 집계 후 캐시).
|
||||||
|
*
|
||||||
|
* @return array<int, string> 신뢰 호스트명 목록
|
||||||
|
*/
|
||||||
|
private function trustedHosts(): array
|
||||||
|
{
|
||||||
|
return $this->trustedHosts ??= TrustedScriptHosts::hosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* same-origin path-only URL 인지 판정합니다.
|
||||||
|
*
|
||||||
|
* 허용: `/` 로 시작하는 경로. 차단: `//`(protocol-relative), scheme 포함 절대 URL.
|
||||||
|
*
|
||||||
|
* @param string $url 검사 대상 URL
|
||||||
|
* @return bool same-origin path 이면 true
|
||||||
|
*/
|
||||||
|
private function isSameOriginPath(string $url): bool
|
||||||
|
{
|
||||||
|
$normalized = self::normalizeForOriginCheck($url);
|
||||||
|
|
||||||
|
// protocol-relative (`//evil.com/...`) 차단
|
||||||
|
if (str_starts_with($normalized, '//')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheme 포함 절대 URL(`https://`, `javascript:`, `data:` 등) 차단
|
||||||
|
if (preg_match('/^[a-z][a-z0-9+.\-]*:/i', $normalized) === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// path-only: `/` 로 시작해야 same-origin 절대 경로
|
||||||
|
return str_starts_with($normalized, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* origin 판정 전에 URL 을 브라우저 URL 파서와 동일하게 정규화합니다.
|
||||||
|
*
|
||||||
|
* 문자열 접두 검사만으로는 authority 우회를 막지 못합니다. 브라우저(WHATWG URL)는
|
||||||
|
* 파싱 전에 ASCII tab·개행을 제거하고, special scheme(http/https)에서 백슬래시를
|
||||||
|
* 슬래시와 동등하게 처리하기 때문입니다. 따라서 `/\/evil.com/x.js` ·
|
||||||
|
* `/{tab}/evil.com/x.js` 는 `//` 로 시작하지 않는데도 실제로는
|
||||||
|
* `https://evil.com/x.js` 로 해석됩니다.
|
||||||
|
*
|
||||||
|
* 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/js/a\b.js`)은 authority 를 만들지
|
||||||
|
* 않으므로 그대로 통과합니다(과차단 없음).
|
||||||
|
*
|
||||||
|
* 클라이언트(`TemplateApp.isAllowedScriptSrc`)·정적 검사
|
||||||
|
* (`layout-scripts-src-same-origin`)와 3층 동형이어야 합니다.
|
||||||
|
*
|
||||||
|
* 구현 SSoT 는 `TrustedScriptHosts::normalizeForOriginCheck` 입니다 — 같은 저장측
|
||||||
|
* 판정 안에서 same-origin 검사(이 규칙)와 신뢰 호스트 검사(`TrustedScriptHosts`)가
|
||||||
|
* 이어 붙으므로, 두 검사가 서로 다른 정규화를 쓰면 한 URL 이 "path 도 아니고
|
||||||
|
* 외부 호스트도 아닌" 상태로 빠져나간다. 위임으로 그 갈림을 구조적으로 막는다.
|
||||||
|
*
|
||||||
|
* @param string $url 원본 URL
|
||||||
|
* @return string 정규화된 URL
|
||||||
|
*/
|
||||||
|
public static function normalizeForOriginCheck(string $url): string
|
||||||
|
{
|
||||||
|
return TrustedScriptHosts::normalizeForOriginCheck($url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\StorageInterface;
|
|||||||
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
||||||
use App\Enums\AttachmentSourceType;
|
use App\Enums\AttachmentSourceType;
|
||||||
use App\Extension\HookManager;
|
use App\Extension\HookManager;
|
||||||
|
use App\Helpers\PermissionHelper;
|
||||||
use App\Models\Attachment;
|
use App\Models\Attachment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\ImageResizer;
|
use App\Support\ImageResizer;
|
||||||
@@ -231,6 +232,8 @@ class AttachmentService
|
|||||||
*/
|
*/
|
||||||
public function reorder(array $orderData): void
|
public function reorder(array $orderData): void
|
||||||
{
|
{
|
||||||
|
$this->assertReorderWithinScope($orderData);
|
||||||
|
|
||||||
// Before 훅
|
// Before 훅
|
||||||
HookManager::doAction('core.attachment.before_reorder', $orderData);
|
HookManager::doAction('core.attachment.before_reorder', $orderData);
|
||||||
|
|
||||||
@@ -240,6 +243,44 @@ class AttachmentService
|
|||||||
HookManager::doAction('core.attachment.after_reorder', $orderData);
|
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<int, array{id: int, order: int}> $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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 다운로드 응답 생성
|
* 다운로드 응답 생성
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ use App\Contracts\Repositories\RoleRepositoryInterface;
|
|||||||
use App\Enums\ExtensionOwnerType;
|
use App\Enums\ExtensionOwnerType;
|
||||||
use App\Enums\MenuPermissionType;
|
use App\Enums\MenuPermissionType;
|
||||||
use App\Extension\HookManager;
|
use App\Extension\HookManager;
|
||||||
|
use App\Helpers\PermissionHelper;
|
||||||
use App\Models\Menu;
|
use App\Models\Menu;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -203,11 +205,19 @@ class MenuService
|
|||||||
/**
|
/**
|
||||||
* 메뉴 순서를 업데이트합니다 (드래그 앤 드롭).
|
* 메뉴 순서를 업데이트합니다 (드래그 앤 드롭).
|
||||||
*
|
*
|
||||||
|
* 형제 `updateMenuOrderWithHierarchy` 와 같은 리소스를 같은 방식으로 쓰므로 스코프
|
||||||
|
* 가드도 대칭이어야 한다. 코어 내 호출부가 없다는 사실은 방어가 아니다 — 이 서비스는
|
||||||
|
* 확장이 주입받을 수 있고, 가드가 형제 경로에만 있으면 여기가 우회로가 된다.
|
||||||
|
*
|
||||||
* @param array $menuOrders 메뉴 ID와 순서 매핑 배열
|
* @param array $menuOrders 메뉴 ID와 순서 매핑 배열
|
||||||
* @return bool 업데이트 성공 여부
|
* @return bool 업데이트 성공 여부
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException 스코프 밖 메뉴가 하나라도 포함된 경우
|
||||||
*/
|
*/
|
||||||
public function updateMenuOrder(array $menuOrders): bool
|
public function updateMenuOrder(array $menuOrders): bool
|
||||||
{
|
{
|
||||||
|
$this->assertMenusWithinScope(array_map('intval', array_values($menuOrders)));
|
||||||
|
|
||||||
// 훅: 메뉴 순서 변경 전 (IDV 정책 가드 지점)
|
// 훅: 메뉴 순서 변경 전 (IDV 정책 가드 지점)
|
||||||
HookManager::doAction('core.menu.before_update_order', $menuOrders);
|
HookManager::doAction('core.menu.before_update_order', $menuOrders);
|
||||||
|
|
||||||
@@ -227,6 +237,8 @@ class MenuService
|
|||||||
*/
|
*/
|
||||||
public function updateMenuOrderWithHierarchy(array $orderData): bool
|
public function updateMenuOrderWithHierarchy(array $orderData): bool
|
||||||
{
|
{
|
||||||
|
$this->assertOrderTargetsWithinScope($orderData);
|
||||||
|
|
||||||
$result = $this->menuRepository->updateOrderWithHierarchy($orderData);
|
$result = $this->menuRepository->updateOrderWithHierarchy($orderData);
|
||||||
|
|
||||||
// 훅: 메뉴 계층 순서 변경 후
|
// 훅: 메뉴 계층 순서 변경 후
|
||||||
@@ -235,6 +247,74 @@ class MenuService
|
|||||||
return $result;
|
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<int> $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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴의 활성화 상태를 토글합니다.
|
* 메뉴의 활성화 상태를 토글합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Contracts\Repositories\RoleRepositoryInterface;
|
use App\Contracts\Repositories\RoleRepositoryInterface;
|
||||||
|
use App\Exceptions\CannotModifyProtectedRoleException;
|
||||||
use App\Exceptions\ExtensionOwnedRoleDeleteException;
|
use App\Exceptions\ExtensionOwnedRoleDeleteException;
|
||||||
use App\Exceptions\SystemRoleDeleteException;
|
use App\Exceptions\SystemRoleDeleteException;
|
||||||
use App\Extension\HookManager;
|
use App\Extension\HookManager;
|
||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\PermissionEscalationGuard;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -17,7 +19,8 @@ use Illuminate\Support\Str;
|
|||||||
class RoleService
|
class RoleService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private RoleRepositoryInterface $roleRepository
|
private RoleRepositoryInterface $roleRepository,
|
||||||
|
private PermissionEscalationGuard $escalationGuard
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +108,12 @@ class RoleService
|
|||||||
$permissions = $data['permissions'] ?? [];
|
$permissions = $data['permissions'] ?? [];
|
||||||
unset($data['permissions']);
|
unset($data['permissions']);
|
||||||
|
|
||||||
|
// 권한 상승 상한을 **역할 생성 전에** 검사한다. 생성 뒤에 검사하면 거부된 요청이
|
||||||
|
// 권한 0개짜리 고아 역할 행을 남긴다(사용자 경로와 동일한 "가드 → 쓰기" 순서).
|
||||||
|
if (! empty($permissions)) {
|
||||||
|
$this->escalationGuard->assertGrantWithinActorCeiling($permissions);
|
||||||
|
}
|
||||||
|
|
||||||
// 훅: 생성 전
|
// 훅: 생성 전
|
||||||
HookManager::doAction('core.role.before_create', $data);
|
HookManager::doAction('core.role.before_create', $data);
|
||||||
|
|
||||||
@@ -134,10 +143,20 @@ class RoleService
|
|||||||
*/
|
*/
|
||||||
public function updateRole(Role $role, array $data): Role
|
public function updateRole(Role $role, array $data): Role
|
||||||
{
|
{
|
||||||
|
// 보호된 역할(코어/확장 소유) 수정 상한: 삭제 경로와 대칭.
|
||||||
|
// 비-슈퍼관리자 액터는 admin 등 코어/확장 소유 역할을 변경할 수 없다.
|
||||||
|
$this->assertActorMayModifyRole($role);
|
||||||
|
|
||||||
// 권한 목록 분리
|
// 권한 목록 분리
|
||||||
$permissions = $data['permissions'] ?? null;
|
$permissions = $data['permissions'] ?? null;
|
||||||
unset($data['permissions']);
|
unset($data['permissions']);
|
||||||
|
|
||||||
|
// 권한 상승 상한을 **속성 업데이트 전에** 검사한다. update 뒤에 검사하면
|
||||||
|
// 403 을 받은 요청이 name/is_active 변경만 반영된 상태를 남긴다.
|
||||||
|
if ($permissions !== null) {
|
||||||
|
$this->escalationGuard->assertGrantWithinActorCeiling($permissions);
|
||||||
|
}
|
||||||
|
|
||||||
// 훅: 업데이트 전 (원본 data 전달)
|
// 훅: 업데이트 전 (원본 data 전달)
|
||||||
HookManager::doAction('core.role.before_update', $role, $data);
|
HookManager::doAction('core.role.before_update', $role, $data);
|
||||||
|
|
||||||
@@ -213,6 +232,15 @@ class RoleService
|
|||||||
*/
|
*/
|
||||||
public function syncPermissions(Role $role, array $permissions): void
|
public function syncPermissions(Role $role, array $permissions): void
|
||||||
{
|
{
|
||||||
|
// 보호된 역할(코어/확장 소유) 수정 상한: updateRole·toggleRoleStatus 와 대칭.
|
||||||
|
// 이 메서드는 public 이므로 서비스를 주입한 확장이 직접 호출할 수 있다 —
|
||||||
|
// 형제 경로에만 가드를 두면 여기가 코어 역할 보호의 우회로가 된다.
|
||||||
|
$this->assertActorMayModifyRole($role);
|
||||||
|
|
||||||
|
// 권한 상승 상한(ceiling): 비-슈퍼관리자 액터는 자신이 보유하지 않았거나
|
||||||
|
// 자신의 범위(scope)보다 넓은 범위의 권한을 부여할 수 없다(SSoT 가드에 위임).
|
||||||
|
$this->escalationGuard->assertGrantWithinActorCeiling($permissions);
|
||||||
|
|
||||||
// 동기화 전 현재 권한 식별자 캡처 (Listener diff 계산용)
|
// 동기화 전 현재 권한 식별자 캡처 (Listener diff 계산용)
|
||||||
$previousPermIdentifiers = $role->permissions()->pluck('identifier')->toArray();
|
$previousPermIdentifiers = $role->permissions()->pluck('identifier')->toArray();
|
||||||
|
|
||||||
@@ -244,6 +272,9 @@ class RoleService
|
|||||||
*/
|
*/
|
||||||
public function toggleRoleStatus(Role $role): bool
|
public function toggleRoleStatus(Role $role): bool
|
||||||
{
|
{
|
||||||
|
// 보호된 역할(코어/확장 소유) 상태변경 상한: 삭제 경로와 대칭.
|
||||||
|
$this->assertActorMayModifyRole($role);
|
||||||
|
|
||||||
$newStatus = ! $role->is_active;
|
$newStatus = ! $role->is_active;
|
||||||
|
|
||||||
// 훅: 상태 변경 전
|
// 훅: 상태 변경 전
|
||||||
@@ -259,6 +290,37 @@ class RoleService
|
|||||||
return $result;
|
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를 자동 생성합니다.
|
* name에서 identifier를 자동 생성합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ use App\Contracts\Repositories\RoleRepositoryInterface;
|
|||||||
use App\Contracts\Repositories\UserRepositoryInterface;
|
use App\Contracts\Repositories\UserRepositoryInterface;
|
||||||
use App\Enums\UserStatus;
|
use App\Enums\UserStatus;
|
||||||
use App\Exceptions\CannotDeleteSuperAdminException;
|
use App\Exceptions\CannotDeleteSuperAdminException;
|
||||||
|
use App\Exceptions\PermissionEscalationException;
|
||||||
use App\Extension\HookManager;
|
use App\Extension\HookManager;
|
||||||
use App\Helpers\PermissionHelper;
|
use App\Helpers\PermissionHelper;
|
||||||
use App\Helpers\TimezoneHelper;
|
use App\Helpers\TimezoneHelper;
|
||||||
use App\Models\ActivityLog;
|
use App\Models\ActivityLog;
|
||||||
use App\Models\Attachment;
|
use App\Models\Attachment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\PermissionEscalationGuard;
|
||||||
|
use App\Support\UserGradeGuard;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
@@ -26,7 +29,8 @@ class UserService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private UserRepositoryInterface $userRepository,
|
private UserRepositoryInterface $userRepository,
|
||||||
private RoleRepositoryInterface $roleRepository,
|
private RoleRepositoryInterface $roleRepository,
|
||||||
private AttachmentService $attachmentService
|
private AttachmentService $attachmentService,
|
||||||
|
private PermissionEscalationGuard $escalationGuard
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,10 +83,19 @@ class UserService
|
|||||||
}
|
}
|
||||||
unset($data['roles']);
|
unset($data['roles']);
|
||||||
|
|
||||||
// 역할 할당 권한 체크: core.permissions.update 권한 없으면 기본 역할 자동 할당
|
// 역할 할당: 요청 역할이 없으면 기본 역할('user')을 자동 배정하고, 요청 역할이
|
||||||
if (! PermissionHelper::check('core.permissions.update')) {
|
// 있으면 액터 상한(ceiling) 검사만 받는다.
|
||||||
|
//
|
||||||
|
// 역할 부여는 "사용자 관리"(core.users.create — 이 경로는 라우트에서 이미 강제됨)의
|
||||||
|
// 일부이지, "역할 정의 수정"(core.permissions.update — 역할에 권한을 가감하는 권한)을
|
||||||
|
// 요구하지 않는다. 권한 상승 방지는 오직 상한(ceiling)이 담당한다: 액터가 보유하지
|
||||||
|
// 않았거나 자신의 범위보다 넓은 권한을 담은 역할은 부여할 수 없다(KVE-2026-1919).
|
||||||
|
// 신규 사용자는 기존 역할이 없으므로 요청 역할 전부가 새로 부여되는 역할이다.
|
||||||
|
if ($roleIds === null || count($roleIds) === 0) {
|
||||||
$defaultRoleId = $this->roleRepository->findByIdentifier('user')?->id;
|
$defaultRoleId = $this->roleRepository->findByIdentifier('user')?->id;
|
||||||
$roleIds = $defaultRoleId ? [$defaultRoleId] : null;
|
$roleIds = $defaultRoleId ? [$defaultRoleId] : null;
|
||||||
|
} else {
|
||||||
|
$this->escalationGuard->assertRoleAssignmentWithinActorCeiling($roleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $this->userRepository->create($data);
|
$user = $this->userRepository->create($data);
|
||||||
@@ -102,6 +115,11 @@ class UserService
|
|||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 상승 상한 위반은 그대로 전파해 컨트롤러가 403 으로 매핑하도록 한다.
|
||||||
|
if ($e instanceof PermissionEscalationException) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'general' => [__('user.create_failed', ['error' => $e->getMessage()])],
|
'general' => [__('user.create_failed', ['error' => $e->getMessage()])],
|
||||||
]);
|
]);
|
||||||
@@ -119,6 +137,10 @@ class UserService
|
|||||||
*/
|
*/
|
||||||
public function updateUser(User $user, array $data): User
|
public function updateUser(User $user, array $data): User
|
||||||
{
|
{
|
||||||
|
// 등급 상한 가드: 비-슈퍼관리자 액터는 슈퍼 관리자 계정을 수정할 수 없다.
|
||||||
|
// (비밀번호·status(withdrawn/blocked)·email 등 모든 수정 경로를 한 지점에서 차단)
|
||||||
|
UserGradeGuard::assertActorMayModify($user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 원본 데이터 보관 (after_update 훅에서 사용)
|
// 원본 데이터 보관 (after_update 훅에서 사용)
|
||||||
$originalData = $data;
|
$originalData = $data;
|
||||||
@@ -148,17 +170,35 @@ class UserService
|
|||||||
}
|
}
|
||||||
unset($data['roles']);
|
unset($data['roles']);
|
||||||
|
|
||||||
// 역할 할당 권한 체크
|
// 역할 할당
|
||||||
if ($roleIds !== null) {
|
if ($roleIds !== null) {
|
||||||
$authUser = Auth::user();
|
$authUser = Auth::user();
|
||||||
|
|
||||||
// core.permissions.update 권한 없으면 역할 변경 불가
|
// 역할 조작 상한(ceiling): 이번 변경으로 붙거나 떨어지는 역할이 액터가 전부
|
||||||
if (! PermissionHelper::check('core.permissions.update', $authUser)) {
|
// 부여할 수 있는 권한만 담고 있는지 확인한다(KVE-2026-1919). 역할 부여는
|
||||||
$roleIds = null;
|
// "사용자 관리"(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 역할을 제거하려는 경우 차단
|
// 자기잠금 방지: 마지막 admin 역할 사용자가 자기 admin 역할을 제거하려는 경우 차단
|
||||||
if ($roleIds !== null && $authUser && $authUser->id === $user->id) {
|
if ($authUser && $authUser->id === $user->id) {
|
||||||
$adminRole = $this->roleRepository->findByIdentifier('admin');
|
$adminRole = $this->roleRepository->findByIdentifier('admin');
|
||||||
if ($adminRole && $user->roles->contains('id', $adminRole->id) && ! in_array($adminRole->id, $roleIds)) {
|
if ($adminRole && $user->roles->contains('id', $adminRole->id) && ! in_array($adminRole->id, $roleIds)) {
|
||||||
// admin 역할을 가진 다른 사용자가 있는지 확인
|
// admin 역할을 가진 다른 사용자가 있는지 확인
|
||||||
@@ -261,6 +301,12 @@ class UserService
|
|||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 상승 상한 위반은 그대로 전파해 컨트롤러가 403 으로 매핑하도록 한다
|
||||||
|
// (일반 실패로 감싸면 422 로 잘못 내려간다).
|
||||||
|
if ($e instanceof PermissionEscalationException) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'general' => [__('user.update_failed', ['error' => $e->getMessage()])],
|
'general' => [__('user.update_failed', ['error' => $e->getMessage()])],
|
||||||
]);
|
]);
|
||||||
@@ -589,22 +635,6 @@ class UserService
|
|||||||
return $excludeUserUuid && $user->uuid === $excludeUserUuid;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자의 활동 로그를 조회합니다.
|
* 사용자의 활동 로그를 조회합니다.
|
||||||
*
|
*
|
||||||
@@ -640,6 +670,9 @@ class UserService
|
|||||||
*/
|
*/
|
||||||
public function unlockAccount(User $user): User
|
public function unlockAccount(User $user): User
|
||||||
{
|
{
|
||||||
|
// 등급 상한 가드: 비-슈퍼관리자 액터는 슈퍼 관리자 계정을 조작할 수 없다(정합성).
|
||||||
|
UserGradeGuard::assertActorMayModify($user);
|
||||||
|
|
||||||
HookManager::doAction('core.user.before_unlock', $user);
|
HookManager::doAction('core.user.before_unlock', $user);
|
||||||
|
|
||||||
$this->userRepository->resetLoginAttempts($user);
|
$this->userRepository->resetLoginAttempts($user);
|
||||||
@@ -653,19 +686,6 @@ class UserService
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자의 마지막 로그인 시간을 현재 시간으로 업데이트합니다.
|
|
||||||
*
|
|
||||||
* @param User $user 대상 사용자 모델
|
|
||||||
* @return User 업데이트된 사용자 모델
|
|
||||||
*/
|
|
||||||
public function updateLastLogin(User $user): User
|
|
||||||
{
|
|
||||||
$this->userRepository->update($user, ['last_login_at' => now()]);
|
|
||||||
|
|
||||||
return $user->fresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자의 언어 설정을 업데이트합니다.
|
* 사용자의 언어 설정을 업데이트합니다.
|
||||||
*
|
*
|
||||||
@@ -698,14 +718,38 @@ class UserService
|
|||||||
|
|
||||||
$statusEnum = UserStatus::from($status);
|
$statusEnum = UserStatus::from($status);
|
||||||
|
|
||||||
|
// 정적 라우트(`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 훅이 통째로 생략되어, 본인 탈퇴와 결과가 달라진다.
|
// before/after_withdraw 훅이 통째로 생략되어, 본인 탈퇴와 결과가 달라진다.
|
||||||
|
// 위에서 등급·스코프로 걸러낸 대상에 한해서만 수행한다.
|
||||||
if ($statusEnum === UserStatus::Withdrawn) {
|
if ($statusEnum === UserStatus::Withdrawn) {
|
||||||
return $this->bulkWithdraw($uuids, $status);
|
$modifiableUuids = array_map(static fn (User $u): string => $u->uuid, $modifiable);
|
||||||
|
|
||||||
|
return $this->bulkWithdraw($modifiableUuids, $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// UUID → 정수 ID 변환 (내부 쿼리용)
|
$userIds = array_map(static fn (User $u): int => $u->id, $modifiable);
|
||||||
$userIds = $this->userRepository->getIdsByUuids($uuids);
|
|
||||||
|
if (empty($userIds)) {
|
||||||
|
HookManager::doAction('sirsoft-core.user.after_bulk_update', $uuids, $status, 0);
|
||||||
|
|
||||||
|
return ['updated_count' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
// DB 트랜잭션으로 일괄 업데이트
|
// DB 트랜잭션으로 일괄 업데이트
|
||||||
$updatedCount = DB::transaction(function () use ($userIds, $statusEnum) {
|
$updatedCount = DB::transaction(function () use ($userIds, $statusEnum) {
|
||||||
|
|||||||
@@ -1138,7 +1138,8 @@ class ApiDocScaffolder
|
|||||||
// 종료 마커를 표의 끝 경계로 삼는데, 전자는 그 마커를 잘라내고 돌려준다.
|
// 종료 마커를 표의 끝 경계로 삼는데, 전자는 그 마커를 잘라내고 돌려준다.
|
||||||
$merged .= $this->applyPreservedErrorTable(
|
$merged .= $this->applyPreservedErrorTable(
|
||||||
$withErrors,
|
$withErrors,
|
||||||
$this->exactGeneratedBlock($existing, $key)
|
$this->exactGeneratedBlock($existing, $key),
|
||||||
|
$section
|
||||||
)."\n";
|
)."\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1252,7 +1253,44 @@ class ApiDocScaffolder
|
|||||||
* @param string|null $previous 기존 문서의 같은 엔드포인트 생성 블록
|
* @param string|null $previous 기존 문서의 같은 엔드포인트 생성 블록
|
||||||
* @return string 에러 표가 보존된 섹션
|
* @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) {
|
if ($previous === null) {
|
||||||
return $section;
|
return $section;
|
||||||
@@ -1285,12 +1323,28 @@ class ApiDocScaffolder
|
|||||||
|
|
||||||
// 상태코드 키로 병합. `+` 는 왼쪽 우선이므로 기존 행을 먼저 둬서, 같은 상태코드면
|
// 상태코드 키로 병합. `+` 는 왼쪽 우선이므로 기존 행을 먼저 둬서, 같은 상태코드면
|
||||||
// 사람이 쓴 구체적 조건이 자동 문구를 이긴다. 자동 추론에만 있는 상태코드는 새로 편입된다.
|
// 사람이 쓴 구체적 조건이 자동 문구를 이긴다. 자동 추론에만 있는 상태코드는 새로 편입된다.
|
||||||
$merged = $previousRows + $this->errorTableRows($section);
|
$currentRows = $this->errorTableRows($section);
|
||||||
|
$merged = $previousRows + $currentRows;
|
||||||
|
|
||||||
if ($merged === []) {
|
if ($merged === []) {
|
||||||
return $section;
|
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);
|
ksort($merged, SORT_NUMERIC);
|
||||||
|
|
||||||
$table = "| 상태코드 | 의미 | 발생 조건 |\n| --- | --- | --- |\n".implode("\n", $merged);
|
$table = "| 상태코드 | 의미 | 발생 조건 |\n| --- | --- | --- |\n".implode("\n", $merged);
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Contracts\Repositories\PermissionRepositoryInterface;
|
||||||
|
use App\Contracts\Repositories\RoleRepositoryInterface;
|
||||||
|
use App\Exceptions\PermissionEscalationException;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한 상승(escalation) 상한 가드 (SSoT)
|
||||||
|
*
|
||||||
|
* 비-슈퍼관리자 액터가 "자신이 보유하지 않았거나 자신의 범위(scope)보다 넓은" 권한을
|
||||||
|
* 부여하는 것을 차단합니다(KVE-2026-1919). 권한을 직접 역할에 부여하는 경로(RoleService)와
|
||||||
|
* 역할 자체를 사용자에게 붙여 그 역할의 권한을 통째로 넘기는 경로(UserService)가 **동일한
|
||||||
|
* 상한 규칙**을 공유하도록, 판정을 한 지점에 모읍니다. 한 경로만 막으면 다른 경로가 우회로가
|
||||||
|
* 됩니다(역할 부여는 곧 그 역할의 전 권한 부여이므로 권한 부여와 같은 상한을 받아야 합니다).
|
||||||
|
*/
|
||||||
|
class PermissionEscalationGuard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param PermissionRepositoryInterface $permissionRepository 권한 조회
|
||||||
|
* @param RoleRepositoryInterface $roleRepository 역할 조회
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private PermissionRepositoryInterface $permissionRepository,
|
||||||
|
private RoleRepositoryInterface $roleRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부여하려는 권한이 액터의 상한(보유 + 범위)을 넘지 않는지 확인합니다.
|
||||||
|
*
|
||||||
|
* 슈퍼 관리자와 내부/Artisan 경로는 상한 검사 없이 통과합니다. 비-슈퍼관리자
|
||||||
|
* 액터는 자신이 보유한 권한만, 그리고 자신의 effective scope 보다 넓지 않은
|
||||||
|
* 범위로만 부여할 수 있습니다(권한 상승 차단).
|
||||||
|
*
|
||||||
|
* @param array<int, array{id?: int, scope_type?: string|null}> $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<int, int> $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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Extension\HookManager;
|
||||||
|
use App\Extension\ModuleManager;
|
||||||
|
use App\Extension\PluginManager;
|
||||||
|
use App\Extension\TemplateManager;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신뢰하는 외부 스크립트 호스트 집계 (KVE-2026-1915 신뢰 출처 허용목록)
|
||||||
|
*
|
||||||
|
* 레이아웃 `scripts[].src` 는 기본적으로 same-origin path-only 만 허용한다(원격 코드 로드
|
||||||
|
* 차단). 그러나 CKEditor5(cdn.ckeditor.com)·Daum 우편번호(t1.daumcdn.net)처럼 외부 CDN
|
||||||
|
* 스크립트를 정당하게 로드하는 번들 확장이 있다. 그 확장이 manifest 의
|
||||||
|
* `trusted_script_hosts` 로 자기 호스트를 **선언**하고, 코어가 활성 확장 전수에서 이 목록을
|
||||||
|
* 집계한다. 런타임 스크립트 로더(TemplateApp)·저장측 검증(SafeLayoutExpressions)·정적 검사
|
||||||
|
* (audit)가 모두 이 집계 결과만 신뢰 호스트로 허용한다.
|
||||||
|
*
|
||||||
|
* 신뢰 경계: 편집기가 저장하는 레이아웃(저권한 액터)에는 외부 스크립트를 허용하지 않는
|
||||||
|
* 것이 기본이고, 여기서 허용되는 것은 **확장이 코드로 선언한 호스트**뿐이다.
|
||||||
|
*/
|
||||||
|
class TrustedScriptHosts
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 확장이 동적으로 신뢰 호스트를 추가할 수 있는 필터 훅 이름.
|
||||||
|
*/
|
||||||
|
public const FILTER_HOOK = 'core.layout.trusted_script_hosts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 모듈·플러그인·템플릿이 선언한 신뢰 호스트 전체를 집계합니다.
|
||||||
|
*
|
||||||
|
* @return array<int, string> 중복 제거된 호스트명 목록 (소문자)
|
||||||
|
*/
|
||||||
|
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<int, string>|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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Exceptions\CannotModifySuperAdminException;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 등급 상한(rank ceiling) 가드 (SSoT)
|
||||||
|
*
|
||||||
|
* 슈퍼 관리자 보호는 그동안 삭제/탈퇴 경로에만 있었고 수정·상태변경·권한부여
|
||||||
|
* 경로에는 없어 비대칭이었습니다(KVE-2026-1919). 이 가드는 "비-슈퍼관리자
|
||||||
|
* 액터는 슈퍼 관리자를 수정할 수 없다"는 규칙을 단일 지점에서 강제해 모든
|
||||||
|
* 쓰기 경로가 동일한 상한을 공유하도록 합니다.
|
||||||
|
*
|
||||||
|
* 인증된 액터가 없는 경우(Artisan, 내부 시더/호출)는 신뢰 경로로 간주해
|
||||||
|
* 가드를 적용하지 않습니다. HTTP 관리 경로는 인증·권한 미들웨어를 통과하므로
|
||||||
|
* 이 지점에서는 항상 액터가 존재합니다.
|
||||||
|
*/
|
||||||
|
class UserGradeGuard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 액터가 대상 사용자를 수정할 수 있는지 판정합니다.
|
||||||
|
*
|
||||||
|
* @param User $target 수정 대상 사용자
|
||||||
|
* @param User|null $actor 행위자(미지정 시 현재 인증 사용자)
|
||||||
|
* @return bool 수정 가능 여부
|
||||||
|
*/
|
||||||
|
public static function mayModify(User $target, ?User $actor = null): bool
|
||||||
|
{
|
||||||
|
$actor ??= Auth::user();
|
||||||
|
|
||||||
|
// 인증 액터 부재 = 내부/Artisan 신뢰 경로 → 가드 미적용
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대상이 슈퍼 관리자가 아니면 등급 상한과 무관
|
||||||
|
if (! $target->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<User> $targets 대상 사용자 목록
|
||||||
|
* @param User|null $actor 행위자(미지정 시 현재 인증 사용자)
|
||||||
|
* @return array<User> 수정 가능한 대상 목록
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Exceptions\CannotModifyProtectedRoleException;
|
||||||
|
use App\Exceptions\CannotModifySuperAdminException;
|
||||||
use App\Exceptions\CoreVersionMismatchException;
|
use App\Exceptions\CoreVersionMismatchException;
|
||||||
use App\Exceptions\IdentityVerificationRequiredException;
|
use App\Exceptions\IdentityVerificationRequiredException;
|
||||||
|
use App\Exceptions\PermissionEscalationException;
|
||||||
use App\Helpers\ResponseHelper;
|
use App\Helpers\ResponseHelper;
|
||||||
use App\Http\Middleware\AdminMiddleware;
|
use App\Http\Middleware\AdminMiddleware;
|
||||||
use App\Http\Middleware\CheckTemplateDependencies;
|
use App\Http\Middleware\CheckTemplateDependencies;
|
||||||
@@ -176,6 +179,19 @@ $app = Application::configure(basePath: dirname(__DIR__))
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 등급 상한(rank ceiling) / 권한 상승 차단 예외 → HTTP 403 (KVE-2026-1919).
|
||||||
|
// 컨트롤러 catch 가 우선 처리하지만, 새 호출부가 로컬 catch 없이 이 예외를 던져도
|
||||||
|
// 조용한 500 대신 일관된 403 이 되도록 전역 렌더러를 둔다(심층 방어). 메시지는 예외
|
||||||
|
// 생성자가 lang 키로 설정한 것을 그대로 쓴다 — 컨트롤러 매핑과 동일 문구.
|
||||||
|
$exceptions->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'
|
// 확장 코어 버전 호환성 검사 실패 → HTTP 422 + error_code: 'core_version_mismatch'
|
||||||
// (extension update/activate/recovery 등 사전 검증 진입 지점에서 throw)
|
// (extension update/activate/recovery 등 사전 검증 진입 지점에서 throw)
|
||||||
$exceptions->render(function (CoreVersionMismatchException $e, Request $request) {
|
$exceptions->render(function (CoreVersionMismatchException $e, Request $request) {
|
||||||
|
|||||||
@@ -178,6 +178,21 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
|||||||
성공 판정은 상태코드가 아니라 **본문의 매직 토큰과 Content-Type** 으로 합니다. 상태코드만 보면
|
성공 판정은 상태코드가 아니라 **본문의 매직 토큰과 Content-Type** 으로 합니다. 상태코드만 보면
|
||||||
"404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는 설정에서 영원히 오판합니다.
|
"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 레퍼런스
|
## 코어 API 레퍼런스
|
||||||
|
|
||||||
<!-- @generated:start:api-readme-index -->
|
<!-- @generated:start:api-readme-index -->
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.activities.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.activities.delete`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -313,7 +313,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.activities.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.activities.delete`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
|||||||
@@ -535,17 +535,58 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
_단건 응답: `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-422 — 응답 예시는 사람이 작성하세요. -->
|
```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` 형식 위반 |
|
| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 |
|
||||||
|
| 429 | Too Many Requests | `throttle:auth-login` 초과 (로그인과 같은 제한을 공유하므로 코드 대입 시도도 함께 억제된다) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -323,7 +323,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -395,7 +395,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -481,7 +481,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.purge`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -352,7 +352,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.read`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -513,7 +513,7 @@ HTTP/1.1 201
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 정의 생성 중 예외 발생 (`IDV 메시지 정의 생성에 실패했습니다.`) |
|
| 500 | Internal Server Error | 정의 생성 중 예외 발생 (`IDV 메시지 정의 생성에 실패했습니다.`) |
|
||||||
|
|
||||||
@@ -713,7 +713,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.read`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1018,7 +1018,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1145,7 +1145,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1417,7 +1417,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1509,7 +1509,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1652,7 +1652,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.read`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -2215,7 +2215,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.admin.identity.providers.read`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|||||||
+106
-8
@@ -40,21 +40,86 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
이 엔드포인트는 `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` 키는 병합 후 결과에는 남지 않는다 (병합 입력으로만 쓰인다). 실제 포함되는 키 집합은 저장된 레이아웃 내용에 따라 달라진다.
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
```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 다 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
**설명**
|
||||||
|
|
||||||
|
레이아웃 편집기에서 **저장하지 않은 편집 중 내용**을 실제 화면으로 확인하기 위한 미리보기 서빙 엔드포인트다. 인증이 아니라 **토큰 자체가 보안 메커니즘**이며(`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
|
### GET /api/layouts/preview/{token}.json
|
||||||
@@ -180,21 +245,54 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
확장자 없는 형태(`GET /api/layouts/preview/{token}`)와 **같은 컨트롤러 메서드**이므로 응답이 동일하다. `ResponseHelper` 봉투 없이 병합·확장 적용이 끝난 **레이아웃 JSON 자체**를 최상위로 반환한다. 필드 표는 위 항목을 참조한다.
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
```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 가 되므로, 이 경우 확장자 없는 형태로 재요청한다 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
**설명**
|
||||||
|
|
||||||
|
확장자 붙은 형태의 미리보기 서빙이다. 동작·토큰 수명(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
|
### GET /api/layouts/{templateIdentifier}/{layoutName}.json
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ _단건 응답: `data` 객체의 필드 (`UserResource::toArray()` 산물 — GE
|
|||||||
| created_at | string | `2026-07-08 10:41:24` | 생성 일시 (사용자 시간대 기준 문자열) |
|
| created_at | string | `2026-07-08 10:41:24` | 생성 일시 (사용자 시간대 기준 문자열) |
|
||||||
| updated_at | string | `2026-07-08 11:02:10` | 수정 일시 (사용자 시간대 기준 문자열) |
|
| updated_at | string | `2026-07-08 11:02:10` | 수정 일시 (사용자 시간대 기준 문자열) |
|
||||||
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
|
| 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`)는 해당 관계가 로드된 경우에만 응답에 포함된다 (프로필 수정 응답에서는 로드하지 않으므로 나타나지 않는다).
|
관계형 필드(`modules`, `plugins`, `menus`, `roles`, `permissions`, `consents`, `terms_consent`, `privacy_consent`)와 카운트 필드(`modules_count`, `plugins_count`, `menus_count`)는 해당 관계가 로드된 경우에만 응답에 포함된다 (프로필 수정 응답에서는 로드하지 않으므로 나타나지 않는다).
|
||||||
|
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ HTTP/1.1 201
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.menus.create`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -742,7 +742,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.menus.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `moved_items[].new_parent_id` 가 자기 자신·자손을 가리키는 순환 참조인 경우 포함 |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `moved_items[].new_parent_id` 가 자기 자신·자손을 가리키는 순환 참조인 경우 포함 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -798,7 +798,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.menus.delete`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1102,7 +1102,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.menus.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1198,7 +1198,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.menus.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
|||||||
+108
-29
@@ -176,7 +176,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.read\|core.menus.read`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -266,7 +266,7 @@ _단건 응답: `data` 객체의 필드 (`data.module` 은 목록과 동일한 `
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 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` 포함) |
|
| 409 | Conflict | `force` 없이 호출했고 필요한 의존 확장이 미충족인 경우 (`error` 에 `warning`, `missing_modules`, `missing_plugins` 포함) |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 활성화 처리 중 예외 발생 (`module.activate_failed`) |
|
| 500 | Internal Server Error | 활성화 처리 중 예외 발생 (`module.activate_failed`) |
|
||||||
@@ -333,7 +333,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 업데이트 확인 처리가 실패한 경우 (`modules.check_updates_failed`) |
|
| 422 | Unprocessable Entity | 업데이트 확인 처리가 실패한 경우 (`modules.check_updates_failed`) |
|
||||||
| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 |
|
| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 |
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ _단건 응답: `data` 는 비활성화된 모듈의 `ModuleResource` 객체 (
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 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` 포함) |
|
| 409 | Conflict | `force` 없이 호출했고 이 모듈에 의존하는 활성 확장이 있는 경우 (`error` 에 `warning`, `dependent_templates`, `dependent_modules`, `dependent_plugins` 포함) |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 비활성화 처리 중 예외 발생 (`module.deactivate_failed`) |
|
| 500 | Internal Server Error | 비활성화 처리 중 예외 발생 (`module.deactivate_failed`) |
|
||||||
@@ -560,7 +560,7 @@ HTTP/1.1 201
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 설치 파이프라인이 던진 검증 오류 (의존 확장 cascade 설치 실패·이미 설치됨 등 — `error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 설치 파이프라인이 던진 검증 오류 (의존 확장 cascade 설치 실패·이미 설치됨 등 — `error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`modules.installation_failed`) |
|
| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`modules.installation_failed`) |
|
||||||
|
|
||||||
@@ -671,7 +671,7 @@ HTTP/1.1 201
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 파일 검증 실패(ZIP 아님·50MB 초과), 또는 ZIP 처리 오류 (module.json 미존재/형식 오류·식별자 누락·이미 설치됨) |
|
| 422 | Unprocessable Entity | 파일 검증 실패(ZIP 아님·50MB 초과), 또는 ZIP 처리 오류 (module.json 미존재/형식 오류·식별자 누락·이미 설치됨) |
|
||||||
| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`module.install_failed`) |
|
| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`module.install_failed`) |
|
||||||
|
|
||||||
@@ -779,7 +779,7 @@ HTTP/1.1 201
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | URL 형식 검증 실패, 또는 GitHub 처리 오류 (저장소 미존재·다운로드 실패·module.json 형식 오류·이미 설치됨) |
|
| 422 | Unprocessable Entity | URL 형식 검증 실패, 또는 GitHub 처리 오류 (저장소 미존재·다운로드 실패·module.json 형식 오류·이미 설치됨) |
|
||||||
| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`module.install_failed`) |
|
| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`module.install_failed`) |
|
||||||
|
|
||||||
@@ -1001,7 +1001,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 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` 에 사유) |
|
| 422 | Unprocessable Entity | 파일 검증 실패(ZIP 아님·50MB 초과), 또는 미리보기 처리 실패 (`module.preview_failed` — `error.error` 에 사유) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -1104,7 +1104,7 @@ _단건 응답: `data` 는 갱신된 모듈의 `ModuleResource` 객체 (목록
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.activate`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 레이아웃 갱신 실패 (모듈 미존재·비활성 상태 — `modules.refresh_layouts_failed`) |
|
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 레이아웃 갱신 실패 (모듈 미존재·비활성 상태 — `modules.refresh_layouts_failed`) |
|
||||||
| 500 | Internal Server Error | 갱신 처리 중 예외 발생 (`module.refresh_layouts_failed`) |
|
| 500 | Internal Server Error | 갱신 처리 중 예외 발생 (`module.refresh_layouts_failed`) |
|
||||||
|
|
||||||
@@ -1155,7 +1155,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.uninstall`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 제거 처리 중 예외 발생 (`module.uninstall_failed`) |
|
| 500 | Internal Server Error | 제거 처리 중 예외 발생 (`module.uninstall_failed`) |
|
||||||
|
|
||||||
@@ -1242,7 +1242,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -1300,7 +1300,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1374,7 +1374,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1433,7 +1433,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1550,7 +1550,7 @@ _단건 응답: `data` 객체의 필드 (`ModuleResource::toDetailArray()` + 주
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
|
||||||
| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) |
|
| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) |
|
||||||
| 500 | Internal Server Error | 조회 중 예외 발생 (`module.fetch_failed`) |
|
| 500 | Internal Server Error | 조회 중 예외 발생 (`module.fetch_failed`) |
|
||||||
|
|
||||||
@@ -1620,7 +1620,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
|
||||||
| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) |
|
| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) |
|
||||||
| 422 | Unprocessable Entity | 수정 레이아웃 확인 실패 (`modules.check_modified_layouts_failed` — `error.errors.module_name`) |
|
| 422 | Unprocessable Entity | 수정 레이아웃 확인 실패 (`modules.check_modified_layouts_failed` — `error.errors.module_name`) |
|
||||||
| 500 | Internal Server Error | 확인 처리 중 예외 발생 |
|
| 500 | Internal Server Error | 확인 처리 중 예외 발생 |
|
||||||
@@ -1728,7 +1728,7 @@ _단건 응답: `data` 객체의 필드 (`target` + `dependencies[]` + `language
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
|
||||||
| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) |
|
| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) |
|
||||||
| 500 | Internal Server Error | 대상 확장을 찾을 수 없거나 프리뷰 빌드 중 예외 발생 (`module.fetch_failed`) |
|
| 500 | Internal Server Error | 대상 확장을 찾을 수 없거나 프리뷰 빌드 중 예외 발생 (`module.fetch_failed`) |
|
||||||
|
|
||||||
@@ -1821,7 +1821,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.uninstall`)이 없는 경우 |
|
||||||
| 404 | Not Found | 해당 식별자의 모듈을 찾을 수 없는 경우 (`module.not_found`) |
|
| 404 | Not Found | 해당 식별자의 모듈을 찾을 수 없는 경우 (`module.not_found`) |
|
||||||
| 500 | Internal Server Error | 삭제 정보 조회 중 예외 발생 (`module.uninstall_info_failed`) |
|
| 500 | Internal Server Error | 삭제 정보 조회 중 예외 발생 (`module.uninstall_info_failed`) |
|
||||||
|
|
||||||
@@ -1932,7 +1932,7 @@ _단건 응답: `data` 는 업데이트된 모듈의 `ModuleResource` 객체 (
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
|
||||||
| 404 | Not Found | 해당 식별자의 모듈을 찾을 수 없는 경우 (`module.not_found`) |
|
| 404 | Not Found | 해당 식별자의 모듈을 찾을 수 없는 경우 (`module.not_found`) |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 업데이트 실패 (업데이트 소스 없음·다운그레이드 차단·코어 버전 비호환 — `error.errors.module_name` 에 사유) |
|
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 업데이트 실패 (업데이트 소스 없음·다운그레이드 차단·코어 버전 비호환 — `error.errors.module_name` 에 사유) |
|
||||||
| 500 | Internal Server Error | 업데이트 처리 중 예외 발생 (`modules.errors.update_failed`) |
|
| 500 | Internal Server Error | 업데이트 처리 중 예외 발생 (`modules.errors.update_failed`) |
|
||||||
@@ -1965,11 +1965,26 @@ Accept: application/json
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
_이 엔드포인트는 표준 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-422 — 응답 예시는 사람이 작성하세요. -->
|
```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` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
|
_이 엔드포인트는 표준 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-200 — 응답 예시는 사람이 작성하세요. -->
|
```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` 가 반환된다.
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 200 | OK (빈 본문) | 활성 확장이 없거나 병합할 에셋이 없는 경우 — 오류가 아니라 빈 번들이다 |
|
||||||
|
|
||||||
|
> 개별 확장의 병합이 실패하면 그 확장만 건너뛰고 나머지는 그대로 병합된다(실패 격리). 건너뛴 사실은 서버 로그(warning)에 남는다.
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -2172,15 +2207,36 @@ Accept: application/json
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
|
_이 엔드포인트는 표준 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-200 — 응답 예시는 사람이 작성하세요. -->
|
```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` 가 반환된다.
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 200 | OK (빈 본문) | 활성 확장이 없거나 병합할 에셋이 없는 경우 — 오류가 아니라 빈 번들이다 |
|
||||||
|
|
||||||
|
> 개별 확장의 병합이 실패하면 그 확장만 건너뛰고 나머지는 그대로 병합된다(실패 격리). 건너뛴 사실은 서버 로그(warning)에 남는다.
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -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-404 — 응답 예시는 사람이 작성하세요. -->
|
```http
|
||||||
|
HTTP/1.1 200
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "설정을 조회했습니다.",
|
||||||
|
"data": {
|
||||||
|
"ProductCard": {
|
||||||
|
"type": "composite",
|
||||||
|
"props": {
|
||||||
|
"product": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -663,7 +663,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -797,7 +797,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.notification-logs.delete`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -287,7 +287,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.notification-logs.delete`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ _단건 응답: `data` 객체의 필드 (`NotificationTemplateResource`)._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -404,7 +404,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.notifications.delete`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -260,7 +260,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -329,7 +329,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -441,7 +441,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.notifications.delete`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -526,7 +526,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -664,7 +664,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.user-notifications.read`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -722,7 +722,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.user-notifications.delete`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -780,7 +780,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -849,7 +849,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -907,7 +907,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.user-notifications.read`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -961,7 +961,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.user-notifications.delete`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1046,7 +1046,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ _단건 응답: `data` 객체의 필드 (`UserResource::toArray()` 산물 — `s
|
|||||||
|
|
||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 403 | Forbidden | 요구 권한(`core.profile.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.profile.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ HTTP/1.1 201
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.permissions.create`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -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}
|
### DELETE /api/admin/roles/{role}
|
||||||
@@ -444,14 +444,24 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
|||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: http-403 — 응답 예시는 사람이 작성하세요. -->
|
```http
|
||||||
|
HTTP/1.1 200
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "역할이 성공적으로 삭제되었습니다.",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.permissions.delete`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -652,7 +662,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.permissions.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -754,7 +764,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.permissions.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ HTTP/1.1 201
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.schedules.create`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -346,7 +346,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.schedules.delete`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -415,7 +415,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.schedules.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -467,7 +467,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.schedules.delete`)이 없는 경우 |
|
||||||
| 404 | Not Found | 지정한 `historyId` 의 실행 이력이 존재하지 않는 경우 (`schedule.history_not_found`) |
|
| 404 | Not Found | 지정한 `historyId` 의 실행 이력이 존재하지 않는 경우 (`schedule.history_not_found`) |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -588,7 +588,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.schedules.delete`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -842,7 +842,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.schedules.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -946,7 +946,7 @@ HTTP/1.1 201
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.schedules.create`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1147,7 +1147,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.schedules.run`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 캐시 무효화(`invalidateByLayout` / `clearAll`) 중 예외가 발생한 경우 (`messages.error_occurred`) |
|
| 500 | Internal Server Error | 캐시 무효화(`invalidateByLayout` / `clearAll`) 중 예외가 발생한 경우 (`messages.error_occurred`) |
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 500 | Internal Server Error | 워밍업 처리 중 예외가 발생한 경우 (`messages.error_occurred`) |
|
| 500 | Internal Server Error | 워밍업 처리 중 예외가 발생한 경우 (`messages.error_occurred`) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 탭 설정 그룹. channels 는 알림 채널 목록으로 각 원소가 id(채널 식별자)·is_active(활성 여부)·sort_order(표시 순서)를 가짐 |
|
| 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)·챌린지 유효시간(분)·최대 시도 횟수) |
|
| 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/public_asset/cache/session/queue 등) 선택 가능한 드라이버 목록을 id/다국어 label 형태로 제공. `public_asset` 은 공개 자산 직접 URL 서빙 디스크 선택지 (코어 none/public/s3 + 플러그인 훅 등록분) |
|
| 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 검증이 같은 값을 공유) |
|
| _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 등 — 권한 맵 기반) |
|
| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
@@ -510,7 +510,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -623,7 +623,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 500 | Internal Server Error | 백업 파일 생성에 실패한 경우 (`settings.backup_failed`) |
|
| 500 | Internal Server Error | 백업 파일 생성에 실패한 경우 (`settings.backup_failed`) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -672,7 +672,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 400 | Bad Request | 백업이 수행되지 않은 경우 (`settings.backup_failed`) |
|
| 400 | Bad Request | 백업이 수행되지 않은 경우 (`settings.backup_failed`) |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 500 | Internal Server Error | 백업 처리 중 예외가 발생한 경우 (`settings.backup_error`) |
|
| 500 | Internal Server Error | 백업 처리 중 예외가 발생한 경우 (`settings.backup_error`) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -721,7 +721,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 400 | Bad Request | 캐시 정리가 수행되지 않은 경우 (`settings.cache_clear_failed`) |
|
| 400 | Bad Request | 캐시 정리가 수행되지 않은 경우 (`settings.cache_clear_failed`) |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 500 | Internal Server Error | 캐시 정리 중 예외가 발생한 경우 (`settings.cache_clear_error`) |
|
| 500 | Internal Server Error | 캐시 정리 중 예외가 발생한 경우 (`settings.cache_clear_error`) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -782,7 +782,7 @@ _단건 응답: `data` 객체의 필드 (GeoIpDatabaseService::updateDatabase()
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 400 | Bad Request | MaxMind 라이선스 키가 설정되지 않은 경우 (`missing_license_key`) |
|
| 400 | Bad Request | MaxMind 라이선스 키가 설정되지 않은 경우 (`missing_license_key`) |
|
||||||
| 401 | Unauthorized | MaxMind 라이선스 키가 유효하지 않은 경우 (`unauthorized`) |
|
| 401 | Unauthorized | MaxMind 라이선스 키가 유효하지 않은 경우 (`unauthorized`) |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 500 | Internal Server Error | MaxMind 연결 실패(`connection_failed`) 또는 다운로드·압축 해제 실패 |
|
| 500 | Internal Server Error | MaxMind 연결 실패(`connection_failed`) 또는 다운로드·압축 해제 실패 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -836,7 +836,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -898,7 +898,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthorized | 본문 `password` 가 요청자 본인의 비밀번호와 일치하지 않는 경우 (`settings.invalid_password`) |
|
| 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` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | `.env` 기록/config 캐시 재생성 실패 (`settings.app_key_regenerate_failed`) |
|
| 500 | Internal Server Error | `.env` 기록/config 캐시 재생성 실패 (`settings.app_key_regenerate_failed`) |
|
||||||
|
|
||||||
@@ -957,7 +957,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 400 | Bad Request | 복원이 수행되지 않은 경우 (`settings.restore_failed`) |
|
| 400 | Bad Request | 복원이 수행되지 않은 경우 (`settings.restore_failed`) |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 백업 파일을 읽을 수 없는 등 복원 중 예외 발생 (`settings.restore_error`) |
|
| 500 | Internal Server Error | 백업 파일을 읽을 수 없는 등 복원 중 예외 발생 (`settings.restore_error`) |
|
||||||
|
|
||||||
@@ -1007,7 +1007,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| php_memory_limit | string | `512M` | PHP `memory_limit` ini 값 |
|
| php_memory_limit | string | `512M` | PHP `memory_limit` ini 값 |
|
||||||
| max_execution_time | string | `36000초` | PHP `max_execution_time` ini 값 (초 단위 접미사 부착) |
|
| max_execution_time | string | `36000초` | PHP `max_execution_time` ini 값 (초 단위 접미사 부착) |
|
||||||
| upload_max_filesize | string | `2G` | PHP `upload_max_filesize` ini 값 |
|
| upload_max_filesize | string | `2G` | PHP `upload_max_filesize` ini 값 |
|
||||||
| opcache | object | `{"loaded":true,"enabled":true}` | PHP OPcache 상태 — `loaded`(확장 로드 여부) / `enabled`(런타임 활성화 여부, `opcache.enable` 설정 기준) |
|
| 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()`) |
|
| install_path | string | `C:\Users\HeuJung\htdocs\g7_2` | 애플리케이션 설치 루트 경로 (`base_path()`) |
|
||||||
| config_path | string | `C:\Users\HeuJung\htdocs\g7_2\storage\…` | 설정 파일 저장 경로 (`storage/app/settings`) |
|
| 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`) |
|
| log_path | string | `C:\Users\HeuJung\htdocs\g7_2\storage\…` | 로그 파일 저장 경로 (`storage/logs`) |
|
||||||
@@ -1236,7 +1236,7 @@ _단건 응답: `data` 객체의 필드 (DriverConnectionTester::testAll() 산
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 테스트 실행 중 예외가 발생한 경우 (`settings.driver_test_error`) |
|
| 500 | Internal Server Error | 테스트 실행 중 예외가 발생한 경우 (`settings.driver_test_error`) |
|
||||||
|
|
||||||
@@ -1330,7 +1330,7 @@ _단건 응답: `data` 객체의 필드 (발송 성공 시에만 반환)._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 발송 실패 시 — `message` 는 `테스트 메일 발송에 실패했습니다.`, `error` 에 원본 예외 메시지(SMTP 인증 실패·연결 거부 등)가 담김 |
|
| 500 | Internal Server Error | 발송 실패 시 — `message` 는 `테스트 메일 발송에 실패했습니다.`, `error` 에 원본 예외 메시지(SMTP 인증 실패·연결 거부 등)가 담김 |
|
||||||
|
|
||||||
@@ -1449,7 +1449,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 400 | Bad Request | 저장이 수행되지 않은 경우 (`settings.update_failed`) |
|
| 400 | Bad Request | 저장이 수행되지 않은 경우 (`settings.update_failed`) |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 저장 중 예외가 발생한 경우 (`settings.update_error`) |
|
| 500 | Internal Server Error | 저장 중 예외가 발생한 경우 (`settings.update_error`) |
|
||||||
|
|||||||
@@ -37,19 +37,50 @@ Accept: application/json
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
|
이 엔드포인트는 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-200 — 응답 예시는 사람이 작성하세요. -->
|
```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';
|
||||||
|
```
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
|
_에러 응답이 정의되어 있지 않다. 이 라우트는 DB 에 접근하지 않으므로 설치 전에도 응답하며, 도달하기만 하면 항상 `200` 이다. 도달하지 못해 `404`/`5xx` 가 관측되면 그것은 에러가 아니라 **판정 입력**이다 (아래 설명 참조)._
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
**설명**
|
||||||
|
|
||||||
|
서버(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
|
### GET /api/system/asset-probe.js
|
||||||
@@ -72,17 +103,49 @@ Accept: application/json
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
확장자 없는 대조군(`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` | 성공 판정용. 상태코드가 아니라 **이 토큰의 존재**로 판정한다 |
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
|
애플리케이션까지 도달했을 때 (`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 가 된다.
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
|
_에러 응답이 정의되어 있지 않다. 이 라우트는 DB 에 접근하지 않으므로 애플리케이션에 도달하면 항상 `200` 이다. `404`/`5xx` 는 에러가 아니라 **판정 입력**이며, 대조군이 성공했다면 `extensionless` 모드로 확정한다._
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
**설명**
|
||||||
|
|
||||||
|
자산 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()` 로 확장자 형태와 확장자 없는 형태를 동시에 등록한다.
|
||||||
|
|
||||||
|
|||||||
+286
-59
@@ -293,7 +293,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.activate`)이 없는 경우 |
|
||||||
| 409 | Conflict | 필요한 의존 모듈/플러그인이 충족되지 않은 경우 (`errors` 에 `warning`, `missing_modules`, `missing_plugins`, `message`) — `force=true` 로 우회 가능 |
|
| 409 | Conflict | 필요한 의존 모듈/플러그인이 충족되지 않은 경우 (`errors` 에 `warning`, `missing_modules`, `missing_plugins`, `message`) — `force=true` 로 우회 가능 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 활성화 처리 실패 (이미 활성 상태·미설치·코어 버전 비호환 등) |
|
| 500 | Server Error | 활성화 처리 실패 (이미 활성 상태·미설치·코어 버전 비호환 등) |
|
||||||
@@ -360,7 +360,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 업데이트 확인 처리 실패 (GitHub API 호출 실패 등) |
|
| 500 | Server Error | 업데이트 확인 처리 실패 (GitHub API 호출 실패 등) |
|
||||||
|
|
||||||
@@ -470,7 +470,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource)._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.activate`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 비활성화 처리 실패 (템플릿 미존재 등) |
|
| 500 | Server Error | 비활성화 처리 실패 (템플릿 미존재 등) |
|
||||||
|
|
||||||
@@ -590,7 +590,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource + cascade 결과)._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 설치 실패 (이미 설치됨·manifest 오류·cascade 의존 확장 설치 실패 등 — `errors` 에 번역된 사유) |
|
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 설치 실패 (이미 설치됨·manifest 오류·cascade 의존 확장 설치 실패 등 — `errors` 에 번역된 사유) |
|
||||||
| 500 | Server Error | 설치 처리 중 예기치 못한 오류 |
|
| 500 | Server Error | 설치 처리 중 예기치 못한 오류 |
|
||||||
|
|
||||||
@@ -703,7 +703,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource — 목록 응답 항
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 파일 검증 위반 또는 ZIP 처리 실패 (template.json 누락/무효, 이미 설치된 식별자, 잘못된 디렉토리명 등) |
|
| 422 | Unprocessable Entity | 파일 검증 위반 또는 ZIP 처리 실패 (template.json 누락/무효, 이미 설치된 식별자, 잘못된 디렉토리명 등) |
|
||||||
| 500 | Server Error | 설치 처리 중 예기치 못한 오류 |
|
| 500 | Server Error | 설치 처리 중 예기치 못한 오류 |
|
||||||
|
|
||||||
@@ -813,7 +813,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource — 목록 응답 항
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | URL 검증 위반 또는 설치 실패 (유효하지 않은 GitHub URL, 저장소 없음, 다운로드 실패, 이미 설치된 식별자 등) |
|
| 422 | Unprocessable Entity | URL 검증 위반 또는 설치 실패 (유효하지 않은 GitHub URL, 저장소 없음, 다운로드 실패, 이미 설치된 식별자 등) |
|
||||||
| 500 | Server Error | 설치 처리 중 예기치 못한 오류 |
|
| 500 | Server Error | 설치 처리 중 예기치 못한 오류 |
|
||||||
|
|
||||||
@@ -862,7 +862,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`,
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 첨부 파일 삭제 실패 (스토리지/DB 삭제 실패 — `첨부 파일 삭제에 실패했습니다.`) |
|
| 500 | Server Error | 첨부 파일 삭제 실패 (스토리지/DB 삭제 실패 — `첨부 파일 삭제에 실패했습니다.`) |
|
||||||
@@ -944,7 +944,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 파일 검증 위반 또는 ZIP 열기 실패 (`manifest 미리보기에 실패했습니다.` + `errors.error`) |
|
| 422 | Unprocessable Entity | 파일 검증 위반 또는 ZIP 열기 실패 (`manifest 미리보기에 실패했습니다.` + `errors.error`) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
@@ -1053,7 +1053,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource — 갱신 후 템플
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.activate`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 레이아웃 갱신 실패 (레이아웃 JSON 무효, layout_name 누락 등) |
|
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 레이아웃 갱신 실패 (레이아웃 JSON 무효, layout_name 누락 등) |
|
||||||
| 500 | Server Error | 레이아웃 갱신 처리 중 예기치 못한 오류 |
|
| 500 | Server Error | 레이아웃 갱신 처리 중 예기치 못한 오류 |
|
||||||
|
|
||||||
@@ -1105,7 +1105,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`,
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.uninstall`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 제거 실패 (활성 상태·파일 삭제 실패 등 — `errors.identifier` 에 번역된 사유) |
|
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 제거 실패 (활성 상태·파일 삭제 실패 등 — `errors.identifier` 에 번역된 사유) |
|
||||||
| 500 | Server Error | 제거 처리 중 예기치 못한 오류 |
|
| 500 | Server Error | 제거 처리 중 예기치 못한 오류 |
|
||||||
|
|
||||||
@@ -1319,7 +1319,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1426,18 +1426,41 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
_`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-404 — 응답 예시는 사람이 작성하세요. -->
|
```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 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1517,18 +1540,31 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
_이 엔드포인트는 표준 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-404 — 응답 예시는 사람이 작성하세요. -->
|
```http
|
||||||
|
HTTP/1.1 200
|
||||||
|
Content-Type: text/css; charset=UTF-8
|
||||||
|
|
||||||
|
.g7-card{border-radius:.5rem}
|
||||||
|
```
|
||||||
|
|
||||||
|
> CSS 가 없는 템플릿도 **빈 본문 200** 으로 응답한다 — 편집기 부팅이 실패하지 않게 하기 위한 폴백이며 404 가 아니다.
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1626,18 +1662,36 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
_`data` 는 템플릿의 `components.json` 내용을 그대로 담은 **컴포넌트 맵**이다 (고정 필드 집합이 아니라 컴포넌트명 → 정의 매핑)._
|
||||||
|
|
||||||
|
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| (컴포넌트명) | object | `{"type":"basic","tag":"div"}` | 컴포넌트 정의. 키는 레이아웃 JSON 의 `name` 과 일치한다 |
|
||||||
|
|
||||||
|
> 활성/`_bundled` 어디에도 `components.json` 이 없으면 `404`(`templates.layout_not_found`)다.
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
|
```http
|
||||||
|
HTTP/1.1 200
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "설정을 조회했습니다.",
|
||||||
|
"data": {
|
||||||
|
"Card": { "type": "composite", "props": { "title": "string" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1736,11 +1790,32 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
_`data` 객체의 필드 (`EditorSpecAssembler::assemble`)._
|
||||||
|
|
||||||
|
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| identifier | string | `sirsoft-admin_basic` | 요청한 템플릿 식별자 (요청값 반향) |
|
||||||
|
| spec | object \| null | `{"palette":[…],"styleControls":{…}}` | 합본된 편집기 스펙. 분할 매니페스트(`editor-spec/` + `$include`)는 **활성 디렉토리 기준**으로 합쳐지며(`_bundled` 폴백 없음), 미분할 원본 파일은 그대로 반환된다. 스펙이 없으면 `null` (404 아님) |
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: http-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 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -93612,7 +93687,7 @@ _단건 응답: `data` 는 템플릿 `lang/{locale}.json` 의 내용을 그대
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -93726,18 +93801,38 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
_`data` 객체의 필드._
|
||||||
|
|
||||||
|
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| identifier | string | `sirsoft-admin_basic` | 요청한 템플릿 식별자 (요청값 반향) |
|
||||||
|
| permissions | array | `[{"identifier":"core.users.read","name":"사용자 조회"}]` | 레이아웃 조건식에 쓸 수 있는 권한 후보 목록 (현재 로케일로 해석된 표시명 포함) |
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
|
```http
|
||||||
|
HTTP/1.1 200
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "설정을 조회했습니다.",
|
||||||
|
"data": {
|
||||||
|
"identifier": "sirsoft-admin_basic",
|
||||||
|
"permissions": [
|
||||||
|
{ "identifier": "core.users.read", "name": "사용자 조회" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -93929,18 +94024,45 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
_`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-404 — 응답 예시는 사람이 작성하세요. -->
|
```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 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -94025,7 +94147,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -94045,8 +94167,8 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| identifier | path | string | 예 | — | 대상 리소스의 식별자 |
|
| identifier | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||||
| extensions | query | string | 아니오 | — | <!-- TODO: 용도 --> |
|
| extensions | query | string | 아니오 | JSON 배열 문자열 | 후보를 수집할 확장 선언. `[{"type":"module","id":"sirsoft-board"}]` 형태의 JSON 문자열이며, `type`·`id` 가 모두 문자열인 항목만 채택된다(그 외는 조용히 무시). 편집 중인 레이아웃이 아직 활성화하지 않은 확장의 후보까지 보려 할 때 쓴다 |
|
||||||
| page_type | query | string | 아니오 | — | <!-- TODO: 용도 --> |
|
| page_type | query | string | 아니오 | — | 치환 변수(`vars`) 후보를 좁힐 페이지 유형. 빈 문자열은 미지정과 같게 처리된다 |
|
||||||
|
|
||||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.seo_candidate.index_validation_rules`).
|
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.seo_candidate.index_validation_rules`).
|
||||||
|
|
||||||
@@ -94307,18 +94429,42 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
_`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-404 — 응답 예시는 사람이 작성하세요. -->
|
```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 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -94461,7 +94607,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -94531,7 +94677,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -94612,7 +94758,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 (`템플릿을 찾을 수 없습니다.`) |
|
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 (`템플릿을 찾을 수 없습니다.`) |
|
||||||
| 422 | Unprocessable Entity | 파일 검증 위반 (이미지 아님, 지원하지 않는 형식 jpg/jpeg/png/gif/webp/svg 외, 크기 초과 등) |
|
| 422 | Unprocessable Entity | 파일 검증 위반 (이미지 아님, 지원하지 않는 형식 jpg/jpeg/png/gif/webp/svg 외, 크기 초과 등) |
|
||||||
| 500 | Server Error | 스토리지 저장 실패 (`첨부 파일 업로드에 실패했습니다.`) |
|
| 500 | Server Error | 스토리지 저장 실패 (`첨부 파일 업로드에 실패했습니다.`) |
|
||||||
@@ -94924,7 +95070,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
|
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`ids` 누락 또는 빈 배열 등) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`ids` 누락 또는 빈 배열 등) |
|
||||||
| 500 | Server Error | 삭제 트랜잭션 실패 |
|
| 500 | Server Error | 삭제 트랜잭션 실패 |
|
||||||
@@ -95005,7 +95151,7 @@ _목록 응답: `data` 는 커스텀 다국어 키 배열입니다 (페이지네
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
|
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -95090,7 +95236,7 @@ _단건 응답: `data` 객체의 필드 (생성된 커스텀 다국어 키, HTTP
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
|
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 키 생성 트랜잭션 실패 |
|
| 500 | Server Error | 키 생성 트랜잭션 실패 |
|
||||||
@@ -95141,7 +95287,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`,
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 대상 템플릿이 없거나, 해당 키가 없거나, 그 키가 경로의 템플릿 소속이 아닌 경우 (교차 템플릿 삭제 차단) |
|
| 404 | Not Found | 대상 템플릿이 없거나, 해당 키가 없거나, 그 키가 경로의 템플릿 소속이 아닌 경우 (교차 템플릿 삭제 차단) |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 삭제 트랜잭션 실패 |
|
| 500 | Server Error | 삭제 트랜잭션 실패 |
|
||||||
@@ -95229,7 +95375,7 @@ _단건 응답: `data` 객체의 필드 (수정된 커스텀 다국어 키)._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 대상 템플릿이 없거나, 해당 키가 없거나, 그 키가 경로의 템플릿 소속이 아닌 경우 |
|
| 404 | Not Found | 대상 템플릿이 없거나, 해당 키가 없거나, 그 키가 경로의 템플릿 소속이 아닌 경우 |
|
||||||
| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) |
|
| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
@@ -95336,7 +95482,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 대상 템플릿을 찾을 수 없거나 프리뷰 구성 실패 |
|
| 500 | Server Error | 대상 템플릿을 찾을 수 없거나 프리뷰 구성 실패 |
|
||||||
@@ -95667,7 +95813,7 @@ _단건 응답: `data` 객체의 필드 (수정된 LayoutExtensionResource)._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 템플릿/확장이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 |
|
| 404 | Not Found | 템플릿/확장이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 |
|
||||||
| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) |
|
| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
@@ -95899,7 +96045,7 @@ _단건 응답: `data` 객체의 필드 (복원 결과로 새로 기록된 버
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 템플릿/확장/버전이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 |
|
| 404 | Not Found | 템플릿/확장/버전이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 복원 트랜잭션 실패 |
|
| 500 | Server Error | 복원 트랜잭션 실패 |
|
||||||
@@ -96441,7 +96587,7 @@ _단건 응답: `data` 객체의 필드 (수정된 LayoutResource)._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
|
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
|
||||||
| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) |
|
| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) |
|
||||||
| 422 | Unprocessable Entity | content 구조 검증 위반 (레이아웃 구조/슬롯/데이터소스 병합/엔드포인트 화이트리스트/외부 URL 차단/권한 구조 규칙) |
|
| 422 | Unprocessable Entity | content 구조 검증 위반 (레이아웃 구조/슬롯/데이터소스 병합/엔드포인트 화이트리스트/외부 URL 차단/권한 구조 규칙) |
|
||||||
@@ -96678,7 +96824,7 @@ _단건 응답: `data` 객체의 필드 (복원 결과로 새로 기록된 버
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
|
||||||
| 404 | Not Found | 대상 템플릿·레이아웃·버전이 없는 경우 |
|
| 404 | Not Found | 대상 템플릿·레이아웃·버전이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 복원 트랜잭션 실패 |
|
| 500 | Server Error | 복원 트랜잭션 실패 |
|
||||||
@@ -96828,7 +96974,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.uninstall`)이 없는 경우 |
|
||||||
| 404 | Not Found | 해당 식별자의 템플릿이 없는 경우 (`템플릿을 찾을 수 없습니다.`) |
|
| 404 | Not Found | 해당 식별자의 템플릿이 없는 경우 (`템플릿을 찾을 수 없습니다.`) |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Server Error | 삭제 정보 조회 실패 |
|
| 500 | Server Error | 삭제 정보 조회 실패 |
|
||||||
@@ -96944,7 +97090,7 @@ _업데이트할 내용이 없거나 템플릿 정보를 다시 읽지 못한
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 업데이트 실패 (미설치, 다운그레이드 차단, 다운로드 실패, 코어 버전 비호환 등 — `errors.template_name` 에 번역된 사유) |
|
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 업데이트 실패 (미설치, 다운그레이드 차단, 다운로드 실패, 코어 버전 비호환 등 — `errors.template_name` 에 번역된 사유) |
|
||||||
| 500 | Server Error | 업데이트 처리 중 예기치 못한 오류 |
|
| 500 | Server Error | 업데이트 처리 중 예기치 못한 오류 |
|
||||||
@@ -96977,11 +97123,24 @@ Accept: application/json
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
_이 엔드포인트는 표준 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-422 — 응답 예시는 사람이 작성하세요. -->
|
```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-404 — 응답 예시는 사람이 작성하세요. -->
|
```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` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
_`data` 는 템플릿의 `template.json` 매니페스트 내용이다 (고정 필드 집합이 아니라 매니페스트 그대로)._
|
||||||
|
|
||||||
|
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| identifier | string | `sirsoft-basic` | 템플릿 식별자 |
|
||||||
|
| version | string | `1.1.1` | 템플릿 버전 |
|
||||||
|
| type | string | `user` | 템플릿 유형 (`admin`/`user`) |
|
||||||
|
| (그 외 매니페스트 키) | mixed | — | `template.json` 이 선언한 나머지 키가 그대로 실린다 |
|
||||||
|
|
||||||
|
> **활성 템플릿만** 조회된다 — 비활성이거나 매니페스트가 없으면 `404`. 응답은 1시간 캐시된다.
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
|
```http
|
||||||
|
HTTP/1.1 200
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "설정을 조회했습니다.",
|
||||||
|
"data": {
|
||||||
|
"identifier": "sirsoft-basic",
|
||||||
|
"name": "Sirsoft Basic",
|
||||||
|
"version": "1.1.1",
|
||||||
|
"type": "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
@@ -189529,11 +189730,37 @@ Accept: application/json
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
_`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-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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ HTTP/1.1 201
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.users.create`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 사용자 생성 중 예외 발생 (`user.create_failed`, `errors.error` 에 예외 메시지) |
|
| 500 | Internal Server Error | 사용자 생성 중 예외 발생 (`user.create_failed`, `errors.error` 에 예외 메시지) |
|
||||||
|
|
||||||
@@ -456,7 +456,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.users.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 예: 요청자 본인 UUID 포함 시 `ExcludeCurrentUser` 위반) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 예: 요청자 본인 UUID 포함 시 `ExcludeCurrentUser` 위반) |
|
||||||
| 500 | Internal Server Error | 일괄 변경 중 예외 발생 (`user.bulk_update_status_failed`) |
|
| 500 | Internal Server Error | 일괄 변경 중 예외 발생 (`user.bulk_update_status_failed`) |
|
||||||
|
|
||||||
@@ -1018,17 +1018,19 @@ HTTP/1.1 200
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "사용자가 삭제되었습니다.",
|
"message": "사용자가 성공적으로 삭제되었습니다.",
|
||||||
"data": null
|
"data": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 슈퍼 관리자 계정을 대상으로 하면 삭제되지 않고 `422`(`exceptions.cannot_delete_super_admin`)로 거부된다. 삭제는 CASCADE 에 의존하지 않고 연관 데이터를 명시적으로 정리한 뒤 수행되며, 정리 단계에서 실패하면 `422` 와 함께 `error.errors.general[0]` 에 상세 사유가 담긴다.
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.users.delete`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
|
|
||||||
@@ -1111,7 +1113,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| withdrawn_at | null | `null` | withdrawn 일시 |
|
| withdrawn_at | null | `null` | withdrawn 일시 |
|
||||||
| blocked_at | null | `null` | blocked 일시 |
|
| blocked_at | null | `null` | blocked 일시 |
|
||||||
| failed_login_attempts | integer | `0` | 연속 로그인 실패 횟수 |
|
| failed_login_attempts | integer | `0` | 연속 로그인 실패 횟수 |
|
||||||
| locked_permanently | boolean | `false` | 무기한 잠금 여부 (보안 설정의 잠금 시간이 `0` 이면 자동 해제 없이 관리자가 직접 풀어야 한다) |
|
| locked_permanently | boolean | `false` | 영구 잠금 여부. true 면 `locked_until` 과 무관하게 잠금이 유지되며, 해제는 성공 로그인 또는 관리자의 잠금 해제로만 이뤄진다 (잠금 시간 설정이 `0`= 무기한일 때 세워진다) |
|
||||||
| locked_until | null | `null` | 계정 잠금 해제 시각 (NULL = 잠금 없음) |
|
| locked_until | null | `null` | 계정 잠금 해제 시각 (NULL = 잠금 없음) |
|
||||||
| is_locked | boolean | `false` | locked 여부 |
|
| is_locked | boolean | `false` | locked 여부 |
|
||||||
| notify_post_complete | boolean | `false` | 게시글 작성 완료 알림 수신 여부 (게시판 모듈 알림 설정) |
|
| notify_post_complete | boolean | `false` | 게시글 작성 완료 알림 수신 여부 (게시판 모듈 알림 설정) |
|
||||||
@@ -1446,7 +1448,7 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`core.users.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 마지막 관리자 본인의 admin 역할 제거 시도 시 `user.last_admin_role_cannot_remove` 포함) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 마지막 관리자 본인의 admin 역할 제거 시도 시 `user.last_admin_role_cannot_remove` 포함) |
|
||||||
| 500 | Internal Server Error | 수정 중 예외 발생 (`user.update_failed`, `errors.error` 에 예외 메시지) |
|
| 500 | Internal Server Error | 수정 중 예외 발생 (`user.update_failed`, `errors.error` 에 예외 메시지) |
|
||||||
|
|||||||
@@ -433,41 +433,57 @@ sirsoft-ecommerce.product.after_update
|
|||||||
sirsoft-ecommerce.product.filter_create_data
|
sirsoft-ecommerce.product.filter_create_data
|
||||||
```
|
```
|
||||||
|
|
||||||
### 서비스 내부 조건부 권한 체크
|
### 서비스 내부 권한 상한(ceiling) 체크
|
||||||
|
|
||||||
미들웨어가 아닌 **서비스 내부**에서 추가적인 권한 체크가 필요한 경우 `PermissionHelper`를 사용합니다.
|
데이터의 일부 필드가 **권한 상승 벡터**일 때(대표적으로 역할 부여 — 역할을 붙이면 그 역할의 전
|
||||||
대표적인 예: 역할(role) 변경처럼 데이터의 일부 필드만 별도 권한이 필요한 경우.
|
권한을 넘기는 것과 같다), 서비스는 미들웨어 권한과 별개로 **상한 가드**를 적용한다.
|
||||||
|
|
||||||
|
핵심 원칙 세 가지:
|
||||||
|
|
||||||
|
1. **게이트는 그 엔드포인트의 라우트 권한(SSoT)과 같은 리소스여야 한다.** 역할 부여는 "사용자
|
||||||
|
관리"(`core.users.update` — 이 경로는 라우트에서 이미 강제됨)의 일부이며, "역할 정의 수정"
|
||||||
|
(`core.permissions.update` — 역할에 권한을 가감하는 **타 리소스** 권한)을 요구하지 않는다. 연관/타
|
||||||
|
리소스 권한으로 원 리소스 조작을 게이팅하면, 원 리소스 권한을 가진 액터가 정당한 작업을 못 한다.
|
||||||
|
2. **권한 상승 방지는 상한 가드(`PermissionEscalationGuard`)가 담당한다.** 액터가 보유하지 않았거나
|
||||||
|
자신의 범위보다 넓은 권한을 담은 역할은 부여할 수 없다.
|
||||||
|
3. **위반은 명시적으로 거부(403)한다 — 조용히 무시하지 않는다.** 상한 위반 시
|
||||||
|
`PermissionEscalationException` 이 전파되어 컨트롤러가 403 으로 매핑한다.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use App\Helpers\PermissionHelper;
|
use App\Support\PermissionEscalationGuard;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PermissionEscalationGuard $escalationGuard,
|
||||||
|
// ...
|
||||||
|
) {}
|
||||||
|
|
||||||
public function updateUser(User $user, array $data): User
|
public function updateUser(User $user, array $data): User
|
||||||
{
|
{
|
||||||
$roleIds = $data['role_ids'] ?? null;
|
$roleIds = $data['role_ids'] ?? null;
|
||||||
unset($data['role_ids'], $data['roles']);
|
unset($data['role_ids'], $data['roles']);
|
||||||
|
|
||||||
// 훅 실행 (생략)...
|
// 훅 실행 / 필드 업데이트 (생략)...
|
||||||
|
|
||||||
$user = $this->userRepository->update($user->id, $data);
|
|
||||||
|
|
||||||
// 역할 변경은 별도 권한으로 보호
|
|
||||||
if ($roleIds !== null) {
|
if ($roleIds !== null) {
|
||||||
$authUser = Auth::user();
|
$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 권한 없으면 역할 변경 무시
|
// 자기잠금 방지: 마지막 admin 이 자기 admin 역할을 떼는 것만 별도 차단
|
||||||
if ($roleIds !== null && ! PermissionHelper::check('core.permissions.update', $authUser)) {
|
// (자기 자신 수정 자체를 막지는 않는다)
|
||||||
$roleIds = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($roleIds !== null) {
|
$user->roles()->sync($roleIds);
|
||||||
$user->roles()->sync($roleIds);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
@@ -475,11 +491,16 @@ public function updateUser(User $user, array $data): User
|
|||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
필수: 역할/권한 변경은 미들웨어 권한과 별개로 서비스에서 명시적 체크 필수
|
필수: 상승 벡터 필드의 게이트는 그 엔드포인트 라우트 권한(SSoT)과 같은 리소스 prefix 여야 함
|
||||||
필수: 자기 자신의 역할 변경은 항상 불가 (관리자 실수 방지)
|
(연관/타 리소스 권한이 원 리소스 조작을 침범 금지)
|
||||||
패턴: 민감 필드 분리 → 별도 권한 체크 → 권한 없으면 해당 필드 무시 (403이 아닌 무시)
|
필수: 권한 상승 방지는 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) 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 설정 기반 한계값
|
## 설정 기반 한계값
|
||||||
|
|
||||||
설정값으로 정해지는 한계(최대 깊이, 최대 개수 등)의 **검증 책임은 검증 계층 단일**이다.
|
설정값으로 정해지는 한계(최대 깊이, 최대 개수 등)의 **검증 책임은 검증 계층 단일**이다.
|
||||||
|
|||||||
@@ -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 개발 체크리스트
|
## Custom Rule 개발 체크리스트
|
||||||
|
|
||||||
- [ ] `/lang/ko/validation.php`에 한국어 메시지 추가
|
- [ ] `/lang/ko/validation.php`에 한국어 메시지 추가
|
||||||
|
|||||||
@@ -123,6 +123,28 @@
|
|||||||
| `dependencies` | `object` | 선택 | 모듈/플러그인 의존성 |
|
| `dependencies` | `object` | 선택 | 모듈/플러그인 의존성 |
|
||||||
| `github_url` | `string\|null` | 선택 | GitHub 저장소 URL (업데이트 감지용) |
|
| `github_url` | `string\|null` | 선택 | GitHub 저장소 URL (업데이트 감지용) |
|
||||||
| `github_changelog_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`)도 동일 필드를 지원합니다.
|
||||||
|
|
||||||
#### 에셋 필드
|
#### 에셋 필드
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
- [레이아웃 JSON 서버 검증](#레이아웃-json-서버-검증)
|
- [레이아웃 JSON 서버 검증](#레이아웃-json-서버-검증)
|
||||||
- [XSS 방지](#xss-방지)
|
- [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--외부-스크립트-신뢰-호스트) 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 인증/토큰 프론트엔드 보안
|
## 인증/토큰 프론트엔드 보안
|
||||||
|
|
||||||
### 인증 원칙
|
### 인증 원칙
|
||||||
|
|||||||
@@ -341,6 +341,28 @@ TypeScript 테스트는 `// @scenario` / `// @effects` 주석 동일 사용.
|
|||||||
|
|
||||||
정적 검사가 매니페스트 cross product 와 effects 항목을 테스트 docblock 마킹과 대조하여 누락을 검출한다 (자동 차단).
|
정적 검사가 매니페스트 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 폭발 관리
|
||||||
|
|
||||||
축이 많아 cross product 가 비현실적으로 커질 때:
|
축이 많아 cross product 가 비현실적으로 커질 때:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
## [1.0.6] - 2026-08-12
|
## [1.0.6] - 2026-08-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
- 웹소켓 서버(백엔드 발송용) endpoint 연결 실패 안내 일본어 번역 추가 (`settings.websocket_server_test_failed`).
|
- 웹소켓 서버(백엔드 발송용) endpoint 연결 실패 안내 일본어 번역 추가 (`settings.websocket_server_test_failed`).
|
||||||
- 공개 자산 스토리지(공개 이미지 직접 URL 서빙) 설정의 드라이버 라벨과 검증 메시지 일본어 번역을 추가했습니다. 환경설정 > 드라이버 탭의 새 설정이 일본어 로케일에서 자연스럽게 표시됩니다.
|
- 공개 자산 스토리지(공개 이미지 직접 URL 서빙) 설정의 드라이버 라벨과 검증 메시지 일본어 번역을 추가했습니다. 환경설정 > 드라이버 탭의 새 설정이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||||
- 설정 항목 단위 저장의 값 형식 안내 일본어 번역을 추가했습니다 (`validation.setting.value.*`) — 켜기/끄기·숫자 설정에 맞지 않는 값을 저장할 때의 안내가 일본어 로케일에서 표시됩니다.
|
- 설정 항목 단위 저장의 값 형식 안내 일본어 번역을 추가했습니다 (`validation.setting.value.*`) — 켜기/끄기·숫자 설정에 맞지 않는 값을 저장할 때의 안내가 일본어 로케일에서 표시됩니다.
|
||||||
|
- 슈퍼 관리자 계정·역할 수정 상한 및 권한 부여 상한 위반 시 표시되는 예외 메시지(`cannot_modify_super_admin`, `cannot_grant_unheld_permission`, `cannot_modify_protected_role`)의 일본어 번역을 추가했습니다.
|
||||||
|
- 레이아웃 저장 시 위험 표현식·외부 리소스 URL 거부 안내(`layout.dangerous_expression`, `layout.external_resource_url`)의 일본어 번역을 추가했습니다.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'cannot_delete_super_admin' => 'スーパー管理者は削除できません。',
|
'cannot_delete_super_admin' => 'スーパー管理者は削除できません。',
|
||||||
|
'cannot_modify_super_admin' => 'スーパー管理者のアカウントまたはロールを変更する権限がありません。',
|
||||||
|
'cannot_grant_unheld_permission' => '自身が保有していない権限、または自身より広い範囲の権限は付与できません。',
|
||||||
|
'cannot_modify_protected_role' => 'システムまたは拡張機能が所有するロールを変更する権限がありません。',
|
||||||
'circular_reference' => 'レイアウト循環参照を検出しました: :trace',
|
'circular_reference' => 'レイアウト循環参照を検出しました: :trace',
|
||||||
'max_depth_exceeded' => 'レイアウトネストの深さが最大許容深度(:max)を超過しました。',
|
'max_depth_exceeded' => 'レイアウトネストの深さが最大許容深度(:max)を超過しました。',
|
||||||
'template_file_copy_failed' => 'テンプレートファイルのコピーに失敗しました: :source → :destination',
|
'template_file_copy_failed' => 'テンプレートファイルのコピーに失敗しました: :source → :destination',
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ return [
|
|||||||
],
|
],
|
||||||
'invalid_json' => '無効な JSON 形式です。',
|
'invalid_json' => '無効な JSON 形式です。',
|
||||||
'must_be_array' => 'レイアウトデータは配列である必要があります。',
|
'must_be_array' => 'レイアウトデータは配列である必要があります。',
|
||||||
|
'dangerous_expression' => '許可されていない式が含まれています: :snippet',
|
||||||
|
'external_resource_url' => '外部リソース URL は許可されていません(同一オリジンのパスのみ): :url',
|
||||||
'required_field_missing' => '必須フィールド \':field\' がありません。',
|
'required_field_missing' => '必須フィールド \':field\' がありません。',
|
||||||
'version_must_be_string' => 'version フィールドは文字列である必要があります。',
|
'version_must_be_string' => 'version フィールドは文字列である必要があります。',
|
||||||
'layout_name_must_be_string' => 'layout_name フィールドは文字列である必要があります。',
|
'layout_name_must_be_string' => 'layout_name フィールドは文字列である必要があります。',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 게시판 유형 삭제 등 관리 작업이 서버 오류로 실패했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
|
- 게시판 유형 삭제 등 관리 작업이 서버 오류로 실패했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
|
||||||
|
- 비공개(비밀) 게시글 목록 마스킹 시 표시되는 제목 플레이스홀더(`secret_post_title`)의 일본어 번역을 추가했습니다.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ return [
|
|||||||
'secret_password_required' => '非公開投稿のパスワードが必要です。',
|
'secret_password_required' => '非公開投稿のパスワードが必要です。',
|
||||||
'secret_password_incorrect' => '非公開投稿のパスワードが一致しません。',
|
'secret_password_incorrect' => '非公開投稿のパスワードが一致しません。',
|
||||||
'secret_post_content' => '非公開投稿です。内容を表示するにはパスワードを入力してください。',
|
'secret_post_content' => '非公開投稿です。内容を表示するにはパスワードを入力してください。',
|
||||||
|
'secret_post_title' => '非公開投稿',
|
||||||
'deleted_post_title' => '削除された投稿',
|
'deleted_post_title' => '削除された投稿',
|
||||||
'deleted_post_content' => '削除された投稿です。',
|
'deleted_post_content' => '削除された投稿です。',
|
||||||
'blinded_post_content' => '管理者によってブロック処理された投稿です。',
|
'blinded_post_content' => '管理者によってブロック処理された投稿です。',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
return [
|
return [
|
||||||
// User related exceptions
|
// User related exceptions
|
||||||
'cannot_delete_super_admin' => 'Super admin cannot be deleted.',
|
'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',
|
'circular_reference' => 'Layout circular reference detected: :trace',
|
||||||
'max_depth_exceeded' => 'Layout nesting depth exceeds maximum allowed depth (:max).',
|
'max_depth_exceeded' => 'Layout nesting depth exceeds maximum allowed depth (:max).',
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ return [
|
|||||||
|
|
||||||
'invalid_json' => 'Invalid JSON format.',
|
'invalid_json' => 'Invalid JSON format.',
|
||||||
'must_be_array' => 'Layout data must be an array.',
|
'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.",
|
'required_field_missing' => "Required field ':field' is missing.",
|
||||||
'version_must_be_string' => 'The version field must be a string.',
|
'version_must_be_string' => 'The version field must be a string.',
|
||||||
'layout_name_must_be_string' => 'The layout_name field must be a string.',
|
'layout_name_must_be_string' => 'The layout_name field must be a string.',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
return [
|
return [
|
||||||
// 사용자 관련 예외
|
// 사용자 관련 예외
|
||||||
'cannot_delete_super_admin' => '슈퍼 관리자는 삭제할 수 없습니다.',
|
'cannot_delete_super_admin' => '슈퍼 관리자는 삭제할 수 없습니다.',
|
||||||
|
'cannot_modify_super_admin' => '슈퍼 관리자 계정 또는 역할은 수정할 권한이 없습니다.',
|
||||||
|
'cannot_grant_unheld_permission' => '본인이 보유하지 않았거나 더 넓은 범위의 권한은 부여할 수 없습니다.',
|
||||||
|
'cannot_modify_protected_role' => '시스템 또는 확장이 소유한 역할은 수정할 권한이 없습니다.',
|
||||||
|
|
||||||
'circular_reference' => '레이아웃 순환 참조 감지: :trace',
|
'circular_reference' => '레이아웃 순환 참조 감지: :trace',
|
||||||
'max_depth_exceeded' => '레이아웃 중첩 깊이가 최대 허용 깊이(:max)를 초과했습니다.',
|
'max_depth_exceeded' => '레이아웃 중첩 깊이가 최대 허용 깊이(:max)를 초과했습니다.',
|
||||||
|
|||||||
@@ -210,6 +210,8 @@ return [
|
|||||||
|
|
||||||
'invalid_json' => '유효하지 않은 JSON 형식입니다.',
|
'invalid_json' => '유효하지 않은 JSON 형식입니다.',
|
||||||
'must_be_array' => '레이아웃 데이터는 배열이어야 합니다.',
|
'must_be_array' => '레이아웃 데이터는 배열이어야 합니다.',
|
||||||
|
'dangerous_expression' => '허용되지 않는 표현식이 포함되어 있습니다: :snippet',
|
||||||
|
'external_resource_url' => '외부 리소스 URL은 허용되지 않습니다(동일 출처 경로만 허용): :url',
|
||||||
'required_field_missing' => "필수 필드 ':field'가 누락되었습니다.",
|
'required_field_missing' => "필수 필드 ':field'가 누락되었습니다.",
|
||||||
'version_must_be_string' => 'version 필드는 문자열이어야 합니다.',
|
'version_must_be_string' => 'version 필드는 문자열이어야 합니다.',
|
||||||
'layout_name_must_be_string' => 'layout_name 필드는 문자열이어야 합니다.',
|
'layout_name_must_be_string' => 'layout_name 필드는 문자열이어야 합니다.',
|
||||||
|
|||||||
@@ -41,11 +41,73 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-403 — 응답 필드는 사람이 작성하세요. -->
|
_목록 응답: `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-403 — 응답 예시는 사람이 작성하세요. -->
|
```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 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.read`)이 없는 경우 |
|
| 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` 에 예외 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
**설명**
|
||||||
|
|
||||||
|
메모 목록을 페이지 단위로 조회한다. 학습용 샘플 모듈의 표준 목록 엔드포인트로, 코어의 `AdminBaseController` + `BaseApiCollection` 조합을 그대로 따른다.
|
||||||
|
|
||||||
|
`per_page` 를 지정하지 않으면 기본값 `10` 이 적용된다. 상·하한(1~100)은 `MemoListRequest` 가 검증하므로 Service 는 검증 없이 값을 그대로 쓴다 — 검증은 FormRequest 책임이라는 규칙의 예시다.
|
||||||
|
|
||||||
|
|
||||||
### POST /api/modules/gnuboard7-hello_module/admin/memos
|
### POST /api/modules/gnuboard7-hello_module/admin/memos
|
||||||
@@ -90,11 +157,45 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: http-403 — 응답 필드는 사람이 작성하세요. -->
|
_단건 응답: `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-403 — 응답 예시는 사람이 작성하세요. -->
|
```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 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.create`)이 없는 경우 |
|
| 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` 에 예외 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
**설명**
|
||||||
|
|
||||||
|
메모를 생성한다. 검증은 `StoreMemoRequest` 가 전담하고 Service 는 검증된 배열만 받는다 — Service 에 검증 로직을 두지 않는다는 규칙의 예시다.
|
||||||
|
|
||||||
|
컨트롤러가 `$request->validated()` 를 넘기므로 FormRequest 에 정의되지 않은 필드는 모델에 도달하지 않는다. `$request->all()` / `except()` 를 쓰면 `$fillable` 을 통해 미정의 필드가 새므로 쓰지 않는다.
|
||||||
|
|
||||||
|
|
||||||
### DELETE /api/modules/gnuboard7-hello_module/admin/memos/{id}
|
### DELETE /api/modules/gnuboard7-hello_module/admin/memos/{id}
|
||||||
@@ -132,11 +238,25 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
_삭제 응답에는 페이로드가 없다. 컨트롤러가 `success(메시지)` 만 호출하므로 `data` 는 `null` 이다._
|
||||||
|
|
||||||
|
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| (없음) | null | `null` | 삭제 성공 시 `data` 는 항상 `null`. 결과 판정은 `success` 와 상태코드로 한다 |
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
```http
|
||||||
|
HTTP/1.1 200
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "메모가 삭제되었습니다.",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
@@ -144,11 +264,16 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.delete`)이 없는 경우 |
|
| 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` 에 예외 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
**설명**
|
||||||
|
|
||||||
|
메모를 삭제한다. 컨트롤러가 먼저 `getMemo($id)` 로 대상을 조회하므로, 존재하지 않는 `id` 는 삭제 시도 전에 `404` 로 걸러진다.
|
||||||
|
|
||||||
|
삭제는 DB CASCADE 에 의존하지 않고 Service 가 명시적으로 수행한다 — 훅 발화·파일 정리·로깅을 보장하기 위해서다.
|
||||||
|
|
||||||
|
|
||||||
### GET /api/modules/gnuboard7-hello_module/admin/memos/{id}
|
### GET /api/modules/gnuboard7-hello_module/admin/memos/{id}
|
||||||
@@ -174,11 +299,43 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
_단건 응답: `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종 |
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
```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 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.read`)이 없는 경우 |
|
| 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` 에 예외 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
**설명**
|
||||||
|
|
||||||
|
메모 단건을 조회하는 관리자 엔드포인트다. 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}
|
### PUT /api/modules/gnuboard7-hello_module/admin/memos/{id}
|
||||||
@@ -224,11 +386,43 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
_단건 응답: `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종 |
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
```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 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.update`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.update`)이 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 404 | Not Found | 해당 `id` 의 메모가 없는 경우 (`gnuboard7-hello_module::messages.memo.not_found`) |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — `title` 필수·255자 이하, `content` 필수) |
|
||||||
|
| 500 | Internal Server Error | 수정 중 예외 발생 (`gnuboard7-hello_module::messages.memo.update_failed`, `errors.error` 에 예외 메시지) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
**설명**
|
||||||
|
|
||||||
|
메모를 수정한다. `PUT` 이므로 `title` 과 `content` 를 **모두** 보내야 한다 (`UpdateMemoRequest` 가 둘 다 필수로 검증). 일부 필드만 보내면 `422` 다.
|
||||||
|
|
||||||
|
컨트롤러는 `getMemo($id)` 로 대상을 먼저 조회하므로 존재하지 않는 `id` 는 수정 시도 전에 `404` 로 걸러지고, Service 에는 `$request->validated()` 결과만 전달되어 FormRequest 미정의 필드가 모델에 도달하지 않는다.
|
||||||
|
|
||||||
|
|
||||||
### GET /api/modules/gnuboard7-hello_module/memos
|
### GET /api/modules/gnuboard7-hello_module/memos
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
|
|
||||||
## [1.0.4] - 2026-08-12
|
## [1.0.4] - 2026-08-12
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- 비밀글의 내용이 열람 권한 없이 새어 나가던 경로를 모두 막았습니다. 게시글 상세 화면은 비밀글 내용을 가렸지만, 상품 문의 목록·비밀글의 댓글 목록·비밀글의 첨부파일(다운로드/미리보기)은 게시글의 비밀 여부를 확인하지 않아 주소만 알면 원문·첨부를 볼 수 있었습니다. 이제 이 경로 전부에서 작성자 본인 또는 게시판 관리 권한(비밀글 열람)을 가진 요청에만 내용을 제공하고, 그 외에는 내용·제목·답변·첨부를 가립니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||||
|
- 회원 프로필의 작성글 목록과 "내가 댓글 단 글" 활동 목록에서 다른 사람의 비밀글·블라인드 글 **본문**이 로그인 없이도 나가던 문제를 수정했습니다. 두 목록은 본문 앞부분을 함께 싣는데 그것을 가리는 설정이 실제로는 한 번도 켜지지 않았습니다. 이제 본인이 볼 때만 본문이 보이고, 다른 사람이 볼 때는 비워집니다. 글의 제목과 목록에서의 표시(비밀글·블라인드 배지)는 게시판 목록과 동일하게 그대로 유지됩니다. (KVE-2026-1914)
|
||||||
|
- 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 회원 화면은 작성자 본인만 삭제하도록 막고 있었지만 관리 화면에는 같은 확인이 없었고, 순서 변경은 양쪽 모두 확인이 없었습니다. 순서는 목록 전체에 대한 하나의 값이라 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부합니다. (KVE-2026-1919)
|
||||||
|
- 게시판을 찾을 수 없을 때 비밀글 보호가 통과되던 문제를 수정했습니다. 첨부파일 서빙과 댓글 목록은 부모 글을 찾지 못하면 검사를 건너뛰고 진행해, 게이트가 있어야 할 자리가 비어 있었습니다. 이제 부모 글을 확인할 수 없으면 차단합니다.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다.
|
- 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다.
|
||||||
@@ -21,6 +28,7 @@
|
|||||||
- 신고 반려 누적 제한 안내 문구를 실제 동작에 맞게 정정했습니다. "설정 건수를 초과하면 차단"으로 적혀 있었지만 실제로는 설정 건수에 도달하는 순간부터 차단됩니다 — 5건으로 설정하면 5번째 반려부터 신고가 막힙니다.
|
- 신고 반려 누적 제한 안내 문구를 실제 동작에 맞게 정정했습니다. "설정 건수를 초과하면 차단"으로 적혀 있었지만 실제로는 설정 건수에 도달하는 순간부터 차단됩니다 — 5건으로 설정하면 5번째 반려부터 신고가 막힙니다.
|
||||||
- 관리자가 게시판 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
- 관리자가 게시판 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||||
- 신고 현황 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
|
- 신고 현황 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
|
||||||
|
- 댓글 목록을 불러올 때 댓글마다 원글을 반복 조회하던 비효율을 제거해 응답을 더 빠르게 했습니다. 댓글이 많은 글일수록 개선 폭이 큽니다.
|
||||||
|
|
||||||
## [1.0.3] - 2026-08-10
|
## [1.0.3] - 2026-08-10
|
||||||
|
|
||||||
|
|||||||
@@ -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` 를 얹으면 내부 검색이 수행됩니다. 매칭이 아주 많을 수 있으므로 총
|
게시판 목록에 `search` 를 얹으면 내부 검색이 수행됩니다. 매칭이 아주 많을 수 있으므로 총
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class AttachmentController extends AdminBaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 삭제 (Service에서 처리)
|
// 삭제 (Service에서 처리)
|
||||||
$result = $this->attachmentService->delete($slug, $id);
|
$result = $this->attachmentService->delete($slug, $id, 'admin');
|
||||||
|
|
||||||
if (! $result) {
|
if (! $result) {
|
||||||
return $this->error('sirsoft-board::messages.attachment.delete_failed', 500);
|
return $this->error('sirsoft-board::messages.attachment.delete_failed', 500);
|
||||||
@@ -149,7 +149,7 @@ class AttachmentController extends AdminBaseController
|
|||||||
|
|
||||||
// FileUploader가 [{id, order}] 형태로 전송 → [ID => order] 매핑으로 변환
|
// FileUploader가 [{id, order}] 형태로 전송 → [ID => order] 매핑으로 변환
|
||||||
$orders = collect($validated['order'])->pluck('order', 'id')->all();
|
$orders = collect($validated['order'])->pluck('order', 'id')->all();
|
||||||
$result = $this->attachmentService->reorder($slug, $orders);
|
$result = $this->attachmentService->reorder($slug, $orders, 'admin');
|
||||||
|
|
||||||
if (! $result) {
|
if (! $result) {
|
||||||
return $this->error('sirsoft-board::messages.attachment.reorder_failed', 500);
|
return $this->error('sirsoft-board::messages.attachment.reorder_failed', 500);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest;
|
|||||||
use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest;
|
use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest;
|
||||||
use Modules\Sirsoft\Board\Http\Requests\VerifyCommentPasswordRequest;
|
use Modules\Sirsoft\Board\Http\Requests\VerifyCommentPasswordRequest;
|
||||||
use Modules\Sirsoft\Board\Http\Resources\CommentResource;
|
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\BoardService;
|
||||||
use Modules\Sirsoft\Board\Services\CommentService;
|
use Modules\Sirsoft\Board\Services\CommentService;
|
||||||
|
|
||||||
@@ -32,7 +34,8 @@ class CommentController extends PublicBaseController
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private CommentService $commentService,
|
private CommentService $commentService,
|
||||||
private BoardService $boardService
|
private BoardService $boardService,
|
||||||
|
private PostRepositoryInterface $postRepository
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,32 @@ class CommentController extends PublicBaseController
|
|||||||
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
|
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);
|
$comments = $this->commentService->getCommentsByPostId($slug, $postId);
|
||||||
|
|
||||||
return $this->success(
|
return $this->success(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||||
use Modules\Sirsoft\Board\Enums\TriggerType;
|
use Modules\Sirsoft\Board\Enums\TriggerType;
|
||||||
|
use Modules\Sirsoft\Board\Models\Post;
|
||||||
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
|
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
|
||||||
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
|
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
|
||||||
use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
|
use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
|
||||||
@@ -31,7 +32,11 @@ class CommentResource extends BaseApiResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
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 [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
@@ -170,8 +175,9 @@ class CommentResource extends BaseApiResource
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fallback: 개별 쿼리 (목록 등 사전 로드 미적용 경로)
|
// fallback: 개별 쿼리 (목록 등 사전 로드 미적용 경로)
|
||||||
|
// 부모 post 는 컨트롤러가 넘긴 인스턴스를 재사용해 댓글당 lazy-load 를 피한다.
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$boardId = $this->post?->board?->id ?? null;
|
$boardId = $this->resolveParentPost($request)?->board?->id ?? null;
|
||||||
|
|
||||||
if (! $user || ! $boardId) {
|
if (! $user || ! $boardId) {
|
||||||
return false;
|
return false;
|
||||||
@@ -206,7 +212,7 @@ class CommentResource extends BaseApiResource
|
|||||||
*/
|
*/
|
||||||
protected function resolveAbilities(Request $request): array
|
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) {
|
if (! $slug) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -251,6 +257,29 @@ class CommentResource extends BaseApiResource
|
|||||||
// 콘텐츠 필터링 메서드
|
// 콘텐츠 필터링 메서드
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부모 게시글(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 +289,14 @@ class CommentResource extends BaseApiResource
|
|||||||
*/
|
*/
|
||||||
private function getFilteredContent(Request $request, ?string $slug): ?string
|
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) 댓글은 사용자가 직접 지운 것이 아니므로
|
// 게시글 삭제로 함께 숨겨진(cascade) 댓글은 사용자가 직접 지운 것이 아니므로
|
||||||
// 마스킹하지 않고 원문을 그대로 노출한다 (글을 볼 수 있는 사람이면 누구나).
|
// 마스킹하지 않고 원문을 그대로 노출한다 (글을 볼 수 있는 사람이면 누구나).
|
||||||
$isCascadeDeleted = $this->deleted_at !== null
|
$isCascadeDeleted = $this->deleted_at !== null
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||||
use Modules\Sirsoft\Board\Enums\ReportReasonType;
|
use Modules\Sirsoft\Board\Enums\ReportReasonType;
|
||||||
use Modules\Sirsoft\Board\Enums\TriggerType;
|
use Modules\Sirsoft\Board\Enums\TriggerType;
|
||||||
|
use Modules\Sirsoft\Board\Models\Post;
|
||||||
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
|
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
|
||||||
use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
|
use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
|
||||||
|
use Modules\Sirsoft\Board\Support\SecretContentGate;
|
||||||
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
|
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
|
||||||
use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
|
use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
|
||||||
|
|
||||||
@@ -686,30 +688,24 @@ class PostResource extends BaseApiResource
|
|||||||
*/
|
*/
|
||||||
private function canViewSecretContent(Request $request, ?string $slug = null): bool
|
private function canViewSecretContent(Request $request, ?string $slug = null): bool
|
||||||
{
|
{
|
||||||
// 1. 작성자 본인 (회원 게시글)
|
// 판정 규칙은 SecretContentGate(SSoT)에 있다 — 리스너·댓글 경로와 규칙을 공유해
|
||||||
$user = Auth::user();
|
// 드리프트를 방지한다.
|
||||||
if ($user && $this->user_id && $this->user_id === $user->id) {
|
return self::canViewSecretForPost($this->resource, $request);
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 비밀번호 검증 완료
|
/**
|
||||||
if ($this->password_verified === true) {
|
* 주어진 게시글의 비밀 원문 열람 권한을 판정합니다 (SSoT 진입점).
|
||||||
return true;
|
*
|
||||||
}
|
* PostResource 외 경로(이커머스 문의 연동 훅, 댓글 목록/리소스, 첨부 서빙)가
|
||||||
|
* 동일 규칙으로 서버측 마스킹을 수행하도록 공유합니다(KVE-2026-1914).
|
||||||
// 3-4. 게시판별 권한 체크
|
*
|
||||||
$slug = $slug ?? $this->getSlug($request);
|
* @param Post $post 대상 게시글
|
||||||
if (! $slug) {
|
* @param Request|null $request HTTP 요청 (미지정 시 현재 요청)
|
||||||
return false;
|
* @return bool 열람 가능 여부
|
||||||
}
|
*/
|
||||||
|
public static function canViewSecretForPost(Post $post, ?Request $request = null): bool
|
||||||
if ($this->isAdminRequest($request)) {
|
{
|
||||||
return $this->checkBoardPermission($slug, 'admin.posts.read-secret')
|
return app(SecretContentGate::class)->canView($post, $request);
|
||||||
|| $this->checkBoardPermission($slug, 'admin.manage');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->checkBoardPermission($slug, 'posts.read-secret', PermissionType::User)
|
|
||||||
|| $this->checkBoardPermission($slug, 'manager', PermissionType::User);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\HookListenerInterface;
|
|||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Modules\Sirsoft\Board\Http\Resources\PostResource;
|
||||||
use Modules\Sirsoft\Board\Models\Post;
|
use Modules\Sirsoft\Board\Models\Post;
|
||||||
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
|
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
|
||||||
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
|
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
|
||||||
@@ -90,7 +91,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
/**
|
/**
|
||||||
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드)
|
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드)
|
||||||
*
|
*
|
||||||
* @param mixed ...$args 훅 인자
|
* @param mixed ...$args 훅 인자
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function handle(...$args): void
|
public function handle(...$args): void
|
||||||
@@ -116,9 +117,11 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
$data['user_id'] = null;
|
$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'])) {
|
if (empty($data['ip_address'])) {
|
||||||
$data['ip_address'] = request()->ip() ?? '0.0.0.0';
|
$data['ip_address'] = '0.0.0.0';
|
||||||
}
|
}
|
||||||
|
|
||||||
// parent_id 있으면 답변글 → 부모 Post 제목으로 Re: 원글제목 설정
|
// parent_id 있으면 답변글 → 부모 Post 제목으로 Re: 원글제목 설정
|
||||||
@@ -151,13 +154,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 $carry 이전 필터 결과 (초기값: [])
|
||||||
* @param array $context 조회 컨텍스트 ['ids' => int[], 'slug' => string]
|
* @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
|
public function getByIds(array $carry, array $context): array
|
||||||
{
|
{
|
||||||
@@ -171,6 +181,12 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
$posts = $this->postRepository->findByIdsWithRelations($ids);
|
$posts = $this->postRepository->findByIdsWithRelations($ids);
|
||||||
|
|
||||||
return $posts->map(function (Post $post) {
|
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 [
|
return [
|
||||||
'id' => $post->id,
|
'id' => $post->id,
|
||||||
'board_id' => $post->board_id,
|
'board_id' => $post->board_id,
|
||||||
@@ -178,26 +194,34 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
'parent_id' => $post->parent_id,
|
'parent_id' => $post->parent_id,
|
||||||
'user_id' => $post->user_id,
|
'user_id' => $post->user_id,
|
||||||
'author_name' => $post->author_name,
|
'author_name' => $post->author_name,
|
||||||
'title' => $post->title,
|
'title' => $canViewSecret
|
||||||
'content' => $post->content,
|
? $post->title
|
||||||
|
: __('sirsoft-board::messages.post.secret_post_title'),
|
||||||
|
'content' => $canViewSecret ? $post->content : null,
|
||||||
'category' => $post->category,
|
'category' => $post->category,
|
||||||
'is_secret' => (bool) $post->is_secret,
|
'is_secret' => $isSecret,
|
||||||
|
// 서버가 요청자 신원으로 내린 열람 판정(SSoT). 소비 서비스가 이 값으로
|
||||||
|
// 마스킹을 재확인(이중 방어)할 수 있도록 함께 실어 보낸다. 소비측은 자기
|
||||||
|
// 권한을 재계산하지 말고 이 값만 신뢰해야 게이트 강도가 갈리지 않는다.
|
||||||
|
'can_view_secret' => $canViewSecret,
|
||||||
'status' => $post->status?->value,
|
'status' => $post->status?->value,
|
||||||
'view_count' => $post->view_count,
|
'view_count' => $post->view_count,
|
||||||
'created_at' => $post->created_at?->toIso8601String(),
|
'created_at' => $post->created_at?->toIso8601String(),
|
||||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||||
// 첨부파일 목록
|
// 첨부파일 목록 (비밀글 비열람자는 빈 배열)
|
||||||
'attachments' => $post->attachments->map(fn ($a) => [
|
'attachments' => $canViewSecret
|
||||||
'id' => $a->id,
|
? $post->attachments->map(fn ($a) => [
|
||||||
'original_filename' => $a->original_filename,
|
'id' => $a->id,
|
||||||
'size' => $a->size,
|
'original_filename' => $a->original_filename,
|
||||||
'size_formatted' => $a->size_formatted,
|
'size' => $a->size,
|
||||||
'is_image' => $a->is_image,
|
'size_formatted' => $a->size_formatted,
|
||||||
'preview_url' => $a->preview_url,
|
'is_image' => $a->is_image,
|
||||||
'download_url' => $a->download_url,
|
'preview_url' => $a->preview_url,
|
||||||
])->values()->all(),
|
'download_url' => $a->download_url,
|
||||||
// 답변 게시글 (parent_id가 있는 자식 글)
|
])->values()->all()
|
||||||
'reply' => $this->getReplyForPost($post),
|
: [],
|
||||||
|
// 답변 게시글 (parent_id가 있는 자식 글, 비밀글 비열람자는 null)
|
||||||
|
'reply' => $canViewSecret ? $this->getReplyForPost($post) : null,
|
||||||
];
|
];
|
||||||
})->all();
|
})->all();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -229,18 +253,18 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'secret_mode' => $board->secret_mode?->value ?? 'disabled',
|
'secret_mode' => $board->secret_mode?->value ?? 'disabled',
|
||||||
'categories' => $board->categories ?? [],
|
'categories' => $board->categories ?? [],
|
||||||
'use_file_upload' => (bool) $board->use_file_upload,
|
'use_file_upload' => (bool) $board->use_file_upload,
|
||||||
'max_file_count' => $board->max_file_count ?? 5,
|
'max_file_count' => $board->max_file_count ?? 5,
|
||||||
'max_file_size' => $board->max_file_size ?? 10,
|
'max_file_size' => $board->max_file_size ?? 10,
|
||||||
'allowed_extensions' => $board->allowed_extensions ?? [],
|
'allowed_extensions' => $board->allowed_extensions ?? [],
|
||||||
'min_title_length' => $board->min_title_length ?? 2,
|
'min_title_length' => $board->min_title_length ?? 2,
|
||||||
'max_title_length' => $board->max_title_length ?? 200,
|
'max_title_length' => $board->max_title_length ?? 200,
|
||||||
'min_content_length' => $board->min_content_length ?? 10,
|
'min_content_length' => $board->min_content_length ?? 10,
|
||||||
'max_content_length' => $board->max_content_length ?? 10000,
|
'max_content_length' => $board->max_content_length ?? 10000,
|
||||||
'attachment_upload_url' => '/api/modules/sirsoft-board/boards/' . $board->slug . '/attachments',
|
'attachment_upload_url' => '/api/modules/sirsoft-board/boards/'.$board->slug.'/attachments',
|
||||||
'attachment_delete_url' => '/api/modules/sirsoft-board/boards/' . $board->slug . '/attachments/:id',
|
'attachment_delete_url' => '/api/modules/sirsoft-board/boards/'.$board->slug.'/attachments/:id',
|
||||||
];
|
];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('EcommerceInquiryHookListener: 게시판 설정 조회 실패', [
|
Log::error('EcommerceInquiryHookListener: 게시판 설정 조회 실패', [
|
||||||
@@ -270,7 +294,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
$post = $this->postRepository->findWithBoard($postId);
|
$post = $this->postRepository->findWithBoard($postId);
|
||||||
|
|
||||||
if (! $post || ! $post->board) {
|
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'] ?? [];
|
$attachmentIds = $data['attachment_ids'] ?? [];
|
||||||
@@ -278,7 +302,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
} catch (ModelNotFoundException $e) {
|
} catch (ModelNotFoundException $e) {
|
||||||
Log::warning('EcommerceInquiryHookListener: Post 수정 실패 - 게시글 또는 게시판 없음', [
|
Log::warning('EcommerceInquiryHookListener: Post 수정 실패 - 게시글 또는 게시판 없음', [
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
@@ -287,7 +311,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('EcommerceInquiryHookListener: Post 수정 실패', [
|
Log::error('EcommerceInquiryHookListener: Post 수정 실패', [
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
@@ -315,7 +339,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
$post = $this->postRepository->findWithBoard($postId);
|
$post = $this->postRepository->findWithBoard($postId);
|
||||||
|
|
||||||
if (! $post || ! $post->board) {
|
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 패턴)
|
// 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴)
|
||||||
@@ -323,7 +347,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
} catch (ModelNotFoundException $e) {
|
} catch (ModelNotFoundException $e) {
|
||||||
Log::warning('EcommerceInquiryHookListener: Post 삭제 실패 - 게시글 또는 게시판 없음', [
|
Log::warning('EcommerceInquiryHookListener: Post 삭제 실패 - 게시글 또는 게시판 없음', [
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
@@ -332,7 +356,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('EcommerceInquiryHookListener: Post 삭제 실패', [
|
Log::error('EcommerceInquiryHookListener: Post 삭제 실패', [
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
@@ -367,7 +391,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $reply->board) {
|
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);
|
$this->postService->updatePost($reply->board->slug, $reply->id, $data);
|
||||||
@@ -376,7 +400,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
} catch (ModelNotFoundException $e) {
|
} catch (ModelNotFoundException $e) {
|
||||||
Log::warning('EcommerceInquiryHookListener: Reply Post 수정 실패 - 게시글 또는 게시판 없음', [
|
Log::warning('EcommerceInquiryHookListener: Reply Post 수정 실패 - 게시글 또는 게시판 없음', [
|
||||||
'parent_post_id' => $parentPostId,
|
'parent_post_id' => $parentPostId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
@@ -385,7 +409,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('EcommerceInquiryHookListener: Reply Post 수정 실패', [
|
Log::error('EcommerceInquiryHookListener: Reply Post 수정 실패', [
|
||||||
'parent_post_id' => $parentPostId,
|
'parent_post_id' => $parentPostId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
@@ -419,7 +443,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $reply->board) {
|
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 패턴)
|
// 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴)
|
||||||
@@ -429,7 +453,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
} catch (ModelNotFoundException $e) {
|
} catch (ModelNotFoundException $e) {
|
||||||
Log::warning('EcommerceInquiryHookListener: Reply Post 삭제 실패 - 게시글 또는 게시판 없음', [
|
Log::warning('EcommerceInquiryHookListener: Reply Post 삭제 실패 - 게시글 또는 게시판 없음', [
|
||||||
'parent_post_id' => $parentPostId,
|
'parent_post_id' => $parentPostId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
@@ -438,7 +462,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('EcommerceInquiryHookListener: Reply Post 삭제 실패', [
|
Log::error('EcommerceInquiryHookListener: Reply Post 삭제 실패', [
|
||||||
'parent_post_id' => $parentPostId,
|
'parent_post_id' => $parentPostId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
|
|||||||
@@ -77,6 +77,27 @@ class AttachmentRepository implements AttachmentRepositoryInterface
|
|||||||
return $post->deleted_at !== null || $post->status === PostStatus::Deleted;
|
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 정렬)
|
* 여러 ID로 첨부파일 조회 (order 정렬)
|
||||||
*
|
*
|
||||||
|
|||||||
+13
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Repositories\Contracts;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Modules\Sirsoft\Board\Models\Attachment;
|
use Modules\Sirsoft\Board\Models\Attachment;
|
||||||
|
use Modules\Sirsoft\Board\Models\Post;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시판 첨부파일 Repository 인터페이스
|
* 게시판 첨부파일 Repository 인터페이스
|
||||||
@@ -39,6 +40,18 @@ interface AttachmentRepositoryInterface
|
|||||||
*/
|
*/
|
||||||
public function isPostDeleted(string $slug, int $postId): bool;
|
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 정렬)
|
* 여러 ID로 첨부파일 조회 (order 정렬)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1064,7 +1064,7 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
* 사용자가 작성한 게시글, 댓글을 단 게시글을 통합하여 반환합니다.
|
* 사용자가 작성한 게시글, 댓글을 단 게시글을 통합하여 반환합니다.
|
||||||
*
|
*
|
||||||
* @param int $userId 사용자 ID
|
* @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 페이지당 항목 수
|
* @param int $perPage 페이지당 항목 수
|
||||||
* @return LengthAwarePaginator 게시글 활동 목록
|
* @return LengthAwarePaginator 게시글 활동 목록
|
||||||
*/
|
*/
|
||||||
@@ -1074,7 +1074,15 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
$search = $filters['search'] ?? null;
|
$search = $filters['search'] ?? null;
|
||||||
$activityType = $filters['activity_type'] ?? 'authored';
|
$activityType = $filters['activity_type'] ?? 'authored';
|
||||||
$sort = $filters['sort'] ?? 'latest'; // latest, oldest, views
|
$sort = $filters['sort'] ?? 'latest'; // latest, oldest, views
|
||||||
$isPublic = $filters['is_public'] ?? false; // 공개 프로필용 필터 (비밀글 제외, 공개 게시글만)
|
// 열람자 관점. 이 값이 대상 사용자와 다르면(비로그인 포함) **타인 관점**이므로
|
||||||
|
// 비밀글·미발행글을 내보내지 않는다.
|
||||||
|
//
|
||||||
|
// 종전에는 `is_public` 옵트인 플래그로 이 판정을 했는데, 그 키를 설정하는 코드가
|
||||||
|
// 저장소 어디에도 없어서 필터가 한 번도 적용되지 않았다(사문). 옵트인은 호출부가
|
||||||
|
// 빠뜨리면 조용히 열리는 방향이라, 열람자 신원으로 판정하는 fail-closed 로 뒤집는다 —
|
||||||
|
// viewer_id 가 없으면 자동으로 가장 좁은 가시성이 된다.
|
||||||
|
$viewerId = $filters['viewer_id'] ?? null;
|
||||||
|
$isOwnView = $viewerId !== null && $viewerId === $userId;
|
||||||
$excludeBoardSlugs = $filters['exclude_board_slugs'] ?? [];
|
$excludeBoardSlugs = $filters['exclude_board_slugs'] ?? [];
|
||||||
|
|
||||||
// board_slug 필터용 board_id 조회
|
// board_slug 필터용 board_id 조회
|
||||||
@@ -1099,12 +1107,15 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
|
|
||||||
$cachedTotal = $filters['cached_total'] ?? null;
|
$cachedTotal = $filters['cached_total'] ?? null;
|
||||||
|
|
||||||
if ($activityType === 'commented' && ! $isPublic) {
|
// 분기 선택은 activity_type 만 본다. 가시성은 각 분기 안에서 처리한다 —
|
||||||
return $this->getUserCommentedActivities($userId, $boardIdFilter, $excludeBoardIds, $search, $orderColumn, $orderDirection, $perPage, $cachedTotal);
|
// 여기서 열람자까지 보고 분기를 바꾸면 `commented` 요청이 조용히 `authored` 결과를
|
||||||
|
// 돌려주게 되어 저장소 계약이 깨진다.
|
||||||
|
if ($activityType === 'commented') {
|
||||||
|
return $this->getUserCommentedActivities($userId, $boardIdFilter, $excludeBoardIds, $search, $orderColumn, $orderDirection, $perPage, $cachedTotal, $viewerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// authored (기본값, 공개 프로필 포함)
|
// 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 +1124,7 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
* @param int $userId 사용자 ID
|
* @param int $userId 사용자 ID
|
||||||
* @param int|null $boardIdFilter 게시판 ID 필터
|
* @param int|null $boardIdFilter 게시판 ID 필터
|
||||||
* @param string|null $search 검색 키워드
|
* @param string|null $search 검색 키워드
|
||||||
* @param bool $isPublic 공개 프로필 여부 (비밀글 제외)
|
* @param bool $isOwnView 열람자가 대상 본인인지 여부 (아니면 비밀글·미발행글 제외)
|
||||||
* @param string $orderColumn 정렬 컬럼
|
* @param string $orderColumn 정렬 컬럼
|
||||||
* @param string $orderDirection 정렬 방향
|
* @param string $orderDirection 정렬 방향
|
||||||
* @param int $perPage 페이지당 항목 수
|
* @param int $perPage 페이지당 항목 수
|
||||||
@@ -1123,7 +1134,7 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
?int $boardIdFilter,
|
?int $boardIdFilter,
|
||||||
array $excludeBoardIds,
|
array $excludeBoardIds,
|
||||||
?string $search,
|
?string $search,
|
||||||
bool $isPublic,
|
bool $isOwnView,
|
||||||
string $orderColumn,
|
string $orderColumn,
|
||||||
string $orderDirection,
|
string $orderDirection,
|
||||||
int $perPage,
|
int $perPage,
|
||||||
@@ -1145,11 +1156,6 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
$query->whereNotIn('board_posts.board_id', $allExcludeIds);
|
$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) {
|
if ($search) {
|
||||||
$keyword = $this->escapeLikeKeyword($search);
|
$keyword = $this->escapeLikeKeyword($search);
|
||||||
$query->where(function ($q) use ($keyword) {
|
$query->where(function ($q) use ($keyword) {
|
||||||
@@ -1180,7 +1186,15 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
);
|
);
|
||||||
|
|
||||||
// paginate 후 10건에만 PHP 가공 적용 (N+1 아님)
|
// 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 [
|
return [
|
||||||
'id' => $post->id,
|
'id' => $post->id,
|
||||||
'board_slug' => $post->board?->slug,
|
'board_slug' => $post->board?->slug,
|
||||||
@@ -1194,9 +1208,11 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
'comment_count' => (int) ($post->comments_count ?? 0),
|
'comment_count' => (int) ($post->comments_count ?? 0),
|
||||||
'created_at' => $this->formatCreatedAt($post->created_at),
|
'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')),
|
'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'
|
'content_plain' => $hideContent
|
||||||
? $this->stripHtmlToPlainText($post->content ?? '')
|
? ''
|
||||||
: ($post->content ?? ''),
|
: (($post->content_mode ?? 'text') === 'html'
|
||||||
|
? $this->stripHtmlToPlainText($post->content ?? '')
|
||||||
|
: ($post->content ?? '')),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1222,7 +1238,8 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
string $orderColumn,
|
string $orderColumn,
|
||||||
string $orderDirection,
|
string $orderDirection,
|
||||||
int $perPage,
|
int $perPage,
|
||||||
?int $cachedTotal = null
|
?int $cachedTotal = null,
|
||||||
|
?int $viewerId = null
|
||||||
): LengthAwarePaginator {
|
): LengthAwarePaginator {
|
||||||
// 테이블명은 모델에서 얻는다 — 문자열로 박으면 테이블명이 바뀔 때 조용히 깨진다
|
// 테이블명은 모델에서 얻는다 — 문자열로 박으면 테이블명이 바뀔 때 조용히 깨진다
|
||||||
$postsTable = (new Post)->getTable();
|
$postsTable = (new Post)->getTable();
|
||||||
@@ -1308,7 +1325,15 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
);
|
);
|
||||||
|
|
||||||
// paginate 후 10건에만 PHP 가공 적용
|
// 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 [
|
return [
|
||||||
'id' => $post->id,
|
'id' => $post->id,
|
||||||
'board_slug' => $post->board?->slug,
|
'board_slug' => $post->board?->slug,
|
||||||
@@ -1322,9 +1347,11 @@ class PostRepository implements PostRepositoryInterface
|
|||||||
'comment_count' => (int) ($post->comments_count ?? 0),
|
'comment_count' => (int) ($post->comments_count ?? 0),
|
||||||
'created_at' => $this->formatCreatedAt($post->created_at),
|
'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')),
|
'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'
|
'content_plain' => $hideContent
|
||||||
? $this->stripHtmlToPlainText($post->content ?? '')
|
? ''
|
||||||
: ($post->content ?? ''),
|
: (($post->content_mode ?? 'text') === 'html'
|
||||||
|
? $this->stripHtmlToPlainText($post->content ?? '')
|
||||||
|
: ($post->content ?? '')),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
|
|||||||
use Modules\Sirsoft\Board\Models\Attachment;
|
use Modules\Sirsoft\Board\Models\Attachment;
|
||||||
use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface;
|
use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface;
|
||||||
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
|
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
|
||||||
|
use Modules\Sirsoft\Board\Support\SecretContentGate;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
|
||||||
@@ -345,6 +346,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로 첨부파일 조회
|
* ID로 첨부파일 조회
|
||||||
*
|
*
|
||||||
@@ -380,9 +420,10 @@ class AttachmentService
|
|||||||
*
|
*
|
||||||
* @param string $slug 게시판 슬러그
|
* @param string $slug 게시판 슬러그
|
||||||
* @param int $id 첨부파일 ID
|
* @param int $id 첨부파일 ID
|
||||||
|
* @param string $context 호출 컨텍스트 (admin | user) — 스코프 권한 식별자 결정에 쓰인다
|
||||||
* @return bool 삭제 성공 여부
|
* @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);
|
$attachment = $this->repository->findById($slug, $id);
|
||||||
|
|
||||||
@@ -390,6 +431,8 @@ class AttachmentService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertAttachmentWithinScope($slug, $attachment, $context);
|
||||||
|
|
||||||
// 삭제 후 재정렬을 위해 정보 저장
|
// 삭제 후 재정렬을 위해 정보 저장
|
||||||
$postId = $attachment->post_id;
|
$postId = $attachment->post_id;
|
||||||
$collection = $attachment->collection;
|
$collection = $attachment->collection;
|
||||||
@@ -425,10 +468,13 @@ class AttachmentService
|
|||||||
*
|
*
|
||||||
* @param string $slug 게시판 슬러그
|
* @param string $slug 게시판 슬러그
|
||||||
* @param array<int, int> $orders 첨부파일 ID => order 매핑
|
* @param array<int, int> $orders 첨부파일 ID => order 매핑
|
||||||
|
* @param string $context 호출 컨텍스트 (admin | user) — 스코프 권한 식별자 결정에 쓰인다
|
||||||
* @return bool 성공 여부
|
* @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 훅
|
// Before 훅
|
||||||
HookManager::doAction('sirsoft-board.attachment.before_reorder', $slug, $orders);
|
HookManager::doAction('sirsoft-board.attachment.before_reorder', $slug, $orders);
|
||||||
|
|
||||||
@@ -440,6 +486,63 @@ class AttachmentService
|
|||||||
return $result;
|
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<int, array{id: int, order: int}> $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 +620,9 @@ class AttachmentService
|
|||||||
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
|
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
|
||||||
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
|
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
|
||||||
|
|
||||||
|
// 비밀글 첨부파일은 열람 권한자만 접근
|
||||||
|
$this->assertSecretPostAttachmentAccess($slug, $attachment);
|
||||||
|
|
||||||
// 다운로드 활동이력 기록 훅
|
// 다운로드 활동이력 기록 훅
|
||||||
// 권한/삭제글 가드 통과 후 발화 → 차단된 시도는 기록하지 않음.
|
// 권한/삭제글 가드 통과 후 발화 → 차단된 시도는 기록하지 않음.
|
||||||
// $context('user'|'admin')는 로그 부가정보용 (log_type 은 요청 경로로 자동 결정).
|
// $context('user'|'admin')는 로그 부가정보용 (log_type 은 요청 경로로 자동 결정).
|
||||||
@@ -580,6 +686,9 @@ class AttachmentService
|
|||||||
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
|
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
|
||||||
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
|
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
|
||||||
|
|
||||||
|
// 비밀글 첨부파일은 열람 권한자만 접근
|
||||||
|
$this->assertSecretPostAttachmentAccess($slug, $attachment);
|
||||||
|
|
||||||
return $this->storage->response(
|
return $this->storage->response(
|
||||||
'attachments',
|
'attachments',
|
||||||
$attachment->path,
|
$attachment->path,
|
||||||
@@ -612,6 +721,9 @@ class AttachmentService
|
|||||||
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
|
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
|
||||||
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
|
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
|
||||||
|
|
||||||
|
// 비밀글 첨부파일은 열람 권한자만 접근
|
||||||
|
$this->assertSecretPostAttachmentAccess($slug, $attachment);
|
||||||
|
|
||||||
// 파일 존재 확인
|
// 파일 존재 확인
|
||||||
if (! $this->storage->exists('attachments', $attachment->path)) {
|
if (! $this->storage->exists('attachments', $attachment->path)) {
|
||||||
Log::error('게시판 첨부파일 스토리지에 없음', [
|
Log::error('게시판 첨부파일 스토리지에 없음', [
|
||||||
|
|||||||
@@ -882,9 +882,12 @@ class PostService
|
|||||||
$filters['user_id'] = $requestParams['user_id'] ?? null;
|
$filters['user_id'] = $requestParams['user_id'] ?? null;
|
||||||
$filters['created_at_from'] = $requestParams['created_at_from'] ?? null;
|
$filters['created_at_from'] = $requestParams['created_at_from'] ?? null;
|
||||||
$filters['created_at_to'] = $requestParams['created_at_to'] ?? 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;
|
$requestedPerPage = isset($requestParams['per_page']) ? (int) $requestParams['per_page'] : null;
|
||||||
@@ -1070,6 +1073,10 @@ class PostService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 본인 활동 화면(`/me/board-activities`)이므로 열람자 = 대상 본인이다.
|
||||||
|
// 저장소는 이 값이 없으면 타인 관점으로 보고 비밀글·미발행글을 걸러낸다(fail-closed).
|
||||||
|
$filters['viewer_id'] = $userId;
|
||||||
|
|
||||||
$result = $this->postRepository->getUserActivities($userId, $filters, $perPage);
|
$result = $this->postRepository->getUserActivities($userId, $filters, $perPage);
|
||||||
|
|
||||||
// 캐시 미적중 시 paginate 결과의 total을 캐시에 저장
|
// 캐시 미적중 시 paginate 결과의 total을 캐시에 저장
|
||||||
@@ -1105,9 +1112,12 @@ class PostService
|
|||||||
/**
|
/**
|
||||||
* 사용자의 공개 게시글 목록을 조회합니다 (공개 프로필용).
|
* 사용자의 공개 게시글 목록을 조회합니다 (공개 프로필용).
|
||||||
*
|
*
|
||||||
* 기존 getUserActivities()를 재사용합니다.
|
* 기존 getUserActivities()를 재사용합니다. 이 목록은 본문 일부(content_plain)를 함께
|
||||||
* 타인 프로필에서 해당 사용자의 모든 게시글을 표시합니다 (비밀글/블라인드 포함).
|
* 싣기 때문에, 열람자가 본인이 아니면 비밀글·블라인드 글의 **본문만** 비운다. 행과
|
||||||
* UI에서 배지로 비밀글/블라인드 상태를 구분합니다.
|
* 제목은 남는다 — 게시판 목록에서 이미 같은 수준으로 보이고(PostResource 의 목록 규칙:
|
||||||
|
* 제목은 노출, 본문만 차단) 프로필 UI 가 그 배지를 그리므로, 행을 지우면 결함 차단에
|
||||||
|
* 필요한 범위를 넘어 기능이 깎인다.
|
||||||
|
* 판정은 저장소가 `viewer_id` 로 수행한다(fail-closed — 값이 없으면 타인 관점).
|
||||||
*
|
*
|
||||||
* @param int $userId 사용자 ID
|
* @param int $userId 사용자 ID
|
||||||
* @param array $filters 필터 옵션 (board_slug, sort 등)
|
* @param array $filters 필터 옵션 (board_slug, sort 등)
|
||||||
@@ -1116,10 +1126,9 @@ class PostService
|
|||||||
*/
|
*/
|
||||||
public function getUserPublicPosts(int $userId, array $filters = [], int $perPage = 20): LengthAwarePaginator
|
public function getUserPublicPosts(int $userId, array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
// 기존 getUserActivities 재사용
|
|
||||||
// 타인 프로필에서 해당 사용자의 모든 게시글 표시 (비밀글/블라인드 포함, 배지로 구분)
|
|
||||||
return $this->postRepository->getUserActivities($userId, array_merge($filters, [
|
return $this->postRepository->getUserActivities($userId, array_merge($filters, [
|
||||||
'activity_type' => 'authored', // 작성글만
|
'activity_type' => 'authored', // 작성글만
|
||||||
|
'viewer_id' => Auth::id(), // 비로그인은 null → 타인 관점
|
||||||
]), $perPage);
|
]), $perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Sirsoft\Board\Support;
|
||||||
|
|
||||||
|
use App\Enums\PermissionType;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Modules\Sirsoft\Board\Models\Post;
|
||||||
|
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀글 원문 열람 권한 판정 SSoT
|
||||||
|
*
|
||||||
|
* 비밀글 게이팅이 PostResource 한 곳에만 있던 탓에 이커머스 연동 훅·댓글 목록 등
|
||||||
|
* 다른 경로가 서버측 마스킹 없이 원문을 반환하던 결함(KVE-2026-1914)을 막기 위해,
|
||||||
|
* 열람 판정 규칙을 한 지점으로 모읍니다. PostResource·리스너·댓글 경로가 모두
|
||||||
|
* 이 게이트를 호출해 규칙 드리프트를 방지합니다.
|
||||||
|
*
|
||||||
|
* 열람 가능 조건 (우선순위 순):
|
||||||
|
* 1. 작성자 본인 (회원 게시글)
|
||||||
|
* 2. 비밀번호 검증 완료 (`password_verified` 플래그 — 상세 컨텍스트에서만 설정, 리스트 미적용)
|
||||||
|
* 3. 게시판별 비밀글 읽기 권한 (posts.read-secret)
|
||||||
|
* 4. 게시판 관리자 권한 (Admin: admin.manage / User: manager)
|
||||||
|
*/
|
||||||
|
class SecretContentGate
|
||||||
|
{
|
||||||
|
use ChecksBoardPermission;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주어진 게시글의 비밀 원문을 현재 요청자가 열람할 수 있는지 판정합니다.
|
||||||
|
*
|
||||||
|
* 게시판 슬러그를 해석할 수 없으면(라우트 슬러그 부재 + board 미로딩) 안전하게
|
||||||
|
* false(마스킹)로 실패합니다.
|
||||||
|
*
|
||||||
|
* @param Post $post 대상 게시글
|
||||||
|
* @param Request|null $request HTTP 요청 (미지정 시 현재 요청)
|
||||||
|
* @return bool 열람 가능 여부
|
||||||
|
*/
|
||||||
|
public function canView(Post $post, ?Request $request = null): bool
|
||||||
|
{
|
||||||
|
$request = $request ?? request();
|
||||||
|
|
||||||
|
// 1. 작성자 본인 (회원 게시글)
|
||||||
|
$user = Auth::user();
|
||||||
|
if ($user && $post->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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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\\');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ return [
|
|||||||
'secret_password_incorrect' => 'Secret post password is incorrect.',
|
'secret_password_incorrect' => 'Secret post password is incorrect.',
|
||||||
// Secret post filtering messages
|
// Secret post filtering messages
|
||||||
'secret_post_content' => 'This is a secret post. Please enter the password to view the content.',
|
'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_title' => 'Deleted post',
|
||||||
'deleted_post_content' => 'This post has been deleted.',
|
'deleted_post_content' => 'This post has been deleted.',
|
||||||
'blinded_post_content' => 'This post has been blinded by administrator.',
|
'blinded_post_content' => 'This post has been blinded by administrator.',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ return [
|
|||||||
'secret_password_incorrect' => '비밀글 비밀번호가 일치하지 않습니다.',
|
'secret_password_incorrect' => '비밀글 비밀번호가 일치하지 않습니다.',
|
||||||
// 비밀글 필터링 메시지
|
// 비밀글 필터링 메시지
|
||||||
'secret_post_content' => '비밀글입니다. 내용을 보려면 비밀번호를 입력해 주세요.',
|
'secret_post_content' => '비밀글입니다. 내용을 보려면 비밀번호를 입력해 주세요.',
|
||||||
|
'secret_post_title' => '비밀글',
|
||||||
'deleted_post_title' => '삭제된 게시글',
|
'deleted_post_title' => '삭제된 게시글',
|
||||||
'deleted_post_content' => '삭제된 게시글입니다.',
|
'deleted_post_content' => '삭제된 게시글입니다.',
|
||||||
'blinded_post_content' => '관리자에 의해 블라인드 처리된 게시글입니다.',
|
'blinded_post_content' => '관리자에 의해 블라인드 처리된 게시글입니다.',
|
||||||
|
|||||||
@@ -504,8 +504,8 @@ Route::post('boards/{slug}/comments/{commentId}/verify-password', [UserCommentCo
|
|||||||
| 로그인한 회원의 게시글 활동(작성, 댓글, 신고)을 조회하는 API입니다.
|
| 로그인한 회원의 게시글 활동(작성, 댓글, 신고)을 조회하는 API입니다.
|
||||||
| - 회원만 접근 가능하므로 auth:sanctum 미들웨어 필요
|
| - 회원만 접근 가능하므로 auth:sanctum 미들웨어 필요
|
||||||
|
|
|
|
||||||
| 최종 URL 예시: /api/me/board-activities
|
| 최종 URL 예시: /api/modules/sirsoft-board/me/board-activities
|
||||||
| 최종 Name 예시: api.me.board-activities.index
|
| 최종 Name 예시: api.modules.sirsoft-board.me.board-activities.index
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
Route::get('/me/board-activities', [UserActivityController::class, 'index'])
|
Route::get('/me/board-activities', [UserActivityController::class, 'index'])
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
||||||
|
|
||||||
|
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
||||||
|
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||||
|
|
||||||
|
use App\Models\Permission;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀글 첨부파일 접근 차단 테스트 (KVE-2026-1914 A-3)
|
||||||
|
*
|
||||||
|
* 첨부는 본문 게이팅(getFilteredContent)과 별개 경로라, 본문이 비밀 마스킹되어도
|
||||||
|
* 첨부 다운로드/미리보기는 게시글 비밀 상태를 검사하지 않아 해시/ID 만으로 원문
|
||||||
|
* 파일이 노출되던 결함을 검증합니다.
|
||||||
|
*
|
||||||
|
* 정책: 비밀글 첨부는 작성자 본인 또는 게시판 manager/posts.read-secret 보유자만 접근.
|
||||||
|
*
|
||||||
|
* 실제 공격자 프로필은 대개 미인증(게스트)이다. preview 라우트는 permission
|
||||||
|
* 미들웨어 없이 optional.sanctum 만 걸려(공개 썸네일 정책) 컨트롤러/서비스의 비밀
|
||||||
|
* 게이트만이 게스트를 막는다 — 그래서 게스트 축을 명시 고정한다. download 라우트는
|
||||||
|
* permission 미들웨어가 게스트를 401 로 선차단한다.
|
||||||
|
*
|
||||||
|
* 시나리오 축(viewer)·효과는 매니페스트 tests/scenarios/board-secret-content-gate.yaml 참조.
|
||||||
|
* 각 test 메서드의 `@scenario viewer=…` 마커가 축 조합을 커버한다(메서드당 단일 값).
|
||||||
|
*
|
||||||
|
* 효과 목록을 클래스 레벨에 몰아 적지 않는다 — 커버리지 룰은 마커 레벨을 구분하지 않으므로,
|
||||||
|
* 클래스 레벨 목록이 있으면 그 메서드를 지워도 효과가 "언급됨" 으로 집계돼 삭제가 무증상
|
||||||
|
* green 이 된다. 마커는 메서드에만 둔다.
|
||||||
|
*/
|
||||||
|
class SecretPostAttachmentAccessTest extends BoardTestCase
|
||||||
|
{
|
||||||
|
private User $regularUser;
|
||||||
|
|
||||||
|
private User $ownerUser;
|
||||||
|
|
||||||
|
private User $managerUser;
|
||||||
|
|
||||||
|
protected function getTestBoardSlug(): string
|
||||||
|
{
|
||||||
|
return 'secret-attach';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDefaultBoardAttributes(string $slug): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'slug' => $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(), '게이트 통과 시 서버 오류가 아니어야 합니다');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
||||||
|
|
||||||
|
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
||||||
|
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||||
|
|
||||||
|
use App\Models\Permission;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Modules\Sirsoft\Board\Http\Resources\CommentResource;
|
||||||
|
use Modules\Sirsoft\Board\Models\Comment;
|
||||||
|
use Modules\Sirsoft\Board\Models\Post;
|
||||||
|
use Modules\Sirsoft\Board\Services\CommentService;
|
||||||
|
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀글 댓글 목록 게이팅 테스트 (KVE-2026-1914 A-4)
|
||||||
|
*
|
||||||
|
* 댓글 목록은 부모 게시글 비밀 상태를 검사하지 않아, 비밀글의 댓글이 비열람자에게
|
||||||
|
* 그대로 노출되던 결함을 검증합니다.
|
||||||
|
*
|
||||||
|
* 정책: 부모 게시글이 비밀글이면 열람 권한(작성자/manager/posts.read-secret) 없는
|
||||||
|
* 요청에는 댓글 목록을 노출하지 않는다(빈 목록).
|
||||||
|
*
|
||||||
|
* 실제 공격자 프로필은 미인증(게스트)이다. 사용자 댓글 목록 라우트는 comments.read
|
||||||
|
* permission 미들웨어가 걸려 있어 그 권한이 없는 게스트를 401 로 선차단한다(부모가
|
||||||
|
* 비밀글이든 아니든). 게스트가 permission 을 갖는 공개 게시판이라도 비밀 게이트가 빈
|
||||||
|
* 목록을 돌려주는 것은 regular(비밀 권한 없는 회원) 경로와 동일하다.
|
||||||
|
*
|
||||||
|
* 시나리오 축(viewer)·효과는 매니페스트 tests/scenarios/board-secret-content-gate.yaml 참조.
|
||||||
|
* 각 test 메서드의 `@scenario viewer=…` 마커가 축 조합을 커버한다(메서드당 단일 값).
|
||||||
|
*
|
||||||
|
* 효과 목록을 클래스 레벨에 몰아 적지 않는다 — 커버리지 룰은 마커 레벨을 구분하지 않으므로,
|
||||||
|
* 클래스 레벨 목록이 있으면 그 메서드를 지워도 효과가 "언급됨" 으로 집계돼 삭제가 무증상
|
||||||
|
* green 이 된다. 마커는 메서드에만 둔다.
|
||||||
|
*/
|
||||||
|
class SecretPostCommentAccessTest extends BoardTestCase
|
||||||
|
{
|
||||||
|
private User $regularUser;
|
||||||
|
|
||||||
|
private User $ownerUser;
|
||||||
|
|
||||||
|
private User $managerUser;
|
||||||
|
|
||||||
|
protected function getTestBoardSlug(): string
|
||||||
|
{
|
||||||
|
return 'secret-comment';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDefaultBoardAttributes(string $slug): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'slug' => $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'], '작성자 본인에게는 비밀글 댓글 원문이 노출되어야 합니다');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
||||||
|
|
||||||
|
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
||||||
|
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||||
|
|
||||||
|
use App\Models\Permission;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 활동/공개 프로필 목록의 비밀글 본문 노출 차단 (KVE-2026-1914 형제)
|
||||||
|
*
|
||||||
|
* 이 두 목록은 본문 일부(`content_plain`, 1000자)를 함께 싣는데 Resource 를 거치지 않고
|
||||||
|
* 저장소 배열을 그대로 응답에 싣는다. 공개 프로필 라우트는 `optional.sanctum` 이라
|
||||||
|
* **미인증**으로 도달하므로, 타인의 비밀글·블라인드 글 본문이 그대로 나갔다.
|
||||||
|
*
|
||||||
|
* 결함의 형태는 **사문화된 옵트인 플래그**였다 — 저장소가 `is_public` 을 읽어 필터를 걸도록
|
||||||
|
* 돼 있었지만 그 키를 설정하는 코드가 어디에도 없어(전수 grep write 0건) 한 번도 적용되지
|
||||||
|
* 않았다. 판정을 열람자 신원(`viewer_id`) 기반 fail-closed 로 뒤집었다.
|
||||||
|
*
|
||||||
|
* 가리는 것은 **본문뿐**이다. 행과 제목은 게시판 목록에서 이미 같은 수준으로 노출되며
|
||||||
|
* (`PostResource` 의 목록 규칙: 제목은 노출, 본문만 차단) 프로필 UI 도 비밀글/블라인드
|
||||||
|
* 배지를 그린다 — 행을 지우면 결함 차단에 필요한 범위를 넘어 기능이 깎인다.
|
||||||
|
*
|
||||||
|
* 시나리오 축·효과는 매니페스트 tests/scenarios/board-activity-secret-masking.yaml 참조.
|
||||||
|
* 각 test 메서드의 `@scenario viewer=…, activity_type=…` 마커가 축 조합을, `@effects …` 가
|
||||||
|
* 효과를 커버한다(메서드당 단일 조합).
|
||||||
|
*
|
||||||
|
* 축 요약(마커 아님 — 평문): viewer 는 guest·other_user·owner, activity_type 은
|
||||||
|
* authored·commented 다. 클래스 레벨에 `@effects` 를 몰아 적으면 그 메서드가 하나도 없어도
|
||||||
|
* 매니페스트 효과가 "언급됨" 으로 집계되어 커버리지를 부풀린다 — 마커는 메서드에만 둔다.
|
||||||
|
*/
|
||||||
|
class UserActivitySecretExposureTest extends BoardTestCase
|
||||||
|
{
|
||||||
|
private User $author;
|
||||||
|
|
||||||
|
private User $stranger;
|
||||||
|
|
||||||
|
protected function getTestBoardSlug(): string
|
||||||
|
{
|
||||||
|
return 'activity-secret';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDefaultBoardAttributes(string $slug): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'slug' => $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(),
|
||||||
|
'본인이 쓴 비밀글이면 댓글 활동 목록에서도 본문이 보여야 합니다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: 공개글과 비밀글을 작성
|
// Given: 공개글과 비밀글을 작성
|
||||||
$this->createPost($this->board->slug, [
|
$this->createPost($this->board->slug, [
|
||||||
@@ -122,19 +125,27 @@ class UserPublicPostsApiTest extends BoardTestCase
|
|||||||
// When: 사용자 게시글을 조회하면
|
// When: 사용자 게시글을 조회하면
|
||||||
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts");
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts");
|
||||||
|
|
||||||
// Then: 모든 게시글이 반환되며 is_secret 배지로 구분
|
// Then: 두 건 모두 나오되 비밀글의 본문만 비어 있다
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
|
|
||||||
$data = $response->json('data.data');
|
$data = $response->json('data.data');
|
||||||
$this->assertCount(2, $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: 공개된 게시글과 블라인드 처리된 게시글
|
// Given: 공개된 게시글과 블라인드 처리된 게시글
|
||||||
$this->createPost($this->board->slug, [
|
$this->createPost($this->board->slug, [
|
||||||
@@ -152,11 +163,15 @@ class UserPublicPostsApiTest extends BoardTestCase
|
|||||||
// When: 사용자 게시글을 조회하면
|
// When: 사용자 게시글을 조회하면
|
||||||
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts");
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts");
|
||||||
|
|
||||||
// Then: 모든 게시글이 반환되며 status 배지로 구분
|
// Then: 두 건 모두 나오되 블라인드 글의 본문만 비어 있다
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
|
|
||||||
$data = $response->json('data.data');
|
$data = $response->json('data.data');
|
||||||
$this->assertCount(2, $data);
|
$this->assertCount(2, $data);
|
||||||
|
|
||||||
|
$blinded = collect($data)->firstWhere('status', 'blinded');
|
||||||
|
$this->assertNotNull($blinded, '블라인드 글도 목록에는 나와야 합니다(배지로 구분)');
|
||||||
|
$this->assertSame('', $blinded['content_plain'], '블라인드 글 본문은 나가면 안 됩니다');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+30
-5
@@ -13,11 +13,7 @@
|
|||||||
*
|
*
|
||||||
* @scenario board-reports-search
|
* @scenario board-reports-search
|
||||||
* @axes field=all field=post_title field=board_name field=author_name field=reporter_name
|
* @axes field=all field=post_title field=board_name field=author_name field=reporter_name
|
||||||
* @effects search_keyword_propagated_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.
|
||||||
* 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.
|
* 활성화 절차: PlaywrightIssueToken 발급이 가능한 환경에서 test.describe.skip → test.describe.
|
||||||
*/
|
*/
|
||||||
@@ -104,6 +100,35 @@ test.describe.skip('게시판 신고현황 — 검색 기능 동작 (#413-72)',
|
|||||||
expect(url.searchParams.get('filters[0][value]')).toBeFalsy();
|
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
|
// @scenario field=post_title
|
||||||
// @effects mobile_search_propagates_to_url_query
|
// @effects mobile_search_propagates_to_url_query
|
||||||
test('모바일 뷰포트에서 검색어가 URL query 에 전달된다', async ({ page, settingsToken }) => {
|
test('모바일 뷰포트에서 검색어가 URL query 에 전달된다', async ({ page, settingsToken }) => {
|
||||||
|
|||||||
+118
@@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Listeners;
|
|||||||
|
|
||||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
|
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
|
||||||
use Modules\Sirsoft\Board\Models\Board;
|
use Modules\Sirsoft\Board\Models\Board;
|
||||||
use Modules\Sirsoft\Board\Models\Post;
|
use Modules\Sirsoft\Board\Models\Post;
|
||||||
@@ -146,6 +148,60 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
|
|||||||
$this->assertStringContainsString('원본 문의 제목', $post->title);
|
$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 반환
|
* createAndReturn: 존재하지 않는 slug → 예외 발생하지 않고 null 반환
|
||||||
*/
|
*/
|
||||||
@@ -214,6 +270,68 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
|
|||||||
$this->assertSame($postId, $result[0]['id']);
|
$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
|
// getBoardSettings
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
|
|
||||||
## [1.1.1] - 2026-08-12
|
## [1.1.1] - 2026-08-12
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- 비밀 상품 문의의 내용이 작성자·관리자가 아닌 사람에게도 노출되던 문제를 막았습니다. 문의 목록은 게시글의 비밀 여부를 서버에서 확인해, 열람 권한이 없는 요청에는 내용·제목·답변·첨부를 가립니다. (목록의 '비밀글 숨김' 옵션과 무관하게 서버가 요청자 신원으로 노출 여부를 결정합니다.) (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||||
|
- 관리자가 숨긴 리뷰의 이미지가 주소만 알면 계속 조회되던 문제를 수정했습니다. 이제 숨김 처리된 리뷰의 이미지는 제공되지 않습니다. 리뷰가 삭제되어 상태를 확인할 수 없는 이미지도 함께 차단합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||||
|
- 상품·주문·리뷰·브랜드·쿠폰·배송정책·추가배송비 관리에 담당 범위 제한을 적용했습니다. 이 권한들은 "본인이 등록한 것만" 처럼 범위를 좁혀 위임할 수 있는데, 그 제한이 실제로는 어느 관리 화면에서도 적용되지 않았습니다. 목록에서 여러 건을 한 번에 처리하는 일괄 기능뿐 아니라, 항목을 하나씩 수정·삭제하는 화면에서도 마찬가지였습니다. 이제 모든 경로에서 대상마다 범위를 확인하며, 일괄 기능은 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. 범위 제한 없이 위임받은 관리자의 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 리뷰 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 상품·카테고리 이미지와 동일하게 리뷰 이미지도 업로드 시점에 변환(압축·리사이즈·포맷 변경)할 수 있습니다. (#96 @lyg-kaban 님께서 건의해주셨습니다.)
|
- 리뷰 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 상품·카테고리 이미지와 동일하게 리뷰 이미지도 업로드 시점에 변환(압축·리사이즈·포맷 변경)할 수 있습니다. (#96 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||||
@@ -67,6 +73,7 @@
|
|||||||
#### 기타
|
#### 기타
|
||||||
|
|
||||||
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
|
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
|
||||||
|
- 추가배송비(도서산간) 템플릿 상세 응답의 편집 권한 표시를 배송정책 권한 기준으로 통일했습니다. 이전에는 상세 응답만 다른 권한(설정 관리)을 기준으로 삼아, 배송정책 관리 권한만 가진 관리자에게 편집 가능 여부가 실제 권한과 다르게 표시될 수 있었습니다.
|
||||||
|
|
||||||
#### 배송비 구간
|
#### 배송비 구간
|
||||||
|
|
||||||
|
|||||||
@@ -283,12 +283,12 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
_단건 응답: 삭제 결과 요약 (`data` 객체)._
|
_단건 응답: 삭제 결과 요약 (`BrandService::deleteBrand()` 가 반환한 배열 — Resource 로 감싸지 않는다)._
|
||||||
|
|
||||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| brand_id | integer | `2` | 삭제된 브랜드의 기본 키 |
|
| brand_id | integer | `1` | 삭제된 브랜드의 ID |
|
||||||
| products_count | integer | `0` | 삭제 시점에 이 브랜드를 쓰던 상품 수 (연결 상품이 있으면 삭제가 거부되므로 언제나 0) |
|
| products_count | integer | `0` | 삭제 시점의 연결 상품 수. **항상 `0`** 이다 — 연결 상품이 하나라도 있으면 삭제 자체가 차단되므로 성공 응답에서는 0 외의 값이 나올 수 없다 |
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ HTTP/1.1 200
|
|||||||
"success": true,
|
"success": true,
|
||||||
"message": "브랜드가 삭제되었습니다.",
|
"message": "브랜드가 삭제되었습니다.",
|
||||||
"data": {
|
"data": {
|
||||||
"brand_id": 2,
|
"brand_id": 1,
|
||||||
"products_count": 0
|
"products_count": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,9 +312,9 @@ HTTP/1.1 200
|
|||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.delete`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.delete`)이 없거나, 대상 브랜드가 요청자의 **스코프 밖**인 경우 (`auth.scope_denied`). 존재하지 않는 ID 도 스코프 판정에서 먼저 걸려 403 이 된다 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 400 | Bad Request | 연결된 상품이 있어 삭제가 차단된 경우(`exceptions.brand_has_products` — 상품 수 포함), 또는 삭제 처리 중 예외 발생 (`exceptions.operation_failed`) |
|
||||||
| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
|
| 404 | Not Found | path 파라미터 형식이 라우트에 매칭되지 않는 경우 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -425,7 +425,6 @@ HTTP/1.1 200
|
|||||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||||
| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
|
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -455,7 +454,25 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
_단건 응답: `data` 객체의 필드 (`BrandResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/brands/{id} (브랜드 수정)** 응답 필드 표와 동일합니다._
|
_단건 응답: `data` 가 브랜드 하나 (`BrandResource`). 필드 구성은 목록 응답의 `data.data[]` 항목과 동일하되, 목록 전용 순번(`number`)은 없다._
|
||||||
|
|
||||||
|
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| id | integer | `20` | 기본 키 (내부 식별자) |
|
||||||
|
| name | object | `{"ko":"ASUS","en":"ASUS"}` | 다국어 원본 이름 (로케일 키 → 값) |
|
||||||
|
| localized_name | string | `ASUS` | `name` 의 현재 로케일 해석 값 |
|
||||||
|
| slug | string | `asus` | URL 친화 식별자 |
|
||||||
|
| url | string | `asus` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) |
|
||||||
|
| website | string \| null | `https://www.asus.com` | 브랜드 공식 웹사이트 URL |
|
||||||
|
| sort_order | integer | `20` | 표시 정렬 순서 값 (작을수록 우선) |
|
||||||
|
| is_active | boolean | `true` | 사용 여부 |
|
||||||
|
| icon | string | `tag` | 아이콘 식별자 (Resource 가 고정값으로 부여) |
|
||||||
|
| created_at | string | `2026-07-30 23:35:46` | 생성 일시 |
|
||||||
|
| updated_at | string | `2026-07-30 23:35:46` | 최종 수정 일시 |
|
||||||
|
| creator | object \| array | `[]` | 생성자 정보 (`id`/`name`) — 관계가 로드된 경우에만 포함 |
|
||||||
|
| updater | object \| array | `[]` | 수정자 정보 (`id`/`name`) — 관계가 로드된 경우에만 포함 |
|
||||||
|
| products_count | integer | `1` | 이 브랜드에 속한 상품 수 (집계) |
|
||||||
|
| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 |
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
@@ -466,23 +483,25 @@ HTTP/1.1 200
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "브랜드 정보를 조회했습니다.",
|
"message": "브랜드를 조회했습니다.",
|
||||||
"data": {
|
"data": {
|
||||||
"id": 1,
|
"id": 20,
|
||||||
"name": {
|
"name": {
|
||||||
"ko": "API 문서 샘플 브랜드",
|
"ko": "ASUS",
|
||||||
"en": "API Doc Sample Brand"
|
"en": "ASUS"
|
||||||
},
|
},
|
||||||
"localized_name": "API 문서 샘플 브랜드",
|
"localized_name": "ASUS",
|
||||||
"slug": "apidoc-sample-brand",
|
"slug": "asus",
|
||||||
"url": "apidoc-sample-brand",
|
"url": "asus",
|
||||||
"website": "https://www.asus.com",
|
"website": "https://www.asus.com",
|
||||||
"sort_order": 0,
|
"sort_order": 20,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"icon": "tag",
|
"icon": "tag",
|
||||||
"created_at": "2026-07-08 10:44:49",
|
"created_at": "2026-07-30 23:35:46",
|
||||||
"updated_at": "2026-07-08 15:00:16",
|
"updated_at": "2026-07-30 23:35:46",
|
||||||
"products_count": 0,
|
"creator": [],
|
||||||
|
"updater": [],
|
||||||
|
"products_count": 1,
|
||||||
"abilities": {
|
"abilities": {
|
||||||
"can_create": true,
|
"can_create": true,
|
||||||
"can_update": true,
|
"can_update": true,
|
||||||
@@ -498,7 +517,7 @@ HTTP/1.1 200
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.read`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | 해당 `id` 의 브랜드가 없는 경우 (`messages.brands.not_found`) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -528,10 +547,21 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
_단건 응답: `data` 객체의 필드 (`BrandResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/brands/{id} (브랜드 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
|
_단건 응답: `data` 가 토글된 브랜드 (`BrandResource`). 필드 구성은 상세 조회와 동일하며, 실질적으로 달라지는 것은 `is_active` 와 `updated_at` 이다._
|
||||||
|
|
||||||
|
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| id | integer | `20` | 브랜드 기본키 |
|
||||||
|
| is_active | boolean | `false` | **토글 후** 의 사용 여부. 화면은 이 값으로 스위치 상태를 갱신한다 |
|
||||||
|
| updated_at | string | `2026-08-16 01:30:00` | 토글 시각으로 갱신된 수정 일시 |
|
||||||
|
| (그 외) | — | — | `name`·`localized_name`·`slug`·`url`·`website`·`sort_order`·`icon`·`created_at`·`creator`·`updater`·`products_count`·`abilities` — 상세 조회 응답과 동일 |
|
||||||
|
|
||||||
|
`message` 는 활성/비활성 구분 없이 `messages.brands.status_changed` 하나다. 현재 상태는 `data.is_active` 로 판정한다.
|
||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
|
비활성으로 토글된 경우:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
HTTP/1.1 200
|
HTTP/1.1 200
|
||||||
```
|
```
|
||||||
@@ -541,21 +571,23 @@ HTTP/1.1 200
|
|||||||
"success": true,
|
"success": true,
|
||||||
"message": "브랜드 상태가 변경되었습니다.",
|
"message": "브랜드 상태가 변경되었습니다.",
|
||||||
"data": {
|
"data": {
|
||||||
"id": 1,
|
"id": 20,
|
||||||
"name": {
|
"name": {
|
||||||
"ko": "API 문서 샘플 브랜드",
|
"ko": "ASUS",
|
||||||
"en": "API Doc Sample Brand"
|
"en": "ASUS"
|
||||||
},
|
},
|
||||||
"localized_name": "API 문서 샘플 브랜드",
|
"localized_name": "ASUS",
|
||||||
"slug": "apidoc-sample-brand",
|
"slug": "asus",
|
||||||
"url": "apidoc-sample-brand",
|
"url": "asus",
|
||||||
"website": "https://www.asus.com",
|
"website": "https://www.asus.com",
|
||||||
"sort_order": 0,
|
"sort_order": 20,
|
||||||
"is_active": true,
|
"is_active": false,
|
||||||
"icon": "tag",
|
"icon": "tag",
|
||||||
"created_at": "2026-07-08 10:44:49",
|
"created_at": "2026-07-30 23:35:46",
|
||||||
"updated_at": "2026-07-08 15:00:16",
|
"updated_at": "2026-08-16 01:30:00",
|
||||||
"products_count": 0,
|
"creator": [],
|
||||||
|
"updater": [],
|
||||||
|
"products_count": 1,
|
||||||
"abilities": {
|
"abilities": {
|
||||||
"can_create": true,
|
"can_create": true,
|
||||||
"can_update": true,
|
"can_update": true,
|
||||||
@@ -571,8 +603,8 @@ HTTP/1.1 200
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 400 | Bad Request | 해당 `id` 의 브랜드가 없거나 토글 처리 중 예외 발생 (`exceptions.operation_failed`) |
|
||||||
| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
|
| 404 | Not Found | path 파라미터 형식이 라우트에 매칭되지 않는 경우 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
|||||||
@@ -637,7 +637,65 @@ _목록 응답: `data.data[]` 배열 항목의 필드. 페이지네이션 없는
|
|||||||
|
|
||||||
**응답 예시**
|
**응답 예시**
|
||||||
|
|
||||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
```http
|
||||||
|
HTTP/1.1 200
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "연결 거래를 조회했습니다.",
|
||||||
|
"data": {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"id": 531,
|
||||||
|
"user_id": 166,
|
||||||
|
"currency": "KRW",
|
||||||
|
"type": "order_use",
|
||||||
|
"type_label": "주문 사용",
|
||||||
|
"admin_badge_group": "blue",
|
||||||
|
"user_display_category": "use",
|
||||||
|
"amount": -500,
|
||||||
|
"amount_formatted": "-500원",
|
||||||
|
"remaining_amount": 0,
|
||||||
|
"remaining_amount_formatted": "0원",
|
||||||
|
"balance_after": 500,
|
||||||
|
"order_id": 436,
|
||||||
|
"order_option_id": 824,
|
||||||
|
"order_cancel_id": null,
|
||||||
|
"source_transaction_id": 528,
|
||||||
|
"granted_by": null,
|
||||||
|
"description": "주문 마일리지 사용",
|
||||||
|
"memo": null,
|
||||||
|
"expires_at": null,
|
||||||
|
"expires_at_formatted": null,
|
||||||
|
"expires_at_date": null,
|
||||||
|
"expired_at": null,
|
||||||
|
"expired_at_formatted": null,
|
||||||
|
"created_at": "2026-07-07T05:47:31+00:00",
|
||||||
|
"created_at_formatted": "2026-07-07 14:47:31",
|
||||||
|
"created_at_date": "2026-07-07",
|
||||||
|
"is_earning": false,
|
||||||
|
"can_edit_expiry": false,
|
||||||
|
"expired_amount": 0,
|
||||||
|
"expired_amount_formatted": "0원",
|
||||||
|
"expiry_state": "active",
|
||||||
|
"abilities": {
|
||||||
|
"can_manage": true,
|
||||||
|
"can_edit": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"abilities": {
|
||||||
|
"can_manage": true
|
||||||
|
},
|
||||||
|
"currencies": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 연결 거래가 없으면 `data.data` 가 빈 배열이다 (404 가 아니다 — 404 는 `{id}` 거래 자체가 없을 때뿐이다). 페이지네이션이 없는 컬렉션이라 `data.pagination` 키는 존재하지 않으며, `data.currencies` 는 이 엔드포인트가 주입하지 않아 항상 `[]` 다.
|
||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
@@ -645,7 +703,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드. 페이지네이션 없는
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.read`)이 없는 경우 |
|
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.read`)이 없는 경우 |
|
||||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
| 404 | Not Found | `{id}` 에 해당하는 마일리지 거래가 없는 경우 |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
|||||||
@@ -3418,7 +3418,7 @@ HTTP/1.1 200
|
|||||||
| product | path | string | 예 | — | 대상 product의 식별자 |
|
| product | path | string | 예 | — | 대상 product의 식별자 |
|
||||||
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
|
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
|
||||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||||
| exclude_secret | query | boolean | 아니오 | — | 비밀글 제외 여부 (기본 `false` — 포함). `true` 면 비밀 문의를 목록에서 제외합니다. 쿼리 문자열 `"true"`/`"false"` 도 해석되며, 해석할 수 없는 값은 그대로 검증되어 422 가 됩니다 |
|
| exclude_secret | query | boolean | 아니오 | — | 비밀글 제외 여부 (기본 `false` — 포함). `true` 면 비밀 문의를 목록에서 제외합니다. 쿼리 문자열 `"true"`/`"false"` 도 해석되며, 해석할 수 없는 값은 그대로 검증되어 422 가 됩니다. **보안 경계가 아니라 단순 표시 필터입니다** — 비밀 문의의 노출/마스킹은 이 값과 무관하게 서버가 요청자 신원으로 결정합니다 |
|
||||||
|
|
||||||
**요청 예시**
|
**요청 예시**
|
||||||
|
|
||||||
@@ -3521,7 +3521,7 @@ HTTP/1.1 200
|
|||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** 상품의 1:1 문의 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductInquiryService::getProductInquiries()`가 게시판 모듈과 연동된 문의 글을 페이지네이션해 `items`와 `board_settings`(비밀글 모드·카테고리 등) 메타를 반환합니다. `per_page`/`page`/`exclude_secret` 쿼리로 조회 범위를 조정하며, 비밀 문의는 설정과 열람 권한에 따라 마스킹됩니다. 상품 상세의 문의 탭에 사용됩니다.
|
**설명** 상품의 1:1 문의 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductInquiryService::getProductInquiries()`가 게시판 모듈과 연동된 문의 글을 페이지네이션해 `items`와 `board_settings`(비밀글 모드·카테고리 등) 메타를 반환합니다. `per_page`/`page`/`exclude_secret` 쿼리로 조회 범위를 조정합니다. 비밀 문의는 요청자 신원(작성자 본인·게시판 비밀글 열람 권한)에 따라 서버가 마스킹하며, 열람 권한이 없으면 `title`은 "비밀글" 플레이스홀더로 치환되고 `content`·`reply`는 `null`, `attachments`는 빈 배열로 내려갑니다(`is_secret`·작성자·답변 여부 등 메타는 유지). 이 마스킹은 게시판 모듈이 실어 보내는 권위 플래그(`can_view_secret`)를 신뢰하며, 플래그가 없으면 fail-closed 로 마스킹합니다. `exclude_secret` 쿼리는 표시 필터일 뿐 이 판정에 관여하지 않습니다. 상품 상세의 문의 탭에 사용됩니다.
|
||||||
|
|
||||||
|
|
||||||
### POST /api/modules/sirsoft-ecommerce/products/{product}/inquiries
|
### POST /api/modules/sirsoft-ecommerce/products/{product}/inquiries
|
||||||
|
|||||||
@@ -73,10 +73,12 @@ Content-Disposition: attachment; filename="review.jpg"
|
|||||||
|
|
||||||
| 상태코드 | 의미 | 발생 조건 |
|
| 상태코드 | 의미 | 발생 조건 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 404 | Not Found | 해시에 해당하는 리뷰 이미지가 없거나(`findByHash()` → null), 레코드는 있으나 스토리지에 실제 파일이 없는 경우 (`messages.reviews.image_not_found`) |
|
| 404 | Not Found | 해시에 해당하는 리뷰 이미지가 없거나(`findByHash()` → null), 레코드는 있으나 스토리지에 실제 파일이 없는 경우, **또는 이미지가 속한 리뷰가 노출(VISIBLE) 상태가 아닌 경우**(숨김·블라인드·대기 리뷰의 이미지는 존재를 감추기 위해 동일하게 404) (`messages.reviews.image_not_found`) |
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** 리뷰에 첨부된 이미지를 해시(12자) 기반으로 공개 서빙합니다. 인증이 필요 없으며, `ReviewImageController@download` 가 `ProductReviewImageService::download()` 로 해시에 해당하는 이미지를 찾아 스트림(`StreamedResponse`)으로 반환합니다. 해시에 해당하는 이미지가 없으면 404 를 반환합니다. `<img src>` 등에서 리뷰 이미지 원본을 표시할 때 사용하며, 실제 파일 경로를 노출하지 않고 해시로만 접근하게 합니다.
|
**설명** 리뷰에 첨부된 이미지를 해시(12자) 기반으로 서빙합니다. `ReviewImageController@download` 가 `ProductReviewImageService::download()` 로 해시에 해당하는 이미지를 찾아 스트림(`StreamedResponse`)으로 반환합니다. `<img src>` 등에서 리뷰 이미지 원본을 표시할 때 사용하며, 실제 파일 경로를 노출하지 않고 해시로만 접근하게 합니다.
|
||||||
|
|
||||||
|
이미지 서빙은 **부모 리뷰가 노출(VISIBLE) 상태일 때로 한정**됩니다(KVE-2026-1914). 숨김·블라인드·대기 상태 리뷰의 이미지는 해시가 유효해도 서빙되지 않고 404 를 반환합니다 — 본문이 감춰진 리뷰의 이미지가 해시만으로 노출되는 것을 막고, 존재 자체를 드러내지 않기 위해 "이미지 없음"과 동일한 404 로 응답합니다. 노출 상태 리뷰의 이미지는 별도 인증 없이 공개 접근할 수 있습니다.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user