diff --git a/AGENTS.md b/AGENTS.md
index e8cdd3f3..d0a48e8a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -495,6 +495,20 @@ catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진
> 상세: [storage-driver.md](docs/extension/storage-driver.md) "제3자 라이브러리에 절대 경로를 넘길 때", [service-repository.md](docs/backend/service-repository.md) "서비스가 제3자 라이브러리를 붙일 때"
+### 직접 전송로는 자격증명을 스스로 싣는다
+
+레이아웃의 `globalHeaders` 는 **데이터소스(DataSourceManager)와 `apiCall` 핸들러(ActionDispatcher)** 에만 적용된다. 코어 ApiClient(`G7Core.api.*`)를 직접 부르거나 `fetch` 를 쓰는 경로는 그 배선을 타지 않아 `Authorization` 과 `Accept-Language` 만 실린다. 게이트된 엔드포인트를 그렇게 부르면 서버는 정당한 사용자를 거부하는데, 화면은 버튼·썸네일을 이미 내준 뒤라 **예외도 콘솔 오류도 없이 그 자리만 비는 것**이 유일한 증상이다.
+
+| ❌ 금지 | ✅ 올바른 사용 |
+|--------|---------------|
+| 게이트된 엔드포인트를 `G7Core.api.*` / `fetch` 로 부르며 자격증명 헤더를 생략 | 호출부가 직접 싣는다 — 비밀글 첨부는 `X-Board-Secret-View-Token`, 비회원 주문은 `X-Guest-Order-Token` |
+| 자격증명이 없을 때 조용히 `return null` 로 이탈 | 회원/비회원 두 경로를 모두 구성한다 — 한쪽을 비우면 서버가 지원하는 기능이 도달 불가로만 남는다 |
+| 헤더 구성을 호출부마다 복제 | 확장·템플릿 안에 단일 지점(`secretContentHeaders()` / `buildOrderRequestHeaders()`)을 두고 경유 |
+| `
` 에 헤더를 실으려 시도 | 이미지 태그는 헤더를 실을 수 없다 — 한시 서명 URL 을 발급하거나 blob 으로 받아 그린다 |
+| 자격증명을 GET 쿼리 문자열로 전달 | 헤더로 보낸다 — 쿼리는 웹서버 접근 기록과 `Referer` 에 그대로 남는다 |
+
+같은 기능을 여러 확장이 제공할 때는 **형제 구현의 강도가 갈리지 않는지** 확인한다. 서버가 비회원을 지원하는데 프론트 한쪽만 토큰을 보내면, 나머지 확장에서는 그 서버 기능이 존재하지만 도달 불가인 상태로 남는다.
+
### 확장·템플릿 구동 에셋은 자체 제공한다
브라우저가 화면을 그리기 위해 제3자 CDN 에 도달해야 하면, 그 도달 실패는 **예외도 로그도 남기지 않고 화면 기능만 조용히 사라진다.** 폐쇄망·방화벽·광고차단기에서 재현되며 자체 서버 로그에 흔적이 없어 운영자가 원인을 특정할 수 없다.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff22b7ec..29110152 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,10 +45,15 @@
- 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010)
- 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011)
- 디버그 도구 엔드포인트 전체에 디버그 모드 확인을 적용했습니다. 종전에는 8개 중 3개에만 확인이 있어, 디버그 모드가 꺼진 운영 사이트에서도 로그인 없이 요청하면 저장된 디버그 데이터를 통째로 삭제할 수 있었습니다. 이제 확인은 개별 엔드포인트가 아니라 디버그 도구 전체에 한 번에 걸리므로, 앞으로 추가되는 엔드포인트도 자동으로 보호됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
+- 본인인증을 마친 뒤 받은 확인값이 유효기간을 넘겨도 계속 통했던 문제를 수정했습니다. 그래서 오래전에 받아 둔 값 하나로 회원탈퇴처럼 본인인증이 필요한 작업을 언제든 다시 수행할 수 있었습니다. 이제 유효기간이 지난 확인값은 받아들이지 않으며, 그 경우 종전처럼 본인인증을 다시 요구합니다. 본인인증이 걸린 모든 지점(회원가입·비밀번호 재설정·정책이 지정한 화면 포함)에 함께 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2029)
+- 설치 마법사에 입력하는 사이트 이름·사이트 주소·업데이트 저장소 주소에 줄바꿈을 섞어 설치 설정 파일에 임의의 설정 항목을 끼워 넣을 수 있던 문제를 수정했습니다. 이제 설치 설정 파일에 기록되는 모든 입력이 같은 검사를 지나며, 줄바꿈이 섞이면 해당 항목에 오류를 표시하고 저장하지 않습니다. 주소 항목은 형식까지 확인합니다. 이 문제는 설치가 끝나지 않은 사이트에서만 성립합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2042)
+- 설치 마법사의 PHP·Composer 실행 경로 입력에서 네트워크 공유 경로(`\\서버\공유`)와 임의 이름의 `.phar` 파일을 지정할 수 있던 문제를 수정했습니다. 공격자가 지정한 원격 파일이나 미리 올려 둔 아카이브가 설치 과정에서 실행될 수 있었습니다. 이제 로컬 절대경로만 허용하고 Composer 자리는 composer 계열 이름만 받습니다. 이 문제는 설치가 끝나지 않은 사이트에서만 성립합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2043)
- 웹소켓 채널 인증 주소가 로그인 확인 없는 형태로 하나 더 등록되어 있던 문제를 수정했습니다. 화면에서는 쓰이지 않는 주소였지만 「웹소켓 사용 안 함」 설정을 우회할 수 있었습니다. 이제 로그인과 설정을 함께 확인하는 주소 하나만 남으며, 채널별 권한 확인은 종전과 동일하게 동작합니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
### Fixed
+- 아웃바운드 프록시를 지정한 사이트에서 글·상품 저장이 수십 초씩 걸리던 문제를 수정했습니다. 사이트가 자기 자신에게 보내는 내부 요청까지 프록시로 나가고 있었고, 프록시가 응답하지 않으면 그 요청이 연결 실패 시각까지 매달렸습니다. 실패는 화면에 드러나지 않고 저장만 느려져 원인을 알기 어려웠습니다. 이제 사이트 자기 주소와 로컬 주소는 운영자가 예외 목록에 적지 않아도 항상 프록시를 거치지 않습니다.
+- 설치 마법사에서 PHP·Composer 경로가 거부될 때 안내 문구가 실제 허용 범위와 달라, 안내대로 고쳐도 계속 거부되던 문제를 수정했습니다. 이제 파일 이름 조건과 사용할 수 없는 경로 형태(네트워크 경로·scheme:// 등)를 문구에 함께 안내합니다.
- 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다.
- 디버그 모드에서도 디버그 도구의 조회 주소(상태·액션 이력·캐시·변경 감지)가 사용자 화면에 가려져 응답하지 못하던 문제를 수정했습니다. 사이트가 미리 예약해 둔 주소는 사용자 화면 처리에서 제외됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
- 「자산 주소에 확장자 사용 안 함」 설정을 켠 사이트에서 글꼴과 국기 아이콘이 표시되지 않던 문제를 수정했습니다. 스타일시트가 그 안에서 상대 경로로 가리키던 글꼴·이미지 파일을 브라우저가 엉뚱한 주소로 찾아 불러오지 못했고, 화면에는 기본 서체와 빈 아이콘만 보였습니다. 이제 스타일시트를 내보낼 때 그 경로를 올바른 주소로 바꿔 전달합니다.
diff --git a/app/Repositories/IdentityVerificationLogRepository.php b/app/Repositories/IdentityVerificationLogRepository.php
index 1dd21814..93ff560b 100644
--- a/app/Repositories/IdentityVerificationLogRepository.php
+++ b/app/Repositories/IdentityVerificationLogRepository.php
@@ -89,7 +89,13 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
}
/**
- * 미소비된 검증 토큰으로 로그를 조회합니다.
+ * 미소비·미만료 검증 토큰으로 로그를 조회합니다.
+ *
+ * 만료 술어(expires_at > now)는 이 지점이 단일 관문이다. 정책 미들웨어·정책 서비스·
+ * 회원가입/비밀번호재설정 리스너·IdvTokenRule 이 모두 이 메서드를 경유하므로
+ * 호출부마다 만료 검사를 중복해 두지 않는다(한쪽 누락 시 그 경로가 우회로가 된다).
+ * expires_at 이 비어 있는 로그도 반환하지 않는다 — 모든 provider 가 challenge 생성 시
+ * expires_at 을 세팅하므로 NULL 은 정상 발급 산물이 아니다.
*
* @param string $token 검증 토큰
* @param string $purpose 본인인증 목적
@@ -102,6 +108,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
->where('purpose', $purpose)
->where('status', IdentityVerificationStatus::Verified->value)
->whereNull('consumed_at')
+ ->where('expires_at', '>', Carbon::now())
->first();
}
diff --git a/app/Support/OutboundProxy.php b/app/Support/OutboundProxy.php
index d6fb7bd9..4899890b 100644
--- a/app/Support/OutboundProxy.php
+++ b/app/Support/OutboundProxy.php
@@ -163,29 +163,56 @@ final class OutboundProxy
* 빈 항목과 중복을 걸러내고 순번을 다시 매깁니다 — 비연속 키는 JSON 직렬화 시 객체가 되어
* Guzzle 이 목록으로 읽지 못합니다.
*
+ * 목록 맨 앞에는 사이트 자기 호스트와 루프백이 항상 들어갑니다 (selfHosts 참조).
+ *
* @param mixed $value 원본 예외 목록
* @return array 정규화된 호스트 목록
*/
private static function normalizeBypass(mixed $value): array
{
- if (! is_array($value)) {
- return [];
- }
+ $hosts = self::selfHosts();
- $hosts = [];
+ if (is_array($value)) {
+ foreach ($value as $host) {
+ if (! is_string($host)) {
+ continue;
+ }
- foreach ($value as $host) {
- if (! is_string($host)) {
- continue;
- }
+ $host = trim($host);
- $host = trim($host);
-
- if ($host !== '') {
- $hosts[] = $host;
+ if ($host !== '') {
+ $hosts[] = $host;
+ }
}
}
return array_values(array_unique($hosts));
}
+
+ /**
+ * 프록시를 거치지 않아야 하는 자기 자신 호스트 목록을 돌려줍니다.
+ *
+ * 아웃바운드 프록시는 **바깥으로 나가는** 트래픽의 출발지를 지정하려는 장치입니다. 그런데
+ * 사이트는 자기 자신에게도 HTTP 를 겁니다 — SEO 렌더러가 데이터소스를 부를 때, API 문서
+ * 생성기가 엔드포인트를 탐침할 때가 그렇습니다. 그 요청까지 프록시로 내보내면 프록시가
+ * 응답하지 않을 때 호출마다 연결 실패 시각까지 매달리고, 실패는 폴백으로 삼켜지므로
+ * 예외도 오류 화면도 없이 저장 요청만 느려집니다. 운영자에게는 원인을 알 단서가 없습니다.
+ *
+ * 자기 자신으로 가는 요청을 프록시로 보내는 것은 어떤 구성에서도 의도가 아니므로, 운영자가
+ * 예외 목록에 적었는지와 무관하게 항상 제외합니다.
+ *
+ * @return array 항상 우회할 호스트 목록
+ */
+ private static function selfHosts(): array
+ {
+ $hosts = ['localhost', '127.0.0.1', '::1'];
+
+ $appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
+
+ if (is_string($appHost) && $appHost !== '') {
+ $hosts[] = $appHost;
+ }
+
+ return $hosts;
+ }
}
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
index f40bda08..66fa08d0 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
@@ -9,6 +9,7 @@
### Added
- 게시판 환경설정 > 일괄 적용 확인 창의 「금지어」 항목 이름 일본어 번역을 추가했습니다 (`admin/settings.bulk_apply.field_labels.blocked_keywords`).
+- 열람 권한이 없는 비밀글에 댓글·답글을 작성하려 할 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 (`messages.comment.post_secret`, `validation.comment.post_id.secret`, `validation.post.parent_id.secret`).
## [1.0.3] - 2026-08-19
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
index 01a0cc78..82632a15 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
@@ -104,6 +104,7 @@ return [
'verify_password_failed' => 'パスワードの確認に失敗しました。',
'post_blinded' => 'ブロック処理された投稿にはコメントを作成できません。',
'post_deleted' => '削除された投稿にはコメントを作成できません。',
+ 'post_secret' => '閲覧権限のない秘密投稿にはコメントを作成できません。',
],
'comments' => [
'comments_disabled' => 'この掲示板はコメント機能が無効化されています。',
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php
index 73ed656a..6eb192d8 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php
@@ -215,6 +215,7 @@ return [
'not_found' => '元の投稿が見つかりません。',
'blinded' => '非表示にされた投稿には返信を作成することはできません。',
'deleted' => '削除された投稿には返信を作成することはできません。',
+ 'secret' => '閲覧権限のない秘密投稿には返信を作成することはできません。',
'depth_exceeded' => 'この掲示板は返信を:max段階までのみ許可しています。',
'notice_not_allowed' => 'お知らせには返信を作成することはできません。',
],
@@ -385,6 +386,7 @@ return [
'not_found' => '投稿が見つかりません。',
'blinded' => 'ブラインド処理された投稿にはコメントを作成できません。',
'deleted' => '削除された投稿にはコメントを作成できません。',
+ 'secret' => '閲覧権限のない秘密投稿にはコメントを作成できません。',
],
'parent_id' => [
'exists' => '存在しないコメントです。',
diff --git a/modules/_bundled/sirsoft-board/AGENTS.md b/modules/_bundled/sirsoft-board/AGENTS.md
index 9fec95c7..350416ec 100644
--- a/modules/_bundled/sirsoft-board/AGENTS.md
+++ b/modules/_bundled/sirsoft-board/AGENTS.md
@@ -156,8 +156,8 @@ CRUD 흐름 자체를 **자기 도메인으로 대체**하는 가장 무거운
| 종류 | 개수 | 위치 |
|---|---|---|
-| PHPUnit | 155개 | `modules/_bundled/sirsoft-board/tests` |
-| Vitest | 33개 | `vitest.config.ts` |
+| PHPUnit | 157개 | `modules/_bundled/sirsoft-board/tests` |
+| Vitest | 34개 | `vitest.config.ts` |
| Playwright | 26개 | `tests/Playwright` |
| 시나리오 매니페스트 | 34개 | `tests/scenarios` |
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 91ac8ec8..e3fcb494 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -14,6 +14,12 @@
### Fixed
+- 비회원 글을 비밀번호로 확인하고 들어가도 저장할 때 「게시글 수정 권한이 없습니다」로 거부되던 문제를 수정했습니다. 본인 확인에 쓰는 값이 한 번만 쓸 수 있는 것인데 수정 화면을 불러오는 과정에서 먼저 소모되어, 정작 저장할 때는 남아 있지 않았습니다. 비밀번호를 다시 받을 방법도 화면에 없어 그 글은 수정 자체가 불가능했습니다. 이제 화면을 불러오는 단계에서는 확인만 하고, 실제로 저장하거나 삭제할 때 한 번 사용합니다.
+- 비밀번호를 입력해 비밀글을 열면 댓글 목록이 보이지 않던 문제를 수정했습니다. 「댓글 N」 표시는 그대로인데 그 아래가 비어 있었고, 댓글을 하나 쓰면 그때서야 기존 댓글이 함께 나타났습니다. 비밀번호 확인 응답이 게시글 상세와 같은 내용을 담지 않아 화면이 목록을 잃어버린 것이며, 이제 두 경로가 같은 내용을 돌려줍니다.
+- 삭제된 비밀글이 비밀번호만 알면 열리던 문제를 수정했습니다. 게시글 상세 조회는 삭제된 글에 게시판 관리 권한을 요구하는데 비밀번호 확인 경로에는 그 확인이 없어, 목록에서 사라진 글의 내용을 주소를 아는 사람이 볼 수 있었습니다. 이제 두 경로가 같은 기준을 적용하며, 게시판 관리 권한자는 종전처럼 볼 수 있습니다.
+- 비회원이 자기 글을 수정하면 그 글의 비밀번호가 암호화되지 않은 상태로 저장되던 문제를 수정했습니다. 수정 화면에서 본인 확인용으로 입력한 비밀번호가 저장 대상으로 잘못 흘러 기존 암호화 값을 덮었고, 그 뒤로는 본인 확인이 항상 실패해 작성자가 자기 글을 다시 수정하거나 삭제할 수 없었습니다. 이제 본인 확인용 입력은 저장되지 않으며, 이미 이 문제로 평문이 된 게시글의 비밀번호는 업데이트 시 자동으로 암호화되어 복구됩니다(작성자가 알던 비밀번호는 그대로 사용됩니다).
+- 비밀글을 볼 수 없는 사용자가 그 비밀글에 댓글·답글을 달거나 신고할 수 있던 문제를 수정했습니다. 종전에는 원문만 가려졌을 뿐 하위 글쓰기에는 열람 권한을 다시 확인하지 않아, 내용을 모르는 제3자가 비밀글에 댓글·대댓글·답글을 남기고 그 글과 댓글을 신고할 수 있었습니다. 이제 이 경로 전부에 원문 열람과 같은 기준을 적용합니다. 작성자 본인, 게시판 관리자, 비밀글 읽기 권한을 가진 사용자는 종전처럼 그대로 작성할 수 있고 일반 게시글은 영향받지 않습니다. 비밀번호를 입력해 원문을 연 사용자도 종전처럼 댓글·답글·신고를 남길 수 있습니다 — 비회원이 쓴 비밀글은 작성자 본인의 유일한 확인 수단이 비밀번호이므로, 그 경로가 열려 있어야 자기 글에 이어서 문의할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2044)
+- 비회원이 자기 글을 수정하거나 삭제할 때 쓰는 임시 확인값이 인터넷 주소에 실려 오가던 것을 요청 헤더로 옮겼습니다. 종전에는 이 값이 웹서버 접속 기록과 이동 경로 기록에 그대로 남아, 기록을 볼 수 있는 사람이 그 값을 손에 넣을 수 있었습니다. 저장·삭제 요청은 종전 방식도 계속 받으므로 외부 연동은 영향받지 않습니다.
- 게시판 환경설정 > 게시판 설정 > 일괄 적용에서 [일괄 적용] 확인 창의 「적용할 항목」 목록이 아무것도 표시되지 않던 문제를 고쳤습니다. 이제 선택한 항목이 기본/목록/게시글/댓글/첨부파일/알림/권한 설정별로 묶여 표시되므로, 무엇이 적용되는지 확인한 뒤 실행할 수 있습니다.
- 위 목록에서 「금지어」 항목만 이름 대신 내부 식별자가 표시되던 문제를 고쳤습니다.
diff --git a/modules/_bundled/sirsoft-board/docs/api/README.md b/modules/_bundled/sirsoft-board/docs/api/README.md
index bdead860..e236b466 100644
--- a/modules/_bundled/sirsoft-board/docs/api/README.md
+++ b/modules/_bundled/sirsoft-board/docs/api/README.md
@@ -21,3 +21,19 @@
+## 공통 요청 헤더
+
+### `X-Board-Secret-View-Token`
+
+비밀글에 딸린 하위 콘텐츠를 만드는 요청(댓글 작성, 대댓글 작성, 답글 작성, 게시글·댓글 신고)은
+부모 게시글의 원문 열람 권한을 다시 확인합니다. 작성자 본인·게시판 관리자·비밀글 읽기 권한자는
+그대로 통과하지만, **비밀번호를 입력해 원문을 연 사용자**는 그 사실이 검증 응답 하나에만
+남기 때문에 이 헤더로 넘겨야 합니다.
+
+- 발급: `POST /boards/{slug}/posts/{id}/verify-password` 응답 최상위 `secret_view_token`
+- 사용: 이후 그 게시글 관련 요청의 `X-Board-Secret-View-Token` 헤더
+- 범위: 발급받은 게시글에만 유효하며 다른 글에는 통하지 않습니다
+- 수명: 소비되지 않고 `secret_view_expires_at` 까지 여러 번 쓸 수 있습니다
+- 미제시: 위 경로에서 `422` 와 함께 "열람 권한이 없는 비밀글" 문구가 반환됩니다.
+ 비밀글이 아닌 게시글에서는 이 헤더가 평가되지 않습니다.
+
diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md
index 113e8727..bfcbc4ce 100644
--- a/modules/_bundled/sirsoft-board/docs/api/boards.md
+++ b/modules/_bundled/sirsoft-board/docs/api/boards.md
@@ -3215,6 +3215,8 @@ HTTP/1.1 200
**설명** 게시글 작성/수정 폼의 초기 입력값을 반환합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 권한이 필요하며, 쿼리 파라미터로 모드가 분기됩니다: `post_id` 가 있으면 기존 글 값(수정 모드), `parent_id` 가 있으면 원글 제목을 기반으로 한 답변글 초기값(답변 모드), 둘 다 없으면 빈 기본값(생성 모드)입니다. 수정/답변 모드에서는 본인·관리자 여부와 비회원 글의 비밀번호/토큰 검증을 확인하며, 답변 대상이 블라인드/삭제 상태면 진입을 차단합니다.
+비회원 글의 본인 확인 값(`verification_token`)은 요청 헤더 `X-Board-Post-Verify-Token` 으로 보냅니다 — 자격증명을 쿼리 문자열에 실으면 웹서버 접근 기록과 `Referer` 에 그대로 남기 때문입니다. 호환을 위해 본문/쿼리의 `verification_token` 도 계속 받으며, 헤더가 있으면 헤더를 먼저 사용합니다. 이 값은 조회 단계에서 소비되지 않고 확인만 하며, 저장·삭제 요청에서 한 번 사용되면 폐기됩니다.
+
### GET /api/modules/sirsoft-board/boards/{slug}/posts/form-meta
@@ -3338,6 +3340,8 @@ HTTP/1.1 200
**설명** 게시글 작성/수정 폼 렌더링에 필요한 게시판 메타 정보를 반환합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 권한이 필요하며, 게시판 설정과 사용자 권한(`user_abilities`)을 담습니다. `post_id` 가 있으면 수정 모드로 작성자/작성일/첨부 목록을 포함하되 회원 글은 본인 또는 관리자만, 비회원 글은 비밀번호/검증 토큰 확인을 요구합니다. `parent_id` 가 있으면 답변 모드로 원글 정보를 포함하며, 답변 기능이 꺼져 있거나 원글이 블라인드/삭제 상태면 차단합니다.
+비회원 글의 본인 확인 값(`verification_token`)은 요청 헤더 `X-Board-Post-Verify-Token` 으로 보냅니다 — 자격증명을 쿼리 문자열에 실으면 웹서버 접근 기록과 `Referer` 에 그대로 남기 때문입니다. 호환을 위해 본문/쿼리의 `verification_token` 도 계속 받으며, 헤더가 있으면 헤더를 먼저 사용합니다. 이 값은 조회 단계에서 소비되지 않고 확인만 하며, 저장·삭제 요청에서 한 번 사용되면 폐기됩니다.
+
### DELETE /api/modules/sirsoft-board/boards/{slug}/posts/{id}
@@ -3853,6 +3857,8 @@ HTTP/1.1 200
**설명** 게시글을 수정합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요하며, `UpdatePostRequest` 로 검증된 값으로 갱신합니다. 작성자 본인, 게시판 관리자, 또는 비회원 글의 검증 토큰·비밀번호 확인 중 하나를 만족해야 수정할 수 있고, 조건 미충족 시 403을 반환합니다.
+`password` 와 `verification_token` 은 **본인 확인용 자격증명이며 저장되지 않습니다.** 게시글 비밀번호는 작성 시점에 설정된 값이 그대로 유지되며, 이 엔드포인트로 변경할 수 없습니다(수정 요청에 실린 값을 저장하면 저장된 해시가 평문으로 덮여 이후 본인 확인이 불가능해집니다). 같은 규칙이 댓글 수정 엔드포인트에도 적용됩니다.
+
### GET /api/modules/sirsoft-board/boards/{slug}/posts/{id}/navigation
@@ -3946,6 +3952,13 @@ Content-Type: application/json
_단건 응답: `data` 객체의 필드. 검증 성공 시 `password_verified` 플래그가 설정되어 게시글 상세(`PostResource`)를 그대로 반환합니다 — 즉 `GET /boards/{slug}/posts/{id}` 와 동일 스키마이며, 차이는 비밀글이라도 `content` 와 `attachments` 가 채워진다는 점입니다._
+_이 응답은 `data` 밖 최상위에 다음 두 필드를 함께 싣습니다._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| secret_view_token | string | `k3Jd…40자` | 열람 확인 토큰. 이후 이 게시글의 댓글·답글·신고 요청에 `X-Board-Secret-View-Token` 헤더로 실어 보내면 비밀번호를 맞힌 사실이 인정됩니다. 이 값을 보내지 않으면 원문을 연 사용자도 하위 글쓰기가 거부됩니다. 토큰은 이 게시글에만 유효하며 소비되지 않아 유효기간 안에서 여러 번 쓸 수 있습니다. |
+| secret_view_expires_at | string | `2026-09-04T20:55:42+09:00` | 위 토큰의 만료 시각 (ISO 8601). 기간은 코어 설정 `cache.post_verify_token_ttl` 을 따릅니다. |
+
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| id | integer | `237` | 게시글 기본 키 |
diff --git a/modules/_bundled/sirsoft-board/docs/extension-points.md b/modules/_bundled/sirsoft-board/docs/extension-points.md
index b71ea23d..18a4747e 100644
--- a/modules/_bundled/sirsoft-board/docs/extension-points.md
+++ b/modules/_bundled/sirsoft-board/docs/extension-points.md
@@ -47,50 +47,50 @@
| `sirsoft-board.board_type.before_update` | action | — | `src/Services/BoardTypeService.php:62` |
| `sirsoft-board.board_type.filter_create_data` | filter | — | `src/Services/BoardTypeService.php:38` |
| `sirsoft-board.board_type.filter_update_data` | filter | — | `src/Services/BoardTypeService.php:66` |
-| `sirsoft-board.comment.after_blind` | action | — | `src/Services/CommentService.php:480` |
-| `sirsoft-board.comment.after_create` | action | — | `src/Services/CommentService.php:375` |
-| `sirsoft-board.comment.after_delete` | action | — | `src/Services/CommentService.php:441` |
-| `sirsoft-board.comment.after_restore` | action | — | `src/Services/CommentService.php:516` |
-| `sirsoft-board.comment.after_update` | action | — | `src/Services/CommentService.php:410` |
-| `sirsoft-board.comment.before_blind` | action | — | `src/Services/CommentService.php:471` |
-| `sirsoft-board.comment.before_create` | action | — | `src/Services/CommentService.php:341` |
-| `sirsoft-board.comment.before_delete` | action | — | `src/Services/CommentService.php:431` |
-| `sirsoft-board.comment.before_restore` | action | — | `src/Services/CommentService.php:507` |
-| `sirsoft-board.comment.before_update` | action | — | `src/Services/CommentService.php:399` |
-| `sirsoft-board.comment.filter_create_data` | filter | — | `src/Services/CommentService.php:344` |
-| `sirsoft-board.comment.filter_update_data` | filter | — | `src/Services/CommentService.php:404` |
+| `sirsoft-board.comment.after_blind` | action | — | `src/Services/CommentService.php:496` |
+| `sirsoft-board.comment.after_create` | action | — | `src/Services/CommentService.php:391` |
+| `sirsoft-board.comment.after_delete` | action | — | `src/Services/CommentService.php:457` |
+| `sirsoft-board.comment.after_restore` | action | — | `src/Services/CommentService.php:532` |
+| `sirsoft-board.comment.after_update` | action | — | `src/Services/CommentService.php:426` |
+| `sirsoft-board.comment.before_blind` | action | — | `src/Services/CommentService.php:487` |
+| `sirsoft-board.comment.before_create` | action | — | `src/Services/CommentService.php:357` |
+| `sirsoft-board.comment.before_delete` | action | — | `src/Services/CommentService.php:447` |
+| `sirsoft-board.comment.before_restore` | action | — | `src/Services/CommentService.php:523` |
+| `sirsoft-board.comment.before_update` | action | — | `src/Services/CommentService.php:415` |
+| `sirsoft-board.comment.filter_create_data` | filter | — | `src/Services/CommentService.php:360` |
+| `sirsoft-board.comment.filter_update_data` | filter | — | `src/Services/CommentService.php:420` |
| `sirsoft-board.comment.store_validation_rules` | filter | — | `src/Http/Requests/StoreCommentRequest.php:91` |
| `sirsoft-board.comment.update_validation_rules` | filter | — | `src/Http/Requests/UpdateCommentRequest.php:67` |
| `sirsoft-board.permissions.after_create` | action | — | `src/Services/BoardService.php:431` |
| `sirsoft-board.permissions.after_delete` | action | — | `src/Services/BoardService.php:600` |
| `sirsoft-board.permissions.after_update` | action | — | `src/Services/BoardService.php:523` |
-| `sirsoft-board.post.after_blind` | action | — | `src/Services/PostService.php:500` |
-| `sirsoft-board.post.after_create` | action | — | `src/Services/PostService.php:275` |
-| `sirsoft-board.post.after_delete` | action | — | `src/Services/PostService.php:459` |
-| `sirsoft-board.post.after_restore` | action | — | `src/Services/PostService.php:564` |
-| `sirsoft-board.post.after_update` | action | — | `src/Services/PostService.php:351` |
-| `sirsoft-board.post.before_blind` | action | — | `src/Services/PostService.php:491` |
-| `sirsoft-board.post.before_create` | action | — | `src/Services/PostService.php:250` |
-| `sirsoft-board.post.before_delete` | action | — | `src/Services/PostService.php:426` |
-| `sirsoft-board.post.before_restore` | action | — | `src/Services/PostService.php:533` |
-| `sirsoft-board.post.before_update` | action | — | `src/Services/PostService.php:324` |
+| `sirsoft-board.post.after_blind` | action | — | `src/Services/PostService.php:510` |
+| `sirsoft-board.post.after_create` | action | — | `src/Services/PostService.php:285` |
+| `sirsoft-board.post.after_delete` | action | — | `src/Services/PostService.php:469` |
+| `sirsoft-board.post.after_restore` | action | — | `src/Services/PostService.php:574` |
+| `sirsoft-board.post.after_update` | action | — | `src/Services/PostService.php:361` |
+| `sirsoft-board.post.before_blind` | action | — | `src/Services/PostService.php:501` |
+| `sirsoft-board.post.before_create` | action | — | `src/Services/PostService.php:260` |
+| `sirsoft-board.post.before_delete` | action | — | `src/Services/PostService.php:436` |
+| `sirsoft-board.post.before_restore` | action | — | `src/Services/PostService.php:543` |
+| `sirsoft-board.post.before_update` | action | — | `src/Services/PostService.php:334` |
| `sirsoft-board.post.filter_content_thumbnail` | filter | — | `src/Models/Post.php:138` |
-| `sirsoft-board.post.filter_create_data` | filter | — | `src/Services/PostService.php:253` |
-| `sirsoft-board.post.filter_update_data` | filter | — | `src/Services/PostService.php:329` |
+| `sirsoft-board.post.filter_create_data` | filter | — | `src/Services/PostService.php:263` |
+| `sirsoft-board.post.filter_update_data` | filter | — | `src/Services/PostService.php:339` |
| `sirsoft-board.post.store_validation_rules` | filter | — | `src/Http/Requests/StorePostRequest.php:120` |
| `sirsoft-board.post.update_validation_rules` | filter | — | `src/Http/Requests/UpdatePostRequest.php:77` |
-| `sirsoft-board.report.after_blind_content` | action | — | `src/Services/ReportService.php:712` |
-| `sirsoft-board.report.after_bulk_update_status` | action | — | `src/Services/ReportService.php:492` |
-| `sirsoft-board.report.after_create` | action | — | `src/Services/ReportService.php:292` |
-| `sirsoft-board.report.after_delete` | action | — | `src/Services/ReportService.php:559` |
-| `sirsoft-board.report.after_delete_content` | action | — | `src/Services/ReportService.php:880` |
-| `sirsoft-board.report.after_restore_content` | action | — | `src/Services/ReportService.php:678` |
-| `sirsoft-board.report.after_update_status` | action | — | `src/Services/ReportService.php:369` |
-| `sirsoft-board.report.before_bulk_update_status` | action | — | `src/Services/ReportService.php:385` |
-| `sirsoft-board.report.before_create` | action | — | `src/Services/ReportService.php:203` |
-| `sirsoft-board.report.before_delete` | action | — | `src/Services/ReportService.php:554` |
-| `sirsoft-board.report.before_update_status` | action | — | `src/Services/ReportService.php:324` |
-| `sirsoft-board.report.filter_create_data` | filter | — | `src/Services/ReportService.php:227` |
+| `sirsoft-board.report.after_blind_content` | action | — | `src/Services/ReportService.php:717` |
+| `sirsoft-board.report.after_bulk_update_status` | action | — | `src/Services/ReportService.php:497` |
+| `sirsoft-board.report.after_create` | action | — | `src/Services/ReportService.php:297` |
+| `sirsoft-board.report.after_delete` | action | — | `src/Services/ReportService.php:564` |
+| `sirsoft-board.report.after_delete_content` | action | — | `src/Services/ReportService.php:885` |
+| `sirsoft-board.report.after_restore_content` | action | — | `src/Services/ReportService.php:683` |
+| `sirsoft-board.report.after_update_status` | action | — | `src/Services/ReportService.php:374` |
+| `sirsoft-board.report.before_bulk_update_status` | action | — | `src/Services/ReportService.php:390` |
+| `sirsoft-board.report.before_create` | action | — | `src/Services/ReportService.php:208` |
+| `sirsoft-board.report.before_delete` | action | — | `src/Services/ReportService.php:559` |
+| `sirsoft-board.report.before_update_status` | action | — | `src/Services/ReportService.php:329` |
+| `sirsoft-board.report.filter_create_data` | filter | — | `src/Services/ReportService.php:232` |
| `sirsoft-board.roles.after_create` | action | — | `src/Services/BoardService.php:411` |
| `sirsoft-board.roles.after_delete` | action | — | `src/Services/BoardService.php:604` |
| `sirsoft-board.search.post.index_should_update` | filter | — | `src/Models/Post.php:280` |
diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/secret-view-token-wiring.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/secret-view-token-wiring.test.ts
new file mode 100644
index 00000000..e0ff1db5
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/secret-view-token-wiring.test.ts
@@ -0,0 +1,116 @@
+/**
+ * 비밀글 열람 확인 토큰 배선 검증 테스트
+ *
+ * @description
+ * 비밀번호로 비밀글 원문을 연 사용자는 그 사실을 다음 요청으로 넘겨야 댓글·답글·신고를
+ * 남길 수 있다. 서버는 그 사실을 검증 응답 하나에만 담으므로, 화면이 ①응답의 토큰을
+ * 보관하고 ②게시판 API 요청에 헤더로 실어야 흐름이 성립한다.
+ *
+ * 이 배선은 빠져도 예외도 콘솔 오류도 남기지 않는다 — 원문이 열린 화면에서 댓글 버튼을
+ * 눌렀을 때 "열람 권한이 없는 비밀글" 로 거부되는 것이 유일한 증상이고, 서버 게이트는
+ * 정상 동작 중이라 백엔드 테스트로는 드러나지 않는다. 그래서 구조로 잠근다.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import userBase from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/_user_base.json';
+import basicShow from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json';
+import boardForm from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/board/form.json';
+
+const HEADER = 'X-Board-Secret-View-Token';
+const MODIFY_HEADER = 'X-Board-Post-Verify-Token';
+const BOARD_API_PATTERN = '/api/modules/sirsoft-board/*';
+
+/**
+ * 레이아웃 트리를 평탄화해 모든 노드를 돌려줍니다.
+ */
+function walk(node: unknown, out: Record[] = []): Record[] {
+ if (Array.isArray(node)) {
+ node.forEach((child) => walk(child, out));
+
+ return out;
+ }
+
+ if (node && typeof node === 'object') {
+ out.push(node as Record);
+ Object.values(node as Record).forEach((value) => walk(value, out));
+ }
+
+ return out;
+}
+
+describe('비밀글 열람 확인 토큰 배선', () => {
+ it('_user_base 의 globalHeaders 가 게시판 API 에 열람 토큰 헤더를 주입한다', () => {
+ const rules = (userBase as Record).globalHeaders;
+
+ expect(Array.isArray(rules)).toBe(true);
+
+ const boardRule = rules.find((rule: any) => rule?.pattern === BOARD_API_PATTERN);
+
+ expect(
+ boardRule,
+ `globalHeaders 에 ${BOARD_API_PATTERN} 규칙이 없습니다 — 열람 토큰이 요청에 실리지 않아 원문을 연 사용자의 댓글·답글·신고가 전부 거부됩니다.`
+ ).toBeDefined();
+
+ expect(boardRule.headers?.[HEADER]).toBe('{{_global.secretViewToken}}');
+ });
+
+ it('비밀번호 검증 성공 시 응답의 토큰을 _global.secretViewToken 에 보관한다', () => {
+ const nodes = walk(basicShow);
+
+ const verifyCall = nodes.find(
+ (node) =>
+ typeof node.target === 'string' &&
+ node.target.includes('/verify-password') &&
+ !node.target.includes('verify-password-for-modify')
+ );
+
+ expect(verifyCall, '비밀글 비밀번호 검증 apiCall 을 찾지 못했습니다.').toBeDefined();
+
+ const stored = walk(verifyCall!.onSuccess).some(
+ (node) =>
+ node.handler === 'setState' &&
+ (node.params as Record | undefined)?.target === 'global' &&
+ typeof (node.params as Record | undefined)?.secretViewToken === 'string'
+ );
+
+ expect(
+ stored,
+ 'verify-password 의 onSuccess 가 secret_view_token 을 _global.secretViewToken 에 보관하지 않습니다 — 토큰이 없으면 globalHeaders 가 빈 값을 보내 헤더 자체가 붙지 않습니다.'
+ ).toBe(true);
+ });
+});
+
+describe('게시글 수정 검증 토큰 전송로', () => {
+ const sources = (boardForm as Record).data_sources as Record[];
+
+ it.each(['form_data', 'form_meta'])(
+ '%s 는 검증 토큰을 헤더로 보낸다 (쿼리 파라미터 금지)',
+ (id) => {
+ const source = sources.find((entry) => entry?.id === id);
+
+ expect(source, `${id} 데이터소스를 찾지 못했습니다.`).toBeDefined();
+
+ expect(
+ source!.params?.verification_token,
+ `${id} 가 검증 토큰을 쿼리 파라미터로 보냅니다 — 자격증명이 주소에 실려 웹서버 접근 기록과 Referer 에 그대로 남습니다.`
+ ).toBeUndefined();
+
+ expect(
+ source!.headers?.[MODIFY_HEADER],
+ `${id} 가 검증 토큰 헤더를 보내지 않습니다 — 비회원 작성자가 자기 글을 수정할 수 없게 됩니다.`
+ ).toBe("{{_local.verificationToken ?? ''}}");
+ }
+ );
+
+ it('검증 토큰을 담는 엔드포인트가 GET 인지 확인한다 (전송로 판단 근거)', () => {
+ sources
+ .filter((source) => source?.headers?.[MODIFY_HEADER])
+ .forEach((source) => {
+ expect(
+ String(source.method).toUpperCase(),
+ '헤더 전송이 필요한 이유는 GET 이기 때문입니다 — 메서드가 바뀌면 이 계약을 다시 판단해야 합니다.'
+ ).toBe('GET');
+ });
+ });
+});
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php
index 3dbb57c8..1a3d15ed 100644
--- a/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php
@@ -47,6 +47,19 @@ class PostNotCommentableException extends Exception
return new self('deleted', 'sirsoft-board::messages.comment.post_deleted');
}
+ /**
+ * 열람 권한이 없는 비밀글에 대한 예외를 생성합니다.
+ *
+ * 비밀글 원문을 볼 수 없는 사용자는 그 게시글의 하위 콘텐츠도 만들 수 없다
+ * (KVE-2026-2044). 판정 SSoT 는 SecretContentGate 다.
+ *
+ * @return self 비밀글 사유 예외
+ */
+ public static function secret(): self
+ {
+ return new self('secret', 'sirsoft-board::messages.comment.post_secret');
+ }
+
/**
* 다국어 메시지 키를 반환합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php
index 0c7baf31..280cb1e4 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php
@@ -26,6 +26,7 @@ use Modules\Sirsoft\Board\Http\Requests\User\VerifyGuestPasswordRequest;
use Modules\Sirsoft\Board\Http\Resources\BoardResource;
use Modules\Sirsoft\Board\Http\Resources\PostCollection;
use Modules\Sirsoft\Board\Http\Resources\PostResource;
+use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\CommentService;
@@ -161,89 +162,7 @@ class PostController extends PublicBaseController
$post->view_count = (int) $post->view_count + 1;
}
- // manager 권한 체크 (삭제 게시글/댓글 포함 여부 결정)
- $canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
-
- // 댓글 로드 (게시판 comment_order 설정 적용, manager 권한 + 토글 ON 시 삭제 댓글 포함)
- $withTrashedComments = $canViewDeleted && $request->boolean('del_cmt');
-
- // comment_page 가 오면 원댓글 기준 페이지네이션 경로를 쓴다. 댓글이 상한을 넘는
- // 글에서도 뒤쪽 댓글에 도달할 수 있어야 하기 때문이다. 파라미터가 없으면
- // 종전대로 상한까지 전량을 싣는다(기존 화면 응답 형태 불변).
- $commentPage = $this->resolveCommentPage($request);
- $commentPagination = null;
-
- if ($commentPage !== null) {
- $paginated = $this->commentService->paginateCommentsByPostId(
- $slug,
- $id,
- perPage: $commentPage['per_page'],
- page: $commentPage['page'],
- context: 'user',
- withTrashed: $withTrashedComments,
- boardId: $board->id,
- board: $board,
- );
-
- $comments = $paginated->getCollection();
- $commentPagination = [
- 'current_page' => $paginated->currentPage(),
- 'per_page' => $paginated->perPage(),
- 'total' => $paginated->total(),
- 'last_page' => $paginated->lastPage(),
- 'has_more_pages' => $paginated->hasMorePages(),
- 'total_relation' => $paginated->totalRelation()->value,
- 'total_is_exact' => $paginated->totalRelation()->isExact(),
- 'result_cap' => $paginated->resultCap(),
- ];
- } else {
- $comments = $this->commentService->getCommentsByPostId($slug, $id, context: 'user', withTrashed: $withTrashedComments, boardId: $board->id, board: $board);
- }
-
- // 신고 여부 일괄 조회 (N+1 방지: 댓글별 개별 쿼리 → 1회 일괄 쿼리)
- $user = $request->user();
- if ($user) {
- $commentIds = $comments->pluck('id')->all();
- $reportedCommentIds = $this->reportService
- ->getReportedTargetIds($user->id, $board->id, 'comment', $commentIds);
-
- foreach ($comments as $comment) {
- $comment->is_already_reported_preloaded = in_array($comment->id, $reportedCommentIds);
- $comment->setRelation('post', $post);
- }
- } else {
- // 비로그인: 신고 불가이므로 모두 false
- foreach ($comments as $comment) {
- $comment->is_already_reported_preloaded = false;
- $comment->setRelation('post', $post);
- }
- }
-
- // 정렬된 댓글을 post에 설정
- $post->setRelation('comments', $comments);
-
- // 댓글 목록은 상한에서 끊길 수 있다. 끊겼다면 그 사실을 화면에 알린다 —
- // 조용히 잘라내면 사용자에게는 "댓글이 그만큼뿐" 으로 보인다.
- // 상한 이하면 이미 전량을 받았으므로 세는 쿼리를 추가하지 않는다.
- $commentCap = PaginationLimits::resultCap('board.comments');
-
- if ($commentPagination !== null) {
- // 페이지네이션 경로는 잘림 여부를 페이지 메타가 그대로 알린다.
- $post->comments_pagination = $commentPagination;
- $post->comments_total = $commentPagination['total'];
- $post->comments_total_is_exact = $commentPagination['total_is_exact'];
- } elseif ($commentCap !== null && $comments->count() >= $commentCap) {
- $commentTotal = $this->commentService->countCommentsByPostId(
- $slug,
- $id,
- $withTrashedComments,
- $board->id
- );
-
- $post->comments_truncated = true;
- $post->comments_total = $commentTotal->total;
- $post->comments_total_is_exact = $commentTotal->totalRelation()->isExact();
- }
+ $this->attachComments($request, $post, $board, $slug, $id);
// 비밀글 권한 체크 및 content 필터링은 PostResource에서 처리
return $this->successWithResource(
@@ -443,7 +362,11 @@ class PostController extends PublicBaseController
}
// 게시글 수정
- $data = $request->validated();
+ // password / verification_token 은 본인 확인용 자격증명이므로 저장 데이터에서 제거한다.
+ // 그대로 넘기면 검증에 쓰인 평문이 기존 bcrypt 해시를 덮어써, 비밀번호가 평문으로
+ // 남고 이후 본인 확인이 "bcrypt 가 아니다" 예외로 끝나 본인이 자기 글을 수정·삭제할
+ // 수 없게 된다. 댓글 수정 경로(CommentController::update)와 같은 규칙이다.
+ $data = collect($request->validated())->except(['password', 'verification_token'])->toArray();
// `attachment_ids` 를 Service 로 넘기지 않으면 검증(형식·개수 상한 합산)은 통과하고
// 첨부만 조용히 연결되지 않는다 (200 + 첨부 0 건). 관리자 경로는 넘기고 있으므로
@@ -550,7 +473,15 @@ class PostController extends PublicBaseController
}
// 게시글 조회 (첨부파일 포함). 이미 조회한 Board 를 넘겨 재조회를 막는다.
- $post = $this->postService->getPostWithCounts($slug, $id, board: $board);
+ // context 는 상세 조회와 같은 값을 넘긴다 — 라우트의 {id} 는 Model 로 resolve
+ // 되지 않아 미들웨어 스코프 검사가 건너뛰므로, 서비스 계층이 유일한 스코프 관문이다.
+ $post = $this->postService->getPostWithCounts($slug, $id, board: $board, context: 'user');
+
+ // 삭제된 게시글은 상세 조회와 동일하게 manager 권한을 요구한다. 이 판정이 없으면
+ // 목록에서 사라진 글의 원문이 주소를 아는 쪽에만 열려 형제 경로로 새어나간다.
+ if ($post->trashed() && ! $this->checkBoardPermission($slug, 'manager', PermissionType::User)) {
+ throw new PostNotFoundException($id);
+ }
// 비밀번호 검증 (Service 사용)
$password = $request->validated('password');
@@ -563,11 +494,22 @@ class PostController extends PublicBaseController
// 검증 성공 - password_verified 플래그 설정하여 PostResource에서 content 포함
$post->password_verified = true;
+ // 상세 조회와 같은 스키마를 돌려준다. 화면은 이 응답으로 게시글 데이터소스를
+ // 통째로 교체하므로, 댓글을 싣지 않으면 목록이 사라진 채 "댓글 N" 헤더만 남는다.
+ $this->attachComments($request, $post, $board, $slug, $id);
+
+ // 이 플래그는 이 응답 안에서만 산다. 화면은 원문이 열린 사람에게 댓글·답글·신고를
+ // 내주는데 그 요청들은 각각 별개라, 같은 사실을 넘길 토큰을 함께 발급한다.
+ $viewToken = $this->postService->issueSecretViewToken($slug, $id);
+
// successWithResource 사용: $this->when() 조건부 필드가 올바르게 직렬화됨
// (toArray 직접 호출 시 MissingValue 객체가 반환되는 문제 방지)
return $this->successWithResource(
'sirsoft-board::messages.posts.password_verified',
- new PostResource($post)
+ (new PostResource($post))->additional([
+ 'secret_view_token' => $viewToken['token'],
+ 'secret_view_expires_at' => $viewToken['expires_at'],
+ ])
);
} catch (ModelNotFoundException $e) {
throw new PostNotFoundException($id);
@@ -681,11 +623,9 @@ class PostController extends PublicBaseController
$requiresPassword = ! $post->user_id && $post->password;
// verification_token이 유효하면 비밀번호 확인 불필요
- if ($requiresPassword && $request->filled('verification_token')) {
- $token = $request->get('verification_token');
- if ($this->isVerificationTokenValid($slug, $postId, $token)) {
- $requiresPassword = false;
- }
+ $token = $this->verificationTokenFrom($request);
+ if ($requiresPassword && $token !== '' && $this->verificationTokenMatches($slug, $postId, $token)) {
+ $requiresPassword = false;
}
$metaData['requires_password'] = $requiresPassword;
@@ -771,9 +711,9 @@ class PostController extends PublicBaseController
// 비밀글 또는 비회원 글의 검증 처리
// 1. verification_token으로 검증 (권장 - 비밀번호 재전송 불필요)
// 2. password로 검증 (fallback)
- if ($request->filled('verification_token')) {
- $token = $request->get('verification_token');
- if ($this->isVerificationTokenValid($slug, $postId, $token)) {
+ $token = $this->verificationTokenFrom($request);
+ if ($token !== '') {
+ if ($this->verificationTokenMatches($slug, $postId, $token)) {
$post->password_verified = true;
}
} elseif ($request->filled('password') && $post->password) {
@@ -798,7 +738,7 @@ class PostController extends PublicBaseController
'parent_id' => $postData['parent_id'] ?? null,
'attachments' => $postData['attachments'] ?? [],
// 비회원 글 수정 시 verification_token 유지 (PUT 요청에 필요)
- 'verification_token' => $request->get('verification_token', ''),
+ 'verification_token' => $this->verificationTokenFrom($request),
];
}
// 답변글 모드
@@ -940,8 +880,8 @@ class PostController extends PublicBaseController
// 3. 비회원 게시글인 경우 verification_token 또는 비밀번호로 확인
if (! $post->user_id && $post->password) {
// 3-1. verification_token 확인 (권장)
- $token = $request->input('verification_token');
- if ($token && $this->isVerificationTokenValid($slug, $post->id, $token)) {
+ $token = $this->verificationTokenFrom($request);
+ if ($token !== '' && $this->consumeVerificationToken($slug, $post->id, $token)) {
return true;
}
@@ -960,14 +900,58 @@ class PostController extends PublicBaseController
// =========================================================================
/**
- * verification_token이 유효한지 확인하고 소비합니다.
+ * 요청에서 게시글 검증 토큰을 꺼냅니다.
+ *
+ * 헤더를 먼저 본다 — 자격증명이 주소에 실리면 웹서버 접근 기록과 Referer 에 남는다.
+ * 폼 조회(GET)는 헤더로만 보내도록 바꿨지만, 저장·삭제는 이미 본문으로 보내고 있고
+ * 외부에서 그 형태로 호출하는 쪽이 있을 수 있어 기존 경로도 계속 받는다.
+ *
+ * @param Request $request HTTP 요청
+ * @return string 검증 토큰 (없으면 빈 문자열)
+ */
+ private function verificationTokenFrom(Request $request): string
+ {
+ $header = $request->header(PostService::VERIFY_TOKEN_HEADER);
+
+ if (is_string($header) && $header !== '') {
+ return $header;
+ }
+
+ $value = $request->input('verification_token');
+
+ return is_string($value) ? $value : '';
+ }
+
+ /**
+ * verification_token 이 유효한지 확인만 합니다 (소비하지 않음).
+ *
+ * 수정 화면은 「비밀번호 확인 → 폼 조회 → 저장」 순으로 같은 토큰을 여러 번 제시한다.
+ * 폼 조회가 토큰을 써 버리면 저장 시점에 남지 않아, 비밀번호를 정확히 넣고 본문까지 본
+ * 사용자가 「수정 권한이 없습니다」로 거부된다 — 토큰을 다시 받을 방법이 화면에 없어
+ * 그 글은 수정 자체가 불가능해진다. 그래서 읽기는 이 확인만 쓴다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param string $token 검증 토큰
* @return bool 토큰 유효 여부
*/
- private function isVerificationTokenValid(string $slug, int $postId, string $token): bool
+ private function verificationTokenMatches(string $slug, int $postId, string $token): bool
+ {
+ return $this->postService->hasValidDeleteVerifyToken($slug, $postId, $token);
+ }
+
+ /**
+ * verification_token 을 확인하고 소비합니다.
+ *
+ * 소비는 상태를 바꾸는 요청(수정·삭제)에서만 한다 — 그 지점이 토큰의 목적이고,
+ * 1회용이라야 같은 토큰으로 두 번 쓰는 것을 막을 수 있다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 게시글 ID
+ * @param string $token 검증 토큰
+ * @return bool 토큰 유효 여부
+ */
+ private function consumeVerificationToken(string $slug, int $postId, string $token): bool
{
return $this->postService->consumeDeleteVerifyToken($slug, $postId, $token);
}
@@ -1052,4 +1036,106 @@ class PostController extends PublicBaseController
return ['page' => $page, 'per_page' => $perPage];
}
+
+ /**
+ * 게시글에 댓글 목록과 그 메타를 적재합니다.
+ *
+ * 상세 조회와 비밀번호 검증이 같은 응답 스키마를 약속하므로(문서: "GET 상세와 동일
+ * 스키마") 적재 규칙을 한 곳에 둔다. 검증 응답에서 이 적재가 빠지면 화면이 그 응답으로
+ * 게시글 데이터소스를 통째로 교체하는 순간 댓글 목록이 사라지는데, 댓글 수는 집계
+ * 컬럼이라 그대로 남아 "댓글 N" 아래가 비어 보인다 — 오류도 경고도 남지 않는다.
+ *
+ * @param Request $request HTTP 요청 (del_cmt / comment_page 파라미터 해석)
+ * @param Post $post 대상 게시글 (comments 관계와 메타가 이 인스턴스에 설정된다)
+ * @param Board $board 이미 조회한 게시판 (재조회 방지)
+ * @param string $slug 게시판 슬러그
+ * @param int $id 게시글 ID
+ */
+ private function attachComments(Request $request, Post $post, Board $board, string $slug, int $id): void
+ {
+ // manager 권한 체크 (삭제 게시글/댓글 포함 여부 결정)
+ $canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
+
+ // 댓글 로드 (게시판 comment_order 설정 적용, manager 권한 + 토글 ON 시 삭제 댓글 포함)
+ $withTrashedComments = $canViewDeleted && $request->boolean('del_cmt');
+
+ // comment_page 가 오면 원댓글 기준 페이지네이션 경로를 쓴다. 댓글이 상한을 넘는
+ // 글에서도 뒤쪽 댓글에 도달할 수 있어야 하기 때문이다. 파라미터가 없으면
+ // 종전대로 상한까지 전량을 싣는다(기존 화면 응답 형태 불변).
+ $commentPage = $this->resolveCommentPage($request);
+ $commentPagination = null;
+
+ if ($commentPage !== null) {
+ $paginated = $this->commentService->paginateCommentsByPostId(
+ $slug,
+ $id,
+ perPage: $commentPage['per_page'],
+ page: $commentPage['page'],
+ context: 'user',
+ withTrashed: $withTrashedComments,
+ boardId: $board->id,
+ board: $board,
+ );
+
+ $comments = $paginated->getCollection();
+ $commentPagination = [
+ 'current_page' => $paginated->currentPage(),
+ 'per_page' => $paginated->perPage(),
+ 'total' => $paginated->total(),
+ 'last_page' => $paginated->lastPage(),
+ 'has_more_pages' => $paginated->hasMorePages(),
+ 'total_relation' => $paginated->totalRelation()->value,
+ 'total_is_exact' => $paginated->totalRelation()->isExact(),
+ 'result_cap' => $paginated->resultCap(),
+ ];
+ } else {
+ $comments = $this->commentService->getCommentsByPostId($slug, $id, context: 'user', withTrashed: $withTrashedComments, boardId: $board->id, board: $board);
+ }
+
+ // 신고 여부 일괄 조회 (N+1 방지: 댓글별 개별 쿼리 → 1회 일괄 쿼리)
+ $user = $request->user();
+ if ($user) {
+ $commentIds = $comments->pluck('id')->all();
+ $reportedCommentIds = $this->reportService
+ ->getReportedTargetIds($user->id, $board->id, 'comment', $commentIds);
+
+ foreach ($comments as $comment) {
+ $comment->is_already_reported_preloaded = in_array($comment->id, $reportedCommentIds);
+ $comment->setRelation('post', $post);
+ }
+ } else {
+ // 비로그인: 신고 불가이므로 모두 false
+ foreach ($comments as $comment) {
+ $comment->is_already_reported_preloaded = false;
+ $comment->setRelation('post', $post);
+ }
+ }
+
+ // 정렬된 댓글을 post에 설정
+ $post->setRelation('comments', $comments);
+
+ // 댓글 목록은 상한에서 끊길 수 있다. 끊겼다면 그 사실을 화면에 알린다 —
+ // 조용히 잘라내면 사용자에게는 "댓글이 그만큼뿐" 으로 보인다.
+ // 상한 이하면 이미 전량을 받았으므로 세는 쿼리를 추가하지 않는다.
+ $commentCap = PaginationLimits::resultCap('board.comments');
+
+ if ($commentPagination !== null) {
+ // 페이지네이션 경로는 잘림 여부를 페이지 메타가 그대로 알린다.
+ $post->comments_pagination = $commentPagination;
+ $post->comments_total = $commentPagination['total'];
+ $post->comments_total_is_exact = $commentPagination['total_is_exact'];
+ } elseif ($commentCap !== null && $comments->count() >= $commentCap) {
+ $commentTotal = $this->commentService->countCommentsByPostId(
+ $slug,
+ $id,
+ $withTrashedComments,
+ $board->id
+ );
+
+ $post->comments_truncated = true;
+ $post->comments_total = $commentTotal->total;
+ $post->comments_total_is_exact = $commentTotal->totalRelation()->isExact();
+ }
+
+ }
}
diff --git a/modules/_bundled/sirsoft-board/src/Rules/CommentValidationRule.php b/modules/_bundled/sirsoft-board/src/Rules/CommentValidationRule.php
index fe05fec0..12ea95ae 100644
--- a/modules/_bundled/sirsoft-board/src/Rules/CommentValidationRule.php
+++ b/modules/_bundled/sirsoft-board/src/Rules/CommentValidationRule.php
@@ -8,13 +8,17 @@ use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
+use Modules\Sirsoft\Board\Support\SecretContentGate;
/**
* 댓글/대댓글 작성 시 검증 규칙
*
* 검증 대상에 따라 다음을 검증합니다:
- * - post_id: 게시글의 블라인드/삭제 상태
- * - parent_id: 부모 댓글의 블라인드/삭제 상태, 게시판 max_comment_depth 초과 여부
+ * - post_id: 게시글의 블라인드/삭제 상태, 비밀글 열람 권한
+ * - parent_id: 부모 댓글의 블라인드/삭제 상태, 부모 게시글의 비밀글 열람 권한,
+ * 게시판 max_comment_depth 초과 여부
+ *
+ * 비밀글 게이트는 요청 단계의 조기 차단이며 최종 관문은 CommentService 다(이중 방어).
*/
class CommentValidationRule implements ValidationRule
{
@@ -78,6 +82,12 @@ class CommentValidationRule implements ValidationRule
if ($post->status === PostStatus::Deleted || $post->deleted_at !== null) {
$fail(__('sirsoft-board::validation.comment.post_id.deleted'));
+
+ return;
+ }
+
+ if (! $this->canWriteChildOf($post, $board)) {
+ $fail(__('sirsoft-board::validation.comment.post_id.secret'));
}
}
@@ -115,9 +125,41 @@ class CommentValidationRule implements ValidationRule
return;
}
+ // 부모 게시글의 비밀글 게이트 — 부모 댓글의 블라인드/삭제 검사와는 별개 축이다.
+ // 대댓글도 비밀글의 하위 콘텐츠이므로 같은 열람 기준을 적용한다 (KVE-2026-2044).
+ $parentPost = Post::where('board_id', $board->id)
+ ->withTrashed()
+ ->find($this->postId ?? $parentComment->post_id);
+
+ if ($parentPost && ! $this->canWriteChildOf($parentPost, $board)) {
+ $fail(__('sirsoft-board::validation.comment.post_id.secret'));
+
+ return;
+ }
+
// 댓글 깊이 제한 검증
if ($parentComment->depth + 1 > $board->max_comment_depth) {
$fail(__('sirsoft-board::validation.comment.depth.exceeded', ['max' => $board->max_comment_depth]));
}
}
+
+ /**
+ * 부모 게시글의 하위 콘텐츠를 작성할 수 있는지 판정합니다.
+ *
+ * 판정 규칙은 읽기 게이트와 같은 SecretContentGate(SSoT)를 재사용한다.
+ * board 관계를 붙여 두는 이유는 게이트의 슬러그 해석이 라우트에 없을 때
+ * 관계로 폴백하기 때문이다(미로딩이면 fail-closed).
+ *
+ * @param Post $post 부모 게시글
+ * @param Board $board 대상 게시판
+ * @return bool 작성 가능 여부
+ */
+ private function canWriteChildOf(Post $post, Board $board): bool
+ {
+ if (! $post->relationLoaded('board')) {
+ $post->setRelation('board', $board);
+ }
+
+ return app(SecretContentGate::class)->canWriteChild($post);
+ }
}
diff --git a/modules/_bundled/sirsoft-board/src/Rules/ParentPostValidationRule.php b/modules/_bundled/sirsoft-board/src/Rules/ParentPostValidationRule.php
index d3495583..2fa7bb4c 100644
--- a/modules/_bundled/sirsoft-board/src/Rules/ParentPostValidationRule.php
+++ b/modules/_bundled/sirsoft-board/src/Rules/ParentPostValidationRule.php
@@ -7,6 +7,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
+use Modules\Sirsoft\Board\Support\SecretContentGate;
/**
* 답글 작성 시 부모 게시글 검증 규칙
@@ -14,6 +15,7 @@ use Modules\Sirsoft\Board\Models\Post;
* parent_id가 있을 때 다음을 검증합니다:
* 1. 게시판의 답글 기능(use_reply) 활성화 여부
* 2. 부모 게시글의 블라인드/삭제 상태
+ * 2-1. 부모 게시글이 비밀글이면 원문 열람 권한 (SecretContentGate SSoT)
* 3. 공지 게시글에는 답글 불가
* 4. 게시판 설정의 max_reply_depth 초과 여부
*/
@@ -78,6 +80,19 @@ class ParentPostValidationRule implements ValidationRule
return;
}
+ // 2-1. 비밀글 하위 쓰기 게이트 (KVE-2026-2044)
+ // 답글도 비밀글의 하위 콘텐츠이므로 원문 열람과 같은 기준을 적용한다.
+ // 판정 SSoT 는 읽기 게이트와 동일한 SecretContentGate 다.
+ if (! $parentPost->relationLoaded('board')) {
+ $parentPost->setRelation('board', $board);
+ }
+
+ if (! app(SecretContentGate::class)->canWriteChild($parentPost)) {
+ $fail(__('sirsoft-board::validation.post.parent_id.secret'));
+
+ return;
+ }
+
// 3. 공지 게시글에는 답글 불가
if ($parentPost->is_notice) {
$fail(__('sirsoft-board::validation.post.parent_id.notice_not_allowed'));
diff --git a/modules/_bundled/sirsoft-board/src/Services/CommentService.php b/modules/_bundled/sirsoft-board/src/Services/CommentService.php
index b87747ce..4e6701a8 100644
--- a/modules/_bundled/sirsoft-board/src/Services/CommentService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/CommentService.php
@@ -19,6 +19,7 @@ use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
+use Modules\Sirsoft\Board\Support\SecretContentGate;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
/**
@@ -299,7 +300,7 @@ class CommentService
* @return bool 댓글 작성 가능 여부
*
* @throws ModelNotFoundException 게시글을 찾을 수 없는 경우
- * @throws PostNotCommentableException 블라인드/삭제된 게시글인 경우
+ * @throws PostNotCommentableException 블라인드/삭제/비열람 비밀 게시글인 경우
*/
public function validatePostForComment(string $slug, int $postId): bool
{
@@ -313,6 +314,21 @@ class CommentService
throw PostNotCommentableException::deleted();
}
+ // 비밀글 하위 쓰기 게이트 (KVE-2026-2044) — 요청 단계 규칙을 우회해도 여기서 막힌다.
+ // 서비스가 최종 관문이므로 판정은 읽기와 같은 SecretContentGate(SSoT)를 쓴다.
+ // 비밀글일 때만 board 를 붙인다: 게이트의 슬러그 해석이 라우트에 없으면 관계로
+ // 폴백하는데, 미로딩이면 fail-closed 라 비-HTTP 호출에서 정상 흐름까지 막힌다.
+ // 비밀글이 아니면 게이트는 언제나 통과하므로 그 조회를 하지 않는다.
+ if ($post->is_secret) {
+ if (! $post->relationLoaded('board')) {
+ $post->load('board');
+ }
+
+ if (! app(SecretContentGate::class)->canWriteChild($post)) {
+ throw PostNotCommentableException::secret();
+ }
+ }
+
return true;
}
diff --git a/modules/_bundled/sirsoft-board/src/Services/PostService.php b/modules/_bundled/sirsoft-board/src/Services/PostService.php
index 5c1727d5..ecaf375f 100644
--- a/modules/_bundled/sirsoft-board/src/Services/PostService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/PostService.php
@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException;
@@ -36,6 +37,15 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
*/
class PostService
{
+ /**
+ * 게시글 수정·삭제용 검증 토큰을 싣는 요청 헤더 이름.
+ *
+ * 이 토큰은 종전에 GET 쿼리 파라미터로만 다녔다 — 자격증명이 주소에 실리면 웹서버
+ * 접근 기록과 Referer 에 그대로 남는다. 헤더로 옮기되, 이미 본문으로 보내는 저장·삭제
+ * 요청과 외부 연동을 깨지 않도록 기존 경로도 계속 받는다.
+ */
+ public const VERIFY_TOKEN_HEADER = 'X-Board-Post-Verify-Token';
+
/**
* 검색 정렬 이름 → [실제 컬럼, 방향] 선언
*
@@ -1371,6 +1381,28 @@ class PostService
];
}
+ /**
+ * 게시글 비밀번호 검증 토큰이 유효한지 확인만 합니다 (소비하지 않음).
+ *
+ * 수정 화면은 「비밀번호 확인 → 폼 조회 → 저장」 순으로 같은 토큰을 여러 번 제시합니다.
+ * 폼 조회 단계가 토큰을 소비하면 저장 시점에는 남아 있지 않아, 비밀번호를 정확히 입력한
+ * 사용자가 수정 권한 없음으로 거부됩니다. 조회는 이 확인을, 상태를 바꾸는 요청만
+ * `consumeDeleteVerifyToken()` 을 씁니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 게시글 ID
+ * @param string $token 검증 토큰
+ * @return bool 토큰 유효 여부
+ */
+ public function hasValidDeleteVerifyToken(string $slug, int $postId, string $token): bool
+ {
+ if ($token === '') {
+ return false;
+ }
+
+ return $this->cache->has("board_post_verify_{$slug}_{$postId}_{$token}");
+ }
+
/**
* 게시글 비밀번호 검증 토큰의 유효성을 확인하고 소비합니다.
*
@@ -1392,6 +1424,67 @@ class PostService
return true;
}
+ /**
+ * 비밀글 열람 확인 토큰을 발급해 캐시에 저장합니다.
+ *
+ * 비밀번호를 맞혔다는 사실은 그 응답 하나에만 살아 있고 다음 요청으로 이어지지 않습니다
+ * (`$post->password_verified` 는 메모리 플래그입니다). 그런데 화면은 원문이 열린 사람에게
+ * 댓글·답글·신고를 내주므로, 그 후속 요청이 같은 사실을 제시할 통로가 필요합니다.
+ *
+ * 수정·삭제용 토큰(storeDeleteVerifyToken)과 달리 **소비하지 않습니다** — 열람자는 한
+ * 화면에서 댓글을 여러 번 달 수 있고, 1회용이면 두 번째부터 다시 비밀번호를 물어야 합니다.
+ * 대신 게시글 단위로 묶이고 유효기간이 있어, 권한 범위는 비밀번호를 아는 것과 같습니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 게시글 ID
+ * @return array{token: string, expires_at: string} 토큰 및 만료 시각
+ */
+ public function issueSecretViewToken(string $slug, int $postId): array
+ {
+ $ttl = (int) g7_core_settings('cache.post_verify_token_ttl', 3600);
+ $token = Str::random(40);
+ $expiresAt = now()->addSeconds($ttl);
+
+ $this->cache->put(self::secretViewTokenKey($slug, $postId, $token), true, $ttl);
+
+ return [
+ 'token' => $token,
+ 'expires_at' => $expiresAt->toIso8601String(),
+ ];
+ }
+
+ /**
+ * 비밀글 열람 확인 토큰이 그 게시글에 대해 유효한지 확인합니다 (소비하지 않음).
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 게시글 ID
+ * @param string|null $token 제시된 토큰
+ * @return bool 유효 여부
+ */
+ public function hasValidSecretViewToken(string $slug, int $postId, ?string $token): bool
+ {
+ if (! is_string($token) || $token === '') {
+ return false;
+ }
+
+ return $this->cache->has(self::secretViewTokenKey($slug, $postId, $token));
+ }
+
+ /**
+ * 비밀글 열람 확인 토큰의 캐시 키를 만듭니다.
+ *
+ * 게시판 슬러그와 게시글 ID 를 키에 넣어, 한 글에서 받은 토큰이 다른 글에 통하지 않게 합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 게시글 ID
+ * @param string $token 토큰
+ * @return string 캐시 키
+ */
+ private static function secretViewTokenKey(string $slug, int $postId, string $token): string
+ {
+ return "board_post_secret_view_{$slug}_{$postId}_{$token}";
+ }
+
/**
* 관리자 작업 이력 배열을 생성합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Services/ReportService.php b/modules/_bundled/sirsoft-board/src/Services/ReportService.php
index d6461cc8..e5581c74 100644
--- a/modules/_bundled/sirsoft-board/src/Services/ReportService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/ReportService.php
@@ -6,19 +6,24 @@ use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
+use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
-use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Modules\Sirsoft\Board\Enums\PostStatus;
-use Modules\Sirsoft\Board\Services\BoardSettingsService;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Enums\TriggerType;
use Modules\Sirsoft\Board\Exceptions\DeletedReportStatusChangeException;
+use Modules\Sirsoft\Board\Exceptions\DuplicateReportException;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
+use Modules\Sirsoft\Board\Support\SecretContentGate;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* 신고 관리 서비스 클래스
@@ -119,7 +124,7 @@ class ReportService
* @param int $id 신고 ID
* @return Report 조회된 신고 모델
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function getReport(int $id): Report
{
@@ -138,9 +143,9 @@ class ReportService
* 1케이스 구조이므로 케이스(report) + 신고자 목록(logs) + 처리 이력(process_histories)을 반환합니다.
*
* @param int $id 케이스 ID
- * @return array{report: Report, reporters: \Illuminate\Database\Eloquent\Collection, cancelled_reports: \Illuminate\Database\Eloquent\Collection, report_count: int, first_reported_at: mixed, last_reported_at: mixed}
+ * @return array{report: Report, reporters: Collection, cancelled_reports: Collection, report_count: int, first_reported_at: mixed, last_reported_at: mixed}
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function getGroupedReportDetail(int $id): array
{
@@ -150,7 +155,7 @@ class ReportService
if (! PermissionHelper::checkScopeAccess($report, 'sirsoft-board.reports.view')) {
throw new AccessDeniedHttpException(__('auth.scope_denied'));
}
-
+
// 신고자 로그 목록 로드 (reporter 관계 eager load)
$report->load(['logs' => fn ($q) => $q->latest()->with('reporter'), 'board']);
@@ -172,12 +177,12 @@ class ReportService
/**
* 신고 케이스의 신고자 목록을 페이지네이션으로 반환합니다.
*
- * @param int $id 신고 케이스 ID
+ * @param int $id 신고 케이스 ID
* @param int $perPage 페이지당 항목 수
- * @param int $page 페이지 번호
- * @return \Illuminate\Pagination\LengthAwarePaginator
+ * @param int $page 페이지 번호
+ * @return LengthAwarePaginator
*/
- public function paginateReporters(int $id, int $perPage = 10, int $page = 1): \Illuminate\Pagination\LengthAwarePaginator
+ public function paginateReporters(int $id, int $perPage = 10, int $page = 1): LengthAwarePaginator
{
$this->reportRepository->findOrFail($id); // 케이스 존재 확인 (404 처리)
@@ -195,7 +200,7 @@ class ReportService
* @param array $data 신고 생성 데이터
* @return Report 케이스 모델
*
- * @throws \Modules\Sirsoft\Board\Exceptions\DuplicateReportException
+ * @throws DuplicateReportException
*/
public function createReport(array $data): Report
{
@@ -301,8 +306,8 @@ class ReportService
* @param array $data 변경할 데이터 (status, process_note)
* @return Report 상태가 변경된 신고 모델
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
- * @throws \Modules\Sirsoft\Board\Exceptions\DeletedReportStatusChangeException 영구삭제 상태에서 변경 시도 시
+ * @throws ModelNotFoundException
+ * @throws DeletedReportStatusChangeException 영구삭제 상태에서 변경 시도 시
*/
public function updateReportStatus(int $id, array $data): Report
{
@@ -504,7 +509,7 @@ class ReportService
* @param string $action 처리 액션 (status 값 또는 reported/re_reported 등 특수 타입)
* @param string|null $reason 처리 사유
* @param int|null $processorId 처리자 ID (신고 접수 시 null)
- * @param \Carbon\Carbon $processedAt 처리 일시
+ * @param Carbon $processedAt 처리 일시
* @param int|null $reporterCount 현재 사이클 신고자 수 (reported/re_reported 시 사용)
* @return array 이력 항목
*/
@@ -539,7 +544,7 @@ class ReportService
* @param int $id 신고 ID
* @return bool 삭제 성공 여부
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function deleteReport(int $id): bool
{
@@ -1254,6 +1259,7 @@ class ReportService
* 신고 대상이 신고 가능한 상태인지 확인합니다.
*
* 블라인드 또는 삭제된 대상은 신고할 수 없습니다.
+ * 열람 권한이 없는 비밀글과 그 비밀글에 달린 댓글도 신고할 수 없습니다.
*
* @param int $boardId 게시판 ID
* @param string $targetType 신고 대상 타입 (post/comment)
@@ -1280,6 +1286,26 @@ class ReportService
return false;
}
+ // 비밀글 하위 쓰기 게이트 (KVE-2026-2044)
+ // 신고도 대상 게시글에 딸린 생성형 쓰기이므로, 원문을 볼 수 없는 사용자는
+ // 그 게시글(또는 그 게시글의 댓글)을 신고할 수 없다. 판정은 읽기 게이트와
+ // 같은 SecretContentGate(SSoT)를 쓴다.
+ $parentPost = $targetType === 'post'
+ ? $target
+ : $this->postRepository->findByBoardId($boardId, (int) $target->post_id);
+
+ // 비밀글일 때만 board 를 붙인다 — 게이트의 슬러그 해석이 라우트에 없으면 관계로
+ // 폴백하고, 미로딩이면 fail-closed 다. 비밀글이 아니면 게이트는 언제나 통과한다.
+ if ($parentPost && $parentPost->is_secret) {
+ if (! $parentPost->relationLoaded('board')) {
+ $parentPost->load('board');
+ }
+
+ if (! app(SecretContentGate::class)->canWriteChild($parentPost)) {
+ return false;
+ }
+ }
+
return true;
}
diff --git a/modules/_bundled/sirsoft-board/src/Support/SecretContentGate.php b/modules/_bundled/sirsoft-board/src/Support/SecretContentGate.php
index 414f1841..976596b4 100644
--- a/modules/_bundled/sirsoft-board/src/Support/SecretContentGate.php
+++ b/modules/_bundled/sirsoft-board/src/Support/SecretContentGate.php
@@ -6,6 +6,7 @@ use App\Enums\PermissionType;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Models\Post;
+use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
/**
@@ -19,13 +20,23 @@ use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
* 열람 가능 조건 (우선순위 순):
* 1. 작성자 본인 (회원 게시글)
* 2. 비밀번호 검증 완료 (`password_verified` 플래그 — 상세 컨텍스트에서만 설정, 리스트 미적용)
+ * 2-b. 비밀번호 검증 응답으로 받은 열람 확인 토큰 (`X-Board-Secret-View-Token` 헤더)
* 3. 게시판별 비밀글 읽기 권한 (posts.read-secret)
* 4. 게시판 관리자 권한 (Admin: admin.manage / User: manager)
+ *
+ * 2 와 2-b 는 같은 사실("비밀번호를 맞혔다")의 두 표현입니다. 2 는 검증한 그 응답 안에서만
+ * 살고, 2-b 는 그 사실을 다음 요청으로 넘깁니다 — 비회원 작성자가 자기 비밀글에 댓글을 다는
+ * 흐름은 별도 요청이라 2-b 가 없으면 성립하지 않습니다.
*/
class SecretContentGate
{
use ChecksBoardPermission;
+ /**
+ * 열람 확인 토큰을 싣는 요청 헤더 이름.
+ */
+ public const VIEW_TOKEN_HEADER = 'X-Board-Secret-View-Token';
+
/**
* 주어진 게시글의 비밀 원문을 현재 요청자가 열람할 수 있는지 판정합니다.
*
@@ -57,6 +68,16 @@ class SecretContentGate
return false;
}
+ // 2-b. 직전 요청에서 비밀번호를 맞히고 받은 열람 확인 토큰.
+ //
+ // 위 2번 플래그는 비밀번호를 검증한 그 응답 안에서만 삽니다. 그런데 댓글·답글·신고는
+ // 각각 별도 요청이라, 토큰이 없으면 원문을 연 사람도 그 사실을 증명할 방법이 없어
+ // 화면이 내준 버튼이 전부 거부됩니다. 토큰은 게시글 단위로 묶이고 유효기간이 있어
+ // 권한 범위는 비밀번호를 아는 것과 같습니다.
+ if ($this->hasSecretViewToken($post, $request, $slug)) {
+ return true;
+ }
+
if ($this->isAdminRequest($request)) {
return $this->checkBoardPermission($slug, 'admin.posts.read-secret')
|| $this->checkBoardPermission($slug, 'admin.manage');
@@ -66,6 +87,52 @@ class SecretContentGate
|| $this->checkBoardPermission($slug, 'manager', PermissionType::User);
}
+ /**
+ * 비밀글의 하위 콘텐츠를 생성할 수 있는지 판정합니다.
+ *
+ * 읽기 게이트(KVE-2026-1914)는 원문 노출만 막았고, 하위 생성 경로(댓글·대댓글·답글·
+ * 신고)는 부모 게시글의 열람 권한을 재적용하지 않아 무권한 사용자가 비밀글에 댓글을
+ * 달거나 신고를 남길 수 있었다(KVE-2026-2044). 판정은 읽기와 같은 canView() 를
+ * 재사용해 강도 분기를 만들지 않는다 — 작성자·비밀글 읽기 권한자·게시판 관리자는
+ * 그대로 통과한다.
+ *
+ * 비밀글이 아니면 항상 통과한다.
+ *
+ * @param Post $post 부모 게시글
+ * @param Request|null $request HTTP 요청 (미지정 시 현재 요청)
+ * @return bool 하위 콘텐츠 생성 가능 여부
+ */
+ public function canWriteChild(Post $post, ?Request $request = null): bool
+ {
+ if (! $post->is_secret) {
+ return true;
+ }
+
+ return $this->canView($post, $request);
+ }
+
+ /**
+ * 요청이 제시한 열람 확인 토큰이 이 게시글에 대해 유효한지 판정합니다.
+ *
+ * 토큰은 헤더로만 받습니다 — 본문으로 받으면 댓글·답글·신고 각각의 FormRequest 에
+ * 필드를 더해야 하고, 그 중 한 곳만 빠져도 그 경로에서만 조용히 거부됩니다.
+ *
+ * @param Post $post 대상 게시글
+ * @param Request $request HTTP 요청
+ * @param string $slug 게시판 슬러그
+ * @return bool 유효한 토큰이 제시되었으면 true
+ */
+ private function hasSecretViewToken(Post $post, Request $request, string $slug): bool
+ {
+ $token = $request->header(self::VIEW_TOKEN_HEADER);
+
+ if (! is_string($token) || $token === '' || ! $post->id) {
+ return false;
+ }
+
+ return app(PostService::class)->hasValidSecretViewToken($slug, (int) $post->id, $token);
+ }
+
/**
* 게시판 슬러그를 해석합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/lang/en/messages.php b/modules/_bundled/sirsoft-board/src/lang/en/messages.php
index cf7b2428..609b28a2 100644
--- a/modules/_bundled/sirsoft-board/src/lang/en/messages.php
+++ b/modules/_bundled/sirsoft-board/src/lang/en/messages.php
@@ -123,6 +123,7 @@ return [
// Comment availability
'post_blinded' => 'You cannot comment on a blinded post.',
'post_deleted' => 'You cannot comment on a deleted post.',
+ 'post_secret' => 'You cannot comment on a secret post you are not allowed to view.',
],
// Additional comment messages
diff --git a/modules/_bundled/sirsoft-board/src/lang/en/validation.php b/modules/_bundled/sirsoft-board/src/lang/en/validation.php
index b0931c47..0a08218b 100644
--- a/modules/_bundled/sirsoft-board/src/lang/en/validation.php
+++ b/modules/_bundled/sirsoft-board/src/lang/en/validation.php
@@ -259,6 +259,7 @@ return [
'not_found' => 'Parent post not found.',
'blinded' => 'Cannot create reply on blinded post.',
'deleted' => 'Cannot create reply on deleted post.',
+ 'secret' => 'Cannot create reply on a secret post you are not allowed to view.',
'depth_exceeded' => 'This board allows replies up to :max level(s) only.',
'notice_not_allowed' => 'Replies cannot be created on notice posts.',
],
@@ -445,6 +446,7 @@ return [
'not_found' => 'Post not found.',
'blinded' => 'Cannot create comment on blinded post.',
'deleted' => 'Cannot create comment on deleted post.',
+ 'secret' => 'Cannot create comment on a secret post you are not allowed to view.',
],
'parent_id' => [
'exists' => 'Parent comment does not exist.',
diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php
index 1d25fa58..f8cb2884 100644
--- a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php
+++ b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php
@@ -123,6 +123,7 @@ return [
// 댓글 작성 가능 여부
'post_blinded' => '블라인드 처리된 게시글에는 댓글을 작성할 수 없습니다.',
'post_deleted' => '삭제된 게시글에는 댓글을 작성할 수 없습니다.',
+ 'post_secret' => '열람 권한이 없는 비밀글에는 댓글을 작성할 수 없습니다.',
],
// 댓글 관련 추가 메시지
diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php
index e5f33fa8..55648b1e 100644
--- a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php
+++ b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php
@@ -259,6 +259,7 @@ return [
'not_found' => '원글을 찾을 수 없습니다.',
'blinded' => '블라인드 처리된 게시글에는 답글을 작성할 수 없습니다.',
'deleted' => '삭제된 게시글에는 답글을 작성할 수 없습니다.',
+ 'secret' => '열람 권한이 없는 비밀글에는 답글을 작성할 수 없습니다.',
'depth_exceeded' => '이 게시판은 답글을 :max단계까지만 허용합니다.',
'notice_not_allowed' => '공지사항에는 답글을 작성할 수 없습니다.',
],
@@ -445,6 +446,7 @@ return [
'not_found' => '게시글을 찾을 수 없습니다.',
'blinded' => '블라인드 처리된 게시글에는 댓글을 작성할 수 없습니다.',
'deleted' => '삭제된 게시글에는 댓글을 작성할 수 없습니다.',
+ 'secret' => '열람 권한이 없는 비밀글에는 댓글을 작성할 수 없습니다.',
],
'parent_id' => [
'exists' => '존재하지 않는 댓글입니다.',
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/SecretPostChildWriteGateTest.php b/modules/_bundled/sirsoft-board/tests/Feature/SecretPostChildWriteGateTest.php
new file mode 100644
index 00000000..9615bbcf
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/SecretPostChildWriteGateTest.php
@@ -0,0 +1,710 @@
+ 게시판 속성
+ */
+ protected function getDefaultBoardAttributes(string $slug): array
+ {
+ return [
+ 'slug' => $slug,
+ 'name' => ['ko' => '비밀글 하위 쓰기', 'en' => 'Secret Child Write'],
+ 'is_active' => true,
+ 'secret_mode' => 'enabled',
+ 'use_comment' => true,
+ 'use_reply' => true,
+ 'use_report' => true,
+ 'max_comment_depth' => 3,
+ 'max_reply_depth' => 3,
+ 'blocked_keywords' => [],
+ ];
+ }
+
+ /**
+ * 테스트 사전 준비를 수행합니다.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->outsider = User::factory()->create();
+ $this->author = User::factory()->create();
+ $this->manager = User::factory()->create();
+
+ $this->grantDefaultGuestPermissions();
+ $this->grantUserRolePermissions([
+ 'posts.read', 'posts.write', 'comments.read', 'comments.write',
+ ]);
+
+ $userRole = Role::where('identifier', 'user')->first();
+ if ($userRole) {
+ foreach ([$this->outsider, $this->author, $this->manager] as $user) {
+ $user->roles()->syncWithoutDetaching([$userRole->id]);
+ }
+ }
+
+ $this->setupManagerRole();
+ $this->resetPermissionMiddlewareCache();
+ }
+
+ /**
+ * 게시판 관리자(manager) 역할을 만들어 manager 사용자에게 부여합니다.
+ */
+ private function setupManagerRole(): void
+ {
+ $slug = $this->board->slug;
+
+ $permIds = [];
+ foreach (['manager', 'posts.read', 'posts.write', 'comments.read', 'comments.write'] as $action) {
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$slug}.{$action}"],
+ [
+ 'name' => ['ko' => $action, 'en' => $action],
+ 'slug' => "sirsoft-board.{$slug}.{$action}",
+ 'type' => 'user',
+ ]
+ );
+ $permIds[] = $perm->id;
+ }
+
+ $managerRole = Role::firstOrCreate(
+ ['identifier' => "{$slug}-manager"],
+ ['name' => ['ko' => '게시판 관리(사용자)', 'en' => 'Board Manager']]
+ );
+ $managerRole->permissions()->syncWithoutDetaching($permIds);
+ $this->manager->roles()->attach($managerRole->id);
+ }
+
+ /**
+ * 비밀 게시글을 만듭니다.
+ *
+ * @param array $attributes 덮어쓸 속성
+ * @return int 생성된 게시글 ID
+ */
+ private function createSecretPost(array $attributes = []): int
+ {
+ return $this->createTestPost(array_merge([
+ 'user_id' => $this->author->id,
+ 'author_name' => '작성자',
+ 'title' => '비밀 게시글',
+ 'content' => '비밀 원문입니다.',
+ 'is_secret' => true,
+ 'status' => 'published',
+ ], $attributes));
+ }
+
+ private function commentsUrl(int $postId): string
+ {
+ return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
+ }
+
+ private function postsUrl(): string
+ {
+ return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts";
+ }
+
+ private function postReportUrl(int $postId): string
+ {
+ return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/reports";
+ }
+
+ private function commentReportUrl(int $commentId): string
+ {
+ return "/api/modules/sirsoft-board/boards/{$this->board->slug}/comments/{$commentId}/reports";
+ }
+
+ /**
+ * 지정 사용자로 요청 컨텍스트를 준비합니다.
+ */
+ private function asUser(User $user): self
+ {
+ $this->resetPermissionMiddlewareCache();
+ $this->actingAs($user, 'sanctum');
+
+ return $this;
+ }
+
+ /**
+ * board_comments 총 건수를 반환합니다.
+ */
+ private function commentCount(): int
+ {
+ return (int) DB::table('board_comments')->where('board_id', $this->board->id)->count();
+ }
+
+ /**
+ * board_posts 총 건수를 반환합니다.
+ */
+ private function postCount(): int
+ {
+ return (int) DB::table('board_posts')->where('board_id', $this->board->id)->count();
+ }
+
+ /**
+ * board_reports 총 건수를 반환합니다.
+ */
+ private function reportCount(): int
+ {
+ return (int) DB::table('boards_reports')->where('board_id', $this->board->id)->count();
+ }
+
+ // =========================================================================
+ // 차단 매트릭스 (무권한 사용자)
+ // =========================================================================
+
+ /**
+ * C1/C2 — 무권한 사용자는 비밀글에 댓글을 달 수 없다.
+ *
+ * @scenario actor=outsider, target=secret_post
+ *
+ * @effects secret_post_comment_blocked_for_outsider
+ */
+ #[Test]
+ public function outsider_cannot_comment_on_secret_post(): void
+ {
+ $postId = $this->createSecretPost();
+ $before = $this->commentCount();
+
+ $response = $this->asUser($this->outsider)->postJson($this->commentsUrl($postId), [
+ 'content' => '무권한 댓글',
+ ]);
+
+ $this->assertContains($response->status(), [403, 422], '비밀글 댓글 작성은 차단되어야 한다');
+ $this->assertSame($before, $this->commentCount(), '차단된 요청은 댓글을 삽입하지 않아야 한다');
+ }
+
+ /**
+ * C3 — 무권한 사용자는 비밀글의 댓글에 대댓글을 달 수 없다.
+ *
+ * @scenario actor=outsider, target=secret_post_comment
+ *
+ * @effects secret_post_reply_comment_blocked_for_outsider
+ */
+ #[Test]
+ public function outsider_cannot_reply_to_comment_on_secret_post(): void
+ {
+ $postId = $this->createSecretPost();
+ $parentCommentId = $this->createTestComment($postId, [
+ 'user_id' => $this->author->id,
+ 'content' => '작성자 댓글',
+ ]);
+ $before = $this->commentCount();
+
+ $response = $this->asUser($this->outsider)->postJson($this->commentsUrl($postId), [
+ 'content' => '무권한 대댓글',
+ 'parent_id' => $parentCommentId,
+ ]);
+
+ $this->assertContains($response->status(), [403, 422], '비밀글 대댓글 작성은 차단되어야 한다');
+ $this->assertSame($before, $this->commentCount());
+ }
+
+ /**
+ * C4 — 무권한 사용자는 비밀글에 답글(답변 게시글)을 달 수 없다.
+ *
+ * @scenario actor=outsider, target=secret_post
+ *
+ * @effects secret_post_reply_post_blocked_for_outsider
+ */
+ #[Test]
+ public function outsider_cannot_write_reply_post_under_secret_post(): void
+ {
+ $postId = $this->createSecretPost();
+ $before = $this->postCount();
+
+ $response = $this->asUser($this->outsider)->postJson($this->postsUrl(), [
+ 'title' => '무권한 답글',
+ 'content' => '무권한 답글 내용입니다. 본문 최소 길이 검증을 넘기기 위한 충분한 길이입니다.',
+ 'parent_id' => $postId,
+ ]);
+
+ $this->assertContains($response->status(), [403, 422], '비밀글 답글 작성은 차단되어야 한다');
+ $this->assertSame($before, $this->postCount(), '차단된 요청은 게시글을 삽입하지 않아야 한다');
+ }
+
+ /**
+ * C5 — 무권한 사용자는 비밀글을 신고할 수 없다.
+ *
+ * @scenario actor=outsider, target=secret_post
+ *
+ * @effects secret_post_report_blocked_for_outsider
+ */
+ #[Test]
+ public function outsider_cannot_report_secret_post(): void
+ {
+ $postId = $this->createSecretPost();
+ $before = $this->reportCount();
+
+ $response = $this->asUser($this->outsider)->postJson($this->postReportUrl($postId), [
+ 'reason_type' => 'spam',
+ 'reason_detail' => '무권한 신고',
+ ]);
+
+ $this->assertContains($response->status(), [403, 422], '비밀글 신고는 차단되어야 한다');
+ $this->assertSame($before, $this->reportCount(), '차단된 요청은 신고를 남기지 않아야 한다');
+ }
+
+ /**
+ * C6 — 무권한 사용자는 비밀글에 달린 댓글을 신고할 수 없다.
+ *
+ * @scenario actor=outsider, target=secret_post_comment
+ *
+ * @effects secret_comment_report_blocked_for_outsider
+ */
+ #[Test]
+ public function outsider_cannot_report_comment_on_secret_post(): void
+ {
+ $postId = $this->createSecretPost();
+ $commentId = $this->createTestComment($postId, [
+ 'user_id' => $this->author->id,
+ 'content' => '작성자 댓글',
+ ]);
+ $before = $this->reportCount();
+
+ $response = $this->asUser($this->outsider)->postJson($this->commentReportUrl($commentId), [
+ 'reason_type' => 'spam',
+ 'reason_detail' => '무권한 신고',
+ ]);
+
+ $this->assertContains($response->status(), [403, 422], '비밀글 댓글 신고는 차단되어야 한다');
+ $this->assertSame($before, $this->reportCount());
+ }
+
+ /**
+ * 요청 계층을 우회해도 서비스 최종 관문이 막는다.
+ *
+ * HTTP 경로에서는 요청 단계 규칙이 먼저 걸리므로 서비스 게이트는 **한 번도 실행되지
+ * 않는다**. 그래서 그 게이트가 살아 있는지는 HTTP 테스트로 증명되지 않는다.
+ * 훅·확장·콘솔처럼 FormRequest 를 지나지 않는 호출자가 실재하므로, 서비스를 직접
+ * 불러 최종 관문을 따로 고정한다 (이중 방어의 두 층을 각각 잠근다).
+ *
+ * @scenario actor=outsider, target=secret_post, layer=service
+ *
+ * @effects secret_post_comment_blocked_for_outsider
+ */
+ #[Test]
+ public function the_service_layer_gate_blocks_even_when_the_request_layer_is_bypassed(): void
+ {
+ $postId = $this->createSecretPost();
+ $before = $this->commentCount();
+
+ $this->resetPermissionMiddlewareCache();
+ $this->actingAs($this->outsider, 'sanctum');
+
+ $service = app(CommentService::class);
+
+ $threw = false;
+ try {
+ $service->createComment($this->board->slug, [
+ 'post_id' => $postId,
+ 'content' => '요청 계층 우회 댓글',
+ 'user_id' => $this->outsider->id,
+ ]);
+ } catch (PostNotCommentableException $e) {
+ $threw = true;
+ $this->assertSame('sirsoft-board::messages.comment.post_secret', $e->getMessageKey());
+ }
+
+ $this->assertTrue($threw, '서비스 최종 관문이 비밀글 하위 쓰기를 막지 않았다');
+ $this->assertSame($before, $this->commentCount());
+ }
+
+ /**
+ * 서비스 최종 관문은 작성자에게는 열려 있다 (정상 흐름 불변).
+ *
+ * @scenario actor=author, target=secret_post, layer=service
+ *
+ * @effects secret_post_comment_allowed_for_author
+ */
+ #[Test]
+ public function the_service_layer_gate_allows_the_author(): void
+ {
+ $postId = $this->createSecretPost();
+ $before = $this->commentCount();
+
+ $this->resetPermissionMiddlewareCache();
+ $this->actingAs($this->author, 'sanctum');
+
+ app(CommentService::class)->createComment($this->board->slug, [
+ 'post_id' => $postId,
+ 'content' => '작성자 댓글 (서비스 직접 호출)',
+ 'user_id' => $this->author->id,
+ ]);
+
+ $this->assertSame($before + 1, $this->commentCount());
+ }
+
+ // =========================================================================
+ // 통과 매트릭스 (정상 흐름 불변)
+ // =========================================================================
+
+ /**
+ * 작성자 본인은 자기 비밀글에 댓글을 달 수 있다.
+ *
+ * @scenario actor=author, target=secret_post
+ *
+ * @effects secret_post_comment_allowed_for_author
+ */
+ #[Test]
+ public function author_can_comment_on_own_secret_post(): void
+ {
+ $postId = $this->createSecretPost();
+ $before = $this->commentCount();
+
+ $response = $this->asUser($this->author)->postJson($this->commentsUrl($postId), [
+ 'content' => '작성자 댓글',
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertSame($before + 1, $this->commentCount());
+ }
+
+ /**
+ * 게시판 관리자는 비밀글에 댓글을 달 수 있다 (답변 시나리오).
+ *
+ * @scenario actor=manager, target=secret_post
+ *
+ * @effects secret_post_comment_allowed_for_manager
+ */
+ #[Test]
+ public function manager_can_comment_on_secret_post(): void
+ {
+ $postId = $this->createSecretPost();
+ $before = $this->commentCount();
+
+ $response = $this->asUser($this->manager)->postJson($this->commentsUrl($postId), [
+ 'content' => '관리자 답변',
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertSame($before + 1, $this->commentCount());
+ }
+
+ /**
+ * 비밀글이 아니면 종전과 같이 누구나 댓글을 달 수 있다 (회귀 방지).
+ *
+ * @scenario actor=outsider, target=public_post
+ *
+ * @effects normal_post_comment_unaffected
+ */
+ #[Test]
+ public function outsider_can_still_comment_on_public_post(): void
+ {
+ $postId = $this->createTestPost([
+ 'user_id' => $this->author->id,
+ 'title' => '공개 게시글',
+ 'content' => '공개 내용',
+ 'is_secret' => false,
+ 'status' => 'published',
+ ]);
+ $before = $this->commentCount();
+
+ $response = $this->asUser($this->outsider)->postJson($this->commentsUrl($postId), [
+ 'content' => '일반 댓글',
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertSame($before + 1, $this->commentCount());
+ }
+
+ /**
+ * 작성자 본인은 자기 비밀글에 답글을 달 수 있다 (정상 흐름 불변).
+ *
+ * @scenario actor=author, target=secret_post
+ *
+ * @effects secret_post_comment_allowed_for_author
+ */
+ #[Test]
+ public function author_can_write_reply_post_under_own_secret_post(): void
+ {
+ $postId = $this->createSecretPost();
+ $before = $this->postCount();
+
+ $response = $this->asUser($this->author)->postJson($this->postsUrl(), [
+ 'title' => '작성자 답글',
+ 'content' => '작성자 답글 내용입니다. 본문 최소 길이 검증을 넘기기 위한 충분한 길이입니다.',
+ 'parent_id' => $postId,
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertSame($before + 1, $this->postCount());
+ }
+
+ /**
+ * 비밀글이 아니면 종전과 같이 답글을 달 수 있다 (회귀 방지).
+ *
+ * @scenario actor=outsider, target=public_post
+ *
+ * @effects normal_post_comment_unaffected
+ */
+ #[Test]
+ public function outsider_can_still_write_reply_post_under_public_post(): void
+ {
+ $postId = $this->createTestPost([
+ 'user_id' => $this->author->id,
+ 'title' => '공개 게시글',
+ 'content' => '공개 내용',
+ 'is_secret' => false,
+ 'status' => 'published',
+ ]);
+ $before = $this->postCount();
+
+ $response = $this->asUser($this->outsider)->postJson($this->postsUrl(), [
+ 'title' => '일반 답글',
+ 'content' => '일반 답글 내용입니다. 본문 최소 길이 검증을 넘기기 위한 충분한 길이입니다.',
+ 'parent_id' => $postId,
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertSame($before + 1, $this->postCount());
+ }
+
+ /**
+ * 비밀글이 아니면 종전과 같이 신고할 수 있다 (회귀 방지).
+ *
+ * @scenario actor=outsider, target=public_post
+ *
+ * @effects normal_post_comment_unaffected
+ */
+ #[Test]
+ public function outsider_can_still_report_public_post(): void
+ {
+ $postId = $this->createTestPost([
+ 'user_id' => $this->author->id,
+ 'title' => '공개 게시글',
+ 'content' => '공개 내용',
+ 'is_secret' => false,
+ 'status' => 'published',
+ ]);
+ $before = $this->reportCount();
+
+ $response = $this->asUser($this->outsider)->postJson($this->postReportUrl($postId), [
+ 'reason_type' => 'spam',
+ 'reason_detail' => '정상 신고',
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertSame($before + 1, $this->reportCount());
+ }
+
+ /**
+ * 비밀번호 검증 응답은 후속 요청에 쓸 열람 확인 토큰을 함께 돌려준다.
+ *
+ * @scenario actor=password_holder, target=secret_post
+ *
+ * @effects secret_view_token_issued_on_password_verify
+ */
+ #[Test]
+ public function password_verification_issues_a_view_token(): void
+ {
+ $postId = $this->createSecretPost([
+ 'user_id' => null,
+ 'password' => bcrypt('guestPw123'),
+ ]);
+
+ $response = $this->postJson($this->verifyPasswordUrl($postId), ['password' => 'guestPw123']);
+
+ $response->assertStatus(200);
+ $this->assertIsString(
+ $response->json('secret_view_token'),
+ '비밀번호 검증 응답에 열람 확인 토큰이 없습니다 — 후속 요청이 열람 사실을 증명할 수단이 사라집니다.'
+ );
+ }
+
+ /**
+ * 비밀번호로 원문을 연 사람은 댓글을 달 수 있다.
+ *
+ * 비회원이 쓴 비밀글의 작성자 본인은 user_id 가 없어 작성자 판정에 걸리지 않는다.
+ * 그에게 유일한 신원 증명이 비밀번호이므로, 이 경로가 막히면 자기 글에 댓글을
+ * 달 수 없다 — 화면은 원문이 열린 뒤 댓글창을 내주므로 사용자에게는 원인이 보이지 않는다.
+ *
+ * @scenario actor=password_holder, target=secret_post
+ *
+ * @effects secret_post_comment_allowed_for_password_holder
+ */
+ #[Test]
+ public function password_holder_can_comment_on_secret_post(): void
+ {
+ $postId = $this->createSecretPost([
+ 'user_id' => null,
+ 'password' => bcrypt('guestPw123'),
+ ]);
+ $token = $this->issueViewToken($postId, 'guestPw123');
+ $before = $this->commentCount();
+
+ $response = $this->asUser($this->outsider)
+ ->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, $token)
+ ->postJson($this->commentsUrl($postId), [
+ 'content' => '비밀번호로 열고 남기는 댓글입니다.',
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertSame($before + 1, $this->commentCount());
+ }
+
+ /**
+ * 비밀번호로 연 사람은 답글도 쓸 수 있다.
+ *
+ * @scenario actor=password_holder, target=secret_post
+ *
+ * @effects secret_post_reply_allowed_for_password_holder
+ */
+ #[Test]
+ public function password_holder_can_write_reply_post_under_secret_post(): void
+ {
+ $postId = $this->createSecretPost([
+ 'user_id' => null,
+ 'password' => bcrypt('guestPw123'),
+ ]);
+ $token = $this->issueViewToken($postId, 'guestPw123');
+ $before = $this->postCount();
+
+ $response = $this->asUser($this->outsider)
+ ->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, $token)
+ ->postJson($this->postsUrl(), [
+ 'title' => '답글 제목',
+ 'content' => '비밀번호로 열고 남기는 답글 본문입니다.',
+ 'parent_id' => $postId,
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertSame($before + 1, $this->postCount());
+ }
+
+ /**
+ * 토큰은 발급받은 게시글에만 통한다 — 다른 비밀글에는 쓰이지 않는다.
+ *
+ * 이 결속이 없으면 비밀번호를 아는 글 하나로 같은 게시판의 모든 비밀글이 열려,
+ * KVE-2026-2044 가 토큰이라는 다른 이름으로 되살아난다.
+ *
+ * @scenario actor=password_holder, target=other_secret_post
+ *
+ * @effects secret_view_token_is_bound_to_its_post
+ */
+ #[Test]
+ public function view_token_does_not_work_on_another_post(): void
+ {
+ $openedPostId = $this->createSecretPost([
+ 'user_id' => null,
+ 'password' => bcrypt('guestPw123'),
+ ]);
+ $otherPostId = $this->createSecretPost([
+ 'user_id' => $this->author->id,
+ 'title' => '남의 비밀글',
+ ]);
+ $token = $this->issueViewToken($openedPostId, 'guestPw123');
+ $before = $this->commentCount();
+
+ $response = $this->asUser($this->outsider)
+ ->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, $token)
+ ->postJson($this->commentsUrl($otherPostId), [
+ 'content' => '남의 비밀글에 다는 댓글입니다.',
+ ]);
+
+ $response->assertStatus(422);
+ $this->assertSame($before, $this->commentCount(), '다른 글의 토큰으로 댓글이 저장되었습니다.');
+ }
+
+ /**
+ * 위조·만료 토큰은 통하지 않는다.
+ *
+ * @scenario actor=outsider, target=secret_post
+ *
+ * @effects secret_view_token_rejects_forged_value
+ */
+ #[Test]
+ public function forged_view_token_is_rejected(): void
+ {
+ $postId = $this->createSecretPost([
+ 'user_id' => null,
+ 'password' => bcrypt('guestPw123'),
+ ]);
+ $before = $this->commentCount();
+
+ $response = $this->asUser($this->outsider)
+ ->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, str_repeat('a', 40))
+ ->postJson($this->commentsUrl($postId), [
+ 'content' => '위조 토큰으로 다는 댓글입니다.',
+ ]);
+
+ $response->assertStatus(422);
+ $this->assertSame($before, $this->commentCount());
+ }
+
+ private function verifyPasswordUrl(int $postId): string
+ {
+ return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password";
+ }
+
+ /**
+ * 비밀번호를 검증해 열람 확인 토큰을 발급받습니다 (실제 엔드포인트 경유).
+ */
+ private function issueViewToken(int $postId, string $password): string
+ {
+ $response = $this->postJson($this->verifyPasswordUrl($postId), ['password' => $password]);
+ $response->assertStatus(200);
+
+ return (string) $response->json('secret_view_token');
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/PlaintextPostPasswordRehashTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/PlaintextPostPasswordRehashTest.php
new file mode 100644
index 00000000..873c9a64
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/PlaintextPostPasswordRehashTest.php
@@ -0,0 +1,128 @@
+run(new UpgradeContext('1.1.0', '1.1.1', '1.1.1', 'extension-upgrade'));
+ }
+
+ private function storedPassword(int $postId): string
+ {
+ return (string) DB::table('board_posts')->where('id', $postId)->value('password');
+ }
+
+ /**
+ * 평문으로 남은 행이 해시로 복구되고, 원래 비밀번호로 검증된다.
+ *
+ * @scenario stored=plaintext
+ *
+ * @effects plaintext_password_rehashed_to_bcrypt
+ */
+ #[Test]
+ public function plaintext_rows_are_rehashed_and_still_verify(): void
+ {
+ $plain = 'legacyPlain123';
+ $postId = $this->createTestPost(['password' => $plain]);
+
+ $this->runRehash();
+
+ $stored = $this->storedPassword($postId);
+
+ $this->assertNotSame($plain, $stored, '평문이 그대로 남았다');
+ $this->assertSame('bcrypt', password_get_info($stored)['algoName'] ?? 'unknown');
+ $this->assertTrue(Hash::check($plain, $stored), '복구 후 원래 비밀번호로 검증되지 않는다');
+ }
+
+ /**
+ * 이미 해시인 행은 건드리지 않는다 (재해싱 금지).
+ *
+ * @scenario stored=bcrypt
+ *
+ * @effects already_hashed_rows_untouched
+ */
+ #[Test]
+ public function already_hashed_rows_are_left_untouched(): void
+ {
+ $hashed = Hash::make('alreadyHashed123');
+ $postId = $this->createTestPost(['password' => $hashed]);
+
+ $this->runRehash();
+
+ $this->assertSame($hashed, $this->storedPassword($postId), '이미 해시인 행이 재해싱되었다');
+ }
+
+ /**
+ * 비밀번호가 없는 회원 게시글은 대상이 아니다.
+ *
+ * @scenario stored=null
+ *
+ * @effects rows_without_password_untouched
+ */
+ #[Test]
+ public function rows_without_password_are_untouched(): void
+ {
+ $postId = $this->createTestPost(['password' => null]);
+
+ $this->runRehash();
+
+ $this->assertNull(DB::table('board_posts')->where('id', $postId)->value('password'));
+ }
+
+ /**
+ * 재실행해도 결과가 변하지 않는다 (멱등).
+ *
+ * @scenario run=twice
+ *
+ * @effects rehash_is_idempotent
+ */
+ #[Test]
+ public function rerunning_does_not_change_the_result(): void
+ {
+ $plain = 'idempotent123';
+ $postId = $this->createTestPost(['password' => $plain]);
+
+ $this->runRehash();
+ $afterFirst = $this->storedPassword($postId);
+
+ $this->runRehash();
+
+ $this->assertSame($afterFirst, $this->storedPassword($postId), '재실행이 값을 바꿨다');
+ $this->assertTrue(Hash::check($plain, $this->storedPassword($postId)));
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/PostGuestTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/PostGuestTest.php
index 2066e389..2c45af77 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/User/PostGuestTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/PostGuestTest.php
@@ -8,6 +8,7 @@ require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
+use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
@@ -517,7 +518,7 @@ class PostGuestTest extends BoardTestCase
$this->setGuestPermissions(['posts.read', 'posts.write']);
// 테스트용 파일 생성
- $file = \Illuminate\Http\UploadedFile::fake()->create('test.pdf', 100);
+ $file = UploadedFile::fake()->create('test.pdf', 100);
// When: 비회원이 파일과 함께 게시글 생성
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
@@ -541,7 +542,7 @@ class PostGuestTest extends BoardTestCase
// Given: 비회원 파일 업로드 권한 있음 (기본 설정)
// 테스트용 파일 생성
- $file = \Illuminate\Http\UploadedFile::fake()->create('test.pdf', 100);
+ $file = UploadedFile::fake()->create('test.pdf', 100);
// When: 비회원이 파일과 함께 게시글 생성
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
@@ -609,4 +610,45 @@ class PostGuestTest extends BoardTestCase
$postIds = array_column($posts, 'id');
$this->assertNotContains($postId, $postIds);
}
-}
\ No newline at end of file
+
+ /**
+ * 회귀 — 비회원 게시글을 수정해도 저장된 비밀번호가 해시로 유지되어야 한다.
+ *
+ * 결함: 수정 요청의 `password` 는 본인 확인용 자격증명인데, 컨트롤러가 그것을 그대로
+ * 저장 데이터로 넘겨 기존 bcrypt 해시를 **평문으로 덮었다**. 그 결과 ① 비회원 게시글
+ * 비밀번호가 평문으로 DB 에 남고 ② 이후 비밀번호 검증이 "bcrypt 가 아니다" 예외로
+ * 끝나 본인이 자기 글을 수정·삭제할 수 없게 됐다.
+ *
+ * 같은 자리에서 댓글 수정 경로는 이미 password/verification_token 을 저장 데이터에서
+ * 제거하고 있었다 — 게시글 경로만 빠져 있던 비대칭이다.
+ */
+ public function test_guest_post_password_stays_hashed_after_update(): void
+ {
+ $password = 'staysHashed123';
+
+ $createResponse = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
+ 'title' => '비밀번호 보존 확인',
+ 'content' => '초기 내용입니다. 최소 10자 이상.',
+ 'author_name' => '테스트비회원',
+ 'password' => $password,
+ ]);
+ $createResponse->assertStatus(201);
+ $postId = $createResponse->json('data.id');
+
+ $this->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
+ 'title' => '수정 후 비밀번호 보존 확인',
+ 'content' => '수정된 내용입니다.',
+ 'password' => $password,
+ ])->assertStatus(200);
+
+ $stored = (string) DB::table('board_posts')->where('id', $postId)->value('password');
+
+ $this->assertNotSame($password, $stored, '수정 후 비밀번호가 평문으로 저장되었다');
+ $this->assertSame(
+ 'bcrypt',
+ password_get_info($stored)['algoName'] ?? 'unknown',
+ '수정 후 저장값이 해시 형식이 아니다 — 이후 본인 확인이 예외로 끝난다'
+ );
+ $this->assertTrue(Hash::check($password, $stored), '수정 후 원래 비밀번호로 검증되지 않는다');
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretVerifyPasswordTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretVerifyPasswordTest.php
index 6acb8947..86ab2c38 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretVerifyPasswordTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretVerifyPasswordTest.php
@@ -10,6 +10,7 @@ use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
+use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
@@ -504,4 +505,158 @@ class PostSecretVerifyPasswordTest extends BoardTestCase
// Then: 404 에러
$response->assertStatus(404);
}
+
+ /**
+ * 비밀번호 검증 응답은 상세 조회와 같은 스키마다 — 댓글 목록을 포함한다.
+ *
+ * 화면은 이 응답으로 게시글 데이터소스를 통째로 교체한다. 그래서 응답에 `comments` 가
+ * 없으면 목록이 비어 보이는데, 댓글 수(`comment_count`)는 집계 컬럼이라 그대로 남아
+ * "댓글 2" 헤더 밑에 아무것도 없는 화면이 된다. 오류도 콘솔 경고도 남지 않고, 댓글을
+ * 하나 쓰면 그때 상세를 다시 불러 목록이 나타나므로 원인을 짚기 어렵다.
+ */
+ public function test_verify_password_response_includes_comments(): void
+ {
+ // Given: 댓글 2개가 달린 비회원 비밀글
+ $postId = $this->createGuestSecretPost('test1234');
+ $this->createTestComment($postId, ['content' => '첫 번째 댓글']);
+ $this->createTestComment($postId, ['content' => '두 번째 댓글']);
+
+ // When: 올바른 비밀번호로 검증
+ $response = $this->postJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password",
+ ['password' => 'test1234']
+ );
+
+ // Then: 상세 조회와 동일하게 댓글이 실려 온다
+ $response->assertStatus(200);
+
+ $comments = $response->json('data.comments');
+
+ $this->assertIsArray(
+ $comments,
+ '비밀번호 검증 응답에 comments 가 없습니다 — 화면이 이 응답으로 데이터소스를 교체하므로 댓글 목록이 통째로 사라집니다.'
+ );
+ $this->assertCount(2, $comments);
+
+ // comment_count 는 게시글 행의 집계 컬럼이라 이 픽스처(DB 직접 삽입)에서는 오르지
+ // 않는다 — 실제 화면에서 "댓글 2" 헤더 아래가 비어 보였던 것이 이 두 값의 어긋남이다.
+ // 여기서 잠그는 계약은 "목록이 응답에 실린다" 쪽이다.
+ }
+
+ /**
+ * 수정 검증 토큰은 폼을 불러온 뒤에도 저장에 쓸 수 있어야 한다.
+ *
+ * 실제 화면 흐름은 「비밀번호 확인 → 폼 조회(form-meta / form-data) → 저장(PUT)」이다.
+ * 폼 조회가 토큰을 소비해 버리면 저장 시점에는 남아 있지 않아, 사용자는 비밀번호를
+ * 정확히 넣고 본문까지 본 상태에서 「게시글 수정 권한이 없습니다」로 거부된다.
+ * 토큰을 새로 받을 방법도 화면에 없어 그 글은 수정 자체가 불가능해진다.
+ */
+ public function test_modify_token_survives_form_load_and_authorizes_update(): void
+ {
+ $postId = $this->createGuestSecretPost('test1234');
+
+ // 1) 비밀번호 확인 → 토큰 발급
+ $verify = $this->postJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password-for-modify",
+ ['password' => 'test1234']
+ );
+ $verify->assertStatus(200);
+ $token = $verify->json('data.verification_token');
+ $this->assertNotEmpty($token);
+
+ // 2) 화면이 하는 대로 폼 메타·데이터를 그 토큰으로 조회
+ $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/form-meta?post_id={$postId}&verification_token={$token}")
+ ->assertStatus(200);
+ $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/form-data?post_id={$postId}&verification_token={$token}")
+ ->assertStatus(200);
+
+ // 3) 같은 토큰으로 저장
+ $update = $this->putJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}",
+ [
+ 'title' => '수정된 제목',
+ 'content' => '수정된 본문입니다.',
+ 'verification_token' => $token,
+ ]
+ );
+
+ $this->assertSame(
+ 200,
+ $update->status(),
+ '폼을 불러온 뒤 같은 토큰으로 저장할 수 없습니다 — 폼 조회가 토큰을 소비해 버린 것입니다.'
+ );
+ }
+
+ /**
+ * 수정 검증 토큰을 헤더로 제시해도 폼 조회와 저장이 모두 성립한다.
+ *
+ * 이 토큰은 종전에 GET 쿼리 파라미터로만 다녔다 — 그러면 웹서버 접근 기록에 자격증명이
+ * 그대로 남는다. 헤더로 옮기되 기존 본문/쿼리 경로도 계속 받아야 하므로, 헤더 단독으로도
+ * 「폼 메타 → 폼 데이터 → 저장」 전 구간이 성립하는지를 이 테스트가 잠근다.
+ *
+ * @scenario actor=guest_author, transport=header
+ *
+ * @effects modify_token_accepted_via_header
+ */
+ public function test_modify_token_is_accepted_via_header_without_query_param(): void
+ {
+ $postId = $this->createGuestSecretPost('test1234');
+ $base = "/api/modules/sirsoft-board/boards/{$this->board->slug}";
+
+ $verify = $this->postJson("{$base}/posts/{$postId}/verify-password-for-modify", ['password' => 'test1234']);
+ $verify->assertStatus(200);
+ $token = (string) $verify->json('data.verification_token');
+ $this->assertNotEmpty($token);
+
+ $header = [PostService::VERIFY_TOKEN_HEADER => $token];
+
+ $meta = $this->withHeaders($header)->getJson("{$base}/posts/form-meta?post_id={$postId}");
+ $meta->assertStatus(200);
+ $this->assertFalse(
+ (bool) $meta->json('data.requires_password'),
+ '헤더로 제시한 검증 토큰이 폼 메타에서 인정되지 않았습니다 — 화면이 비밀번호를 다시 묻습니다.'
+ );
+
+ $data = $this->withHeaders($header)->getJson("{$base}/posts/form-data?post_id={$postId}");
+ $data->assertStatus(200);
+ $this->assertNotEmpty(
+ $data->json('data.content'),
+ '헤더로 제시한 검증 토큰이 폼 데이터에서 인정되지 않아 본문이 비어 있습니다.'
+ );
+
+ $update = $this->withHeaders($header)->putJson("{$base}/posts/{$postId}", [
+ 'title' => '헤더 경로 수정',
+ 'content' => '헤더로만 토큰을 제시해 저장합니다.',
+ ]);
+
+ $this->assertSame(
+ 200,
+ $update->status(),
+ '헤더로만 제시한 검증 토큰으로는 저장할 수 없습니다 — 주소에서 자격증명을 뺄 수 없습니다.'
+ );
+ }
+
+ /**
+ * 삭제된 비밀글은 비밀번호를 알아도 열리지 않는다 (manager 권한자 제외).
+ *
+ * 상세 조회(show)는 삭제된 글에 manager 권한을 요구한다. 검증 경로가 그 판정을 하지
+ * 않으면 같은 원문이 형제 엔드포인트로 새어나간다 — 화면에서는 목록에 안 보이니
+ * 드러나지 않고, 주소를 아는 쪽만 열 수 있다.
+ */
+ public function test_verify_password_does_not_open_deleted_post(): void
+ {
+ $postId = $this->createGuestSecretPost('test1234');
+ DB::table('board_posts')->where('id', $postId)->update(['deleted_at' => now()]);
+
+ $response = $this->postJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password",
+ ['password' => 'test1234']
+ );
+
+ $this->assertContains(
+ $response->status(),
+ [403, 404],
+ '삭제된 비밀글이 비밀번호만으로 열렸습니다 — 상세 조회는 manager 권한을 요구합니다.'
+ );
+ }
}
diff --git a/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_1.php b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_1.php
new file mode 100644
index 00000000..fa5869aa
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_1.php
@@ -0,0 +1,18 @@
+logger->warning('[board:1.1.1] board_posts.password 미존재 — 스킵');
+
+ return;
+ }
+
+ $rehashed = 0;
+ $alreadyHashed = 0;
+
+ DB::table(self::POSTS_TABLE)
+ ->whereNotNull('password')
+ ->where('password', '!=', '')
+ ->orderBy('id')
+ ->select('id', 'password')
+ ->chunkById(200, function ($posts) use (&$rehashed, &$alreadyHashed) {
+ foreach ($posts as $post) {
+ $stored = (string) $post->password;
+
+ if ((password_get_info($stored)['algoName'] ?? 'unknown') === 'bcrypt') {
+ $alreadyHashed++;
+
+ continue;
+ }
+
+ DB::table(self::POSTS_TABLE)
+ ->where('id', $post->id)
+ ->update(['password' => password_hash($stored, PASSWORD_BCRYPT)]);
+
+ $rehashed++;
+ }
+ });
+
+ $context->logger->info(
+ "[board:1.1.1] 게시글 비밀번호 복구: 재해싱 {$rehashed} / 이미 해시 {$alreadyHashed}"
+ );
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/AGENTS.md b/modules/_bundled/sirsoft-ecommerce/AGENTS.md
index 875c4a91..98382028 100644
--- a/modules/_bundled/sirsoft-ecommerce/AGENTS.md
+++ b/modules/_bundled/sirsoft-ecommerce/AGENTS.md
@@ -195,7 +195,7 @@ CRUD 를 바꾸고 싶으면 이 4종 중 하나를 잡으면 되고, 이 모듈
| 종류 | 개수 | 위치 |
|---|---|---|
-| PHPUnit | 400개 | `modules/_bundled/sirsoft-ecommerce/tests` |
+| PHPUnit | 401개 | `modules/_bundled/sirsoft-ecommerce/tests` |
| Vitest | 140개 | `vitest.config.ts` |
| Playwright | 42개 | `tests/Playwright` |
| 시나리오 매니페스트 | 92개 | `tests/scenarios` |
diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
index b82e6964..7bae08d5 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -20,6 +20,8 @@
### Fixed
+- 장바구니에서 수량을 바꾸면 배송 불가 안내와 주문 차단이 풀리던 문제를 수정했습니다. 선택한 배송 국가로 보낼 수 없는 상품이 담겨 있으면 주문이 막혀야 하는데, 수량을 한 번만 조정해도 그 잠금이 사라져 그대로 주문할 수 있었습니다. 수량 변경 응답이 장바구니 조회 응답보다 적은 정보를 담고 있었던 것이며, 이제 두 응답이 같은 내용을 돌려줍니다.
+- 비회원 주문의 결제 취소 기록이 주문번호만으로 실행되던 문제를 수정했습니다. 종전에는 로그인하지 않은 누구든 주문번호만 알면 다른 사람의 비회원 주문을 결제 취소 상태로 바꿀 수 있었습니다. 이제 비회원 주문 조회에 쓰는 것과 같은 본인 확인 절차를 거친 경우에만 처리되며, 그 밖의 요청은 주문을 찾을 수 없다는 응답으로 차단됩니다. 회원 주문은 종전과 같이 본인만 가능합니다. 비회원이 결제창에서 결제를 취소한 경우에는 주문 직후 발급된 본인 확인 정보로 종전처럼 취소 이력이 남습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2041)
- 상세설명을 편집기(HTML)로 작성한 상품을 등록하거나 수정할 때 저장이 실패하던 문제를 수정했습니다. 상품 설명의 보안 정화에 쓰는 구성요소가 모듈 설치 폴더 안에 자기 캐시 파일을 만들려 했기 때문에, 보안상 모듈 폴더에 쓰기를 막아 둔 서버에서는 저장이 항상 오류로 끝났고 다시 시도해도 같은 결과였습니다. 이제 이 캐시는 `storage` 폴더 아래에 만들어지며, 그 위치마저 쓸 수 없는 경우에는 캐시 없이 정화만 수행해 저장이 실패하지 않습니다(설명은 종전과 똑같이 정화됩니다). (#125 @lyg-kaban 님께서 제보해주셨습니다.)
### Changed
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md
index 326e199a..db4ac388 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md
@@ -902,6 +902,11 @@ _단건 응답: `data` 객체의 필드._
| items | array | `[{"id":962,"quantity":2, …}]` | 수량 반영 후의 장바구니 전체 아이템 목록 (CartItemResource — 필드 구성은 POST `/cart` 의 "장바구니 아이템 필드" 표와 동일. 프론트가 refetch 없이 화면을 갱신하도록 전체를 함께 반환) |
| item_count | integer | `1` | 장바구니 아이템 개수 |
| calculation | object | `{"items":[…],"summary":{…},"promotions":{…},"validation_errors":[]}` | 선택 아이템 기준 금액 계산 결과 (GET `/cart` 의 `calculation` 과 동일 구조 — 소계·할인·배송비·적립·최종 결제금액 등) |
+| item_ids | array | `[962]` | 장바구니 아이템 ID 목록 |
+| has_unshippable_items | boolean | `false` | 선택된 배송 국가로 보낼 수 없는 상품이 1건이라도 있으면 `true` (주문 전체 차단 플래그) |
+| selected_shipping_country | string | `KR` | 현재 선택된 배송 국가 코드 |
+
+> 이 응답은 GET `/cart` 와 **같은 키 집합**입니다. 화면이 이 응답으로 장바구니 데이터를 통째로 교체하므로(refetch 제거), 한쪽에만 있는 키가 생기면 수량을 바꾸는 순간 그 값이 사라집니다 — 특히 `has_unshippable_items` 가 빠지면 주문 차단이 조용히 풀립니다.
**응답 예시**
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md
index 5335d627..3cbd0dea 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md
@@ -1850,7 +1850,11 @@ HTTP/1.1 200
-**설명** 회원/비회원이 PG 결제창을 닫았을 때 결제 취소 이력만 기록합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `Public\OrderController@cancelPayment`가 `OrderProcessingService::recordPaymentCancellation()`으로 주문 상태는 변경하지 않고 `order_payments`에 취소창 닫힘 이력(`cancel_code`·`cancel_message`)만 남깁니다. 결제 SDK가 사용자 취소 콜백을 받았을 때 프론트가 호출해 결제 시도 이력을 추적하는 용도입니다.
+**설명** 회원/비회원이 PG 결제창을 닫았을 때 결제 취소 이력만 기록합니다.
+
+**비회원 요청은 `X-Guest-Order-Token` 헤더가 필요합니다.** 이 경로는 `optional.sanctum` 으로 회원/비회원이 공유하므로 소유권 판정이 분기됩니다 — 회원 주문은 로그인 본인만, 비회원 주문(`user_id` 없음)은 비회원 주문 조회에서 발급받은 토큰이 그 주문번호와 일치할 때만 통과합니다. 토큰이 없거나 만료·위조·다른 주문의 것이면 정보 노출을 막기 위해 존재하지 않는 주문과 동일하게 `404` 를 반환합니다. 토큰 발급은 비회원 주문 조회 인증(`POST /api/modules/sirsoft-ecommerce/guest/orders/authenticate`)이 담당합니다.
+
+`optional.sanctum`으로 회원/비회원 모두 접근하며, `Public\OrderController@cancelPayment`가 `OrderProcessingService::recordPaymentCancellation()`으로 주문 상태는 변경하지 않고 `order_payments`에 취소창 닫힘 이력(`cancel_code`·`cancel_message`)만 남깁니다. 결제 SDK가 사용자 취소 콜백을 받았을 때 프론트가 호출해 결제 시도 이력을 추적하는 용도입니다.
### GET /api/modules/sirsoft-ecommerce/user/orders
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/CartController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/CartController.php
index 7d3ae198..ab5a6f2f 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/CartController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/CartController.php
@@ -99,15 +99,11 @@ class CartController extends PublicBaseController
selectedCartIds: $selectedIds
);
- return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.cart.fetched', [
- 'items' => CartItemResource::collection($result->items),
- 'item_ids' => $result->items->pluck('id')->values()->toArray(),
- 'item_count' => $result->count(),
- 'calculation' => $result->calculation->toArray(),
- // 선택된 배송국가로 배송 불가한 상품이 1개라도 있으면 주문 전체 차단 플래그 (D1 — layer 1)
- 'has_unshippable_items' => $this->hasUnshippableItems($result->items),
- 'selected_shipping_country' => ResolveShippingCountry::getCountry(),
- ]);
+ return ResponseHelper::moduleSuccess(
+ 'sirsoft-ecommerce',
+ 'messages.cart.fetched',
+ $this->buildCartPayload($result)
+ );
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
@@ -216,11 +212,11 @@ class CartController extends PublicBaseController
selectedCartIds: $selectedIds
);
- return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.cart.quantity_updated', [
- 'items' => CartItemResource::collection($result->items),
- 'item_count' => $result->count(),
- 'calculation' => $result->calculation->toArray(),
- ]);
+ return ResponseHelper::moduleSuccess(
+ 'sirsoft-ecommerce',
+ 'messages.cart.quantity_updated',
+ $this->buildCartPayload($result)
+ );
} catch (CartUnavailableException $e) {
// 판매불가/재고/구매수량 한도 위반 — generic 500 이 아닌 사유별 422 매핑
return $this->cartUnavailableResponse($e);
@@ -523,6 +519,31 @@ class CartController extends PublicBaseController
);
}
+ /**
+ * 장바구니 응답 페이로드를 조립합니다.
+ *
+ * 조회와 수량 변경이 같은 조립기를 쓴다. 화면은 수량 변경 응답으로 장바구니 데이터소스를
+ * **통째로 교체**하므로(refetch 제거), 두 응답의 키 집합이 갈라지면 조회에만 있던 값이
+ * 수량을 한 번 바꾸는 순간 사라진다. 특히 `has_unshippable_items` 는 배송 불가 상품이
+ * 담겼을 때 주문 버튼을 잠그는 값이라, 사라지면 그 잠금이 조용히 풀린다 — 요청은 정상
+ * 성공하고 오류도 경고도 남지 않는다.
+ *
+ * @param mixed $result getCartWithCalculation 결과 (items / count / calculation)
+ * @return array 응답 페이로드
+ */
+ protected function buildCartPayload($result): array
+ {
+ return [
+ 'items' => CartItemResource::collection($result->items),
+ 'item_ids' => $result->items->pluck('id')->values()->toArray(),
+ 'item_count' => $result->count(),
+ 'calculation' => $result->calculation->toArray(),
+ // 선택된 배송국가로 배송 불가한 상품이 1개라도 있으면 주문 전체 차단 플래그 (D1 — layer 1)
+ 'has_unshippable_items' => $this->hasUnshippableItems($result->items),
+ 'selected_shipping_country' => ResolveShippingCountry::getCountry(),
+ ];
+ }
+
/**
* 선택된 배송국가로 배송 불가한 상품이 카트에 1개라도 있는지 판정합니다. (D1 — layer 1)
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/CancelPaymentRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/CancelPaymentRequest.php
index 25d45b7f..06ea1965 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/CancelPaymentRequest.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/CancelPaymentRequest.php
@@ -9,6 +9,8 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
+use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
+use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
/**
* 결제 취소 기록 요청
@@ -45,14 +47,14 @@ class CancelPaymentRequest extends FormRequest
/**
* 추가 검증 로직
*
- * @param Validator $validator
+ * @param Validator $validator
* @return void
*/
protected function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
$orderNumber = $this->route('orderNumber');
- $this->order = Order::where('order_number', $orderNumber)->first();
+ $this->order = app(OrderRepositoryInterface::class)->findByOrderNumber((string) $orderNumber);
if (! $this->order) {
abort(ResponseHelper::moduleError(
@@ -62,14 +64,33 @@ class CancelPaymentRequest extends FormRequest
));
}
- // 소유권 검증: 회원 주문은 본인만, 비회원 주문은 주문번호로 접근 허용
- $userId = Auth::id();
- if ($this->order->user_id !== null && $this->order->user_id !== $userId) {
- abort(ResponseHelper::moduleError(
- 'sirsoft-ecommerce',
- 'exceptions.order_not_found',
- 404
- ));
+ // 소유권 검증
+ // - 회원 주문: 본인만 통과.
+ // - 비회원 주문: 보호된 게스트 경로(guest/orders/*)와 동일하게 X-Guest-Order-Token 을 요구한다.
+ // 주문번호만으로 통과시키면 익명 요청이 결제를 cancelled 로 영속 변경할 수 있다.
+ // 미들웨어(VerifyGuestOrderToken)를 라우트에 붙이지 않는 이유는 그 미들웨어에
+ // 회원 pass-through 가 없어 공유 라우트에서 로그인 회원이 404 가 되기 때문이다.
+ if ($this->order->user_id !== null) {
+ if ($this->order->user_id !== Auth::id()) {
+ abort(ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.order_not_found',
+ 404
+ ));
+ }
+ } else {
+ $verified = app(GuestOrderAuthService::class)->verifyToken(
+ $this->header('X-Guest-Order-Token'),
+ (string) $orderNumber
+ );
+
+ if (! $verified) {
+ abort(ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.order_not_found',
+ 404
+ ));
+ }
}
if ($this->order->order_status !== OrderStatusEnum::PENDING_ORDER) {
@@ -84,7 +105,7 @@ class CancelPaymentRequest extends FormRequest
/**
* 검증 실패 시 응답 커스터마이징
*
- * @param Validator $validator
+ * @param Validator $validator
* @return void
*
* @throws ValidationException
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php
index dbca6702..0f39a2ee 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php
@@ -266,6 +266,51 @@ class CartControllerTest extends ModuleTestCase
$this->assertEquals(1, $response->json('data.item_count'));
}
+ /**
+ * 수량 변경 응답은 장바구니 조회 응답과 같은 키 집합이어야 합니다.
+ *
+ * 화면은 수량 변경 응답으로 장바구니 데이터소스를 **통째로 교체**한다(refetch 제거).
+ * 그래서 조회 응답에만 있는 키는 수량을 한 번 바꾸는 순간 사라진다. 특히
+ * `has_unshippable_items` 는 배송 불가 상품이 담겼을 때 주문 버튼을 잠그는 값이라,
+ * 사라지면 그 잠금이 조용히 풀린다 — 오류도 경고도 남지 않는다.
+ */
+ public function test_update_quantity_response_matches_cart_query_shape(): void
+ {
+ // Given: 장바구니에 아이템이 존재
+ $data = $this->createProductWithOption();
+ $cartKey = 'ck_'.str_repeat('e', 32);
+
+ $addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
+ 'product_id' => $data['product']->id,
+ 'items' => [
+ ['product_option_id' => $data['option']->id, 'quantity' => 2],
+ ],
+ ], ['X-Cart-Key' => $cartKey]);
+ $addResponse->assertStatus(201);
+ $cartId = $addResponse->json('data.items.0.id');
+
+ // When: 조회와 수량 변경을 각각 호출
+ $queryResponse = $this->getJson('/api/modules/sirsoft-ecommerce/cart', ['X-Cart-Key' => $cartKey]);
+ $quantityResponse = $this->patchJson(
+ "/api/modules/sirsoft-ecommerce/cart/{$cartId}/quantity",
+ ['quantity' => 3],
+ ['X-Cart-Key' => $cartKey]
+ );
+
+ $queryResponse->assertStatus(200);
+ $quantityResponse->assertStatus(200);
+
+ // Then: 조회 응답의 키가 하나도 빠지지 않는다
+ $queryKeys = array_keys($queryResponse->json('data'));
+ $quantityKeys = array_keys($quantityResponse->json('data'));
+
+ $this->assertSame(
+ [],
+ array_values(array_diff($queryKeys, $quantityKeys)),
+ '수량 변경 응답에 장바구니 조회 응답의 키가 빠져 있습니다 — 화면이 데이터소스를 통째로 교체하므로 그 값들이 사라집니다.'
+ );
+ }
+
/**
* #90 수량을 0으로 변경 시도 시 422 에러를 반환합니다.
*/
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php
index d214f25f..88912781 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\User;
use App\Extension\HookManager;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
@@ -12,6 +13,7 @@ use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
use Modules\Sirsoft\Ecommerce\Enums\ProductSalesStatus;
use Modules\Sirsoft\Ecommerce\Models\Cart;
use Modules\Sirsoft\Ecommerce\Models\Order;
+use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\Product;
@@ -19,6 +21,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
use Modules\Sirsoft\Ecommerce\Models\UserAddress;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
+use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
@@ -822,14 +825,24 @@ class UserOrderControllerTest extends ModuleTestCase
}
/**
- * 비로그인 사용자가 비회원 주문 결제 취소 기록 성공
+ * 비로그인 사용자가 유효한 게스트 조회 토큰으로 비회원 주문 결제 취소 기록 성공
+ *
+ * 주문번호만으로는 통과하지 않는다 — 보호된 게스트 경로와 동일하게
+ * X-Guest-Order-Token 이 필요하다 (토큰 부재 차단은 GuestCancelPaymentAuthTest).
*/
public function test_비로그인_사용자_비회원_주문_결제_취소_기록_성공(): void
{
+ $guestPassword = 'guest12';
+ $guestPhone = '010-1234-5678';
+
// 비회원 주문 (user_id = null)
$order = Order::factory()->create([
'user_id' => null,
'order_status' => OrderStatusEnum::PENDING_ORDER,
+ 'guest_lookup_password_hash' => Hash::make($guestPassword),
+ ]);
+ OrderAddress::factory()->shipping()->forOrder($order)->create([
+ 'orderer_phone' => $guestPhone,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
@@ -837,8 +850,13 @@ class UserOrderControllerTest extends ModuleTestCase
'payment_method' => PaymentMethodEnum::CARD,
]);
+ $token = app(GuestOrderAuthService::class)
+ ->authenticate($order->order_number, $guestPhone, $guestPassword, '10.0.0.1')['token'];
+
$response = $this->postJson(
- "/api/modules/sirsoft-ecommerce/orders/{$order->order_number}/cancel-payment"
+ "/api/modules/sirsoft-ecommerce/orders/{$order->order_number}/cancel-payment",
+ [],
+ ['X-Guest-Order-Token' => $token]
);
$response->assertStatus(200)
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/GuestCancelPaymentAuthTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/GuestCancelPaymentAuthTest.php
new file mode 100644
index 00000000..44b722aa
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/GuestCancelPaymentAuthTest.php
@@ -0,0 +1,218 @@
+order_number}/cancel-payment";
+ }
+
+ /**
+ * 결제 대기 상태의 비회원 주문 + 유효 토큰을 만듭니다.
+ *
+ * @return array{0: Order, 1: string}
+ */
+ private function makeGuestOrderWithToken(string $orderNumber): array
+ {
+ $order = Order::factory()->forGuest()->create([
+ 'order_number' => $orderNumber,
+ 'order_status' => OrderStatusEnum::PENDING_ORDER,
+ 'guest_lookup_password_hash' => Hash::make(self::PASSWORD),
+ ]);
+
+ OrderAddress::factory()->shipping()->forOrder($order)->create([
+ 'orderer_phone' => self::PHONE,
+ ]);
+
+ OrderPayment::factory()->create([
+ 'order_id' => $order->id,
+ 'payment_status' => PaymentStatusEnum::READY,
+ 'payment_method' => PaymentMethodEnum::CARD,
+ ]);
+
+ /** @var GuestOrderAuthService $service */
+ $service = app(GuestOrderAuthService::class);
+ $token = $service->authenticate($order->order_number, self::PHONE, self::PASSWORD, '10.0.0.9')['token'];
+
+ return [$order, $token];
+ }
+
+ /**
+ * 비회원 주문에 토큰 없이 익명 요청하면 404 이고 결제 상태가 변하지 않는다.
+ */
+ public function test_guest_order_without_token_is_rejected(): void
+ {
+ [$order] = $this->makeGuestOrderWithToken('ORD-CP-NOTOKEN');
+
+ $response = $this->postJson($this->cancelPaymentUrl($order), [
+ 'cancel_code' => 'USER_CANCEL',
+ ]);
+
+ $response->assertStatus(404);
+
+ $this->assertSame(
+ PaymentStatusEnum::READY->value,
+ $order->fresh()->payment->payment_status->value,
+ '차단된 요청은 결제 상태를 바꾸지 않아야 한다'
+ );
+ }
+
+ /**
+ * 위조/무효 토큰도 차단된다.
+ */
+ public function test_guest_order_with_invalid_token_is_rejected(): void
+ {
+ [$order] = $this->makeGuestOrderWithToken('ORD-CP-BADTOKEN');
+
+ $response = $this->postJson(
+ $this->cancelPaymentUrl($order),
+ ['cancel_code' => 'USER_CANCEL'],
+ ['X-Guest-Order-Token' => '9999999999|'.str_repeat('a', 64)]
+ );
+
+ $response->assertStatus(404);
+ $this->assertSame(
+ PaymentStatusEnum::READY->value,
+ $order->fresh()->payment->payment_status->value
+ );
+ }
+
+ /**
+ * 다른 비회원 주문으로 발급된 토큰은 재사용할 수 없다.
+ */
+ public function test_guest_token_of_other_order_is_rejected(): void
+ {
+ [$order] = $this->makeGuestOrderWithToken('ORD-CP-TARGET');
+ [, $otherToken] = $this->makeGuestOrderWithToken('ORD-CP-OTHER');
+
+ $response = $this->postJson(
+ $this->cancelPaymentUrl($order),
+ ['cancel_code' => 'USER_CANCEL'],
+ ['X-Guest-Order-Token' => $otherToken]
+ );
+
+ $response->assertStatus(404);
+ $this->assertSame(
+ PaymentStatusEnum::READY->value,
+ $order->fresh()->payment->payment_status->value
+ );
+ }
+
+ /**
+ * 유효한 게스트 토큰이면 정상 취소된다 (정상 흐름 불변).
+ */
+ public function test_guest_order_with_valid_token_succeeds(): void
+ {
+ [$order, $token] = $this->makeGuestOrderWithToken('ORD-CP-VALID');
+
+ $response = $this->postJson(
+ $this->cancelPaymentUrl($order),
+ ['cancel_code' => 'USER_CANCEL', 'cancel_message' => '사용자가 결제를 취소했습니다.'],
+ ['X-Guest-Order-Token' => $token]
+ );
+
+ $response->assertStatus(200)->assertJsonPath('success', true);
+
+ $this->assertSame(
+ PaymentStatusEnum::CANCELLED->value,
+ $order->fresh()->payment->payment_status->value
+ );
+ }
+
+ /**
+ * 회원 주문의 소유자는 게스트 토큰 없이 그대로 통과한다 (회귀 방지).
+ */
+ public function test_member_owner_still_succeeds_without_guest_token(): void
+ {
+ $user = $this->createUser();
+ $this->actingAs($user);
+
+ $order = Order::factory()->forUser($user)->create([
+ 'order_status' => OrderStatusEnum::PENDING_ORDER,
+ ]);
+ OrderPayment::factory()->create([
+ 'order_id' => $order->id,
+ 'payment_status' => PaymentStatusEnum::READY,
+ 'payment_method' => PaymentMethodEnum::CARD,
+ ]);
+
+ $response = $this->postJson($this->cancelPaymentUrl($order), [
+ 'cancel_code' => 'USER_CANCEL',
+ ]);
+
+ $response->assertStatus(200)->assertJsonPath('success', true);
+ $this->assertSame(
+ PaymentStatusEnum::CANCELLED->value,
+ $order->fresh()->payment->payment_status->value
+ );
+ }
+
+ /**
+ * 회원 주문에 타인이 접근하면 404 (기존 동작 보존).
+ */
+ public function test_member_order_of_another_user_is_rejected(): void
+ {
+ $user = $this->createUser();
+ $other = $this->createUser();
+ $this->actingAs($user);
+
+ $order = Order::factory()->forUser($other)->create([
+ 'order_status' => OrderStatusEnum::PENDING_ORDER,
+ ]);
+ OrderPayment::factory()->create([
+ 'order_id' => $order->id,
+ 'payment_status' => PaymentStatusEnum::READY,
+ 'payment_method' => PaymentMethodEnum::CARD,
+ ]);
+
+ $response = $this->postJson($this->cancelPaymentUrl($order));
+
+ $response->assertStatus(404);
+ $this->assertSame(
+ PaymentStatusEnum::READY->value,
+ $order->fresh()->payment->payment_status->value
+ );
+ }
+
+ /**
+ * 존재하지 않는 주문번호는 404 (정보 노출 차단 — 동일 문구).
+ */
+ public function test_unknown_order_number_is_rejected(): void
+ {
+ $response = $this->postJson(
+ '/api/modules/sirsoft-ecommerce/orders/ORD-CP-NOPE/cancel-payment'
+ );
+
+ $response->assertStatus(404);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md b/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md
index 4fc0398c..425d6fcc 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md
@@ -149,8 +149,8 @@ OS 판별 후 `executeCliWindows()`/`executeCliLinux()` 로 분기 → CLI 인
| 종류 | 개수 | 위치 |
|---|---|---|
-| PHPUnit | 28개 | `plugins/_bundled/sirsoft-pay_nhnkcp/tests` |
-| Vitest | 8개 | `vitest.config.ts` |
+| PHPUnit | 29개 | `plugins/_bundled/sirsoft-pay_nhnkcp/tests` |
+| Vitest | 9개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
index 13fdaf2f..06813d14 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
@@ -6,8 +6,13 @@
## [1.0.4] - 2026-09-04
+### Fixed
+
+- 비회원으로 결제하신 손님이 주문완료 화면과 비회원 주문 상세 화면에서 영수증 버튼과 결제수단 표시를 볼 수 없던 문제를 고쳤습니다. 서버는 비회원 주문을 확인해 줄 준비가 되어 있었는데 화면이 확인값을 함께 보내지 않아 조회 자체가 되지 않았고, 오류 메시지도 남지 않아 원인을 알기 어려웠습니다.
+
### Security
+- 모바일 가상계좌 결제에서 제3자가 입금 계좌를 자기 계좌로 바꿔치기할 수 있던 문제를 수정했습니다. 모바일은 결제사가 계좌 정보를 브라우저를 거쳐 그대로 전달하는 방식이라, 주문번호만 아는 사람이 위조한 계좌 정보를 보내면 그 주문의 입금 계좌가 바뀌어 구매자가 엉뚱한 곳으로 입금할 수 있었습니다. 이제 결제창을 열 때 본인 확인을 거쳐 발급한 일회용 확인값을 결제사를 통해 되돌려 받아 대조하며, 확인값이 없거나 맞지 않는 요청은 주문을 전혀 건드리지 않고 결제 화면으로 되돌립니다. 확인값은 한 번 쓰면 소멸하므로 같은 값을 다시 보내도 통하지 않습니다. PC 결제는 종전처럼 결제사 서버 확인을 거칩니다. 업데이트를 적용하는 순간 이미 결제창이 열려 있던 모바일 가상계좌 결제 건은 확인값이 없어 실패로 처리되므로, 결제가 몰리지 않는 시간에 적용하시고 적용 직후 결제 실패 건이 있으면 확인해 주세요. (KISA 측에서 제보해주셨습니다 — KVE-2026-2019)
- 결제창 프로그램을 불러오는 주소가 NHN KCP 의 주소인지 불러오기 직전에 확인합니다. 확인되지 않는 주소면 결제를 진행하지 않고 안내를 표시합니다.
- 제3자가 남의 주문번호만 알면 결제창을 거치지 않고도 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 결과 콜백은 로그인도 서명 확인도 거치지 않는 경로여서, 위조한 결제 정보를 보내 승인을 일부러 실패시키면 그 주문이 결제 실패로 처리되었습니다. 이제 실제 결제 승인이 이루어진 뒤의 실패만 주문에 반영하며, 승인 전 단계의 실패는 결제 화면으로 되돌려 보내기만 합니다. 구매자가 결제창을 닫아 생기는 정상적인 결제 실패는 종전처럼 기록됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2018)
- 같은 결제 결과 콜백에서 가상계좌 경로를 통해서도 남의 주문을 건드릴 수 있던 문제를 수정했습니다. 결제수단을 가상계좌라고 주장하는 값을 요청에 섞으면, 카드로 주문한 건도 결제사 확인을 거치지 않는 경로로 흘러 그 주문이 취소되거나 위조된 입금 계좌가 그 주문에 기록될 수 있었습니다. 이제 결제수단은 요청에 실려 온 값이 아니라 주문에 저장된 값으로만 판단하며, 가상계좌 발급이 확인되지 않은 경우에는 주문을 그대로 두고 결제 화면으로 되돌려 보냅니다.
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js
index c41d41bb..ac95d327 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js
@@ -1,4 +1,4 @@
-(function(){"use strict";function De(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function Be(e){return e.trim().slice(0,160)}function He(e){if(e)return e.replace(/\/payment\/close-report$/,"/payment/retry")}async function oe(e,t,n=!1){const o=window.G7Core?.api;if(typeof o?.post=="function"){await o.post(e,t);return}const r=await fetch(De(e),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),keepalive:n});if(!r.ok){let a=`NHN KCP payment request failed (${r.status})`;try{const s=await r.json(),i=Array.isArray(s?.errors?.message)?s.errors.message[0]:s?.error??s?.message;typeof i=="string"&&i.trim()!==""&&(a=i)}catch{}throw new Error(a)}}async function Fe(e){const t=He(e.closeReportUrl);t&&await oe(t,{oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??""})}const I="g7:sirsoft-pay_nhnkcp:pendingClose";function M(){try{return window.sessionStorage??null}catch{return null}}function Ue(e){const t=M();if(t)try{t.setItem(I,JSON.stringify(e))}catch{}}function re(){const e=M();if(e)try{e.removeItem(I)}catch{}}function qe(){const e=M();if(!e)return null;try{const t=e.getItem(I);if(!t)return null;const n=JSON.parse(t);return n&&typeof n.oid=="string"&&n.oid!==""?n:null}catch{return null}}async function Ge(){const e=qe();if(!e)return;let t;try{t=new URLSearchParams(window.location.search)}catch{return}const n=t.get("orderId")??"";if(n!==""&&n!==e.oid)return;if(/\/(complete|success)(\/|$|\?)/.test(window.location.pathname)){re();return}const o=t.get("error")??"",r=t.get("message")??"";o===""&&n===""||(re(),await ae(e,r!==""?r:o))}async function ae(e,t="kcp-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:Be(t)||"kcp-window-closed"};try{await oe(e.closeReportUrl,n,!0)}catch(o){console.warn("[sirsoft-pay_nhnkcp] failed to report payment window close",o)}}const Ye="nhnkcp_applepay";function T(e){return e===Ye}function x(){if(typeof navigator>"u")return!1;const e=navigator,t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase(),o=e.maxTouchPoints??0;return/iphone|ipad|ipod/.test(t)||/iphone|ipad|ipod|ios/.test(n)?!0:/macintosh|mac os x/.test(t)&&o>1}function R(){return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?"애플페이는 IOS 기기에 모바일결제만 가능합니다.":"Apple Pay is available only for mobile payments on iOS devices."}function L(e){return(e??"KRW").trim().toUpperCase()||"KRW"}function We(e){return L(e)==="KRW"}const ze=["testpay.kcp.co.kr","pay.kcp.co.kr"];function Je(e,t){const n=window?.G7Core?.t;if(typeof n!="function")return t;const o=`sirsoft-pay_nhnkcp.${e}`,r=n(o);return typeof r=="string"&&r!==o?r:t}function Ve(e){let t=null;try{t=new URL(e)}catch{t=null}if(t===null||t.protocol!=="https:"||!ze.includes(t.hostname.toLowerCase()))throw new Error(Je("payment.error.sdk_url_untrusted","결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다."))}function Qe(e){const t=L(e);return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?`NHN KCP는 KRW 결제만 지원합니다. (${t})`:`NHN KCP supports KRW payments only. (${t})`}function Xe(e){const t=Math.max(0,Math.round(Number(e.amount??0))),n=Math.min(Math.max(0,Math.round(Number(e.tax_free_amount??0))),t);if(n<=0)return{};const o=Math.max(0,Math.round(Number(e.tax_amount??0))),r=Math.max(0,Math.round(Number(e.vat_amount??0))),a=Math.max(0,t-n),s=a>0&&o>0&&r>0?Math.min(a,Math.round(a*(r/o))):0,i=a-s;return{tax_flag:"TG03",comm_tax_mny:String(i),comm_vat_mny:String(s),comm_free_mny:String(n)}}class $ extends Error{constructor(t){super(t??"결제가 취소되었습니다."),this.name="KcpCancelledError"}}const Ze={card:"100000000000",bank_transfer:"010000000000",virtual_account:"001000000000",mobile:"000010000000",bank:"010000000000",vbank:"001000000000",phone:"000010000000"},se={nhnkcp_payco:{payco_direct:"Y"},nhnkcp_naverpay:{naverpay_direct:"Y"},nhnkcp_naverpay_point:{naverpay_direct:"Y",naverpay_point_direct:"Y"},nhnkcp_kakaopay:{kakaopay_direct:"Y"},nhnkcp_applepay:{applepay_direct:"Y"}};function et(e,t){const n=e.toLowerCase();return!t||!Object.prototype.hasOwnProperty.call(se,n)?{}:{param_opt_1:n,nhnkcp_easy_pay_method:n}}function tt(e){if(!e.isConnected)return!0;const t=window.getComputedStyle(e);if(t.display==="none"||t.visibility==="hidden")return!0;const n=e.getBoundingClientRect();return n.width<=0||n.height<=0}function nt(e,t){let n=!1,o,r;const a=()=>{n=!0,r&&(r.disconnect(),r=void 0),o!==void 0&&(window.clearInterval(o),o=void 0)},s=()=>{n||!tt(e)||(a(),t())},i=document.body??document.documentElement;return i&&typeof MutationObserver<"u"&&(r=new MutationObserver(s),r.observe(i,{attributes:!0,attributeFilter:["class","hidden","style"],childList:!0,subtree:!0})),o=window.setInterval(s,300),a}function ot(){if(typeof navigator>"u")return!1;if(x())return!0;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function rt(e,t){const{pgPaymentData:n,paymentMethod:o}=e.params||{};if(!n){console.error("[sirsoft-pay_nhnkcp] pgPaymentData is required");return}const r=o??n.payment_method??n.pay_method??"card",a=typeof r=="string"&&r.startsWith("nhnkcp_"),s=window.G7Core;let i=null;try{if(!We(n.currency)){const u=Qe(n.currency);s?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:r}),s?.modal?.open?.("nhnkcp_payment_error_modal");return}if(T(r)&&!x()){const u=R();s?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:r}),s?.modal?.open?.("nhnkcp_payment_error_modal");return}const c=await s.api.get("/modules/sirsoft-ecommerce/payments/client-config/nhnkcp");if(!c.data){console.error("[sirsoft-pay_nhnkcp] Failed to fetch client config",c);return}const d=c.data;i={closeReportUrl:d.callback_urls.close_report,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??"",payment_method:r};const l=window.location.origin+d.callback_urls.callback;await Fe(i),Ue(i),ot()?await at(s,n,r,a,l):await st(d,n,r,a,l)}catch(c){if(c instanceof $){i&&await ae(i,c.message||"kcp-window-closed"),s?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}console.error("[sirsoft-pay_nhnkcp] requestPayment error",c);const l=c?.response?.data,u=l?.errors?Object.values(l.errors)[0]?.[0]:void 0,p=l?.error??l?.message??u??(c instanceof Error?c.message:"Unknown error");s?.state?.setLocal?.({paymentErrorMessage:p,isSubmittingOrder:!1,paymentMethod:r}),s?.modal?.open?.("nhnkcp_payment_error_modal")}}async function at(e,t,n,o,r){const a=await e.api.post("/plugins/sirsoft-pay_nhnkcp/mobile/approval-key",{order_number:t.order_number,amount:t.amount,good_name:t.order_name,pay_method:n,buyr_name:t.customer_name??"",buyr_mail:t.customer_email??"",buyr_tel1:t.customer_phone??"",ret_url:r,currency:L(t.currency)});if(!a.success||!a.data){const d=a.errors,l=d?Object.values(d)[0]?.[0]:void 0;throw new Error(a.error??a.message??l??"KCP 모바일 승인키 획득 실패")}const{pay_url:s,fields:i}=a.data,c=document.createElement("form");c.method="POST",c.action=s,c.acceptCharset="euc-kr",c.style.display="none";for(const[d,l]of Object.entries(i)){const u=document.createElement("input");u.type="hidden",u.name=d,u.value=String(l),c.appendChild(u)}document.body.appendChild(c),c.submit(),await new Promise(()=>{})}async function st(e,t,n,o,r){const a=o?"100000000000":Ze[t.pay_method??"card"]??"100000000000",i={site_cd:n==="nhnkcp_payco"?e.easy_pay_client_id??e.client_id:e.use_escrow&&!o?e.escrow_client_id??e.client_id:e.client_id,ordr_idxx:t.order_number,good_name:t.order_name,good_mny:String(t.amount),buyr_name:t.customer_name??"",buyr_mail:t.customer_email??"",buyr_tel1:t.customer_phone??"",pay_method:a,Ret_URL:r};if(Object.assign(i,Xe(t)),Object.assign(i,et(n,o)),a==="001000000000"&&(i.vcnt_expire_term=String(e.vbank_expire_days??3),i.disp_tax_yn="N"),e.use_escrow&&!o&&(i.escw_used="Y",i.pay_mod="O"),o){i.payco_direct="",i.naverpay_direct="A",i.kakaopay_direct="A",i.applepay_direct="A";const d=se[n];d&&Object.assign(i,d)}const c=Object.entries(i).map(([d,l])=>``).join("");Ve(e.sdk_url),await new Promise((d,l)=>{document.getElementById("kcp-sdk-iframe")?.remove(),document.getElementById("kcp-dim-overlay")?.remove();const u=document.createElement("div");u.id="kcp-dim-overlay",u.style.cssText="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:99998;",document.body.appendChild(u);const p=document.createElement("iframe");p.id="kcp-sdk-iframe",p.setAttribute("allowtransparency","true"),p.style.cssText="position:fixed;top:0;left:0;width:100%;height:100%;border:0;z-index:99999;background:transparent;",document.body.appendChild(p);const f=p.contentWindow;let Z=!1,y,k;const _n=()=>{k?.(),k=void 0,y!==void 0&&(clearTimeout(y),y=void 0),p.remove(),u.remove()},N=m=>{Z||(Z=!0,_n(),l(m))},gn=()=>{Z=!0,k?.(),k=void 0,y!==void 0&&(clearTimeout(y),y=void 0)};k=nt(p,()=>{N(new $("kcp-payment-frame-closed"))}),f.m_Completepayment=function(m){const A=m.elements.res_cd?.value??"",te=m.elements.res_msg?.value??"";if(A==="0000"){gn();const h=f.document.forms.namedItem("order_info"),C=h&&h!==m?h:m;if(h&&h!==m)for(let ne=0;ne{N(m)};const ee=p.contentDocument||f.document;ee.open(),ee.write(`
+(function(){"use strict";function De(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function je(e){return e.trim().slice(0,160)}function Be(e){if(e)return e.replace(/\/payment\/close-report$/,"/payment/retry")}async function oe(e,t,n=!1){const r=window.G7Core?.api;if(typeof r?.post=="function"){await r.post(e,t);return}const o=await fetch(De(e),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),keepalive:n});if(!o.ok){let a=`NHN KCP payment request failed (${o.status})`;try{const s=await o.json(),i=Array.isArray(s?.errors?.message)?s.errors.message[0]:s?.error??s?.message;typeof i=="string"&&i.trim()!==""&&(a=i)}catch{}throw new Error(a)}}async function He(e){const t=Be(e.closeReportUrl);t&&await oe(t,{oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??""})}const T="g7:sirsoft-pay_nhnkcp:pendingClose";function M(){try{return window.sessionStorage??null}catch{return null}}function Fe(e){const t=M();if(t)try{t.setItem(T,JSON.stringify(e))}catch{}}function ae(){const e=M();if(e)try{e.removeItem(T)}catch{}}function Ge(){const e=M();if(!e)return null;try{const t=e.getItem(T);if(!t)return null;const n=JSON.parse(t);return n&&typeof n.oid=="string"&&n.oid!==""?n:null}catch{return null}}async function Ue(){const e=Ge();if(!e)return;let t;try{t=new URLSearchParams(window.location.search)}catch{return}const n=t.get("orderId")??"";if(n!==""&&n!==e.oid)return;if(/\/(complete|success)(\/|$|\?)/.test(window.location.pathname)){ae();return}const r=t.get("error")??"",o=t.get("message")??"";r===""&&n===""||(ae(),await se(e,o!==""?o:r))}async function se(e,t="kcp-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:je(t)||"kcp-window-closed"};try{await oe(e.closeReportUrl,n,!0)}catch(r){console.warn("[sirsoft-pay_nhnkcp] failed to report payment window close",r)}}const qe="nhnkcp_applepay";function R(e){return e===qe}function x(){if(typeof navigator>"u")return!1;const e=navigator,t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase(),r=e.maxTouchPoints??0;return/iphone|ipad|ipod/.test(t)||/iphone|ipad|ipod|ios/.test(n)?!0:/macintosh|mac os x/.test(t)&&r>1}function L(){return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?"애플페이는 IOS 기기에 모바일결제만 가능합니다.":"Apple Pay is available only for mobile payments on iOS devices."}function O(e){return(e??"KRW").trim().toUpperCase()||"KRW"}function Ye(e){return O(e)==="KRW"}const We=["testpay.kcp.co.kr","pay.kcp.co.kr"];function ze(e,t){const n=window?.G7Core?.t;if(typeof n!="function")return t;const r=`sirsoft-pay_nhnkcp.${e}`,o=n(r);return typeof o=="string"&&o!==r?o:t}function Je(e){let t=null;try{t=new URL(e)}catch{t=null}if(t===null||t.protocol!=="https:"||!We.includes(t.hostname.toLowerCase()))throw new Error(ze("payment.error.sdk_url_untrusted","결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다."))}function Ve(e){const t=O(e);return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?`NHN KCP는 KRW 결제만 지원합니다. (${t})`:`NHN KCP supports KRW payments only. (${t})`}function Qe(e){const t=Math.max(0,Math.round(Number(e.amount??0))),n=Math.min(Math.max(0,Math.round(Number(e.tax_free_amount??0))),t);if(n<=0)return{};const r=Math.max(0,Math.round(Number(e.tax_amount??0))),o=Math.max(0,Math.round(Number(e.vat_amount??0))),a=Math.max(0,t-n),s=a>0&&r>0&&o>0?Math.min(a,Math.round(a*(o/r))):0,i=a-s;return{tax_flag:"TG03",comm_tax_mny:String(i),comm_vat_mny:String(s),comm_free_mny:String(n)}}class $ extends Error{constructor(t){super(t??"결제가 취소되었습니다."),this.name="KcpCancelledError"}}const Xe={card:"100000000000",bank_transfer:"010000000000",virtual_account:"001000000000",mobile:"000010000000",bank:"010000000000",vbank:"001000000000",phone:"000010000000"},ie={nhnkcp_payco:{payco_direct:"Y"},nhnkcp_naverpay:{naverpay_direct:"Y"},nhnkcp_naverpay_point:{naverpay_direct:"Y",naverpay_point_direct:"Y"},nhnkcp_kakaopay:{kakaopay_direct:"Y"},nhnkcp_applepay:{applepay_direct:"Y"}};function Ze(e,t){const n=e.toLowerCase();return!t||!Object.prototype.hasOwnProperty.call(ie,n)?{}:{param_opt_1:n,nhnkcp_easy_pay_method:n}}function et(e){if(!e.isConnected)return!0;const t=window.getComputedStyle(e);if(t.display==="none"||t.visibility==="hidden")return!0;const n=e.getBoundingClientRect();return n.width<=0||n.height<=0}function tt(e,t){let n=!1,r,o;const a=()=>{n=!0,o&&(o.disconnect(),o=void 0),r!==void 0&&(window.clearInterval(r),r=void 0)},s=()=>{n||!et(e)||(a(),t())},i=document.body??document.documentElement;return i&&typeof MutationObserver<"u"&&(o=new MutationObserver(s),o.observe(i,{attributes:!0,attributeFilter:["class","hidden","style"],childList:!0,subtree:!0})),r=window.setInterval(s,300),a}function nt(){if(typeof navigator>"u")return!1;if(x())return!0;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function rt(e,t){const{pgPaymentData:n,paymentMethod:r}=e.params||{};if(!n){console.error("[sirsoft-pay_nhnkcp] pgPaymentData is required");return}const o=r??n.payment_method??n.pay_method??"card",a=typeof o=="string"&&o.startsWith("nhnkcp_"),s=window.G7Core;let i=null;try{if(!Ye(n.currency)){const u=Ve(n.currency);s?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:o}),s?.modal?.open?.("nhnkcp_payment_error_modal");return}if(R(o)&&!x()){const u=L();s?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:o}),s?.modal?.open?.("nhnkcp_payment_error_modal");return}const c=await s.api.get("/modules/sirsoft-ecommerce/payments/client-config/nhnkcp");if(!c.data){console.error("[sirsoft-pay_nhnkcp] Failed to fetch client config",c);return}const d=c.data;i={closeReportUrl:d.callback_urls.close_report,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??"",payment_method:o};const l=window.location.origin+d.callback_urls.callback;await He(i),Fe(i),nt()?await ot(s,n,o,a,l):await at(d,n,o,a,l)}catch(c){if(c instanceof $){i&&await se(i,c.message||"kcp-window-closed"),s?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:o});return}console.error("[sirsoft-pay_nhnkcp] requestPayment error",c);const l=c?.response?.data,u=l?.errors?Object.values(l.errors)[0]?.[0]:void 0,p=l?.error??l?.message??u??(c instanceof Error?c.message:"Unknown error");s?.state?.setLocal?.({paymentErrorMessage:p,isSubmittingOrder:!1,paymentMethod:o}),s?.modal?.open?.("nhnkcp_payment_error_modal")}}async function ot(e,t,n,r,o){const a=await e.api.post("/plugins/sirsoft-pay_nhnkcp/mobile/approval-key",{order_number:t.order_number,amount:t.amount,good_name:t.order_name,pay_method:n,buyr_name:t.customer_name??"",buyr_mail:t.customer_email??"",buyr_tel1:t.customer_phone??"",ret_url:o,currency:O(t.currency)});if(!a.success||!a.data){const d=a.errors,l=d?Object.values(d)[0]?.[0]:void 0;throw new Error(a.error??a.message??l??"KCP 모바일 승인키 획득 실패")}const{pay_url:s,fields:i}=a.data,c=document.createElement("form");c.method="POST",c.action=s,c.acceptCharset="euc-kr",c.style.display="none";for(const[d,l]of Object.entries(i)){const u=document.createElement("input");u.type="hidden",u.name=d,u.value=String(l),c.appendChild(u)}document.body.appendChild(c),c.submit(),await new Promise(()=>{})}async function at(e,t,n,r,o){const a=r?"100000000000":Xe[t.pay_method??"card"]??"100000000000",i={site_cd:n==="nhnkcp_payco"?e.easy_pay_client_id??e.client_id:e.use_escrow&&!r?e.escrow_client_id??e.client_id:e.client_id,ordr_idxx:t.order_number,good_name:t.order_name,good_mny:String(t.amount),buyr_name:t.customer_name??"",buyr_mail:t.customer_email??"",buyr_tel1:t.customer_phone??"",pay_method:a,Ret_URL:o};if(Object.assign(i,Qe(t)),Object.assign(i,Ze(n,r)),a==="001000000000"&&(i.vcnt_expire_term=String(e.vbank_expire_days??3),i.disp_tax_yn="N"),e.use_escrow&&!r&&(i.escw_used="Y",i.pay_mod="O"),r){i.payco_direct="",i.naverpay_direct="A",i.kakaopay_direct="A",i.applepay_direct="A";const d=ie[n];d&&Object.assign(i,d)}const c=Object.entries(i).map(([d,l])=>``).join("");Je(e.sdk_url),await new Promise((d,l)=>{document.getElementById("kcp-sdk-iframe")?.remove(),document.getElementById("kcp-dim-overlay")?.remove();const u=document.createElement("div");u.id="kcp-dim-overlay",u.style.cssText="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:99998;",document.body.appendChild(u);const p=document.createElement("iframe");p.id="kcp-sdk-iframe",p.setAttribute("allowtransparency","true"),p.style.cssText="position:fixed;top:0;left:0;width:100%;height:100%;border:0;z-index:99999;background:transparent;",document.body.appendChild(p);const f=p.contentWindow;let ee=!1,y,k;const gn=()=>{k?.(),k=void 0,y!==void 0&&(clearTimeout(y),y=void 0),p.remove(),u.remove()},A=m=>{ee||(ee=!0,gn(),l(m))},wn=()=>{ee=!0,k?.(),k=void 0,y!==void 0&&(clearTimeout(y),y=void 0)};k=tt(p,()=>{A(new $("kcp-payment-frame-closed"))}),f.m_Completepayment=function(m){const S=m.elements.res_cd?.value??"",ne=m.elements.res_msg?.value??"";if(S==="0000"){wn();const h=f.document.forms.namedItem("order_info"),C=h&&h!==m?h:m;if(h&&h!==m)for(let re=0;re{A(m)};const te=p.contentDocument||f.document;te.open(),te.write(`