From cf1d8ab52c15fba65b1bf93ce4f2e0bed4d3fcb6 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 17 Aug 2026 15:24:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(security):=20=EB=B3=B4=EC=95=88=20=EC=A0=9C?= =?UTF-8?q?=EB=B3=B4=2014=EA=B1=B4=20+=20=EC=9C=A0=EC=82=AC=20=EA=B2=B0?= =?UTF-8?q?=ED=95=A8=20=EC=A0=84=EC=88=98=EC=A1=B0=EC=82=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sir.kr 커뮤니티 제보(Xbuilder 님 외)와 전수조사 발굴분을 코어·게시판· 이커머스·페이지·ckeditor5 전반에 반영. - 통합 검색 하이라이트: 코어 SearchHighlighter 로 원문 이스케이프 후 강조 (저장형 XSS 차단, board/ecommerce/page 3중복제 통합) - 공개 최신글·인기글·관리자 대시보드: 읽기권한 없는 게시판·블라인드·비활성 게시판 글 필터. 비밀글은 기존 공개 정책대로 제목 노출 유지(본문만 보호) - 게시판 관리 대시보드 권한 게이트, 이커머스 쿠폰 다운로드·리뷰 대상·주문옵션 확정 스코프 강화, 취소/환불 항목 검증 회원/비회원 대칭 - 만료 토큰 거부(SetLocale), 회원 탈퇴·정보관리 실패 시 내부 오류 원문 마스킹 - 예외→응답 매핑 정비(generic catch 상태코드), ckeditor 업로드 권한 게이트 정리 정책 정합 정정: 착수 계획이 "비밀글 제목 노출=취약점"으로 전제해 공개 최신글· 대시보드에 넣었던 is_secret 필터는, 저장소가 확정한 "제목 공개, 본문만 보호" 정책(2026-01-02/02-04/06-18/06-19)과 배치되어 되돌림. 읽기권한·블라인드 필터는 유지. Chrome MCP 실측(T1~T10) + 회귀 테스트로 확인. --- CHANGELOG.md | 3 + .../Repositories/MenuRepositoryInterface.php | 8 + .../Api/Auth/ProfileController.php | 6 +- app/Http/Middleware/SetLocale.php | 15 +- app/Models/User.php | 3 + app/Repositories/MenuRepository.php | 11 + app/Search/SearchHighlighter.php | 62 +++++ app/Services/UserService.php | 20 +- docs/backend/api/me.md | 2 + docs/backend/api/search.md | 4 +- lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 4 + .../_bundled/g7-core-ja/backend/ja/user.php | 8 +- .../CHANGELOG.md | 1 + .../backend/ja/exceptions.php | 1 + lang/en/user.php | 8 +- lang/ko/user.php | 8 +- modules/_bundled/sirsoft-board/CHANGELOG.md | 4 + .../sirsoft-board/docs/api/dashboard.md | 8 +- modules/_bundled/sirsoft-board/module.php | 27 ++ .../Controllers/User/CommentController.php | 32 ++- .../Http/Requests/UpdateCommentRequest.php | 13 +- .../src/Listeners/SearchPostsListener.php | 29 +-- .../src/Repositories/BoardRepository.php | 17 ++ .../Contracts/BoardRepositoryInterface.php | 10 + .../src/Repositories/PostRepository.php | 4 + .../src/Repositories/ReportRepository.php | 18 +- .../src/Services/BoardService.php | 112 +++++++-- .../src/Services/CommentService.php | 45 ++++ .../_bundled/sirsoft-board/src/routes/api.php | 6 +- .../tests/Feature/Admin/DashboardApiTest.php | 10 +- .../Board/PublicRecentPostsVisibilityTest.php | 163 ++++++++++++ .../Comment/CommentVerifyTokenTest.php | 233 +++++++++++++++++ .../Dashboard/DashboardPermissionTest.php | 164 ++++++++++++ .../RecentAcrossBoardsVisibilityTest.php | 81 ++++++ .../Search/AvailableBoardsPermissionTest.php | 186 ++++++++++++++ .../Feature/User/BoardPopularApiTest.php | 42 ++++ .../Feature/User/BoardRecentPostsApiTest.php | 61 ++++- .../Listeners/SearchPostsListenerTest.php | 63 +++++ .../scenarios/security-fixes-2026-08.yaml | 45 ++++ .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 6 + .../sirsoft-ecommerce/docs/api/presets.md | 8 +- .../OrderOptionNotConfirmableException.php | 41 +++ .../Controllers/Public/OrderController.php | 11 +- .../Http/Controllers/User/OrderController.php | 11 +- .../Concerns/ValidatesCancelItems.php | 65 +++++ .../Public/GuestCancelOrderRequest.php | 12 +- .../Public/GuestEstimateRefundRequest.php | 26 +- .../Http/Requests/User/CancelOrderRequest.php | 60 +---- .../Requests/User/EstimateRefundRequest.php | 43 +--- .../src/Listeners/SearchProductsListener.php | 11 +- .../src/Services/OrderService.php | 18 ++ .../src/Services/ProductReviewService.php | 8 +- .../src/Services/UserCouponService.php | 32 +++ .../src/lang/en/exceptions.php | 1 + .../src/lang/ko/exceptions.php | 1 + .../sirsoft-ecommerce/src/routes/api.php | 7 +- .../Coupon/CouponDownloadGuardTest.php | 149 +++++++++++ .../Admin/CouponDirectIssueTest.php | 12 +- .../Order/GuestCancelItemValidationTest.php | 124 +++++++++ .../Order/GuestConfirmOptionGateTest.php | 237 ++++++++++++++++++ .../GuestRefundEstimateValidationTest.php | 107 ++++++++ .../ProductReviewProductIdScopeTest.php | 65 +++++ .../SearchPresetPermissionTest.php | 89 +++++++ .../Listeners/SearchProductsListenerTest.php | 37 +++ .../scenarios/security-fixes-2026-08.yaml | 44 ++++ modules/_bundled/sirsoft-page/CHANGELOG.md | 1 + .../src/Listeners/SearchPagesListener.php | 13 +- .../Listeners/SearchPagesListenerTest.php | 55 ++++ .../scenarios/security-fixes-2026-08.yaml | 19 ++ .../_bundled/sirsoft-ckeditor5/CHANGELOG.md | 4 + .../sirsoft-ckeditor5/components.json | 2 +- .../sirsoft-ckeditor5/dist/js/plugin.iife.js | 4 +- .../sirsoft-ckeditor5/docs/api/upload.md | 4 +- .../resources/extensions/html-editor.json | 1 - .../resources/js/handlers/initEditor.ts | 17 +- .../Controllers/ImageUploadController.php | 10 +- .../Http/Controllers/ImageUploadAuthTest.php | 113 +++++++++ .../scenarios/security-fixes-2026-08.yaml | 20 ++ routes/api.php | 6 +- .../Http/CoreExceptionMessageLeakTest.php | 92 +++++++ .../Middleware/SetLocaleExpiredTokenTest.php | 89 +++++++ .../User/UserWithdrawErrorLeakTest.php | 80 ++++++ tests/Unit/Search/SearchHighlighterTest.php | 110 ++++++++ tests/scenarios/security-fixes-2026-08.yaml | 39 +++ 84 files changed, 3195 insertions(+), 246 deletions(-) create mode 100644 app/Search/SearchHighlighter.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Board/PublicRecentPostsVisibilityTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Comment/CommentVerifyTokenTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Dashboard/DashboardPermissionTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Dashboard/RecentAcrossBoardsVisibilityTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Search/AvailableBoardsPermissionTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/scenarios/security-fixes-2026-08.yaml create mode 100644 modules/_bundled/sirsoft-ecommerce/src/Exceptions/OrderOptionNotConfirmableException.php create mode 100644 modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/ValidatesCancelItems.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Coupon/CouponDownloadGuardTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Order/GuestCancelItemValidationTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Order/GuestConfirmOptionGateTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Refund/GuestRefundEstimateValidationTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Review/ProductReviewProductIdScopeTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/SearchPreset/SearchPresetPermissionTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/scenarios/security-fixes-2026-08.yaml create mode 100644 modules/_bundled/sirsoft-page/tests/scenarios/security-fixes-2026-08.yaml create mode 100644 plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Http/Controllers/ImageUploadAuthTest.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/tests/scenarios/security-fixes-2026-08.yaml create mode 100644 tests/Feature/Http/CoreExceptionMessageLeakTest.php create mode 100644 tests/Feature/Middleware/SetLocaleExpiredTokenTest.php create mode 100644 tests/Feature/User/UserWithdrawErrorLeakTest.php create mode 100644 tests/Unit/Search/SearchHighlighterTest.php create mode 100644 tests/scenarios/security-fixes-2026-08.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f696d99..9678bcd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ - 역할 생성·수정이 권한 상한에 걸려 거부될 때 변경 일부가 남던 문제를 수정했습니다. 권한 확인이 저장 뒤에 있었기 때문에, 거부된 요청인데도 권한이 하나도 없는 빈 역할이 만들어지거나 역할 이름 변경만 반영된 상태가 남았습니다. 이제 저장 전에 확인해 거부 시 아무것도 변경되지 않습니다. - 첨부파일 순서 변경과 메뉴 순서 변경에도 담당 범위 제한을 적용했습니다. 두 기능은 대상을 목록으로 한 번에 받는 방식이라 범위 확인이 걸리지 않았고, 그래서 "본인 것만" 으로 범위를 좁혀 위임받은 관리자가 다른 사람이 올린 첨부파일이나 만든 메뉴의 순서를 바꿀 수 있었습니다. 기본 제공 역할인 "매니저" 가 첨부파일에서 이 구성에 해당합니다. 순서는 목록 전체에 대한 하나의 값이라 일부만 반영하면 나머지와 어긋나므로, 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. (KVE-2026-1919) - 레이아웃 표현식에서 객체의 숨은 내부 구조에 접근하는 우회 경로를 막았습니다. 표현식 평가기는 위험한 이름으로의 직접 접근을 막고 있었지만, 모든 객체가 공통으로 가진 오래된 방식의 접근 함수는 그 검사를 거치지 않아 같은 곳에 닿을 수 있었습니다. 이 경로로 사이트 전체의 공통 동작을 바꾸거나 망가뜨릴 수 있었습니다(임의 코드 실행으로는 이어지지 않습니다). 이제 화면 로드·저장·정비 검사 세 곳이 모두 이 이름들을 거부하며, 기존 레이아웃이 쓰는 정상 표현식은 그대로 동작합니다. (KVE-2026-1915) +- 회원 탈퇴·회원 정보 관리 작업이 실패할 때, 응답 메시지에 데이터베이스 오류 원문 같은 내부 시스템 정보가 그대로 노출될 수 있던 문제를 수정했습니다. 이제 이런 경우 사용자에게는 일반 안내 문구만 표시하고, 원본 오류는 서버 로그에만 기록합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.) +- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.) +- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다. ### Added diff --git a/app/Contracts/Repositories/MenuRepositoryInterface.php b/app/Contracts/Repositories/MenuRepositoryInterface.php index 4918dfe4..0a2ae822 100644 --- a/app/Contracts/Repositories/MenuRepositoryInterface.php +++ b/app/Contracts/Repositories/MenuRepositoryInterface.php @@ -59,6 +59,14 @@ interface MenuRepositoryInterface */ public function findBySlug(string $slug): ?Menu; + /** + * URL 로 메뉴를 찾습니다. + * + * @param string $url 메뉴 URL + * @return Menu|null 찾은 메뉴 모델 또는 null + */ + public function findByUrl(string $url): ?Menu; + /** * 새로운 메뉴를 생성합니다. * diff --git a/app/Http/Controllers/Api/Auth/ProfileController.php b/app/Http/Controllers/Api/Auth/ProfileController.php index 373b2728..e4dc707f 100644 --- a/app/Http/Controllers/Api/Auth/ProfileController.php +++ b/app/Http/Controllers/Api/Auth/ProfileController.php @@ -16,6 +16,7 @@ use App\Services\UserService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; /** @@ -321,7 +322,10 @@ class ProfileController extends AuthBaseController 'user.withdraw_failed' ); } catch (\Exception $e) { - return $this->error('user.withdraw_failed', 500, null, ['error' => $e->getMessage()]); + // 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다. + Log::error('User withdraw failed (profile)', ['exception' => $e]); + + return $this->error('user.withdraw_failed', 500); } } } diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php index c1d80aa2..fd60ee35 100644 --- a/app/Http/Middleware/SetLocale.php +++ b/app/Http/Middleware/SetLocale.php @@ -2,11 +2,11 @@ namespace App\Http\Middleware; +use App\Models\User; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Log; use Laravel\Sanctum\PersonalAccessToken; use Symfony\Component\HttpFoundation\Response; @@ -75,9 +75,9 @@ class SetLocale * 따라서 Bearer 토큰을 직접 파싱하여 사용자를 가져옵니다. * * @param Request $request HTTP 요청 - * @return \App\Models\User|null 사용자 또는 null + * @return User|null 사용자 또는 null */ - private function resolveUser(Request $request): ?\App\Models\User + private function resolveUser(Request $request): ?User { // 이미 인증된 경우 (세션 기반 인증) if (Auth::check()) { @@ -88,7 +88,14 @@ class SetLocale $bearerToken = $request->bearerToken(); if ($bearerToken) { $token = PersonalAccessToken::findToken($bearerToken); - if ($token && $token->tokenable instanceof \App\Models\User) { + + // 만료된 토큰은 인증되지 않은 것으로 취급 (guest 로케일 폴백). + // OptionalSanctumMiddleware 와 동일한 만료 검사를 적용한다. + if ($token && $token->expires_at && $token->expires_at->isPast()) { + return null; + } + + if ($token && $token->tokenable instanceof User) { return $token->tokenable; } } diff --git a/app/Models/User.php b/app/Models/User.php index 7cb2b495..379ef4b8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -640,6 +640,9 @@ class User extends Authenticatable implements HasLocalePreference // 닉네임에 suffix 추가 (있는 경우만, 날짜 없이) if ($this->nickname) { // nickname 은 마이그레이션에서 길이를 명시(50)한 컬럼이다. + // 이 접미사는 유일성 토큰(id)이 없다 — 현재 nickname/name 에 unique 인덱스가 + // 없어 무해하지만, 향후 unique 인덱스를 추가하면 email 과 동일한 충돌 + // (같은 값 재가입 후 재탈퇴 실패)이 재발하므로 그때 id 부착으로 전환할 것. $this->nickname = $this->appendWithdrawnSuffix($this->nickname, '_탈퇴', self::NICKNAME_MAX_LENGTH); } diff --git a/app/Repositories/MenuRepository.php b/app/Repositories/MenuRepository.php index d4179169..fe97533d 100644 --- a/app/Repositories/MenuRepository.php +++ b/app/Repositories/MenuRepository.php @@ -157,6 +157,17 @@ class MenuRepository implements MenuRepositoryInterface return Menu::where('slug', $slug)->first(); } + /** + * URL 로 메뉴를 찾습니다. + * + * @param string $url 메뉴 URL + * @return Menu|null 찾은 메뉴 모델 또는 null + */ + public function findByUrl(string $url): ?Menu + { + return Menu::where('url', $url)->first(); + } + /** * 새로운 메뉴를 생성합니다. * diff --git a/app/Search/SearchHighlighter.php b/app/Search/SearchHighlighter.php new file mode 100644 index 00000000..3a90b46b --- /dev/null +++ b/app/Search/SearchHighlighter.php @@ -0,0 +1,62 @@ + 로 감싼다. 이스케이프를 건너뛰면 제목·본문에 + * 삽입된 태그가 그대로 실행 계약으로 나간다(게시판/상품/페이지 검색 공통). + */ +class SearchHighlighter +{ + /** + * 원문을 HTML 이스케이프한 뒤 검색어만 로 감쌉니다. + * + * @param string|null $text 원문 텍스트(평문) + * @param string $keyword 검색어 + * @return string 이스케이프 완료된 안전한 HTML + */ + public static function highlight(?string $text, string $keyword): string + { + if ($text === null || $text === '') { + return ''; + } + + $safe = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); + + if ($keyword === '') { + return $safe; + } + + // 키워드도 동일하게 이스케이프해, 이스케이프된 원문과 일관되게 매칭한다. + $safeKeyword = htmlspecialchars($keyword, ENT_QUOTES, 'UTF-8'); + $escapedKeyword = preg_quote($safeKeyword, '/'); + + $result = preg_replace('/('.$escapedKeyword.')/iu', '$1', $safe); + + // 유효하지 않은 UTF-8 등으로 preg_replace 가 null 을 반환하면 이스케이프본을 유지한다. + return $result ?? $safe; + } + + /** + * HTML 본문을 태그 없는 평문으로 변환합니다. + * + * 엔티티를 먼저 디코드한 뒤 태그를 제거해, 엔티티로 인코딩된 태그가 + * 평문화 단계에서 실제 태그로 부활하지 못하게 합니다. + * + * @param string|null $html 본문(HTML 또는 평문) + * @return string 태그가 제거된 평문 + */ + public static function toPlainText(?string $html): string + { + if ($html === null || $html === '') { + return ''; + } + + $decoded = html_entity_decode($html, ENT_QUOTES, 'UTF-8'); + + return trim((string) preg_replace('/\s+/', ' ', strip_tags($decoded))); + } +} diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 74e69c9a..77403b06 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -120,8 +120,11 @@ class UserService throw $e; } + // 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다. + Log::error('User create failed', ['exception' => $e]); + throw ValidationException::withMessages([ - 'general' => [__('user.create_failed', ['error' => $e->getMessage()])], + 'general' => [__('user.create_failed')], ]); } } @@ -307,8 +310,11 @@ class UserService throw $e; } + // 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다. + Log::error('User update failed', ['user_id' => $user->id, 'exception' => $e]); + throw ValidationException::withMessages([ - 'general' => [__('user.update_failed', ['error' => $e->getMessage()])], + 'general' => [__('user.update_failed')], ]); } } @@ -430,8 +436,11 @@ class UserService throw $e; } + // 원본 예외는 로그로만 남기고, 사용자 응답에는 원문(SQL 상태코드·경로 등)을 싣지 않는다. + Log::error('User withdraw failed', ['user_id' => $user->id, 'exception' => $e]); + throw ValidationException::withMessages([ - 'general' => [__('user.withdraw_failed', ['error' => $e->getMessage()])], + 'general' => [__('user.withdraw_failed')], ]); } } @@ -509,8 +518,11 @@ class UserService throw $e; } + // 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다. + Log::error('User delete failed', ['user_id' => $user->id, 'exception' => $e]); + throw ValidationException::withMessages([ - 'general' => [__('user.delete_failed', ['error' => $e->getMessage()])], + 'general' => [__('user.delete_failed')], ]); } } diff --git a/docs/backend/api/me.md b/docs/backend/api/me.md index 5f5323e2..6f1edfc7 100644 --- a/docs/backend/api/me.md +++ b/docs/backend/api/me.md @@ -66,6 +66,8 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지 익명화된 이메일에는 사용자 ID 가 포함되어 구조적으로 유일하다 — 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 충돌하지 않는다. +탈퇴 처리 중 예기치 못한 오류(예: 데이터베이스 예외)가 발생하면 500 과 함께 고정된 안내 메시지(`user.withdraw_failed`)만 반환한다 — 예외 원문(SQL 상태코드·쿼리 등)은 응답에 싣지 않고 서버 로그에만 기록한다(회원에게 노출되는 경로이므로 내부 정보 유출을 차단한다). + **추가 오류 응답** | 상태코드 | 의미 | 발생 조건 | diff --git a/docs/backend/api/search.md b/docs/backend/api/search.md index d8bc09d6..4270031f 100644 --- a/docs/backend/api/search.md +++ b/docs/backend/api/search.md @@ -21,7 +21,7 @@ - **라우트명**: `api.search` - **컨트롤러**: `App\Http\Controllers\Api\Public\PublicSearchController@search` -- **인증/권한**: 공개 (인증 불필요) +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) **요청 파라미터** @@ -104,6 +104,6 @@ HTTP/1.1 200 **설명** -프론트엔드 통합 검색(`search/index.json`)이 호출하는 공개 엔드포인트입니다. 인증이 필요 없으며 게스트도 사용할 수 있습니다. 코어 컨트롤러는 검색 결과를 직접 생성하지 않고, 검증된 파라미터로 검색 컨텍스트(q/type/sort/page/per_page 및 요청 객체)를 구성한 뒤 `core.search.results` Filter 훅을 실행합니다. 게시판·상품 등 각 검색 대상 모듈이 이 훅에 리스너를 등록해 자신의 카테고리 결과를 추가하고, `core.search.build_response` 훅으로 응답 구조를 완성합니다. 따라서 활성 검색 모듈이 없으면 항상 빈 결과(`total: 0`)가 반환됩니다. 검색 엔진 자체는 Scout + `DatabaseFulltextEngine`(MySQL FULLTEXT) 기반이며, 상세는 `docs/backend/search-system.md`를 참고하세요. +프론트엔드 통합 검색(`search/index.json`)이 호출하는 공개 엔드포인트입니다. 인증 없이(게스트) 사용할 수 있고, Bearer 토큰을 보내면 회원으로 해석되어 게시판별 열람 권한이 검색 결과와 `available_boards` 필터 목록에 반영됩니다(`optional.sanctum` — 위조 토큰은 401, 만료 토큰은 게스트로 처리). 코어 컨트롤러는 검색 결과를 직접 생성하지 않고, 검증된 파라미터로 검색 컨텍스트(q/type/sort/page/per_page 및 요청 객체)를 구성한 뒤 `core.search.results` Filter 훅을 실행합니다. 게시판·상품 등 각 검색 대상 모듈이 이 훅에 리스너를 등록해 자신의 카테고리 결과를 추가하고, `core.search.build_response` 훅으로 응답 구조를 완성합니다. 따라서 활성 검색 모듈이 없으면 항상 빈 결과(`total: 0`)가 반환됩니다. 검색 엔진 자체는 Scout + `DatabaseFulltextEngine`(MySQL FULLTEXT) 기반이며, 상세는 `docs/backend/search-system.md`를 참고하세요. diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 23a32aa3..b3c8655a 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -6,6 +6,10 @@ ## [1.0.6] - 2026-08-13 +### Changed + +- 회원 관리(생성·수정·삭제·탈퇴) 실패 안내에서 오류 원문 노출(`:error`)이 제거된 것에 맞춰, 해당 실패 문구의 일본어 번역을 재시도 안내 문구로 갱신했습니다 (`user.create_failed`, `user.update_failed`, `user.delete_failed`, `user.withdraw_failed`). + ### Added - 검색엔진 드라이버 라벨 일본어 번역을 추가했습니다 (`settings.drivers.search.*`) — 환경설정 > 드라이버 탭의 검색엔진 선택지가 일본어 로케일에서 표시됩니다. diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/user.php b/lang-packs/_bundled/g7-core-ja/backend/ja/user.php index 1c544630..e63c77bd 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/user.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/user.php @@ -16,11 +16,11 @@ return [ 'fetch_success' => 'ユーザー情報を正常に取得しました。', 'fetch_failed' => 'ユーザー情報の取得に失敗しました。', 'create_success' => 'ユーザーが正常に作成されました。', - 'create_failed' => 'ユーザー作成に失敗しました: :error', + 'create_failed' => 'ユーザー作成に失敗しました。しばらくしてからもう一度お試しください。', 'update_success' => 'ユーザー情報が正常に更新されました。', - 'update_failed' => 'ユーザー情報の更新に失敗しました: :error', + 'update_failed' => 'ユーザー情報の更新に失敗しました。しばらくしてからもう一度お試しください。', 'delete_success' => 'ユーザーが正常に削除されました。', - 'delete_failed' => 'ユーザー削除に失敗しました: :error', + 'delete_failed' => 'ユーザー削除に失敗しました。しばらくしてからもう一度お試しください。', 'statistics_success' => 'ユーザー統計を正常に取得しました。', 'statistics_failed' => 'ユーザー統計の取得に失敗しました。', 'search_success' => 'ユーザー検索が正常に完了しました。', @@ -54,7 +54,7 @@ return [ 'password_change_success' => 'パスワードが正常に変更されました。', 'password_change_failed' => 'パスワード変更に失敗しました。', 'withdraw_success' => '会員退会が完了しました。', - 'withdraw_failed' => '会員退会に失敗しました: :error', + 'withdraw_failed' => '会員退会に失敗しました。しばらくしてからもう一度お試しください。', 'withdraw_admin_forbidden' => '管理者アカウントは退会できません。', 'validation' => [ 'page_integer' => 'ページ番号は数字である必要があります。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 477c1b3a..984312bf 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 확정 불가 상태의 주문 옵션 구매확정 시도 안내(`exceptions.order_option_not_confirmable`)의 일본어 번역을 추가했습니다. - 이용할 수 없는 결제수단 선택 시의 주문 거절 안내 일본어 번역을 추가했습니다 (`validation.order.payment_method_unavailable`). - 관리자 주문설정 결제수단 목록의 「지정 PG 삭제됨」 배지 일본어 번역을 추가했습니다. - 배송지 변경 처리 중 서버 오류가 발생했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다. diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php index bfa64a9d..0774befb 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php @@ -84,4 +84,5 @@ return [ 'coupon_issue_not_cancellable' => '未使用状態の発行分のみキャンセルできます。', 'country_not_shippable' => '選択された配送先国には配送できない商品です。', 'order_shipping_address_update_failed' => '配送先変更処理中にエラーが発生しました。', + 'order_option_not_confirmable' => '現在の状態では購入確定できない注文オプションです。', ]; diff --git a/lang/en/user.php b/lang/en/user.php index cf083c71..8f26375a 100644 --- a/lang/en/user.php +++ b/lang/en/user.php @@ -21,11 +21,11 @@ return [ 'fetch_success' => 'User information retrieved successfully.', 'fetch_failed' => 'Failed to retrieve user information.', 'create_success' => 'User created successfully.', - 'create_failed' => 'Failed to create user: :error', + 'create_failed' => 'Failed to create user. Please try again later.', 'update_success' => 'User information updated successfully.', - 'update_failed' => 'Failed to update user information: :error', + 'update_failed' => 'Failed to update user information. Please try again later.', 'delete_success' => 'User deleted successfully.', - 'delete_failed' => 'Failed to delete user: :error', + 'delete_failed' => 'Failed to delete user. Please try again later.', 'statistics_success' => 'User statistics retrieved successfully.', 'statistics_failed' => 'Failed to retrieve user statistics.', 'search_success' => 'User search completed successfully.', @@ -69,7 +69,7 @@ return [ // Withdraw messages 'withdraw_success' => 'Account withdrawal completed successfully.', - 'withdraw_failed' => 'Failed to withdraw account: :error', + 'withdraw_failed' => 'Failed to withdraw account. Please try again later.', 'withdraw_admin_forbidden' => 'Administrator account cannot be withdrawn.', 'withdraw_failed_unknown' => 'Withdrawal failed due to an unknown error.', diff --git a/lang/ko/user.php b/lang/ko/user.php index 52518d8c..fc000849 100644 --- a/lang/ko/user.php +++ b/lang/ko/user.php @@ -21,11 +21,11 @@ return [ 'fetch_success' => '사용자 정보를 성공적으로 가져왔습니다.', 'fetch_failed' => '사용자 정보를 가져오는데 실패했습니다.', 'create_success' => '사용자가 성공적으로 생성되었습니다.', - 'create_failed' => '사용자 생성에 실패했습니다: :error', + 'create_failed' => '사용자 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'update_success' => '사용자 정보가 성공적으로 업데이트되었습니다.', - 'update_failed' => '사용자 정보 업데이트에 실패했습니다: :error', + 'update_failed' => '사용자 정보 업데이트에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'delete_success' => '사용자가 성공적으로 삭제되었습니다.', - 'delete_failed' => '사용자 삭제에 실패했습니다: :error', + 'delete_failed' => '사용자 삭제에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'statistics_success' => '사용자 통계를 성공적으로 가져왔습니다.', 'statistics_failed' => '사용자 통계를 가져오는데 실패했습니다.', 'search_success' => '사용자 검색을 성공적으로 완료했습니다.', @@ -69,7 +69,7 @@ return [ // 회원 탈퇴 관련 메시지 'withdraw_success' => '회원 탈퇴가 완료되었습니다.', - 'withdraw_failed' => '회원 탈퇴에 실패했습니다: :error', + 'withdraw_failed' => '회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'withdraw_admin_forbidden' => '관리자 계정은 탈퇴할 수 없습니다.', 'withdraw_failed_unknown' => '알 수 없는 오류로 탈퇴 처리에 실패했습니다.', diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index a3c7174b..a3b054d3 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -12,6 +12,10 @@ - 회원 프로필의 작성글 목록과 "내가 댓글 단 글" 활동 목록에서 다른 사람의 비밀글·블라인드 글 **본문**이 로그인 없이도 나가던 문제를 수정했습니다. 두 목록은 본문 앞부분을 함께 싣는데 그것을 가리는 설정이 실제로는 한 번도 켜지지 않았습니다. 이제 본인이 볼 때만 본문이 보이고, 다른 사람이 볼 때는 비워집니다. 글의 제목과 목록에서의 표시(비밀글·블라인드 배지)는 게시판 목록과 동일하게 그대로 유지됩니다. (KVE-2026-1914) - 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 회원 화면은 작성자 본인만 삭제하도록 막고 있었지만 관리 화면에는 같은 확인이 없었고, 순서 변경은 양쪽 모두 확인이 없었습니다. 순서는 목록 전체에 대한 하나의 값이라 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부합니다. (KVE-2026-1919) - 게시판을 찾을 수 없을 때 비밀글 보호가 통과되던 문제를 수정했습니다. 첨부파일 서빙과 댓글 목록은 부모 글을 찾지 못하면 검사를 건너뛰고 진행해, 게이트가 있어야 할 자리가 비어 있었습니다. 이제 부모 글을 확인할 수 없으면 차단합니다. +- 로그인 없이 열리는 목록(최근 글·인기 글)과 관리 대시보드의 요약 목록에서, 열람 권한이 없는 게시판의 글과 블라인드·비활성 게시판의 글이 노출되던 문제를 막았습니다. 이제 이 목록들은 블라인드·비활성 게시판 글을 제외하고, 열람 권한이 있는 게시판의 글만 각 요청자에게 보여 줍니다. 인기 게시판 목록과 통합 검색의 게시판 필터에서도 열람 권한이 없는 비공개 게시판이 더 이상 나타나지 않습니다. 비밀글은 기존과 동일하게 목록에 제목과 🔒 표시가 유지되며 본문만 보호됩니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.) +- 게시판 관리 대시보드(개요·글 추이·최근 글·미처리 신고)에 별도의 열람 권한을 요구하도록 했습니다. 이전에는 관리자이기만 하면 게시판 권한이 전혀 없어도 이 화면의 정보를 볼 수 있었습니다. 미처리 신고 집계는 비활성 게시판을 제외합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.) +- 통합 검색 결과의 제목·본문 미리보기에서 검색어 강조 처리가 원문을 그대로 실어 보내던 것을, 안전하게 변환한 뒤 강조하도록 바로잡았습니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.) +- 댓글 비밀번호 확인 후 받는 임시 토큰이 실제 검증에 연결되어 있지 않던 문제를 수정했습니다. 이제 이 토큰으로 댓글을 수정·삭제할 수 있으며(비밀번호 재입력 대체), 토큰은 한 번만 사용되고 만료됩니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.) ### Added diff --git a/modules/_bundled/sirsoft-board/docs/api/dashboard.md b/modules/_bundled/sirsoft-board/docs/api/dashboard.md index fa32f66f..c4fcf863 100644 --- a/modules/_bundled/sirsoft-board/docs/api/dashboard.md +++ b/modules/_bundled/sirsoft-board/docs/api/dashboard.md @@ -21,7 +21,7 @@ - **라우트명**: `api.modules.sirsoft-board.admin.dashboard.overview` - **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@overview` -- **인증/권한**: `auth:sanctum` +- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view` **요청 파라미터** @@ -77,7 +77,7 @@ HTTP/1.1 200 - **라우트명**: `api.modules.sirsoft-board.admin.dashboard.pending-reports` - **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@pendingReports` -- **인증/권한**: `auth:sanctum` +- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view` **요청 파라미터** @@ -151,7 +151,7 @@ HTTP/1.1 200 - **라우트명**: `api.modules.sirsoft-board.admin.dashboard.post-graph` - **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@postGraph` -- **인증/권한**: `auth:sanctum` +- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view` **요청 파라미터** @@ -253,7 +253,7 @@ HTTP/1.1 200 - **라우트명**: `api.modules.sirsoft-board.admin.dashboard.recent-posts` - **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@recentPosts` -- **인증/권한**: `auth:sanctum` +- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view` **요청 파라미터** diff --git a/modules/_bundled/sirsoft-board/module.php b/modules/_bundled/sirsoft-board/module.php index 6abef201..ee208eb6 100644 --- a/modules/_bundled/sirsoft-board/module.php +++ b/modules/_bundled/sirsoft-board/module.php @@ -256,6 +256,33 @@ class Module extends AbstractModule ], ], ], + // 대시보드 조회 권한 (type: admin) + [ + 'identifier' => 'dashboard', + 'name' => [ + 'ko' => '게시판 대시보드', + 'en' => 'Board Dashboard', + ], + 'description' => [ + 'ko' => '게시판 대시보드 조회 권한', + 'en' => 'Board dashboard view permissions', + ], + 'permissions' => [ + [ + 'action' => 'view', + 'name' => [ + 'ko' => '대시보드 조회', + 'en' => 'View Dashboard', + ], + 'description' => [ + 'ko' => '게시판 대시보드(현황/추세/최신글/미처리 신고) 조회', + 'en' => 'View board dashboard (overview, trend, recent posts, pending reports)', + ], + 'type' => 'admin', + 'roles' => ['admin', 'manager'], + ], + ], + ], // 신고 관리 권한 (type: admin) [ 'identifier' => 'reports', diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php index ab19c115..6800db73 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php @@ -189,12 +189,22 @@ class CommentController extends PublicBaseController $slug ); + // 비회원 댓글: 평문 비밀번호 재전송 대신 검증 토큰으로도 본인 확인 (게시글과 동형) + if (! $canUpdate && $request->filled('verification_token')) { + $canUpdate = $this->commentService->consumeCommentVerifyToken( + $slug, + $commentId, + (string) $request->input('verification_token') + ); + } + if (! $canUpdate) { return $this->forbidden('sirsoft-board::messages.comment.update_forbidden'); } - // 검증된 필드만 반영 (미검증 입력의 대량 할당 차단). password는 검증용이므로 제거 - $data = collect($request->validated())->except('password')->toArray(); + // 검증된 필드만 반영 (미검증 입력의 대량 할당 차단). + // password/verification_token 은 본인 확인용이므로 저장 데이터에서 제거 + $data = collect($request->validated())->except(['password', 'verification_token'])->toArray(); $updatedComment = $this->commentService->updateComment($slug, $commentId, $data, $postId); return $this->successWithResource( @@ -243,6 +253,16 @@ class CommentController extends PublicBaseController $slug ); + // 비회원 댓글: 평문 비밀번호 재전송 대신 검증 토큰으로도 본인 확인 (게시글과 동형) + $verificationToken = request()->input('verification_token'); + if (! $canDelete && ! empty($verificationToken)) { + $canDelete = $this->commentService->consumeCommentVerifyToken( + $slug, + $commentId, + (string) $verificationToken + ); + } + if (! $canDelete) { return $this->forbidden('sirsoft-board::messages.comment.delete_forbidden'); } @@ -288,16 +308,18 @@ class CommentController extends PublicBaseController return $this->error('sirsoft-board::messages.comment.invalid_password', 401); } - // 검증 성공 시 임시 토큰 생성 (프론트엔드에서 로컬 스토리지에 저장) + // 검증 성공 시 1회용 토큰을 발급하고 캐시에 저장한다. + // (게시글과 동형 — update/destroy 가 이 토큰을 소비해 평문 비밀번호 재전송을 대체) $verificationToken = Str::random(32); + $tokenResult = $this->commentService->storeCommentVerifyToken($slug, $commentId, $verificationToken); return $this->success( 'sirsoft-board::messages.comment.password_verified', [ 'verified' => true, 'comment_id' => $commentId, - 'verification_token' => $verificationToken, - 'expires_at' => now()->addHours(1)->toIso8601String(), // 1시간 유효 + 'verification_token' => $tokenResult['token'], + 'expires_at' => $tokenResult['expires_at'], ] ); } catch (ModelNotFoundException) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateCommentRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateCommentRequest.php index 302054c7..ab8d2ed6 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateCommentRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateCommentRequest.php @@ -6,7 +6,7 @@ use App\Extension\HookManager; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; -use Modules\Sirsoft\Board\Models\Board; +use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; /** @@ -32,7 +32,7 @@ class UpdateCommentRequest extends FormRequest public function rules(): array { $slug = $this->route('slug'); - $board = Board::where('slug', $slug)->first(); + $board = app(BoardRepositoryInterface::class)->findBySlug($slug); if (! $board) { return []; @@ -41,6 +41,10 @@ class UpdateCommentRequest extends FormRequest // 비회원 여부 확인 (request()->user()를 사용해야 PermissionMiddleware에서 설정한 사용자를 인식) $isGuest = ! $this->user(); + // 검증 토큰(verify-password 로 발급)이 있으면 평문 비밀번호 재전송을 요구하지 않는다. + // (게시글 수정 경로와 동형 — 토큰이 본인 확인을 대체하며, 컨트롤러가 1회 소비한다) + $hasVerificationToken = $this->filled('verification_token'); + // 금지 키워드 목록 가져오기 (게시글과 동일하게 게시판 설정 기준) $blockedKeywords = $board->blocked_keywords ?? []; @@ -54,8 +58,9 @@ class UpdateCommentRequest extends FormRequest ], 'is_secret' => ['boolean'], 'status' => ['nullable', 'string', Rule::in(PostStatus::values())], - // 비회원인 경우 비밀번호 필수 (수정 권한 검증용) - 'password' => [$isGuest ? 'required' : 'nullable', 'string', 'min:4', 'max:20'], + // 비회원인 경우 비밀번호 필수 (수정 권한 검증용). 단, 검증 토큰이 있으면 선택. + 'password' => [$isGuest && ! $hasVerificationToken ? 'required' : 'nullable', 'string', 'min:4', 'max:20'], + 'verification_token' => ['nullable', 'string'], ]; // 훅: 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 필터 제공 diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php index 0c2574b3..c0093e2a 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Listeners; use App\Contracts\Extension\HookListenerInterface; use App\Helpers\PermissionHelper; use App\Search\SearchCategoryPayload; +use App\Search\SearchHighlighter; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Board\Services\BoardService; use Modules\Sirsoft\Board\Services\PostService; @@ -213,7 +214,9 @@ class SearchPostsListener implements HookListenerInterface $type = $context['type'] ?? 'all'; $boardIds = collect($boards)->pluck('id')->all(); - $extra = ['available_boards' => $this->boardService->getActiveBoardsListForFilter()]; + // available_boards(필터 드롭다운)는 검색 결과 필터와 동일하게 읽기 권한을 통과한 + // 게시판만 담는다 — 권한 없는 게시판이 필터 목록으로 노출되지 않도록 컨텍스트 사용자를 전달. + $extra = ['available_boards' => $this->boardService->getActiveBoardsListForFilter($context['user'] ?? null)]; /** 조회 결과 항목을 화면 형태로 가공한다. */ $format = fn (iterable $items): array => collect($items) @@ -324,14 +327,9 @@ class SearchPostsListener implements HookListenerInterface $contentPreview = $this->extractContentPreview($post->content, $keyword, 150, $contentMode); $boardSlug = $post->board?->slug ?? ''; - // 텍스트 모드: 태그 문자열이 그대로 보존되므로 htmlspecialchars로 이스케이프 후 하이라이트 - // HTML 모드: strip_tags 처리된 평문이므로 바로 하이라이트 - if ($contentMode === 'text') { - $escapedPreview = htmlspecialchars($contentPreview, ENT_QUOTES, 'UTF-8'); - $contentPreviewHighlighted = $this->highlightKeyword($escapedPreview, $keyword); - } else { - $contentPreviewHighlighted = $this->highlightKeyword($contentPreview, $keyword); - } + // 하이라이트 필드는 소비 측이 HTML 로 렌더하므로, 공유 헬퍼가 원문을 이스케이프한 뒤 + // 검색어만 로 감싼다. 텍스트 모드의 리터럴 태그 문자열도 여기서 이스케이프된다. + $contentPreviewHighlighted = $this->highlightKeyword($contentPreview, $keyword); return [ 'id' => $post->id, @@ -366,13 +364,7 @@ class SearchPostsListener implements HookListenerInterface */ private function highlightKeyword(?string $text, string $keyword): string { - if (empty($text) || empty($keyword)) { - return $text ?? ''; - } - - $escapedKeyword = preg_quote($keyword, '/'); - - return preg_replace('/('.$escapedKeyword.')/iu', '$1', $text); + return SearchHighlighter::highlight($text, $keyword); } /** @@ -393,9 +385,10 @@ class SearchPostsListener implements HookListenerInterface return ''; } - // HTML 모드: 태그 제거 후 평문 추출, 텍스트 모드: 태그 문자열 그대로 보존 + // HTML 모드: 태그 제거 후 평문 추출(엔티티 디코드를 태그 제거보다 먼저 수행), + // 텍스트 모드: 태그 문자열 그대로 보존(하이라이트 시점에 이스케이프됨) if ($contentMode === 'html') { - $plainText = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($content)))); + $plainText = SearchHighlighter::toPlainText($content); } else { $plainText = trim(preg_replace('/\s+/', ' ', $content)); } diff --git a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php index 7bcaf162..25bd56f1 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php @@ -126,6 +126,21 @@ class BoardRepository implements BoardRepositoryInterface return $board->delete(); } + /** + * 게시판을 영구 삭제합니다. + * + * @param int $id 게시판 ID + * @return bool 삭제 성공 여부 + * + * @throws ModelNotFoundException + */ + public function forceDelete(int $id): bool + { + $board = $this->findOrFail($id); + + return (bool) $board->forceDelete(); + } + /** * 모든 게시판을 조회합니다. * @@ -222,6 +237,8 @@ class BoardRepository implements BoardRepositoryInterface ->whereNull('deleted_at') ->whereNull('parent_id') ->where('status', 'published') + // 비밀글도 제목은 공개한다(본문만 보호) — 2026-02-04 확정 정책. 목록/검색/홈과 동일. + // 게시판별 열람 권한은 BoardService::getCachedRecentPosts 가 응답 시점에 적용한다. ->orderBy('created_at', 'desc') ->limit($limit) ); diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php index b31f471a..db070514 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php @@ -68,6 +68,16 @@ interface BoardRepositoryInterface */ public function delete(int $id): bool; + /** + * 게시판을 영구 삭제합니다. + * + * @param int $id 게시판 ID + * @return bool 삭제 성공 여부 + * + * @throws ModelNotFoundException + */ + public function forceDelete(int $id): bool; + /** * 모든 게시판을 조회합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php index 91c2b3b4..8e8218a8 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php @@ -1807,6 +1807,10 @@ class PostRepository implements PostRepositoryInterface return Post::query() ->whereNull('deleted_at') ->whereNull('parent_id') + // 노출 제한 필터 — 미발행(블라인드·삭제)·비활성 게시판 글은 대시보드 최신글에서 제외한다. + // 비밀글은 제목 공개 정책(2026-01-02)에 따라 관리자에게 제목을 노출한다(제외하지 않음). + ->where('status', PostStatus::Published->value) + ->whereHas('board', fn ($q) => $q->where('is_active', true)) ->with(['board', 'user']) ->orderByDesc('created_at') ->limit($limit) diff --git a/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php index bc373e19..a6a7eac8 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php @@ -668,8 +668,15 @@ class ReportRepository implements ReportRepositoryInterface */ public function getPendingAcrossBoards(int $limit): Collection { - return Report::query() + $query = Report::query() ->whereIn('status', [ReportStatus::Pending, ReportStatus::Review]) + // 비활성 게시판의 신고는 대시보드 집계에서 제외한다(활성 게시판 스코프). + ->whereHas('board', fn ($q) => $q->where('is_active', true)); + + // 신고 조회 권한 스코프 필터링 (목록 엔드포인트와 동일 게이트) + PermissionHelper::applyPermissionScope($query, 'sirsoft-board.reports.view'); + + return $query ->with(['board', 'author']) ->orderByDesc('last_reported_at') ->limit($limit) @@ -683,8 +690,13 @@ class ReportRepository implements ReportRepositoryInterface */ public function countPendingAcrossBoards(): int { - return Report::query() + $query = Report::query() ->whereIn('status', [ReportStatus::Pending, ReportStatus::Review]) - ->count(); + // 목록(getPendingAcrossBoards)과 동일 스코프여야 배지 건수와 목록 건수가 일치한다. + ->whereHas('board', fn ($q) => $q->where('is_active', true)); + + PermissionHelper::applyPermissionScope($query, 'sirsoft-board.reports.view'); + + return $query->count(); } } diff --git a/modules/_bundled/sirsoft-board/src/Services/BoardService.php b/modules/_bundled/sirsoft-board/src/Services/BoardService.php index c6eaa3fa..b4e5d0f6 100644 --- a/modules/_bundled/sirsoft-board/src/Services/BoardService.php +++ b/modules/_bundled/sirsoft-board/src/Services/BoardService.php @@ -5,6 +5,8 @@ namespace Modules\Sirsoft\Board\Services; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\StorageInterface; use App\Contracts\Repositories\MenuRepositoryInterface; +use App\Contracts\Repositories\RoleRepositoryInterface; +use App\Contracts\Repositories\UserRepositoryInterface; use App\Enums\ExtensionOwnerType; use App\Extension\HookManager; use App\Extension\Traits\ClearsTemplateCaches; @@ -53,6 +55,9 @@ class BoardService * @param RoleService $roleService 역할 서비스 * @param CacheInterface $cache 캐시 드라이버 * @param MenuRepositoryInterface $menuRepository 코어 메뉴 리포지토리 (메뉴 조회용) + * @param StorageInterface $storage 스토리지 드라이버 + * @param RoleRepositoryInterface $roleRepository 코어 역할 리포지토리 (게시판별 역할 동기화용) + * @param UserRepositoryInterface $userRepository 코어 사용자 리포지토리 (통계·UUID 변환용) */ public function __construct( private BoardRepositoryInterface $boardRepository, @@ -64,7 +69,9 @@ class BoardService private RoleService $roleService, private CacheInterface $cache, private MenuRepositoryInterface $menuRepository, - private StorageInterface $storage + private StorageInterface $storage, + private RoleRepositoryInterface $roleRepository, + private UserRepositoryInterface $userRepository ) {} /** @@ -114,6 +121,7 @@ class BoardService * ID로 게시판을 조회합니다. * * @param int $id 게시판 ID + * @return Board 게시판 모델 * * @throws ModelNotFoundException */ @@ -153,6 +161,7 @@ class BoardService * * @param string $orderBy 정렬 기준 (기본: created_at) * @param string $orderDirection 정렬 방향 (기본: desc) + * @return Collection 활성 게시판 컬렉션 */ public function getActiveBoards( string $orderBy = 'created_at', @@ -211,7 +220,7 @@ class BoardService $boardStats = $this->getActiveBoardStats(); return [ - 'users' => User::count(), + 'users' => $this->userRepository->getStatistics()['total_users'], 'boards' => $boardStats['boards'], 'posts' => $boardStats['posts'], 'comments' => $boardStats['comments'], @@ -229,12 +238,18 @@ class BoardService { $ttl = (int) g7_core_settings('cache.default_ttl', 86400); - return $this->cache->remember( + // 캐시는 사용자와 무관한 안전집합(발행 + 활성 게시판)으로만 채운다. + // 비밀글은 제목 공개 정책(2026-02-04)에 따라 제목을 포함한다(본문 미포함 — 사용자 무관 데이터). + // 게시판별 열람 권한은 캐시에 담지 않고 응답 시점에 호출자 기준으로 적용해, + // 고권한 사용자의 결과가 캐시를 오염시켜 저권한 사용자에게 새는 것을 막는다. + $posts = $this->cache->remember( "recent_posts_{$limit}", fn () => $this->getRecentPosts($limit), $ttl, tags: ['board-posts'] ); + + return $this->filterItemsByBoardReadPermission($posts, 'board_slug'); } /** @@ -244,17 +259,22 @@ class BoardService * * @param string $period 기간 (today, week, month, year) * @param int $limit 조회 개수 + * @return array 캐시된 인기글 목록 */ public function getCachedPopularPosts(string $period = 'week', int $limit = 20): array { $ttl = (int) g7_core_settings('cache.default_ttl', 86400); - return $this->cache->remember( + // 캐시는 안전집합(비밀글 제외 + 발행 + 활성 게시판)으로만 채우고, 게시판별 + // 열람 권한은 응답 시점에 호출자 기준으로 적용한다(캐시 오염 회피). + $posts = $this->cache->remember( "popular_posts_{$period}_{$limit}", fn () => $this->getPopularPosts($period, $limit), $ttl, tags: ['board-posts'] ); + + return $this->filterItemsByBoardReadPermission($posts, 'board_slug'); } /** @@ -267,12 +287,16 @@ class BoardService { $ttl = (int) g7_core_settings('cache.default_ttl', 86400); - return $this->cache->remember( + // 캐시는 활성 게시판 전체로 채우고, 게시판별 열람 권한은 응답 시점에 + // 호출자 기준으로 적용한다(캐시 오염 회피 — 저권한 사용자에게 고권한 결과가 새지 않도록). + $boards = $this->cache->remember( "popular_boards_{$limit}", fn () => $this->getPopularBoards($limit), $ttl, tags: ['board-list'] ); + + return $this->filterItemsByBoardReadPermission($boards, 'slug'); } /** @@ -519,13 +543,13 @@ class BoardService HookManager::doAction('sirsoft-board.roles.after_delete', $board->slug); // 5. 등록된 메뉴 제거 (addToAdminMenu()로 등록된 경우에만) - $menu = Menu::where('url', '/admin/board/'.$board->slug)->first(); + $menu = $this->menuRepository->findByUrl('/admin/board/'.$board->slug); if ($menu) { $this->menuService->deleteMenu($menu); } // 6. 게시판 영구 삭제 - $board->forceDelete(); + $this->boardRepository->forceDelete($board->id); }); Log::info('Board deleted', [ @@ -665,10 +689,10 @@ class BoardService $boardNameKo = $board->name['ko'] ?? $board->name['en'] ?? $board->slug; $boardNameEn = $board->name['en'] ?? $board->name['ko'] ?? $board->slug; - // 중복 방어: 이전 삭제 실패 등으로 role이 남아있을 수 있음 - $managerRole = Role::firstOrCreate( - ['identifier' => "sirsoft-board.{$board->slug}.manager"], - [ + // 중복 방어: 이전 삭제 실패 등으로 role이 남아있을 수 있음 (기존 role 은 갱신 없이 재사용 — firstOrCreate 동형) + $managerRole = $this->roleRepository->findByIdentifier("sirsoft-board.{$board->slug}.manager") + ?? $this->roleRepository->create([ + 'identifier' => "sirsoft-board.{$board->slug}.manager", 'name' => [ 'ko' => "{$boardNameKo} 게시판 관리자", 'en' => "{$boardNameEn} Board Manager", @@ -679,12 +703,11 @@ class BoardService ], 'extension_type' => ExtensionOwnerType::Module, 'extension_identifier' => 'sirsoft-board', - ] - ); + ]); - $stepRole = Role::firstOrCreate( - ['identifier' => "sirsoft-board.{$board->slug}.step"], - [ + $stepRole = $this->roleRepository->findByIdentifier("sirsoft-board.{$board->slug}.step") + ?? $this->roleRepository->create([ + 'identifier' => "sirsoft-board.{$board->slug}.step", 'name' => [ 'ko' => "{$boardNameKo} 게시판 스텝", 'en' => "{$boardNameEn} Board Step", @@ -695,8 +718,7 @@ class BoardService ], 'extension_type' => ExtensionOwnerType::Module, 'extension_identifier' => 'sirsoft-board', - ] - ); + ]); return ['manager' => $managerRole, 'step' => $stepRole]; } @@ -713,13 +735,16 @@ class BoardService "sirsoft-board.{$board->slug}.step", ]; - $roles = Role::whereIn('identifier', $roleIdentifiers)->get(); + foreach ($roleIdentifiers as $identifier) { + $role = $this->roleRepository->findByIdentifier($identifier); + if (! $role) { + continue; + } - foreach ($roles as $role) { // 그누보드7 규정: detach 후 삭제 $role->permissions()->detach(); $role->users()->detach(); - $role->delete(); + $this->roleRepository->delete($role); } } @@ -757,7 +782,10 @@ class BoardService ]; foreach ($roleUpdates as $identifier => $attributes) { - Role::where('identifier', $identifier)->update($attributes); + $role = $this->roleRepository->findByIdentifier($identifier); + if ($role) { + $this->roleRepository->update($role, $attributes); + } } } @@ -779,7 +807,7 @@ class BoardService continue; } - $role = Role::where('identifier', $roleIdentifier)->first(); + $role = $this->roleRepository->findByIdentifier($roleIdentifier); if (! $role) { Log::warning('게시판 역할을 찾을 수 없습니다.', [ 'board_slug' => $board->slug, @@ -790,7 +818,7 @@ class BoardService } $userUuids = $data[$dataKey] ?? []; - $userIds = User::whereIn('uuid', $userUuids)->pluck('id')->toArray(); + $userIds = $this->userRepository->getIdsByUuids($userUuids); $role->users()->sync($userIds); } } @@ -886,6 +914,7 @@ class BoardService * * @param string $period 기간 (today, week, month, all) * @param int $limit 조회 개수 + * @return array 인기글 목록 */ public function getPopularPosts(string $period = 'week', int $limit = 20): array { @@ -938,7 +967,7 @@ class BoardService $menuUrl = '/admin/board/'.$board->slug; // 중복 체크: 동일한 URL의 메뉴가 이미 존재하는지 확인 - $existingMenu = Menu::where('url', $menuUrl)->first(); + $existingMenu = $this->menuRepository->findByUrl($menuUrl); if ($existingMenu) { throw new MenuAlreadyExistsException(__('sirsoft-board::messages.boards.menu_already_exists')); @@ -1104,18 +1133,49 @@ class BoardService /** * 필터용 전체 활성 게시판 목록을 배열로 반환합니다. * + * $user 가 전달되면 그 사용자가 읽기 권한(posts.read)을 가진 게시판만 남긴다. + * 통합 검색 필터 드롭다운(available_boards)이 열람 불가 게시판까지 노출하지 않도록, + * 검색 결과 필터와 동일한 게이트를 목록 구성에도 적용한다. + * + * @param User|null $user 기준 사용자 (null 이면 현재 인증 사용자 또는 guest) * @return array 활성 게시판 목록 */ - public function getActiveBoardsListForFilter(): array + public function getActiveBoardsListForFilter(?User $user = null): array { return $this->boardRepository->getActiveBoardsList() + ->filter(fn ($board) => PermissionHelper::check("sirsoft-board.{$board->slug}.posts.read", $user)) ->map(fn ($board) => [ 'slug' => $board->slug, 'name' => $board->getLocalizedName(), ]) + ->values() ->toArray(); } + /** + * 공개 목록의 각 항목을 호출자 읽기 권한(posts.read) 기준으로 필터링합니다. + * + * 공개 엔드포인트(최근/인기 게시글·인기 게시판)의 전역 캐시는 사용자와 무관하게 + * 안전집합으로만 채워지므로, 게시판별 열람 권한은 캐시가 아니라 응답 시점에 + * 현재 호출자 기준으로 적용한다. 고권한 사용자의 결과가 캐시에 남아 저권한/비회원 + * 사용자에게 새는 것을 막는다. + * + * @param array> $items 캐시된 목록 + * @param string $slugKey 각 항목에서 게시판 슬러그를 담은 키 + * @return array> 읽기 권한을 통과한 항목만 남긴 목록 + */ + private function filterItemsByBoardReadPermission(array $items, string $slugKey): array + { + $user = Auth::user(); + + return array_values(array_filter($items, function ($item) use ($slugKey, $user) { + $slug = $item[$slugKey] ?? null; + + return $slug !== null + && PermissionHelper::check("sirsoft-board.{$slug}.posts.read", $user); + })); + } + /** * 환경설정 기본값을 기존 게시판에 일괄 적용합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Services/CommentService.php b/modules/_bundled/sirsoft-board/src/Services/CommentService.php index c7f68546..b87747ce 100644 --- a/modules/_bundled/sirsoft-board/src/Services/CommentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/CommentService.php @@ -575,6 +575,51 @@ class CommentService $this->cache->put("comment_cooldown_{$slug}_{$identifier}", true, $seconds); } + /** + * 댓글 비밀번호 검증 토큰을 캐시에 저장하고 만료 시각을 반환합니다. + * + * 게시글(PostService::storeDeleteVerifyToken)과 동형 — 비회원이 비밀번호를 + * 확인하면 1회용 토큰을 발급해, 이후 수정/삭제 요청에서 평문 비밀번호 재전송 대신 + * 이 토큰으로 본인 확인을 대체한다. + * + * @param string $slug 게시판 슬러그 + * @param int $commentId 댓글 ID + * @param string $token 검증 토큰 + * @return array{token: string, expires_at: string} 토큰 및 만료 시각 + */ + public function storeCommentVerifyToken(string $slug, int $commentId, string $token): array + { + $ttl = (int) g7_core_settings('cache.post_verify_token_ttl', 3600); + $expiresAt = now()->addSeconds($ttl); + $this->cache->put("board_comment_verify_{$slug}_{$commentId}_{$token}", true, $ttl); + + return [ + 'token' => $token, + 'expires_at' => $expiresAt->toIso8601String(), + ]; + } + + /** + * 댓글 비밀번호 검증 토큰의 유효성을 확인하고 소비합니다. + * + * 토큰이 유효하면 즉시 삭제하여 재사용을 방지합니다(단일 사용). + * + * @param string $slug 게시판 슬러그 + * @param int $commentId 댓글 ID + * @param string $token 검증 토큰 + * @return bool 토큰 유효 여부 + */ + public function consumeCommentVerifyToken(string $slug, int $commentId, string $token): bool + { + $key = "board_comment_verify_{$slug}_{$commentId}_{$token}"; + if (! $this->cache->has($key)) { + return false; + } + $this->cache->forget($key); + + return true; + } + /** * 관리자 작업 이력 배열을 생성합니다. * diff --git a/modules/_bundled/sirsoft-board/src/routes/api.php b/modules/_bundled/sirsoft-board/src/routes/api.php index 7a05d231..a004a833 100644 --- a/modules/_bundled/sirsoft-board/src/routes/api.php +++ b/modules/_bundled/sirsoft-board/src/routes/api.php @@ -126,20 +126,24 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () ->middleware('permission:admin,sirsoft-board.settings.read') ->name('admin.settings.show'); - // 대시보드 - 오늘 새 글/댓글 현황 (진입 가드는 코어 core.dashboard.read + admin) + // 대시보드 - 오늘 새 글/댓글 현황 (형제 settings 라우트와 동일하게 명시적 permission 가드) Route::get('dashboard/overview', [DashboardController::class, 'overview']) + ->middleware('permission:admin,sirsoft-board.dashboard.view') ->name('admin.dashboard.overview'); // 대시보드 - 7일 추세 그래프 (막대 + 합계 + 변화율) Route::get('dashboard/post-graph', [DashboardController::class, 'postGraph']) + ->middleware('permission:admin,sirsoft-board.dashboard.view') ->name('admin.dashboard.post-graph'); // 대시보드 - 최신 게시글 Route::get('dashboard/recent-posts', [DashboardController::class, 'recentPosts']) + ->middleware('permission:admin,sirsoft-board.dashboard.view') ->name('admin.dashboard.recent-posts'); // 대시보드 - 미처리 신고 Route::get('dashboard/pending-reports', [DashboardController::class, 'pendingReports']) + ->middleware('permission:admin,sirsoft-board.dashboard.view') ->name('admin.dashboard.pending-reports'); }); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/DashboardApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/DashboardApiTest.php index eddeb818..f888e524 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/DashboardApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/DashboardApiTest.php @@ -33,9 +33,8 @@ class DashboardApiTest extends ModuleTestCase BoardStat::query()->delete(); - // isAdmin() 은 admin 타입 권한 보유 여부로 판정되므로 임의 권한을 부여한다. - // 대시보드 라우트는 admin 미들웨어만 사용 (별도 permission 가드 없음). - $this->adminUser = $this->createAdminUser(['sirsoft-board.boards.read']); + // 대시보드 라우트는 admin 미들웨어 + permission:admin,sirsoft-board.dashboard.view 가드를 사용한다. + $this->adminUser = $this->createAdminUser(['sirsoft-board.boards.read', 'sirsoft-board.dashboard.view']); $this->normalUser = $this->createUser(); Board::create([ @@ -59,6 +58,11 @@ class DashboardApiTest extends ModuleTestCase $this->actingAs($this->normalUser)->getJson(self::BASE.'/overview')->assertStatus(403); } + /** + * @scenario case=dashboard_permission_gate + * + * @effects dashboard_requires_view_permission + */ #[Test] public function test_overview_returns_today_counts_for_admin(): void { diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Board/PublicRecentPostsVisibilityTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Board/PublicRecentPostsVisibilityTest.php new file mode 100644 index 00000000..4101fe1d --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Board/PublicRecentPostsVisibilityTest.php @@ -0,0 +1,163 @@ +update(['is_active' => false]); + Cache::flush(); + + $this->readableBoard = Board::factory()->create(['is_active' => true, 'name' => ['ko' => '공개', 'en' => 'Readable']]); + $this->hiddenBoard = Board::factory()->create(['is_active' => true, 'name' => ['ko' => '비공개', 'en' => 'Hidden']]); + + // 읽기 가능한 게시판: 공개글 + 비밀글 + $this->insertPost($this->readableBoard->id, '공개글읽기가능', 100, false, PostStatus::Published->value); + $this->insertPost($this->readableBoard->id, '비밀글읽기가능', 90, true, PostStatus::Published->value); + // 읽기 불가능한 게시판: 공개글 + $this->insertPost($this->hiddenBoard->id, '숨김게시판글', 200, false, PostStatus::Published->value); + + // 비회원 읽기 권한은 readableBoard 에만 부여 + $this->grantRead('guest', $this->readableBoard->slug); + PermissionMiddleware::clearGuestRoleCache(); + } + + // ========== N-3 최근글 ========== + + /** + * @scenario case=recent_readable_filter + * + * @effects unreadable_board_titles_absent_for_caller + */ + #[Test] + public function guest_recent_posts_shows_readable_titles_including_secret_but_excludes_unreadable_board(): void + { + $titles = $this->titles($this->getJson('/api/modules/sirsoft-board/boards/posts/recent?limit=20')); + + $this->assertContains('공개글읽기가능', $titles); + // 비밀글도 제목은 공개된다(본문만 보호) — 2026-02-04 확정 정책. + $this->assertContains('비밀글읽기가능', $titles, '비밀글도 제목은 최근글에 노출된다(본문만 보호).'); + $this->assertNotContains('숨김게시판글', $titles, '읽기 권한 없는 게시판 글은 노출되면 안 된다.'); + } + + /** + * @scenario case=recent_readable_filter + * + * @effects unreadable_board_titles_absent_for_caller + */ + #[Test] + public function low_permission_member_recent_posts_excludes_unreadable_board_titles(): void + { + $member = $this->memberWithReadOn($this->readableBoard->slug); + + $titles = $this->titles( + $this->actingAs($member)->getJson('/api/modules/sirsoft-board/boards/posts/recent?limit=20') + ); + + $this->assertContains('공개글읽기가능', $titles); + $this->assertContains('비밀글읽기가능', $titles, '비밀글도 제목은 노출된다(본문만 보호).'); + $this->assertNotContains('숨김게시판글', $titles, '읽기 권한 없는 게시판 글은 저권한 회원에게도 노출되면 안 된다.'); + } + + // ========== N-4 인기글 ========== + + /** + * @scenario case=popular_readable_filter + * + * @effects unreadable_board_titles_absent_for_caller + */ + #[Test] + public function guest_popular_posts_excludes_unreadable_board_titles(): void + { + $titles = $this->titles($this->getJson('/api/modules/sirsoft-board/boards/popular?limit=20')); + + $this->assertContains('공개글읽기가능', $titles); + $this->assertNotContains('숨김게시판글', $titles, '인기글도 읽기 권한 없는 게시판 글을 제외해야 한다.'); + // 인기글은 비밀글도 원래 제외한다(2026-01-16 컨벤션). + $this->assertNotContains('비밀글읽기가능', $titles); + } + + // ========== 헬퍼 ========== + + /** + * @param TestResponse $response + * @return array 응답 데이터의 title 목록 + */ + private function titles($response): array + { + $response->assertStatus(200); + + return array_column($response->json('data'), 'title'); + } + + private function insertPost(int $boardId, string $title, int $viewCount, bool $isSecret, string $status): void + { + DB::table('board_posts')->insert([ + 'board_id' => $boardId, + 'title' => $title, + 'content' => '내용', + 'author_name' => '작성자', + 'view_count' => $viewCount, + 'is_secret' => $isSecret, + 'status' => $status, + 'ip_address' => '127.0.0.1', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function grantRead(string $roleIdentifier, string $slug): void + { + $role = Role::where('identifier', $roleIdentifier)->first(); + if (! $role) { + return; + } + $perm = Permission::firstOrCreate( + ['identifier' => "sirsoft-board.{$slug}.posts.read"], + ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user'] + ); + $role->permissions()->syncWithoutDetaching([$perm->id]); + } + + private function memberWithReadOn(string $slug): User + { + $member = User::factory()->create(); + $userRole = Role::where('identifier', 'user')->first(); + $member->roles()->attach($userRole->id); + $this->grantRead('user', $slug); + PermissionMiddleware::clearGuestRoleCache(); + + return $member; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Comment/CommentVerifyTokenTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Comment/CommentVerifyTokenTest.php new file mode 100644 index 00000000..6d586d90 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Comment/CommentVerifyTokenTest.php @@ -0,0 +1,233 @@ + $slug, + 'name' => ['ko' => '댓글 검증 토큰 테스트', 'en' => 'Comment Verify Token'], + 'is_active' => true, + 'use_comment' => true, + 'comment_order' => 'DESC', + 'min_comment_length' => 2, + 'max_comment_length' => 1000, + 'secret_mode' => 'disabled', + 'blocked_keywords' => [], + ]; + } + + private int $postId; + + private int $commentId; + + protected function setUp(): void + { + parent::setUp(); + + // 비회원이 verify/수정/삭제에 접근하려면 comments.write + comments.read 권한 필요 + $this->setGuestPermissions(['posts.read', 'comments.read', 'comments.write']); + + $this->postId = $this->createTestPost([ + 'title' => '댓글 대상 게시글', + 'status' => 'published', + ]); + + // 비회원(guest) 댓글 — 비밀번호 보호 + $this->commentId = $this->createTestComment($this->postId, [ + 'user_id' => null, + 'author_name' => '비회원', + 'content' => '원본 댓글 내용', + 'password' => Hash::make('pw1234'), + ]); + + $this->resetPermissionMiddlewareCache(); + } + + /** + * 올바른 비밀번호 확인 시 verification_token 이 발급된다. + * + * @return string 발급된 토큰 + */ + private function issueToken(): string + { + $response = $this->postJson( + "/api/modules/sirsoft-board/boards/{$this->board->slug}/comments/{$this->commentId}/verify-password", + ['password' => 'pw1234'] + ); + + $response->assertStatus(200) + ->assertJsonPath('data.verified', true); + + $token = $response->json('data.verification_token'); + $this->assertNotEmpty($token, 'verify-password 는 verification_token 을 발급해야 한다.'); + + return $token; + } + + /** + * @scenario case=comment_token_lifecycle + * + * @effects comment_verify_token_single_use_lifecycle + */ + #[Test] + public function verify_password_issues_token(): void + { + $token = $this->issueToken(); + + $this->assertIsString($token); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9]{32}$/', $token); + } + + /** + * @scenario case=comment_token_lifecycle + * + * @effects comment_verify_token_single_use_lifecycle + */ + #[Test] + public function update_succeeds_with_valid_token(): void + { + $token = $this->issueToken(); + + $response = $this->putJson( + "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}", + ['content' => '토큰으로 수정한 댓글', 'verification_token' => $token] + ); + + $response->assertStatus(200); + + $this->assertSame( + '토큰으로 수정한 댓글', + DB::table('board_comments')->where('id', $this->commentId)->value('content') + ); + } + + /** + * @scenario case=comment_token_lifecycle + * + * @effects comment_verify_token_single_use_lifecycle + */ + #[Test] + public function delete_succeeds_with_valid_token(): void + { + $token = $this->issueToken(); + + $response = $this->deleteJson( + "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}", + ['verification_token' => $token] + ); + + $response->assertStatus(200); + + $this->assertNotNull( + DB::table('board_comments')->where('id', $this->commentId)->value('deleted_at'), + '토큰 삭제 후 댓글은 소프트 삭제되어야 한다.' + ); + } + + /** + * @scenario case=comment_token_lifecycle + * + * @effects comment_verify_token_single_use_lifecycle + */ + #[Test] + public function update_is_rejected_with_wrong_token(): void + { + $this->issueToken(); // 실제 토큰은 발급하되, 요청에는 다른 값을 보낸다 + + $response = $this->putJson( + "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}", + ['content' => '잘못된 토큰 수정 시도', 'verification_token' => str_repeat('x', 32)] + ); + + $response->assertStatus(403); + + $this->assertSame( + '원본 댓글 내용', + DB::table('board_comments')->where('id', $this->commentId)->value('content'), + '잘못된 토큰이면 내용이 변경되면 안 된다.' + ); + } + + /** + * @scenario case=comment_token_lifecycle + * + * @effects comment_verify_token_single_use_lifecycle + */ + #[Test] + public function token_is_single_use(): void + { + $token = $this->issueToken(); + + // 1회차: 성공 + $this->putJson( + "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}", + ['content' => '첫 수정', 'verification_token' => $token] + )->assertStatus(200); + + // 2회차: 같은 토큰 재사용 → 소비되어 거부 + $this->putJson( + "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}", + ['content' => '두 번째 수정', 'verification_token' => $token] + )->assertStatus(403); + + $this->assertSame( + '첫 수정', + DB::table('board_comments')->where('id', $this->commentId)->value('content'), + '소비된 토큰의 재사용은 반영되면 안 된다.' + ); + } + + /** + * @scenario case=comment_token_lifecycle + * + * @effects comment_verify_token_single_use_lifecycle + */ + #[Test] + public function expired_token_is_rejected(): void + { + $token = $this->issueToken(); + + // 토큰 TTL(기본 1시간)을 넘겨 이동 → 캐시에서 만료 + $this->travel(2)->hours(); + + try { + $this->putJson( + "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}", + ['content' => '만료 토큰 수정 시도', 'verification_token' => $token] + )->assertStatus(403); + } finally { + $this->travelBack(); + } + + $this->assertSame( + '원본 댓글 내용', + DB::table('board_comments')->where('id', $this->commentId)->value('content'), + '만료 토큰이면 내용이 변경되면 안 된다.' + ); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/DashboardPermissionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/DashboardPermissionTest.php new file mode 100644 index 00000000..c1057dfe --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/DashboardPermissionTest.php @@ -0,0 +1,164 @@ +adminWithout = $this->createIsolatedAdminWithoutDashboardView(); + // adminWith: dashboard.view 보유 (공유 admin 역할 사용 — 프로덕션 기본 상태와 동일) + $this->adminWith = $this->createAdminUser(['sirsoft-board.boards.read', 'sirsoft-board.dashboard.view']); + } + + /** + * dashboard.view 가 없는 권한-격리 관리자(고유 역할)를 생성합니다. + * + * admin 가드(isAdmin) 통과를 위해 admin.access 를, 무해한 admin 권한으로 + * boards.read 를 부여하되 dashboard.view 는 부여하지 않습니다. + * + * @return User 대시보드 권한이 없는 관리자 + */ + private function createIsolatedAdminWithoutDashboardView(): User + { + $user = User::factory()->create(); + + $role = Role::create([ + 'identifier' => 'board-dash-guard-'.$user->id, + 'name' => ['ko' => '대시보드 미보유 관리자', 'en' => 'Admin without dashboard'], + ]); + $user->roles()->attach($role->id); + + foreach (['admin.access', 'sirsoft-board.boards.read'] as $identifier) { + $permission = Permission::firstOrCreate( + ['identifier' => $identifier], + ['name' => ['ko' => $identifier, 'en' => $identifier], 'type' => PermissionType::Admin], + ); + $role->permissions()->syncWithoutDetaching([$permission->id]); + } + + return $user; + } + + // ========== ⑱-1 permission 가드 ========== + + /** + * @scenario case=dashboard_permission_gate + * + * @effects dashboard_requires_view_permission + */ + #[Test] + public function admin_without_dashboard_view_is_forbidden_on_all_endpoints(): void + { + foreach (self::ENDPOINTS as $endpoint) { + $this->actingAs($this->adminWithout) + ->getJson(self::BASE.'/'.$endpoint) + ->assertStatus(403); + } + } + + /** + * @scenario case=dashboard_permission_gate + * + * @effects dashboard_requires_view_permission + */ + #[Test] + public function admin_with_dashboard_view_can_access_all_endpoints(): void + { + foreach (self::ENDPOINTS as $endpoint) { + $this->actingAs($this->adminWith) + ->getJson(self::BASE.'/'.$endpoint) + ->assertStatus(200); + } + } + + /** + * @scenario case=dashboard_permission_gate + * + * @effects dashboard_requires_view_permission + */ + #[Test] + public function unauthenticated_request_is_rejected(): void + { + foreach (self::ENDPOINTS as $endpoint) { + $this->getJson(self::BASE.'/'.$endpoint)->assertStatus(401); + } + } + + // ========== N-5 미처리 신고 board-active 스코프 ========== + + /** + * @scenario case=pending_reports_scope + * + * @effects pending_reports_scoped_to_active_boards + */ + #[Test] + public function pending_reports_excludes_inactive_board_reports(): void + { + $activeBoard = Board::factory()->create(['is_active' => true]); + $inactiveBoard = Board::factory()->create(['is_active' => false]); + + $this->createPendingReport($activeBoard); + $this->createPendingReport($inactiveBoard); + + $response = $this->actingAs($this->adminWith) + ->getJson(self::BASE.'/pending-reports'); + + $response->assertStatus(200); + + // 비활성 게시판 신고는 제외되어 총 1건, 목록도 1건이어야 한다. + $this->assertSame(1, $response->json('data.total'), '미처리 신고 총계는 활성 게시판 신고만 세야 한다.'); + $this->assertCount(1, $response->json('data.items'), '미처리 신고 목록은 활성 게시판 신고만 담아야 한다.'); + } + + /** + * 지정 게시판에 미처리(pending) 신고 케이스를 생성합니다. + * + * @param Board $board 대상 게시판 + * @return Report 생성된 신고 + */ + private function createPendingReport(Board $board): Report + { + return Report::create([ + 'board_id' => $board->id, + 'target_type' => 'post', + 'target_id' => 1, + 'status' => ReportStatus::Pending, + 'last_reported_at' => now(), + ]); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/RecentAcrossBoardsVisibilityTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/RecentAcrossBoardsVisibilityTest.php new file mode 100644 index 00000000..b296edfb --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/RecentAcrossBoardsVisibilityTest.php @@ -0,0 +1,81 @@ +admin = $this->createAdminUser(['sirsoft-board.dashboard.view']); + } + + /** + * @scenario case=dashboard_recent_visibility + * + * @effects dashboard_summary_excludes_hidden_posts + */ + #[Test] + public function recent_posts_shows_secret_but_excludes_blinded_and_inactive_board_posts(): void + { + $activeBoard = Board::factory()->create(['is_active' => true]); + $inactiveBoard = Board::factory()->create(['is_active' => false]); + + $this->insertPost($activeBoard->id, '공개 발행글', ['is_secret' => false, 'status' => PostStatus::Published->value]); + $this->insertPost($activeBoard->id, '비밀글', ['is_secret' => true, 'status' => PostStatus::Published->value]); + $this->insertPost($activeBoard->id, '블라인드글', ['is_secret' => false, 'status' => PostStatus::Blinded->value]); + $this->insertPost($inactiveBoard->id, '비활성게시판글', ['is_secret' => false, 'status' => PostStatus::Published->value]); + + $response = $this->actingAs($this->admin)->getJson(self::ENDPOINT.'?limit=20'); + $response->assertStatus(200); + + $titles = array_column($response->json('data'), 'title'); + + $this->assertContains('공개 발행글', $titles, '공개 발행글은 최신글에 노출되어야 한다.'); + $this->assertContains('비밀글', $titles, '비밀글은 제목 공개 정책에 따라 관리자 대시보드에 노출되어야 한다.'); + $this->assertNotContains('블라인드글', $titles, '블라인드글은 최신글에서 제외되어야 한다.'); + $this->assertNotContains('비활성게시판글', $titles, '비활성 게시판 글은 최신글에서 제외되어야 한다.'); + } + + /** + * 게시글을 직접 삽입합니다. + * + * @param int $boardId 게시판 ID + * @param string $title 제목 + * @param array $attributes 추가 속성 + */ + private function insertPost(int $boardId, string $title, array $attributes = []): void + { + DB::table('board_posts')->insert(array_merge([ + 'board_id' => $boardId, + 'title' => $title, + 'content' => '내용', + 'author_name' => '작성자', + 'ip_address' => '127.0.0.1', + 'created_at' => now(), + 'updated_at' => now(), + ], $attributes)); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Search/AvailableBoardsPermissionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Search/AvailableBoardsPermissionTest.php new file mode 100644 index 00000000..89792f46 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Search/AvailableBoardsPermissionTest.php @@ -0,0 +1,186 @@ +update(['is_active' => false]); + Cache::flush(); + + $this->readableBoard = Board::factory()->create([ + 'is_active' => true, + 'name' => ['ko' => '공개 게시판', 'en' => 'Readable Board'], + ]); + $this->hiddenBoard = Board::factory()->create([ + 'is_active' => true, + 'name' => ['ko' => '비공개 게시판', 'en' => 'Hidden Board'], + ]); + + // 비회원 읽기 권한은 readableBoard 에만 부여한다. + $this->grantGuestRead($this->readableBoard); + + PermissionMiddleware::clearGuestRoleCache(); + } + + /** + * ⑦ getActiveBoardsListForFilter 는 비회원에게 읽기 권한 없는 게시판을 제외한다. + * + * @scenario case=available_boards_guest + * + * @effects available_boards_match_read_permission + */ + #[Test] + public function available_boards_excludes_boards_guest_cannot_read(): void + { + $list = app(BoardService::class)->getActiveBoardsListForFilter(null); + + $slugs = array_column($list, 'slug'); + + $this->assertContains($this->readableBoard->slug, $slugs, '읽기 권한 있는 게시판은 필터 목록에 포함되어야 한다.'); + $this->assertNotContains($this->hiddenBoard->slug, $slugs, '읽기 권한 없는 게시판은 필터 목록에서 제외되어야 한다.'); + } + + /** + * ⑦ 회원 기준으로도 읽기 권한 있는 게시판만 필터 목록에 포함된다. + * + * @scenario case=available_boards_member + * + * @effects available_boards_match_read_permission + */ + #[Test] + public function available_boards_filters_by_member_read_permission(): void + { + $member = User::factory()->create(); + $userRole = Role::where('identifier', 'user')->first(); + $member->roles()->attach($userRole->id); + + // 회원 역할에 readableBoard 읽기 권한만 부여 + $perm = Permission::firstOrCreate( + ['identifier' => "sirsoft-board.{$this->readableBoard->slug}.posts.read"], + ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user'] + ); + $userRole->permissions()->syncWithoutDetaching([$perm->id]); + PermissionMiddleware::clearGuestRoleCache(); + + $list = app(BoardService::class)->getActiveBoardsListForFilter($member); + + $slugs = array_column($list, 'slug'); + + $this->assertContains($this->readableBoard->slug, $slugs); + $this->assertNotContains($this->hiddenBoard->slug, $slugs); + } + + /** + * N-2 공개 인기 게시판 목록은 비회원 읽기 권한을 통과한 게시판만 노출한다. + * + * @scenario case=popular_boards_readable + * + * @effects popular_boards_exclude_unreadable + */ + #[Test] + public function popular_boards_endpoint_excludes_boards_guest_cannot_read(): void + { + $response = $this->getJson('/api/modules/sirsoft-board/boards/popular-boards?limit=20'); + $response->assertStatus(200); + + $slugs = array_column($response->json('data'), 'slug'); + + $this->assertContains($this->readableBoard->slug, $slugs, '읽기 권한 있는 게시판은 인기 게시판 목록에 포함되어야 한다.'); + $this->assertNotContains($this->hiddenBoard->slug, $slugs, '읽기 권한 없는 게시판은 인기 게시판 목록에서 제외되어야 한다.'); + } + + /** + * ⑦ HTTP 검색 엔드포인트가 Bearer 회원을 해석해 available_boards 에 반영한다. + * + * 라우트에 optional.sanctum 이 없으면 Bearer 토큰이 있어도 $request->user() 가 + * null 이라 모든 호출자가 guest 로 판정된다 — 서비스 직접 호출 테스트로는 이 + * 사각이 보이지 않으므로 실제 HTTP 경로로 검증한다 (actingAs 금지: actingAs 는 + * 미들웨어 없이도 유저를 심어 라이브와 어긋난다). + * + * @scenario case=available_boards_bearer_http + * + * @effects search_route_resolves_bearer_user, available_boards_match_read_permission + */ + #[Test] + public function search_endpoint_resolves_bearer_user_for_available_boards(): void + { + $member = User::factory()->create(); + $userRole = Role::where('identifier', 'user')->first(); + $member->roles()->attach($userRole->id); + + // 회원 역할에 두 게시판 읽기 권한 부여 — hiddenBoard 는 회원 역할에만 있으므로 + // (guest 미보유) Bearer 유저가 해석될 때만 목록에 나타난다. + foreach ([$this->hiddenBoard, $this->readableBoard] as $board) { + $perm = Permission::firstOrCreate( + ['identifier' => "sirsoft-board.{$board->slug}.posts.read"], + ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user'] + ); + $userRole->permissions()->syncWithoutDetaching([$perm->id]); + } + PermissionMiddleware::clearGuestRoleCache(); + + $token = $member->createToken('search-bearer-test')->plainTextToken; + + $response = $this->getJson('/api/search?q=테스트', ['Authorization' => 'Bearer '.$token]); + $response->assertStatus(200); + + $slugs = array_column($response->json('data.posts.available_boards') ?? [], 'slug'); + + $this->assertContains( + $this->hiddenBoard->slug, + $slugs, + 'Bearer 인증 회원의 읽기 권한이 available_boards 에 반영되어야 한다 (검색 라우트 optional.sanctum).' + ); + $this->assertContains($this->readableBoard->slug, $slugs); + } + + /** + * 게시판에 비회원(guest) 읽기 권한(posts.read)을 부여합니다. + * + * @param Board $board 대상 게시판 + */ + private function grantGuestRead(Board $board): void + { + $guestRole = Role::where('identifier', 'guest')->first(); + if (! $guestRole) { + return; + } + + $perm = Permission::firstOrCreate( + ['identifier' => "sirsoft-board.{$board->slug}.posts.read"], + ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user'] + ); + $guestRole->permissions()->syncWithoutDetaching([$perm->id]); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php index 8a86a861..5edd92a6 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php @@ -5,6 +5,9 @@ namespace Modules\Sirsoft\Board\Tests\Feature\User; // ModuleTestCase를 수동으로 require (autoload 전에 로드 필요) require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Http\Middleware\PermissionMiddleware; +use App\Models\Permission; +use App\Models\Role; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Board\Enums\PostStatus; @@ -49,6 +52,10 @@ class BoardPopularApiTest extends ModuleTestCase /** * 인기 게시글 API가 올바른 구조로 응답하는지 테스트 + * + * @scenario case=popular_readable_filter + * + * @effects unreadable_board_titles_absent_for_caller */ public function test_popular_returns_correct_structure(): void { @@ -139,6 +146,7 @@ class BoardPopularApiTest extends ModuleTestCase { // Given: view_count가 다른 게시글 생성 $board = Board::factory()->create(['is_active' => true]); + $this->grantGuestRead($board); DB::table('board_posts')->insert([ ['board_id' => $board->id, 'title' => 'Post 1', 'content' => 'Content 1', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()], ['board_id' => $board->id, 'title' => 'Post 2', 'content' => 'Content 2', 'view_count' => 300, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()], @@ -164,6 +172,7 @@ class BoardPopularApiTest extends ModuleTestCase { // Given: 오늘과 어제 게시글 생성 $board = Board::factory()->create(['is_active' => true]); + $this->grantGuestRead($board); DB::table('board_posts')->insert([ ['board_id' => $board->id, 'title' => 'Today Post', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()], ['board_id' => $board->id, 'title' => 'Yesterday Post', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subDay(), 'updated_at' => now()->subDay()], @@ -187,6 +196,7 @@ class BoardPopularApiTest extends ModuleTestCase { // Given: 최근 1주일과 2주 전 게시글 생성 $board = Board::factory()->create(['is_active' => true]); + $this->grantGuestRead($board); DB::table('board_posts')->insert([ ['board_id' => $board->id, 'title' => 'This Week', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subDays(3), 'updated_at' => now()], ['board_id' => $board->id, 'title' => 'Two Weeks Ago', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subWeeks(2), 'updated_at' => now()], @@ -211,6 +221,7 @@ class BoardPopularApiTest extends ModuleTestCase { // Given: 1년 이내/이전 게시글 생성 $board = Board::factory()->create(['is_active' => true]); + $this->grantGuestRead($board); DB::table('board_posts')->insert([ ['board_id' => $board->id, 'title' => 'Recent', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()], ['board_id' => $board->id, 'title' => 'Six Months Ago', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subMonths(6), 'updated_at' => now()], @@ -242,6 +253,7 @@ class BoardPopularApiTest extends ModuleTestCase { // Given: 1년 이내/이전 게시글 생성 $board = Board::factory()->create(['is_active' => true]); + $this->grantGuestRead($board); DB::table('board_posts')->insert([ ['board_id' => $board->id, 'title' => 'Recent', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()], ['board_id' => $board->id, 'title' => 'Over One Year', 'content' => 'Content', 'view_count' => 300, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subMonths(13), 'updated_at' => now()], @@ -313,6 +325,7 @@ class BoardPopularApiTest extends ModuleTestCase { // Given: 게시글과 댓글 생성 $board = Board::factory()->create(['is_active' => true]); + $this->grantGuestRead($board); // comments_count 컬럼에 직접 값 설정 (캐시 컬럼 방식) $postId = DB::table('board_posts')->insertGetId([ 'board_id' => $board->id, @@ -343,6 +356,7 @@ class BoardPopularApiTest extends ModuleTestCase { // Given: 게스트가 작성한 게시글 $board = Board::factory()->create(['is_active' => true]); + $this->grantGuestRead($board); DB::table('board_posts')->insert([ 'board_id' => $board->id, 'title' => 'Guest Post', @@ -455,6 +469,10 @@ class BoardPopularApiTest extends ModuleTestCase ]); } + // 공개 인기글은 게시판별 열람 권한(posts.read)을 통과한 게시판만 노출한다. + // 프로덕션 공개 게시판과 동일하게 guest read 권한을 부여한다. + $this->grantGuestRead($board); + for ($i = 0; $i < $postCount; $i++) { DB::table('board_posts')->insert([ 'board_id' => $board->id, @@ -471,4 +489,28 @@ class BoardPopularApiTest extends ModuleTestCase return $board; } + + /** + * 게시판에 비회원(guest) 읽기 권한(posts.read)을 부여합니다. + * + * 공개 인기글 API 는 게시판별 열람 권한을 응답 시점에 적용하므로, + * 공개 노출을 기대하는 테스트 게시판은 프로덕션처럼 guest read 권한을 갖춰야 한다. + * + * @param Board $board 대상 게시판 + */ + private function grantGuestRead(Board $board): void + { + $guestRole = Role::where('identifier', 'guest')->first(); + if (! $guestRole) { + return; + } + + $perm = Permission::firstOrCreate( + ['identifier' => "sirsoft-board.{$board->slug}.posts.read"], + ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user'] + ); + $guestRole->permissions()->syncWithoutDetaching([$perm->id]); + + PermissionMiddleware::clearGuestRoleCache(); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php index 6f4c9e6c..8554c501 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php @@ -5,6 +5,9 @@ namespace Modules\Sirsoft\Board\Tests\Feature\User; // ModuleTestCase를 수동으로 require (autoload 전에 로드 필요) require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Http\Middleware\PermissionMiddleware; +use App\Models\Permission; +use App\Models\Role; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Board\Enums\PostStatus; @@ -178,17 +181,20 @@ class BoardRecentPostsApiTest extends ModuleTestCase } /** - * 비밀글이 포함되어 is_secret 필드가 반환되는지 테스트 + * 비밀글이 포함되어 is_secret 필드가 반환되는지 테스트. + * + * 비밀글도 제목은 공개한다(본문만 보호) — 2026-02-04 확정 정책. + * (게시판별 열람 권한 필터는 별도 — createBoardWithPosts 가 guest read 권한을 부여한다.) */ public function test_recent_posts_includes_secret_posts_with_is_secret_field(): void { - // Given: 비밀글 포함 게시글 생성 - $this->createBoardWithPosts(3, includingSecret: true); + // Given: 공개글 + 비밀글 혼합 생성 (i%2==0 이 비밀글, 게시판에 guest read 권한 부여됨) + $this->createBoardWithPosts(4, includingSecret: true); - // When: API 호출 + // When: API 호출 (비로그인) $response = $this->getJson('/api/modules/sirsoft-board/boards/posts/recent'); - // Then: is_secret 필드 포함 및 비밀글 반환 + // Then: is_secret 필드 포함 및 비밀글도 응답에 포함 $response->assertStatus(200); $data = $response->json('data'); @@ -199,21 +205,22 @@ class BoardRecentPostsApiTest extends ModuleTestCase $this->assertArrayHasKey('is_secret', $post); } - // 비밀글이 포함되어 있어야 함 + // 비밀글이 포함되어 있어야 함 (제목 공개 정책 — 비밀글도 목록/최근글에 표시) $secretPosts = array_filter($data, fn ($post) => $post['is_secret'] === true); - $this->assertNotEmpty($secretPosts, '비밀글이 응답에 포함되어야 합니다'); + $this->assertNotEmpty($secretPosts, '비밀글이 응답에 포함되어야 합니다(제목 공개).'); } /** - * 비밀글도 제목이 정상적으로 표시되는지 테스트 + * 비밀글도 제목이 정상적으로 표시되는지 테스트 (제목 공개 정책 — 본문만 보호). */ public function test_secret_post_title_is_visible_in_recent_posts(): void { - // Given: 비밀글이 있는 게시판 생성 + // Given: 비밀글이 있는 게시판 생성 (guest read 권한 부여) $board = Board::factory()->create([ 'is_active' => true, 'name' => ['ko' => '비밀게시판', 'en' => 'Secret Board'], ]); + $this->grantGuestRead($board); DB::table('board_posts')->insert([ 'board_id' => $board->id, @@ -227,10 +234,10 @@ class BoardRecentPostsApiTest extends ModuleTestCase 'updated_at' => now(), ]); - // When: API 호출 + // When: API 호출 (비로그인) $response = $this->getJson('/api/modules/sirsoft-board/boards/posts/recent'); - // Then: 비밀글 제목이 보여야 함 (마스킹 안 됨) + // Then: 비밀글 제목이 보여야 함 (마스킹 안 됨 — 제목 공개 정책) $response->assertStatus(200); $data = $response->json('data'); @@ -270,8 +277,8 @@ class BoardRecentPostsApiTest extends ModuleTestCase /** * 게시판과 게시글을 생성하는 헬퍼 * - * @param int $postCount 생성할 게시글 수 - * @param bool $includingSecret 비밀글 포함 여부 + * @param int $postCount 생성할 게시글 수 + * @param bool $includingSecret 비밀글 포함 여부 * @return Board 생성된 게시판 */ private function createBoardWithPosts(int $postCount, bool $includingSecret = false): Board @@ -280,6 +287,10 @@ class BoardRecentPostsApiTest extends ModuleTestCase 'is_active' => true, ]); + // 공개 최근글은 게시판별 열람 권한(posts.read)을 통과한 게시판만 노출한다. + // 프로덕션에서 공개 게시판은 guest read 권한을 갖도록 생성되므로 동일하게 부여한다. + $this->grantGuestRead($board); + for ($i = 0; $i < $postCount; $i++) { $isSecret = $includingSecret && ($i % 2 === 0); @@ -299,4 +310,28 @@ class BoardRecentPostsApiTest extends ModuleTestCase return $board; } + + /** + * 게시판에 비회원(guest) 읽기 권한(posts.read)을 부여합니다. + * + * 공개 최근글 API 는 게시판별 열람 권한을 응답 시점에 적용하므로, + * 공개 노출을 기대하는 테스트 게시판은 프로덕션처럼 guest read 권한을 갖춰야 한다. + * + * @param Board $board 대상 게시판 + */ + private function grantGuestRead(Board $board): void + { + $guestRole = Role::where('identifier', 'guest')->first(); + if (! $guestRole) { + return; + } + + $perm = Permission::firstOrCreate( + ['identifier' => "sirsoft-board.{$board->slug}.posts.read"], + ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user'] + ); + $guestRole->permissions()->syncWithoutDetaching([$perm->id]); + + PermissionMiddleware::clearGuestRoleCache(); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php index 9b242f06..3041f24a 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php @@ -265,6 +265,69 @@ class SearchPostsListenerTest extends ModuleTestCase ->once(); } + /** + * 제목/본문에 삽입된 태그가 하이라이트 필드에서 이스케이프되는지 확인 (⑧/N-8) + * + * @scenario case=search_highlight_escape + * + * @effects highlighted_fields_escaped + */ + public function test_format_post_result_escapes_markup_in_highlighted_fields(): void + { + $user = User::factory()->make(['id' => 9999]); + + $board = $this->createBoardStub(1, 'notice', '공지사항'); + + $this->boardService + ->method('getActiveBoardsForSearch') + ->willReturn(new Collection([$board])); + + $this->boardService + ->method('getActiveBoardsListForFilter') + ->willReturn([]); + + $post = (object) [ + 'id' => 1, + 'title' => ' 테스트', + // 엔티티로 인코딩된 태그가 html 모드 프리뷰에서 부활하면 안 된다 (N-8). + 'content' => '<script>alert(1)</script> 테스트 본문', + 'content_mode' => 'html', + 'author_name' => '작성자', + 'created_at' => now(), + 'view_count' => 0, + 'comments_count' => 0, + 'user' => null, + 'board' => $this->createBoardStub(1, 'notice', '공지사항'), + ]; + + $this->postService + ->method('searchAcrossBoards') + ->willReturn($this->boundedPage(new Collection([$post]), 1)); + + Gate::before(fn ($u) => $u->id === 9999 ? true : null); + + $result = $this->listener->searchPosts([], [ + 'type' => 'all', + 'q' => '테스트', + 'sort' => 'relevance', + 'page' => 1, + 'per_page' => 10, + 'user' => $user, + 'request' => null, + ]); + + $item = $result['posts']['items'][0]; + + // 제목 하이라이트: 태그 이스케이프 + 검색어만 + $this->assertStringNotContainsString('assertStringContainsString('<img', $item['title_highlighted']); + $this->assertStringContainsString('테스트', $item['title_highlighted']); + + // 본문 프리뷰: 부활한 ', '없는키워드'); + + $this->assertStringNotContainsString('