fix(release): 7.0.7 사전점검 실측 발견 결함 6건 보완 및 규정 반영
브라우저·SEO 실측(T1~T7 매트릭스 + 봇 렌더 스윕)에서 발견된 결함을 보완한다. - 코어: 회원 일괄 상태변경 응답 메시지의 :count 플레이스홀더 미치환 수정 - 게시판·페이지: 비밀글·삭제글·미발행 첨부 썸네일이 KVE-2026-1914 게이트로 깨진 것을 한시 서명 preview URL(게이트 통과 직렬화 발급 + 유효 서명 허용)로 복구 — 무서명·변조·만료 요청 게이트는 종전과 동일 - 페이지: 발행/미발행 전환(after_publish)에도 SEO 캐시·사이트맵 갱신 구독 - 게시판: 게시판 디렉토리(전체 목록 + 내장 최근글)에 열람 권한 필터 적용 - 검색 화면: searchResults 데이터소스 auth_mode=optional 선언으로 로그인 회원 검색 범위 복구 + data-sources.md 선언 의무 규정화(coverage 등록)
This commit is contained in:
@@ -165,7 +165,7 @@
|
||||
| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
|
||||
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 |
|
||||
| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
|
||||
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
- 회원 삭제·회원 정보 수정·회원가입·역할 삭제·비밀번호 재설정·첨부 파일 삭제·메뉴 삭제·예약 작업 삭제가 중간에 실패하면 일부만 처리된 상태로 남던 문제를 수정했습니다. 회원 정보 수정은 프로필 변경·로그인 세션 정리·역할 변경이 함께 처리되므로, 역할 변경에서 실패하면 이름만 바뀌고 로그인이 끊긴 상태로 남았습니다. 이제 각 작업은 전부 성공하거나 전부 취소되며, 파일 삭제는 데이터 정리가 확정된 뒤에 수행됩니다.
|
||||
- 페이지 모듈의 첨부 파일 설정(개수·용량·형식)이 저장해도 반영되지 않던 문제를 수정했습니다.
|
||||
- 회원 목록에서 여러 명을 골라 일괄 탈퇴할 때, 탈퇴할 수 없는 계정(관리자 등)이 섞여 있으면 그 건이 조용히 건너뛰어지던 문제를 수정했습니다. 이제 처리된 인원·처리하지 못한 인원과 그 사유가 응답에 함께 담깁니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 회원 일괄 상태 변경의 결과 안내 문구에 처리 인원수가 채워지지 않고 자리표시 원문이 그대로 표시되던 문제를 수정했습니다.
|
||||
- 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이 동작하지 않던 문제를 수정했습니다. S3 구동에 필요한 스토리지 어댑터가 빠져 있었고, 연결 테스트는 통과해도 실제 저장 경로는 동작하지 않았습니다. 이제 어댑터가 기본 포함되고, 연결 테스트도 실제 연결 경로와 같은 설정(엔드포인트·Path-style 포함)을 사용합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 파일 스토리지를 S3 로 저장해도 첨부 파일 업로드는 계속 서버 로컬 디스크에 저장되던 문제를 수정했습니다. 이제 S3 저장 시 새 첨부 파일이 S3 로 저장되며, 기존 파일은 종전 위치에서 계속 서빙됩니다. 서버 환경변수(`ATTACHMENT_DISK`)로 저장 위치를 명시한 경우에는 그 값이 우선합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- phpredis PHP 확장이 없는 서버에서 캐시·세션·큐 드라이버로 Redis 를 선택하면 사이트 전체가 열리지 않던 문제를 예방했습니다 — Redis 클라이언트 라이브러리(predis)가 기본 포함되어 확장 없이도 동작합니다.
|
||||
|
||||
@@ -329,9 +329,13 @@ class UserController extends AdminBaseController
|
||||
$validated = $request->validated();
|
||||
$result = $this->userService->bulkUpdateStatus($validated['ids'], $validated['status']);
|
||||
|
||||
// 메시지 키(user.bulk_status_updated)가 :count 플레이스홀더를 가지므로
|
||||
// 치환 파라미터를 함께 전달한다 (생략 시 원문 ":count명의 …" 이 그대로 노출)
|
||||
return $this->success(
|
||||
'user.bulk_status_updated',
|
||||
$result
|
||||
$result,
|
||||
200,
|
||||
['count' => $result['updated_count'] ?? 0]
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('user.bulk_update_status_failed', 422, $e->errors());
|
||||
|
||||
@@ -373,6 +373,12 @@ auth_mode: "optional" 동작 흐름:
|
||||
> `fallback`이 정의되어 있으면 fallback 데이터가 렌더링에 사용되고, 없으면 데이터 없이 처리됩니다.
|
||||
> 이를 통해 비로그인 상태에서 인증 필수 API 호출로 인한 401 → 로그인 리다이렉트 문제를 방지합니다.
|
||||
|
||||
### 라우트가 optional.sanctum 이면 optional 선언 의무
|
||||
|
||||
로그인 여부에 따라 응답 범위가 달라지는 서버 라우트(`optional.sanctum` — 통합검색처럼 회원 열람 범위를 해석하는 API)를 소비하는 데이터 소스는 `auth_mode: "optional"` 을 반드시 선언한다.
|
||||
|
||||
기본값이 `none` 이므로 미선언 시 로그인 회원의 요청에도 토큰이 실리지 않는다. 이 실패는 오류·경고 없이 "로그인 회원이 비회원 범위 응답을 받는" 형태로만 나타난다 — 서버측 권한 해석이 통째로 무력화되는데 화면·콘솔·네트워크 어디에도 실패 신호가 없다. 서버 라우트의 미들웨어와 데이터 소스의 `auth_mode` 는 한 계약의 양끝이므로, 어느 한쪽을 바꾸면 반대편도 함께 확인한다.
|
||||
|
||||
### 하위 호환성
|
||||
|
||||
`auth_required: true`는 `auth_mode: "required"`와 동일하게 동작합니다.
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
- 게시글·댓글 삭제와 게시글 수정 시 비회원 확인용 비밀번호/검증 토큰 입력에 형식 검증을 추가해 비정상 형태(배열 주입 등)의 요청을 차단합니다.
|
||||
- 관리자 게시판 목록 조회의 정렬 기준과 페이지 크기를 허용된 값으로만 제한했습니다.
|
||||
|
||||
- 비밀글의 내용이 열람 권한 없이 새어 나가던 경로를 모두 막았습니다. 게시글 상세 화면은 비밀글 내용을 가렸지만, 상품 문의 목록·비밀글의 댓글 목록·비밀글의 첨부파일(다운로드/미리보기)은 게시글의 비밀 여부를 확인하지 않아 주소만 알면 원문·첨부를 볼 수 있었습니다. 이제 이 경로 전부에서 작성자 본인 또는 게시판 관리 권한(비밀글 열람)을 가진 요청에만 내용을 제공하고, 그 외에는 내용·제목·답변·첨부를 가립니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||
- 비밀글의 내용이 열람 권한 없이 새어 나가던 경로를 모두 막았습니다. 게시글 상세 화면은 비밀글 내용을 가렸지만, 상품 문의 목록·비밀글의 댓글 목록·비밀글의 첨부파일(다운로드/미리보기)은 게시글의 비밀 여부를 확인하지 않아 주소만 알면 원문·첨부를 볼 수 있었습니다. 이제 이 경로 전부에서 작성자 본인 또는 게시판 관리 권한(비밀글 열람)을 가진 요청에만 내용을 제공하고, 그 외에는 내용·제목·답변·첨부를 가립니다. 열람 권한자의 상세 화면에 표시되는 비밀글·삭제글 첨부의 이미지 썸네일은 한시적으로만 유효한 서명 주소로 전달되어, 보호를 유지한 채 종전처럼 정상 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||
- 회원 프로필의 작성글 목록과 "내가 댓글 단 글" 활동 목록에서 다른 사람의 비밀글·블라인드 글 **본문**이 로그인 없이도 나가던 문제를 수정했습니다. 두 목록은 본문 앞부분을 함께 싣는데 그것을 가리는 설정이 실제로는 한 번도 켜지지 않았습니다. 이제 본인이 볼 때만 본문이 보이고, 다른 사람이 볼 때는 비워집니다. 글의 제목과 목록에서의 표시(비밀글·블라인드 배지)는 게시판 목록과 동일하게 그대로 유지됩니다. (KVE-2026-1914)
|
||||
- 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 회원 화면은 작성자 본인만 삭제하도록 막고 있었지만 관리 화면에는 같은 확인이 없었고, 순서 변경은 양쪽 모두 확인이 없었습니다. 순서는 목록 전체에 대한 하나의 값이라 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부합니다. (KVE-2026-1919)
|
||||
- 게시판을 찾을 수 없을 때 비밀글 보호가 통과되던 문제를 수정했습니다. 첨부파일 서빙과 댓글 목록은 부모 글을 찾지 못하면 검사를 건너뛰고 진행해, 게이트가 있어야 할 자리가 비어 있었습니다. 이제 부모 글을 확인할 수 없으면 차단합니다.
|
||||
- 로그인 없이 열리는 목록(최근 글·인기 글)과 관리 대시보드의 요약 목록에서, 열람 권한이 없는 게시판의 글과 블라인드·비활성 게시판의 글이 노출되던 문제를 막았습니다. 이제 이 목록들은 블라인드·비활성 게시판 글을 제외하고, 열람 권한이 있는 게시판의 글만 각 요청자에게 보여 줍니다. 인기 게시판 목록과 통합 검색의 게시판 필터에서도 열람 권한이 없는 비공개 게시판이 더 이상 나타나지 않습니다. 비밀글은 기존과 동일하게 목록에 제목과 🔒 표시가 유지되며 본문만 보호됩니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 전체 게시판 목록(게시판 디렉토리)과 그 안의 게시판별 최신 글 목록에도 같은 열람 권한 기준을 적용했습니다. 위 목록들에는 제한이 적용되었지만 이 경로에는 빠져 있어, 첫 화면과 전체 게시판 화면에서 열람 권한이 없는 게시판의 이름과 글 제목이 로그인 없이도 보였습니다. 열람 권한이 있는 방문자에게는 종전과 동일하게 표시됩니다.
|
||||
- 게시판 관리 대시보드(개요·글 추이·최근 글·미처리 신고)에 별도의 열람 권한을 요구하도록 했습니다. 이전에는 관리자이기만 하면 게시판 권한이 전혀 없어도 이 화면의 정보를 볼 수 있었습니다. 미처리 신고 집계는 비활성 게시판을 제외합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 통합 검색 결과의 제목·본문 미리보기에서 검색어 강조 처리가 원문을 그대로 실어 보내던 것을, 안전하게 변환한 뒤 강조하도록 바로잡았습니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 댓글 비밀번호 확인 후 받는 임시 토큰이 실제 검증에 연결되어 있지 않던 문제를 수정했습니다. 이제 이 토큰으로 댓글을 수정·삭제할 수 있으며(비밀번호 재입력 대체), 토큰은 한 번만 사용되고 만료됩니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -2370,7 +2370,9 @@ Cache-Control: public, max-age=86400
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 해시로 지정한 이미지 첨부파일을 인라인 미리보기로 제공합니다. 게시글 본문 내 이미지 표시용이라 비회원도 접근할 수 있으며(권한 체크 없음), 이미지가 아닌 파일 요청 시 400을 반환합니다. 응답에는 레이아웃 캐시 TTL(기본 24시간) 기반 캐싱 헤더가 포함되고, 삭제글 첨부 등 차단 대상은 403을 반환합니다.
|
||||
**설명** 해시로 지정한 이미지 첨부파일을 인라인 미리보기로 제공합니다. 게시글 본문 내 이미지 표시용이라 비회원도 접근할 수 있으며(권한 체크 없음), 이미지가 아닌 파일 요청 시 400을 반환합니다. 응답에는 레이아웃 캐시 TTL(기본 24시간) 기반 캐싱 헤더가 포함되고, 삭제글·비밀글 첨부 등 차단 대상은 403을 반환합니다.
|
||||
|
||||
브라우저 `<img src>` 는 Authorization 헤더를 실을 수 없으므로, 비밀글·삭제글의 열람 권한자에게 직렬화되는 상세 응답의 `preview_url` 에는 `expires`·`signature` 쿼리가 붙은 **한시 서명 URL** 이 실리며, 이 엔드포인트는 유효한 서명을 열람 권한과 동등한 자격으로 허용합니다. 서명이 변조·만료된 요청은 종전과 동일하게 403 으로 차단됩니다. 정상글의 `preview_url` 은 무서명 공개 경로입니다.
|
||||
|
||||
|
||||
### POST /api/modules/sirsoft-board/boards/{slug}/attachments
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
|
||||
@@ -83,11 +84,17 @@ class AttachmentController extends PublicBaseController
|
||||
* 이미지 파일만 미리보기를 제공합니다.
|
||||
* 비회원도 이미지를 볼 수 있습니다.
|
||||
*
|
||||
* 비밀글·삭제글 첨부는 서비스의 콘텐츠 상태 게이트가 차단하되, 게이트를 통과한
|
||||
* 응답 직렬화(PostResource)가 발급한 한시 서명 URL 은 동등한 자격으로 허용한다 —
|
||||
* <img> 는 Authorization 헤더를 실을 수 없는 렌더 경로이기 때문이다.
|
||||
*
|
||||
* @param Request $request HTTP 요청 객체 (한시 서명 검증용)
|
||||
* @param string $slug 게시판 슬러그
|
||||
* @param string $hash 첨부파일 해시 (12자)
|
||||
* @return BinaryFileResponse|Response|JsonResponse 이미지 응답 또는 에러 응답
|
||||
*/
|
||||
public function preview(string $slug, string $hash): BinaryFileResponse|Response|JsonResponse
|
||||
// audit:allow controller-base-request-injection reason: GET 이미지 서빙. 요청 URL 의 한시 서명 검증(hasValidSignature)만 수행. 검증할 body 없음(slug/hash 는 라우트 파라미터)
|
||||
public function preview(Request $request, string $slug, string $hash): BinaryFileResponse|Response|JsonResponse
|
||||
{
|
||||
try {
|
||||
// 게시판 존재 여부 확인
|
||||
@@ -105,8 +112,12 @@ class AttachmentController extends PublicBaseController
|
||||
return $this->badRequest(__('sirsoft-board::messages.attachment.not_image'));
|
||||
}
|
||||
|
||||
// 파일 정보 조회
|
||||
$fileInfo = $this->attachmentService->getFileInfo($slug, $attachment->id);
|
||||
// 파일 정보 조회 (유효 서명은 콘텐츠 상태 게이트 통과 자격의 위임)
|
||||
$fileInfo = $this->attachmentService->getFileInfo(
|
||||
$slug,
|
||||
$attachment->id,
|
||||
signatureVerified: $request->hasValidSignature(absolute: false)
|
||||
);
|
||||
|
||||
if (! $fileInfo) {
|
||||
return $this->notFound(__('sirsoft-board::messages.attachment.not_found'));
|
||||
|
||||
@@ -45,8 +45,9 @@ class BoardController extends PublicBaseController
|
||||
*/
|
||||
public function index(IndexBoardRequest $request): JsonResponse
|
||||
{
|
||||
// 활성화된 게시판만 조회
|
||||
$boards = $this->boardService->getActiveBoards();
|
||||
// 활성 게시판 중 호출자가 열람 가능한 게시판만 조회 — 디렉토리와 내장 최근글이
|
||||
// 비공개 게시판·글 제목의 우회 노출로가 되지 않게 한다 (최근글·인기글과 동일 기준)
|
||||
$boards = $this->boardService->getReadableActiveBoards();
|
||||
|
||||
// limit 파라미터 (0이면 최신글 미포함, 최대 10개)
|
||||
$recentPostsLimit = $request->recentPostsLimit();
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Board\Http\Resources;
|
||||
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Sirsoft\Board\Models\Attachment;
|
||||
|
||||
/**
|
||||
* 첨부파일 API 리소스
|
||||
@@ -13,6 +15,35 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
class AttachmentResource extends BaseApiResource
|
||||
{
|
||||
/**
|
||||
* 서명 preview URL 발급 대상 게시판 슬러그 (null 이면 무서명 공개 URL).
|
||||
*
|
||||
* 비밀글·삭제글처럼 무서명 preview 가 게이트에 차단되는 게시글의 첨부를,
|
||||
* 게이트를 통과한 응답(PostResource 상세 직렬화)에 한해 <img> 렌더 가능한
|
||||
* 한시 서명 URL 로 직렬화하기 위한 플래그다.
|
||||
*/
|
||||
private ?string $signedPreviewSlug = null;
|
||||
|
||||
/**
|
||||
* 첨부파일 목록을 리소스 배열로 변환합니다.
|
||||
*
|
||||
* @param iterable<int, Attachment>|null $attachments 첨부파일 목록
|
||||
* @param string|null $signedPreviewSlug 서명 preview URL 발급 대상 슬러그 (비밀글·삭제글 전용)
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function collectionFor($attachments, ?string $signedPreviewSlug = null): array
|
||||
{
|
||||
return Collection::make($attachments ?? [])
|
||||
->map(function ($attachment) use ($signedPreviewSlug) {
|
||||
$resource = new self($attachment);
|
||||
$resource->signedPreviewSlug = $signedPreviewSlug;
|
||||
|
||||
return $resource;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스를 배열로 변환합니다.
|
||||
*
|
||||
@@ -31,7 +62,9 @@ class AttachmentResource extends BaseApiResource
|
||||
'collection' => $this->collection,
|
||||
'order' => $this->order,
|
||||
'download_url' => $this->download_url,
|
||||
'preview_url' => $this->preview_url,
|
||||
'preview_url' => $this->signedPreviewSlug !== null && $this->resource->is_image
|
||||
? $this->resource->previewUrlForSlug($this->signedPreviewSlug, signed: true)
|
||||
: $this->preview_url,
|
||||
'is_image' => $this->is_image,
|
||||
'meta' => $this->meta,
|
||||
];
|
||||
|
||||
@@ -539,6 +539,13 @@ class PostResource extends BaseApiResource
|
||||
return null;
|
||||
}
|
||||
|
||||
// 비밀글·삭제글 첨부는 무서명 preview 가 서빙 게이트에 차단되므로,
|
||||
// 이 직렬화(게이트 통과가 확인된 응답)에 한해 <img> 렌더 가능한
|
||||
// 한시 서명 preview URL 을 발급한다. 정상글은 무서명 공개 URL 유지.
|
||||
$signedPreviewSlug = ($this->deleted_at || $this->is_secret)
|
||||
? ($slug ?? $this->getSlug($request))
|
||||
: null;
|
||||
|
||||
// 삭제된 게시글: 관리 권한자가 아니면 첨부 목록 미노출.
|
||||
// 단, 게시글 삭제로 함께 숨겨진(cascade) 첨부는 사용자가 직접 지운 것이 아니므로
|
||||
// 글을 볼 수 있는 사람에게는 노출한다 (cascade 댓글 노출과 일관).
|
||||
@@ -548,14 +555,14 @@ class PostResource extends BaseApiResource
|
||||
&& $attachment->trigger_type === TriggerType::Cascade->value
|
||||
)->values();
|
||||
|
||||
return AttachmentResource::collection($cascadeAttachments);
|
||||
return AttachmentResource::collectionFor($cascadeAttachments, $signedPreviewSlug);
|
||||
}
|
||||
|
||||
if ($this->is_secret && ! $this->canViewSecretContent($request, $slug)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AttachmentResource::collection($this->attachments);
|
||||
return AttachmentResource::collectionFor($this->attachments, $signedPreviewSlug);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
@@ -166,6 +167,8 @@ class Attachment extends Model
|
||||
? $this->board->slug
|
||||
: Board::find($this->board_id)?->slug;
|
||||
|
||||
// route() 생성은 빈 슬러그(미해석 board)에서 예외가 되므로 문자열 조립을 유지한다
|
||||
// — 빈 슬러그 허용은 AttachmentPreviewUrlTest 가 고정한 기존 계약이다.
|
||||
return '/api/modules/sirsoft-board/boards/'.$boardSlug.'/attachment/'.$this->hash;
|
||||
}
|
||||
|
||||
@@ -193,14 +196,39 @@ class Attachment extends Model
|
||||
* 목록 직렬화(PostResource)처럼 slug 를 라우트/관계에서 이미 알고 있어
|
||||
* Board::find() 재조회(N+1)를 피해야 하는 호출측이 사용합니다.
|
||||
*
|
||||
* 브라우저 <img src> 는 Authorization 헤더를 실을 수 없으므로, 비밀글·삭제글
|
||||
* 첨부를 열람 권한자 화면에 표시할 때는 한시 서명 URL 을 발급한다($signed).
|
||||
* 서명 URL 은 게이트를 통과한 응답 직렬화 시점에만 발급되고(PostResource),
|
||||
* 서빙 엔드포인트가 유효 서명을 허용한다 — 무서명 게이트는 종전과 동일.
|
||||
*
|
||||
* @param string $slug 게시판 슬러그
|
||||
* @param bool $signed 한시 서명 URL 발급 여부 (비밀글·삭제글 <img> 렌더용)
|
||||
* @return string 미리보기 URL
|
||||
*/
|
||||
public function previewUrlForSlug(string $slug): string
|
||||
public function previewUrlForSlug(string $slug, bool $signed = false): string
|
||||
{
|
||||
if ($signed) {
|
||||
return URL::temporarySignedRoute(
|
||||
'api.modules.sirsoft-board.boards.attachment.preview',
|
||||
now()->addMinutes(self::SIGNED_PREVIEW_TTL_MINUTES),
|
||||
['slug' => $slug, 'hash' => $this->hash],
|
||||
absolute: false
|
||||
);
|
||||
}
|
||||
|
||||
// route() 생성은 빈 슬러그(미해석 board)에서 예외가 되므로 문자열 조립을 유지한다
|
||||
// — 빈 슬러그 허용은 AttachmentPreviewUrlTest 가 고정한 기존 계약이다.
|
||||
return '/api/modules/sirsoft-board/boards/'.$slug.'/attachment/'.$this->hash.'/preview';
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 preview URL 의 유효 시간(분).
|
||||
*
|
||||
* 상세 화면이 열려 있는 동안 썸네일이 유지될 만큼 길고,
|
||||
* URL 유출 시 노출 창을 좁힐 만큼 짧은 값.
|
||||
*/
|
||||
public const SIGNED_PREVIEW_TTL_MINUTES = 30;
|
||||
|
||||
/**
|
||||
* 이미지 여부 확인
|
||||
*
|
||||
|
||||
@@ -853,11 +853,17 @@ class AttachmentService
|
||||
* 컨트롤러에서 fileResponse()로 캐싱 헤더와 함께 응답할 수 있도록
|
||||
* 파일 경로와 메타 정보를 반환합니다.
|
||||
*
|
||||
* $signatureVerified 는 요청 URL 의 한시 서명이 검증된 경우다 — 서명은
|
||||
* 비밀글·삭제글 게이트를 통과한 응답 직렬화(PostResource)만 발급하므로,
|
||||
* 검증된 서명은 그 게이트 통과 자격의 위임으로 보고 콘텐츠 상태 게이트를
|
||||
* 재적용하지 않는다 (<img> 는 인증 헤더를 실을 수 없는 렌더 경로).
|
||||
*
|
||||
* @param string $slug 게시판 슬러그
|
||||
* @param int $id 첨부파일 ID
|
||||
* @param bool $signatureVerified 한시 서명 검증 통과 여부
|
||||
* @return array{path: string, mime_type: string, filename: string}|null 파일 정보 또는 null
|
||||
*/
|
||||
public function getFileInfo(string $slug, int $id): ?array
|
||||
public function getFileInfo(string $slug, int $id, bool $signatureVerified = false): ?array
|
||||
{
|
||||
$attachment = $this->repository->findById($slug, $id);
|
||||
|
||||
@@ -865,11 +871,13 @@ class AttachmentService
|
||||
return null;
|
||||
}
|
||||
|
||||
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
|
||||
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
|
||||
if (! $signatureVerified) {
|
||||
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
|
||||
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
|
||||
|
||||
// 비밀글 첨부파일은 열람 권한자만 접근
|
||||
$this->assertSecretPostAttachmentAccess($slug, $attachment);
|
||||
// 비밀글 첨부파일은 열람 권한자만 접근
|
||||
$this->assertSecretPostAttachmentAccess($slug, $attachment);
|
||||
}
|
||||
|
||||
// 파일 존재 확인
|
||||
if (! $this->storage->exists('attachments', $attachment->path)) {
|
||||
|
||||
@@ -199,6 +199,29 @@ class BoardService
|
||||
return $this->boardRepository->getActiveBoardsOrdered($orderBy, $orderDirection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 게시판 중 호출자가 열람할 수 있는 게시판만 조회합니다.
|
||||
*
|
||||
* 게시판 디렉토리(전체 게시판 목록 + 내장 최근글)처럼 비로그인에게도 열리는
|
||||
* 응답에 사용합니다 — 최근글·인기글·인기게시판과 동일한 열람 권한 기준
|
||||
* (sirsoft-board.{slug}.posts.read)을 호출자 시점에 적용해, 디렉토리 경로가
|
||||
* 비공개 게시판·글 제목의 우회 노출로가 되지 않게 합니다.
|
||||
*
|
||||
* @param string $orderBy 정렬 기준 컬럼
|
||||
* @param string $orderDirection 정렬 방향
|
||||
* @return Collection 호출자가 열람 가능한 활성 게시판 컬렉션
|
||||
*/
|
||||
public function getReadableActiveBoards(
|
||||
string $orderBy = 'created_at',
|
||||
string $orderDirection = 'desc'
|
||||
): Collection {
|
||||
$user = Auth::user();
|
||||
|
||||
return $this->getActiveBoards($orderBy, $orderDirection)
|
||||
->filter(fn ($board) => PermissionHelper::check("sirsoft-board.{$board->slug}.posts.read", $user))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴용 경량 게시판 목록을 조회합니다.
|
||||
*
|
||||
|
||||
+48
@@ -108,6 +108,54 @@ class PublicRecentPostsVisibilityTest extends ModuleTestCase
|
||||
$this->assertNotContains('비밀글읽기가능', $titles);
|
||||
}
|
||||
|
||||
// ========== 게시판 디렉토리(boards index + 내장 최근글) ==========
|
||||
//
|
||||
// 최근글·인기글·인기게시판·대시보드에는 열람 권한 필터가 적용되었지만,
|
||||
// 게시판 디렉토리(GET /boards?limit=N)는 활성 게시판 전체 + 내장 최근글 제목을
|
||||
// 무필터로 직렬화해 우회로가 남아 있었다 (7.0.7 사전점검 SEO 봇 렌더 실측 발견 —
|
||||
// 게스트 홈 화면에 회원전용 게시판 글 제목이 노출).
|
||||
|
||||
/**
|
||||
* @scenario case=board_directory_readable_filter
|
||||
*
|
||||
* @effects board_directory_excludes_unreadable_board_and_posts
|
||||
*/
|
||||
#[Test]
|
||||
public function guest_board_directory_excludes_unreadable_board_and_its_post_titles(): void
|
||||
{
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards?limit=3');
|
||||
$response->assertStatus(200);
|
||||
|
||||
$slugs = array_column($response->json('data'), 'slug');
|
||||
$this->assertContains($this->readableBoard->slug, $slugs);
|
||||
$this->assertNotContains($this->hiddenBoard->slug, $slugs, '읽기 권한 없는 게시판은 디렉토리에 노출되면 안 된다.');
|
||||
|
||||
// 내장 최근글 제목까지 전수 검사 — 게시판 행이 아닌 다른 경로로도 새면 안 된다
|
||||
$raw = json_encode($response->json('data'), JSON_UNESCAPED_UNICODE);
|
||||
$this->assertStringContainsString('공개글읽기가능', $raw);
|
||||
$this->assertStringNotContainsString('숨김게시판글', $raw, '읽기 권한 없는 게시판의 글 제목이 디렉토리 응답에 노출되면 안 된다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=board_directory_readable_filter
|
||||
*
|
||||
* @effects board_directory_includes_readable_board_for_permitted_member
|
||||
*/
|
||||
#[Test]
|
||||
public function member_with_read_permission_sees_restricted_board_in_directory(): void
|
||||
{
|
||||
$member = $this->memberWithReadOn($this->hiddenBoard->slug);
|
||||
|
||||
$response = $this->actingAs($member)->getJson('/api/modules/sirsoft-board/boards?limit=3');
|
||||
$response->assertStatus(200);
|
||||
|
||||
$slugs = array_column($response->json('data'), 'slug');
|
||||
$this->assertContains($this->hiddenBoard->slug, $slugs, '열람 권한을 가진 회원에게는 해당 게시판이 디렉토리에 보여야 한다.');
|
||||
|
||||
$raw = json_encode($response->json('data'), JSON_UNESCAPED_UNICODE);
|
||||
$this->assertStringContainsString('숨김게시판글', $raw, '열람 권한자는 그 게시판의 최근글 제목도 볼 수 있어야 한다.');
|
||||
}
|
||||
|
||||
// ========== 헬퍼 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
||||
|
||||
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__ . '/../../ModuleTestCase.php';
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Http\Middleware\PermissionMiddleware;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
@@ -16,7 +19,6 @@ use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
*/
|
||||
class BoardAccessTest extends ModuleTestCase
|
||||
{
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@@ -45,6 +47,11 @@ class BoardAccessTest extends ModuleTestCase
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
// 디렉토리는 호출자 열람 권한 필터를 적용하므로 게스트 읽기 권한을 부여한다
|
||||
$this->grantGuestRead('active-1');
|
||||
$this->grantGuestRead('active-2');
|
||||
$this->grantGuestRead('inactive');
|
||||
|
||||
// When: 비로그인 상태에서 게시판 목록 API 호출
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards');
|
||||
|
||||
@@ -83,8 +90,14 @@ class BoardAccessTest extends ModuleTestCase
|
||||
public function test_inactive_boards_not_included_in_list(): void
|
||||
{
|
||||
// Given: 비활성화된 게시판 3개, 활성화된 게시판 1개 생성
|
||||
Board::factory()->count(3)->create(['is_active' => false]);
|
||||
Board::factory()->create(['is_active' => true]);
|
||||
$inactiveBoards = Board::factory()->count(3)->create(['is_active' => false]);
|
||||
$activeBoard = Board::factory()->create(['is_active' => true]);
|
||||
|
||||
// 디렉토리는 호출자 열람 권한 필터를 적용하므로 게스트 읽기 권한을 부여한다
|
||||
foreach ($inactiveBoards as $b) {
|
||||
$this->grantGuestRead($b->slug);
|
||||
}
|
||||
$this->grantGuestRead($activeBoard->slug);
|
||||
|
||||
// When: 게시판 목록 조회
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards');
|
||||
@@ -107,7 +120,7 @@ class BoardAccessTest extends ModuleTestCase
|
||||
]);
|
||||
|
||||
// When: 게시판 상세 조회
|
||||
$response = $this->getJson("/api/modules/sirsoft-board/boards/test-board");
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards/test-board');
|
||||
|
||||
// Then: 성공 응답, 게시판 정보 반환
|
||||
$response->assertStatus(200);
|
||||
@@ -132,7 +145,7 @@ class BoardAccessTest extends ModuleTestCase
|
||||
]);
|
||||
|
||||
// When: 비활성화된 게시판 상세 조회 시도
|
||||
$response = $this->getJson("/api/modules/sirsoft-board/boards/inactive-board");
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards/inactive-board');
|
||||
|
||||
// Then: 404 응답
|
||||
$response->assertStatus(404);
|
||||
@@ -144,7 +157,7 @@ class BoardAccessTest extends ModuleTestCase
|
||||
public function test_accessing_non_existent_board_returns_404(): void
|
||||
{
|
||||
// When: 존재하지 않는 게시판 조회
|
||||
$response = $this->getJson("/api/modules/sirsoft-board/boards/non-existent");
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards/non-existent');
|
||||
|
||||
// Then: 404 응답
|
||||
$response->assertStatus(404);
|
||||
@@ -155,8 +168,11 @@ class BoardAccessTest extends ModuleTestCase
|
||||
*/
|
||||
public function test_board_list_response_does_not_include_pagination(): void
|
||||
{
|
||||
// Given: 게시판 여러 개 생성
|
||||
Board::factory()->count(5)->create(['is_active' => true]);
|
||||
// Given: 게시판 여러 개 생성 (디렉토리 열람 권한 필터 대응 — 게스트 읽기 권한 부여)
|
||||
$boards = Board::factory()->count(5)->create(['is_active' => true]);
|
||||
foreach ($boards as $b) {
|
||||
$this->grantGuestRead($b->slug);
|
||||
}
|
||||
|
||||
// When: 게시판 목록 조회
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards');
|
||||
@@ -166,4 +182,28 @@ class BoardAccessTest extends ModuleTestCase
|
||||
$this->assertArrayNotHasKey('pagination', $response->json('data'));
|
||||
$this->assertIsArray($response->json('data'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* guest 역할에 게시판 읽기 권한을 부여합니다.
|
||||
*
|
||||
* 디렉토리(전체 게시판 목록)는 호출자 열람 권한 필터를 적용하므로,
|
||||
* 게스트 시점 검증용 게시판에는 명시적으로 읽기 권한을 부여한다
|
||||
* (PublicRecentPostsVisibilityTest 와 동일 관례).
|
||||
*
|
||||
* @param string $slug 게시판 슬러그
|
||||
*/
|
||||
private function grantGuestRead(string $slug): void
|
||||
{
|
||||
$role = Role::where('identifier', 'guest')->first();
|
||||
if (! $role) {
|
||||
return;
|
||||
}
|
||||
|
||||
$perm = Permission::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$slug}.posts.read"],
|
||||
['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
|
||||
);
|
||||
$role->permissions()->syncWithoutDetaching([$perm->id]);
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
||||
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Http\Middleware\PermissionMiddleware;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
@@ -13,10 +16,12 @@ use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
* 전체 게시판 목록 API 테스트
|
||||
*
|
||||
* 전체 게시판 목록 페이지용 경량 API의 동작을 검증합니다.
|
||||
* 디렉토리는 호출자 열람 권한(sirsoft-board.{slug}.posts.read) 필터를 적용하므로,
|
||||
* 게스트 시점 검증용 게시판에는 setUp 에서 guest 읽기 권한을 부여한다
|
||||
* (최근글·인기글 가시성 테스트와 동일 관례 — PublicRecentPostsVisibilityTest).
|
||||
*/
|
||||
class BoardListApiTest extends ModuleTestCase
|
||||
{
|
||||
|
||||
/**
|
||||
* 테스트 환경 설정
|
||||
*/
|
||||
@@ -27,6 +32,31 @@ class BoardListApiTest extends ModuleTestCase
|
||||
DB::table('boards')->update(['is_active' => false]);
|
||||
// 이 테스트 클래스에서 사용하는 슬러그 정리 (커밋된 경우 대비)
|
||||
DB::table('boards')->whereIn('slug', ['free', 'inactive', 'old', 'new'])->delete();
|
||||
|
||||
// 디렉토리 열람 권한 필터 대응 — 이 클래스가 쓰는 슬러그에 guest 읽기 권한 부여
|
||||
foreach (['free', 'inactive', 'old', 'new'] as $slug) {
|
||||
$this->grantGuestRead($slug);
|
||||
}
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* guest 역할에 게시판 읽기 권한을 부여합니다.
|
||||
*
|
||||
* @param string $slug 게시판 슬러그
|
||||
*/
|
||||
private function grantGuestRead(string $slug): void
|
||||
{
|
||||
$role = Role::where('identifier', 'guest')->first();
|
||||
if (! $role) {
|
||||
return;
|
||||
}
|
||||
|
||||
$perm = Permission::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$slug}.posts.read"],
|
||||
['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
|
||||
);
|
||||
$role->permissions()->syncWithoutDetaching([$perm->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
|
||||
/**
|
||||
@@ -306,4 +307,137 @@ class SecretPostAttachmentAccessTest extends BoardTestCase
|
||||
$this->assertNotSame(403, $response->getStatusCode(), '정상글 첨부는 권한 차단되면 안 됩니다');
|
||||
$this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 서명 preview URL (비밀글 <img> 썸네일 렌더 경로)
|
||||
//
|
||||
// 브라우저 <img src> 는 Authorization 헤더를 실을 수 없어, 비밀 게이트 도입 후
|
||||
// 열람 권한자(작성자·manager) 화면의 비밀글 첨부 썸네일이 무인증 요청 → 403 으로
|
||||
// 깨졌다. 게이트를 통과한 응답 직렬화(PostResource)가 한시 서명 URL 을 발급하고,
|
||||
// 서빙 엔드포인트가 유효 서명을 허용한다 — 무서명 게이트는 종전과 동일하다.
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 서명 preview URL 을 만든다.
|
||||
*
|
||||
* @param string $hash 첨부 해시
|
||||
* @param int $minutes 유효 시간(분, 음수면 만료된 URL)
|
||||
* @return string 상대경로 서명 URL
|
||||
*/
|
||||
private function signedPreviewUrl(string $hash, int $minutes = 30): string
|
||||
{
|
||||
return URL::temporarySignedRoute(
|
||||
'api.modules.sirsoft-board.boards.attachment.preview',
|
||||
now()->addMinutes($minutes),
|
||||
['slug' => $this->board->slug, 'hash' => $hash],
|
||||
absolute: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 한시 서명 preview URL 은 무인증(게스트) 요청도 비밀 게이트를 통과한다.
|
||||
*
|
||||
* @scenario viewer=guest
|
||||
*
|
||||
* @effects guest_with_valid_signature_passes_secret_preview_gate
|
||||
*/
|
||||
public function test_guest_with_valid_signed_url_passes_secret_preview_gate(): void
|
||||
{
|
||||
$postId = $this->secretPost();
|
||||
$this->createAttachment($postId, 'secsignedAAA', image: true);
|
||||
|
||||
$response = $this->get($this->signedPreviewUrl('secsignedAAA'));
|
||||
|
||||
// 실제 파일이 없어 404 여도 403(비밀 차단)은 아니어야 한다
|
||||
$this->assertNotSame(403, $response->getStatusCode(), '유효 서명 URL 은 비밀 게이트를 통과해야 합니다');
|
||||
$this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 변조된 서명 preview URL 은 종전과 동일하게 비밀 게이트에 차단된다 (403).
|
||||
*
|
||||
* @scenario viewer=guest
|
||||
*
|
||||
* @effects guest_with_tampered_signature_still_blocked
|
||||
*/
|
||||
public function test_guest_with_tampered_signature_still_blocked(): void
|
||||
{
|
||||
$postId = $this->secretPost();
|
||||
$this->createAttachment($postId, 'sectamperAAA', image: true);
|
||||
|
||||
$tampered = preg_replace_callback(
|
||||
'/(signature=)([0-9a-f]+)/',
|
||||
fn ($m) => $m[1].substr($m[2], 0, -8).strrev(substr($m[2], -8)),
|
||||
$this->signedPreviewUrl('sectamperAAA')
|
||||
);
|
||||
|
||||
$this->get($tampered)->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료된 서명 preview URL 은 비밀 게이트에 차단된다 (403, 한시성 보장).
|
||||
*
|
||||
* @scenario viewer=guest
|
||||
*
|
||||
* @effects guest_with_expired_signature_still_blocked
|
||||
*/
|
||||
public function test_guest_with_expired_signature_still_blocked(): void
|
||||
{
|
||||
$postId = $this->secretPost();
|
||||
$this->createAttachment($postId, 'secexpireAAA', image: true);
|
||||
|
||||
$this->get($this->signedPreviewUrl('secexpireAAA', minutes: -1))->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* 열람 권한자(작성자)에게 직렬화되는 비밀글 상세 응답의 첨부 preview_url 은
|
||||
* 한시 서명 URL 이고, 그 URL 은 무인증 <img> 요청으로도 게이트를 통과한다
|
||||
* (렌더 계약의 양끝 검증).
|
||||
*
|
||||
* @scenario viewer=owner
|
||||
*
|
||||
* @effects secret_post_detail_serializes_signed_preview_url
|
||||
*/
|
||||
public function test_secret_post_detail_serializes_signed_preview_url_for_owner(): void
|
||||
{
|
||||
$postId = $this->secretPost();
|
||||
$this->createAttachment($postId, 'secserialAAA', image: true);
|
||||
|
||||
$previewUrl = $this->actingAs($this->ownerUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}")
|
||||
->assertStatus(200)
|
||||
->json('data.attachments.0.preview_url');
|
||||
|
||||
$this->assertIsString($previewUrl);
|
||||
$this->assertStringContainsString('signature=', $previewUrl);
|
||||
|
||||
$response = $this->get($previewUrl);
|
||||
$this->assertNotSame(403, $response->getStatusCode(), '직렬화된 서명 URL 은 무인증 게이트를 통과해야 합니다');
|
||||
$this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상글(비밀 아님) 상세 응답의 첨부 preview_url 은 종전과 동일한 무서명
|
||||
* 공개 hash 경로다 (공개 콘텐츠에 만료성 URL 이 섞이는 회귀 방지).
|
||||
*
|
||||
* @scenario viewer=regular
|
||||
*
|
||||
* @effects normal_post_detail_serializes_plain_preview_url
|
||||
*/
|
||||
public function test_normal_post_detail_serializes_plain_preview_url(): void
|
||||
{
|
||||
$postId = $this->createTestPost([
|
||||
'title' => '정상글 첨부 직렬화',
|
||||
'status' => 'published',
|
||||
'is_secret' => false,
|
||||
]);
|
||||
$this->createAttachment($postId, 'normserialAA', image: true);
|
||||
|
||||
$previewUrl = $this->actingAs($this->regularUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}")
|
||||
->assertStatus(200)
|
||||
->json('data.attachments.0.preview_url');
|
||||
|
||||
$this->assertSame($this->previewUrl('normserialAA'), $previewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,17 @@ description: |
|
||||
- ⑧: 검색 하이라이트가 원문을 이스케이프하지 않던 문제 — 코어 SearchHighlighter 위임.
|
||||
|
||||
axes:
|
||||
case: [recent_readable_filter, popular_readable_filter, popular_boards_readable, available_boards_guest, available_boards_member, available_boards_bearer_http, dashboard_permission_gate, dashboard_recent_visibility, pending_reports_scope, comment_token_lifecycle, search_highlight_escape]
|
||||
case: [recent_readable_filter, popular_readable_filter, popular_boards_readable, available_boards_guest, available_boards_member, available_boards_bearer_http, dashboard_permission_gate, dashboard_recent_visibility, pending_reports_scope, comment_token_lifecycle, search_highlight_escape, board_directory_readable_filter]
|
||||
|
||||
exclusions: []
|
||||
|
||||
effects:
|
||||
- unreadable_board_titles_absent_for_caller
|
||||
- popular_boards_exclude_unreadable
|
||||
# 게시판 디렉토리(boards index + 내장 최근글) — 최근글·인기글·인기게시판에 필터가 걸린 뒤에도
|
||||
# 이 경로가 무필터로 남아 비공개 게시판·글 제목의 우회 노출로였다 (7.0.7 사전점검 SEO 실측 발견)
|
||||
- board_directory_excludes_unreadable_board_and_posts
|
||||
- board_directory_includes_readable_board_for_permitted_member
|
||||
- available_boards_match_read_permission
|
||||
- search_route_resolves_bearer_user
|
||||
- dashboard_requires_view_permission
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
### Security
|
||||
|
||||
- 미발행(초안) 페이지에 붙은 이미지 첨부의 미리보기가 주소만 알면 누구에게나 열리던 문제를 수정했습니다. 다운로드는 이미 발행 상태를 확인하고 있었지만 미리보기는 확인하지 않아, 발행 전 콘텐츠의 이미지가 새어 나갈 수 있었습니다. 이제 미리보기도 다운로드와 같은 기준을 적용해, 발행된 페이지의 첨부는 누구나, 미발행 페이지의 첨부는 페이지 조회 권한이 있는 관리자만 미리볼 수 있습니다. 편집 중인 초안의 썸네일은 편집 권한이 있는 관리자에게 종전처럼 정상 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||
- 미발행(초안) 페이지에 붙은 이미지 첨부의 미리보기가 주소만 알면 누구에게나 열리던 문제를 수정했습니다. 다운로드는 이미 발행 상태를 확인하고 있었지만 미리보기는 확인하지 않아, 발행 전 콘텐츠의 이미지가 새어 나갈 수 있었습니다. 이제 미리보기도 다운로드와 같은 기준을 적용해, 발행된 페이지의 첨부는 누구나, 미발행 페이지의 첨부는 페이지 조회 권한이 있는 관리자만 미리볼 수 있습니다. 권한이 있는 관리자 화면(편집·상세·미발행 미리보기)의 초안 첨부 썸네일은 한시적으로만 유효한 서명 주소로 전달되어, 보호를 유지한 채 종전처럼 정상 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||
- 페이지 일괄 발행/발행취소와 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 페이지를 하나씩 여는 화면에는 이 확인이 있었지만 목록에서 여러 건을 한 번에 처리하는 경로와 첨부 관리 경로에는 없어, "본인이 만든 페이지만" 으로 범위를 좁혀 위임받은 관리자가 담당 밖 페이지까지 발행 상태를 바꾸거나 첨부를 지울 수 있었습니다. 이제 대상마다 같은 기준으로 확인하며, 범위 밖 대상이 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. 범위 제한 없이 위임받은 관리자의 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
|
||||
- 페이지 통합 검색 결과의 제목·본문 미리보기에서 검색어 강조 처리가 원문을 그대로 실어 보내던 것을, 안전하게 변환한 뒤 강조하도록 바로잡았습니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 페이지를 발행하거나 미발행으로 바꿔도(발행 버튼·일괄 발행/미발행) 검색엔진용 미리 생성 캐시와 사이트맵이 갱신되지 않던 문제를 수정했습니다. 편집 화면에서 저장하는 경우에만 갱신이 걸려 있어, 발행 상태만 바꾸면 검색엔진에 이전 상태(미발행 안내 또는 이전 콘텐츠)가 계속 노출되고 사이트맵에도 반영되지 않았습니다. 이제 모든 발행 경로에서 동일하게 갱신됩니다.
|
||||
- 통합검색에서 페이지 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 페이지 첨부 파일의 최대 개수·용량·허용 형식 설정이 저장해도 반영되지 않던 문제를 수정했습니다. 설정값을 읽어 오는 경로 자체가 없어 항상 내장 기본값이 쓰였습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 페이지 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 발행·발행 취소 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
|
||||
|
||||
@@ -1230,11 +1230,13 @@ Content-Disposition: inline
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 404 | Not Found | 해시에 해당하는 첨부가 없는 경우(`첨부파일을 찾을 수 없습니다.`), 미발행 페이지의 첨부를 `sirsoft-page.pages.read` 권한 없이 요청한 경우(존재를 숨기기 위해 동일하게 404), 첨부가 이미지가 아니거나 스토리지에 실제 파일이 없는 경우(`첨부파일이 스토리지에 존재하지 않습니다.`) |
|
||||
| 404 | Not Found | 해시에 해당하는 첨부가 없는 경우(`첨부파일을 찾을 수 없습니다.`), 미발행 페이지의 첨부를 `sirsoft-page.pages.read` 권한·유효 서명 없이 요청한 경우(존재를 숨기기 위해 동일하게 404), 첨부가 이미지가 아니거나 스토리지에 실제 파일이 없는 경우(`첨부파일이 스토리지에 존재하지 않습니다.`) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 이미지 첨부 썸네일을 인라인으로 미리봅니다(해시 기반, 12자). `download` 와 동일한 발행상태 게이트를 적용합니다 — 발행된 페이지의 썸네일은 누구나, 미발행 페이지의 썸네일은 `sirsoft-page.pages.read` 권한 관리자만 미리볼 수 있습니다. 편집 중인 초안(미발행)의 `<img>` 썸네일은 편집 권한을 가진 관리자에게 정상 노출되고, 무인가 사용자에게는 미발행 썸네일이 차단됩니다. 이미지 스트리밍 응답이므로 실측 대상이 아닙니다.
|
||||
**설명** 이미지 첨부 썸네일을 인라인으로 미리봅니다(해시 기반, 12자). `download` 와 동일한 발행상태 게이트를 적용합니다 — 발행된 페이지의 썸네일은 누구나, 미발행 페이지의 썸네일은 `sirsoft-page.pages.read` 권한 관리자만 미리볼 수 있습니다. 무인가 사용자에게는 미발행 썸네일이 차단됩니다.
|
||||
|
||||
브라우저 `<img src>` 는 Authorization 헤더를 실을 수 없으므로, 미발행 페이지의 관리자 응답(관리자 상세·미발행 미리보기)에는 `expires`·`signature` 쿼리가 붙은 **한시 서명 URL** 이 `preview_url` 로 직렬화되며, 이 엔드포인트는 유효한 서명을 권한과 동등한 자격으로 허용합니다. 서명이 변조·만료된 요청은 404 로 차단됩니다. 발행 페이지의 `preview_url` 은 종전과 동일한 무서명 공개 경로입니다. 이미지 스트리밍 응답이므로 실측 대상이 아닙니다.
|
||||
|
||||
|
||||
### GET /api/modules/sirsoft-page/pages/{slug}
|
||||
|
||||
+6
-2
@@ -80,14 +80,18 @@ class PublicPageAttachmentController extends PublicBaseController
|
||||
return $this->notFound('sirsoft-page::messages.attachment.not_found');
|
||||
}
|
||||
|
||||
// 미발행 페이지의 첨부는 페이지 조회 권한 관리자만 미리보기 가능 (download 와 동일 게이트)
|
||||
// 미발행 페이지의 첨부는 페이지 조회 권한 관리자만 미리보기 가능 (download 와 동일 게이트).
|
||||
// 브라우저 <img> 는 Authorization 헤더를 실을 수 없으므로, 게이트를 통과한 응답
|
||||
// (관리자 상세 직렬화)이 발급한 한시 서명 URL 도 동등한 자격으로 허용한다 —
|
||||
// 서명은 발급 시점에 게이트를 통과한 요청에만 실리므로 무서명 게이트는 약화되지 않는다.
|
||||
$published = $attachment->page && $attachment->page->published;
|
||||
$canReadUnpublished = $request->user()?->hasPermission(
|
||||
'sirsoft-page.pages.read',
|
||||
PermissionType::Admin
|
||||
) ?? false;
|
||||
$hasValidSignature = $request->hasValidSignature(absolute: false);
|
||||
|
||||
if (! $published && ! $canReadUnpublished) {
|
||||
if (! $published && ! $canReadUnpublished && ! $hasValidSignature) {
|
||||
return $this->notFound('sirsoft-page::messages.attachment.not_found');
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,30 @@ use Modules\Sirsoft\Page\Models\PageAttachment;
|
||||
*/
|
||||
class PageAttachmentResource extends BaseApiResource
|
||||
{
|
||||
/**
|
||||
* 미발행 첨부의 <img> 렌더용 한시 서명 preview URL 발급 여부.
|
||||
*
|
||||
* 게이트를 통과한 응답(관리자 상세 등)의 직렬화 시점에만 참이 된다 —
|
||||
* 발행 페이지는 무서명 공개 URL 을 유지한다 (SEO·캐시 안정성).
|
||||
*/
|
||||
private bool $signedPreview = false;
|
||||
|
||||
/**
|
||||
* 첨부파일 목록을 리소스 배열로 변환합니다.
|
||||
*
|
||||
* @param iterable<int, PageAttachment>|null $attachments 첨부파일 목록
|
||||
* @param bool $signedPreview 한시 서명 preview URL 발급 여부 (미발행 페이지 전용)
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function collectionFor($attachments): array
|
||||
public static function collectionFor($attachments, bool $signedPreview = false): array
|
||||
{
|
||||
return Collection::make($attachments ?? [])
|
||||
->map(fn ($attachment) => new self($attachment))
|
||||
->map(function ($attachment) use ($signedPreview) {
|
||||
$resource = new self($attachment);
|
||||
$resource->signedPreview = $signedPreview;
|
||||
|
||||
return $resource;
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
@@ -43,7 +57,7 @@ class PageAttachmentResource extends BaseApiResource
|
||||
'order' => $this->order,
|
||||
'is_image' => $this->isImage(),
|
||||
'download_url' => $this->resource->downloadUrl(),
|
||||
'preview_url' => $this->resource->previewUrl(),
|
||||
'preview_url' => $this->resource->previewUrl(signed: $this->signedPreview),
|
||||
'created_at' => $this->created_at
|
||||
? $this->formatDateTimeStringForUser($this->created_at)
|
||||
: null,
|
||||
|
||||
@@ -38,9 +38,15 @@ class PageResource extends BaseApiResource
|
||||
'uuid' => $this->updater->uuid,
|
||||
'name' => $this->updater->name,
|
||||
]),
|
||||
// 미발행 페이지 첨부는 한시 서명 preview URL 로 직렬화한다.
|
||||
// 이 리소스는 게이트(pages.read 권한 미들웨어)를 통과한 관리자 응답에만 쓰이므로
|
||||
// 서명 발급이 곧 게이트 통과 자격의 위임이다. 발행 페이지는 무서명 공개 URL 유지.
|
||||
'attachments' => $this->whenLoaded(
|
||||
'attachments',
|
||||
fn () => PageAttachmentResource::collectionFor($this->attachments)
|
||||
fn () => PageAttachmentResource::collectionFor(
|
||||
$this->attachments,
|
||||
signedPreview: ! $this->published
|
||||
)
|
||||
),
|
||||
...$this->formatTimestamps(),
|
||||
...$this->resourceMeta($request),
|
||||
|
||||
@@ -59,9 +59,15 @@ class PublicPageResource extends BaseApiResource
|
||||
: null,
|
||||
'seo_meta' => $this->seo_meta,
|
||||
'current_version' => $this->current_version,
|
||||
// 미발행 페이지는 pages.read 관리자의 미리보기(is_preview)로만 이 리소스에
|
||||
// 도달하므로(공개 게이트가 게스트를 404 차단), 그 화면의 <img> 썸네일용으로
|
||||
// 한시 서명 preview URL 을 직렬화한다. 발행 페이지는 무서명 공개 URL 유지.
|
||||
'attachments' => $this->whenLoaded(
|
||||
'attachments',
|
||||
fn () => PageAttachmentResource::collectionFor($this->attachments)
|
||||
fn () => PageAttachmentResource::collectionFor(
|
||||
$this->attachments,
|
||||
signedPreview: ! $this->published
|
||||
)
|
||||
),
|
||||
'created_at' => $this->created_at
|
||||
? $this->formatDateTimeStringForUser($this->created_at)
|
||||
|
||||
@@ -43,6 +43,14 @@ class SeoPageCacheListener implements HookListenerInterface
|
||||
'method' => 'onPageChange',
|
||||
'priority' => 20,
|
||||
],
|
||||
// 발행/미발행 전환(단건 setPublished·일괄 bulk-publish)은 after_update 를 거치지
|
||||
// 않고 after_publish 만 발화한다 — 미구독 시 발행 상태를 바꿔도 봇 캐시가 이전
|
||||
// 상태(미발행 soft-404 / 발행 콘텐츠)로 남고 사이트맵 증분 색인도 빠진다.
|
||||
// onPageChange 가 모델의 published 최신 상태로 색인(upsert)/해제(remove)를 판정한다.
|
||||
'sirsoft-page.page.after_publish' => [
|
||||
'method' => 'onPageChange',
|
||||
'priority' => 20,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Modules\Sirsoft\Page\Database\Factories\PageAttachmentFactory;
|
||||
|
||||
class PageAttachment extends Model
|
||||
@@ -152,20 +153,51 @@ class PageAttachment extends Model
|
||||
*/
|
||||
public function downloadUrl(): string
|
||||
{
|
||||
return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash;
|
||||
return route(
|
||||
'api.modules.sirsoft-page.pages.attachment.download',
|
||||
['hash' => $this->hash],
|
||||
absolute: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 미리보기 URL을 반환합니다 (공개 hash 라우트).
|
||||
*
|
||||
* 브라우저 <img src> 는 Authorization 헤더를 실을 수 없으므로, 미발행 페이지의
|
||||
* 첨부를 권한자 화면(관리자 상세 등)에 표시할 때는 한시 서명 URL 을 발급한다.
|
||||
* 서명 URL 은 게이트를 통과한 응답 직렬화 시점에만 발급되고(PageResource),
|
||||
* 서빙 엔드포인트가 유효 서명을 무인증 허용한다 — 무서명 게이트는 종전과 동일.
|
||||
*
|
||||
* @param bool $signed 한시 서명 URL 발급 여부 (미발행 첨부의 <img> 렌더용)
|
||||
* @return string|null 미리보기 URL (이미지가 아니면 null)
|
||||
*/
|
||||
public function previewUrl(): ?string
|
||||
public function previewUrl(bool $signed = false): ?string
|
||||
{
|
||||
if (! $this->isImage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash.'/preview';
|
||||
if ($signed) {
|
||||
return URL::temporarySignedRoute(
|
||||
'api.modules.sirsoft-page.pages.attachment.preview',
|
||||
now()->addMinutes(self::SIGNED_PREVIEW_TTL_MINUTES),
|
||||
['hash' => $this->hash],
|
||||
absolute: false
|
||||
);
|
||||
}
|
||||
|
||||
return route(
|
||||
'api.modules.sirsoft-page.pages.attachment.preview',
|
||||
['hash' => $this->hash],
|
||||
absolute: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 preview URL 의 유효 시간(분).
|
||||
*
|
||||
* 관리자 상세 화면이 열려 있는 동안 썸네일이 유지될 만큼 길고,
|
||||
* URL 유출 시 노출 창을 좁힐 만큼 짧은 값.
|
||||
*/
|
||||
public const SIGNED_PREVIEW_TTL_MINUTES = 30;
|
||||
}
|
||||
|
||||
@@ -421,7 +421,9 @@ class PageControllerTest extends FeatureTestCase
|
||||
*
|
||||
* 썸네일 <img>·다운로드는 토큰을 실을 수 없어 인증 라우트에 물리면 401 로 깨진다.
|
||||
* 게시판·이커머스 표준과 동일하게 공개 hash 라우트로 단일화하고, 미발행 콘텐츠
|
||||
* 다운로드 차단은 공개 라우트 내부의 권한 게이트가 담당한다.
|
||||
* 차단은 공개 라우트 내부의 발행상태 게이트가 담당한다(KVE-2026-1914).
|
||||
* 미발행 페이지의 preview URL 은 게이트를 통과할 수 있도록 한시 서명이 실린다
|
||||
* — 무인증 <img> 요청으로도 렌더 가능해야 하기 때문이다.
|
||||
*/
|
||||
public function test_admin_show_returns_public_attachment_urls(): void
|
||||
{
|
||||
@@ -442,10 +444,13 @@ class PageControllerTest extends FeatureTestCase
|
||||
|
||||
$response->assertStatus(200);
|
||||
$att = $response->json('data.attachments.0');
|
||||
$this->assertSame(
|
||||
"/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview",
|
||||
// 미발행 preview: 공개 hash 라우트 + 한시 서명 (무인증 <img> 렌더 경로)
|
||||
$this->assertStringStartsWith(
|
||||
"/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview?",
|
||||
$att['preview_url']
|
||||
);
|
||||
$this->assertStringContainsString('signature=', $att['preview_url']);
|
||||
// download 는 인증 fetch(blob) 경로이므로 무서명 공개 hash 라우트 유지
|
||||
$this->assertSame(
|
||||
"/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}",
|
||||
$att['download_url']
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Modules\Sirsoft\Page\Tests\Feature\User;
|
||||
require_once __DIR__.'/../../FeatureTestCase.php';
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Page\Models\Page;
|
||||
use Modules\Sirsoft\Page\Models\PageAttachment;
|
||||
@@ -195,4 +196,130 @@ class PublicPageAttachmentAccessTest extends FeatureTestCase
|
||||
$this->getJson(self::BASE.'/'.$attachment->hash)
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
// ── 서명 preview URL (미발행 썸네일 <img> 렌더 경로) ──────────────
|
||||
//
|
||||
// 브라우저 <img src> 는 Authorization 헤더를 실을 수 없어, 게이트가 조여진 뒤
|
||||
// 관리자 상세 화면의 미발행 첨부 썸네일이 무인증 요청 → 404 로 깨졌다.
|
||||
// 게이트를 통과한 응답(관리자 상세)이 한시 서명 URL 을 발급하고, 서빙 엔드포인트가
|
||||
// 유효 서명을 허용하는 방식으로 복구한다 — 무서명 요청 게이트는 종전과 동일하다.
|
||||
|
||||
/**
|
||||
* 유효한 한시 서명 preview URL 은 무인증 요청(<img>)에도 미발행 첨부를 서빙한다.
|
||||
*
|
||||
* @scenario resource=page_attachment, parent_state=restricted
|
||||
*
|
||||
* @effects unpublished_page_attachment_preview_allowed_with_valid_signature
|
||||
*/
|
||||
public function test_unpublished_preview_allowed_with_valid_signature_without_auth(): void
|
||||
{
|
||||
$attachment = $this->makeAttachment(false, 'attach-access-signed');
|
||||
$this->mockServicePassThrough($attachment);
|
||||
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'api.modules.sirsoft-page.pages.attachment.preview',
|
||||
now()->addMinutes(30),
|
||||
['hash' => $attachment->hash],
|
||||
absolute: false
|
||||
);
|
||||
|
||||
$this->getJson($signedUrl)->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명이 변조된 preview URL 은 미발행 첨부를 서빙하지 않는다 (404 존재 은닉).
|
||||
*
|
||||
* @scenario resource=page_attachment, parent_state=restricted
|
||||
*
|
||||
* @effects unpublished_page_attachment_preview_blocked_with_tampered_signature
|
||||
*/
|
||||
public function test_unpublished_preview_blocked_with_tampered_signature(): void
|
||||
{
|
||||
$attachment = $this->makeAttachment(false, 'attach-access-tampered');
|
||||
$this->mockServicePassThrough($attachment);
|
||||
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'api.modules.sirsoft-page.pages.attachment.preview',
|
||||
now()->addMinutes(30),
|
||||
['hash' => $attachment->hash],
|
||||
absolute: false
|
||||
);
|
||||
// signature 쿼리 값의 마지막 8자를 뒤집어 변조한다
|
||||
$tampered = preg_replace_callback(
|
||||
'/(signature=)([0-9a-f]+)/',
|
||||
fn ($m) => $m[1].substr($m[2], 0, -8).strrev(substr($m[2], -8)),
|
||||
$signedUrl
|
||||
);
|
||||
|
||||
$this->getJson($tampered)->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료된 서명 preview URL 은 미발행 첨부를 서빙하지 않는다 (한시성 보장).
|
||||
*
|
||||
* @scenario resource=page_attachment, parent_state=restricted
|
||||
*
|
||||
* @effects unpublished_page_attachment_preview_blocked_with_expired_signature
|
||||
*/
|
||||
public function test_unpublished_preview_blocked_with_expired_signature(): void
|
||||
{
|
||||
$attachment = $this->makeAttachment(false, 'attach-access-expired');
|
||||
$this->mockServicePassThrough($attachment);
|
||||
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'api.modules.sirsoft-page.pages.attachment.preview',
|
||||
now()->subMinute(),
|
||||
['hash' => $attachment->hash],
|
||||
absolute: false
|
||||
);
|
||||
|
||||
$this->getJson($signedUrl)->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 상세 응답은 미발행 페이지 첨부에 서명 preview URL 을 직렬화하고,
|
||||
* 그 URL 은 무인증 <img> 요청으로도 열린다 (렌더 계약의 양끝 검증).
|
||||
*
|
||||
* @scenario resource=page_attachment, parent_state=restricted
|
||||
*
|
||||
* @effects admin_response_serializes_signed_preview_url_for_unpublished_page
|
||||
*/
|
||||
public function test_admin_detail_serializes_signed_preview_url_for_unpublished_page(): void
|
||||
{
|
||||
$attachment = $this->makeAttachment(false, 'attach-access-serialize');
|
||||
$admin = $this->createAdminUser(['sirsoft-page.pages.read']);
|
||||
|
||||
$previewUrl = $this->actingAs($admin)
|
||||
->getJson('/api/modules/sirsoft-page/admin/pages/'.$attachment->page_id)
|
||||
->assertStatus(200)
|
||||
->json('data.attachments.0.preview_url');
|
||||
|
||||
$this->assertIsString($previewUrl);
|
||||
$this->assertStringContainsString('signature=', $previewUrl);
|
||||
|
||||
// 발급된 서명 URL 은 인증 헤더 없이도 서빙된다 (<img> 경로)
|
||||
$this->mockServicePassThrough($attachment);
|
||||
$this->getJson($previewUrl)->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 페이지 첨부의 preview URL 은 종전과 동일한 무서명 공개 hash 경로다
|
||||
* (SEO·캐시 안정성 — 발행 콘텐츠에 만료성 URL 이 섞이는 회귀 방지).
|
||||
*
|
||||
* @scenario resource=page_attachment, parent_state=public
|
||||
*
|
||||
* @effects published_page_serializes_plain_preview_url
|
||||
*/
|
||||
public function test_published_page_serializes_plain_preview_url(): void
|
||||
{
|
||||
$attachment = $this->makeAttachment(true, 'attach-access-plain');
|
||||
$admin = $this->createAdminUser(['sirsoft-page.pages.read']);
|
||||
|
||||
$previewUrl = $this->actingAs($admin)
|
||||
->getJson('/api/modules/sirsoft-page/admin/pages/'.$attachment->page_id)
|
||||
->assertStatus(200)
|
||||
->json('data.attachments.0.preview_url');
|
||||
|
||||
$this->assertSame(self::BASE.'/'.$attachment->hash.'/preview', $previewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,15 +358,17 @@ class PublicPageControllerTest extends FeatureTestCase
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ─── 썸네일(preview) 공개 서빙 매트릭스 (별건: 관리자 상세 썸네일 401 회귀) ───
|
||||
// ─── 썸네일(preview) 서빙 매트릭스 (KVE-2026-1914 이후: 미발행은 게이트) ───
|
||||
|
||||
/**
|
||||
* 미발행 페이지의 이미지 첨부 미리보기(썸네일)는 비로그인이어도 200을 반환한다.
|
||||
* 미발행 페이지의 이미지 첨부 미리보기(썸네일)는 비로그인에게 404 로 차단된다.
|
||||
*
|
||||
* 썸네일 <img> 는 토큰을 실을 수 없으므로 공개 hash 서빙으로 둔다.
|
||||
* 보안은 hash 비추측성 + 파일 다운로드 권한 게이트가 담당한다(트레이드오프 수용).
|
||||
* KVE-2026-1914 이전에는 공개 hash 서빙 트레이드오프로 200 이었으나, 이후
|
||||
* preview 도 download 와 동일한 발행상태 게이트를 적용한다. 권한자 화면의
|
||||
* <img> 썸네일은 응답 직렬화 시점에 발급되는 한시 서명 URL 이 담당한다
|
||||
* (PublicPageAttachmentAccessTest 서명 축 참조).
|
||||
*/
|
||||
public function test_public_preview_unpublished_image_returns_200_for_guest(): void
|
||||
public function test_public_preview_unpublished_image_blocked_for_guest(): void
|
||||
{
|
||||
$admin = $this->createAdminUser([]);
|
||||
|
||||
@@ -392,7 +394,7 @@ class PublicPageControllerTest extends FeatureTestCase
|
||||
|
||||
$response = $this->getJson("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,17 +57,24 @@ class SeoPageCacheListenerTest extends ModuleTestCase
|
||||
$this->assertArrayHasKey('sirsoft-page.page.after_delete', $hooks);
|
||||
// 버전 복원도 SEO 캐시 무효화가 필요하다 (복원 후 봇에 복원 전 버전 잔존 회귀 방지)
|
||||
$this->assertArrayHasKey('sirsoft-page.page.after_restore', $hooks);
|
||||
// 발행/미발행 전환(단건 setPublished·일괄 bulk-publish)은 after_publish 만 발화한다.
|
||||
// 미구독 시 발행 상태를 바꿔도 SEO 캐시가 이전 상태(soft-404/이전 콘텐츠)로 남고
|
||||
// 사이트맵 증분 색인도 수행되지 않는다 (7.0.7 사전점검 브라우저+curl 실측 발견).
|
||||
$this->assertArrayHasKey('sirsoft-page.page.after_publish', $hooks);
|
||||
|
||||
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_create']['method']);
|
||||
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_update']['method']);
|
||||
// delete 는 모델 상태와 무관하게 색인을 제거해야 하므로 전용 메서드로 분리
|
||||
$this->assertEquals('onPageDelete', $hooks['sirsoft-page.page.after_delete']['method']);
|
||||
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_restore']['method']);
|
||||
// publish 는 모델의 published 최신 상태로 onPageChange 가 색인/해제를 판정한다
|
||||
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_publish']['method']);
|
||||
|
||||
$this->assertEquals(20, $hooks['sirsoft-page.page.after_create']['priority']);
|
||||
$this->assertEquals(20, $hooks['sirsoft-page.page.after_update']['priority']);
|
||||
$this->assertEquals(20, $hooks['sirsoft-page.page.after_delete']['priority']);
|
||||
$this->assertEquals(20, $hooks['sirsoft-page.page.after_restore']['priority']);
|
||||
$this->assertEquals(20, $hooks['sirsoft-page.page.after_publish']['priority']);
|
||||
}
|
||||
|
||||
// ─── onPageChange: 캐시 무효화 상태 검증 ──────────────
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
feature: 페이지 첨부 서빙 (썸네일 공개 hash 서빙 + 다운로드 권한 게이트)
|
||||
|
||||
description: |
|
||||
[2026-08-18 후속] 아래 "preview 는 모든 조합 공개(200)" 정책은 KVE-2026-1914(A-6 S-1)로
|
||||
대체되었다 — preview 도 download 와 동일한 발행상태 게이트를 적용하며, 권한자 화면의
|
||||
<img> 썸네일은 한시 서명 URL 로 서빙한다. 현행 계약과 회귀 가드는
|
||||
tests/scenarios/file-serving-parent-state-gate.yaml(코어) 이 SSoT 다.
|
||||
본 문서는 당시 결정 기록으로 보존한다.
|
||||
|
||||
별건(관리자 상세 첨부 썸네일·다운로드 미노출): 커밋 d48c684 가 관리자 화면 첨부 URL 을
|
||||
auth:sanctum 보호 admin 라우트로 바꿔, 토큰을 실을 수 없는 <img>/<a> 브라우저 직접 GET 이
|
||||
발행/미발행 무관 401 로 깨지던 회귀를 수정한다. 게시판·이커머스 표준으로 정렬.
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- 장바구니 "전체 선택" 체크박스가 다크 모드에서 테두리 색을 따르지 않던 문제를 수정했습니다.
|
||||
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
|
||||
- 쇼핑(주문서 할인·재주문·배송지 저장·상품 문의)과 마이페이지 화면에서 요청이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 비밀번호 변경 실패 안내가 항상 일반 문구로만 나오던 문제도 함께 바로잡았습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다.
|
||||
- 로그인한 회원이 통합검색을 해도 비회원과 같은 범위로만 검색되던 문제를 수정했습니다. 검색 화면이 로그인 정보를 함께 보내지 않아, 회원 전용 게시판처럼 로그인해야 볼 수 있는 글이 검색 결과에서 빠졌습니다. 비회원의 검색 범위는 종전과 동일합니다.
|
||||
|
||||
## [1.1.0] - 2026-08-10
|
||||
|
||||
|
||||
@@ -84,6 +84,18 @@ describe('통합검색 — 화면 상태의 SSoT 는 URL 쿼리', () => {
|
||||
expect(source.endpoint, '엔드포인트에 남은 전역 상태').not.toMatch(/_global\.search/);
|
||||
});
|
||||
|
||||
it('검색 데이터소스는 로그인 회원의 토큰을 실어 보낸다 (auth_mode=optional)', () => {
|
||||
// 검색 라우트는 optional.sanctum 으로 로그인 회원의 열람 범위(회원 전용 게시판 포함)를
|
||||
// 해석한다. 데이터소스 auth_mode 기본값은 'none' 이라 미선언이면 토큰이 실리지 않아,
|
||||
// 로그인 회원도 비회원 범위로 검색되고 서버측 권한 해석이 통째로 무력화된다
|
||||
// (7.0.7 사전점검 브라우저 실측 발견 — 서버는 Bearer 를 해석하도록 고쳐졌지만
|
||||
// 화면이 보내지 않아 계약의 반대편 끝이 끊겨 있었다).
|
||||
const layout = JSON.parse(read(indexPath));
|
||||
const source = layout.data_sources?.find((ds: any) => ds.id === 'searchResults');
|
||||
|
||||
expect(source?.auth_mode, 'searchResults auth_mode').toBe('optional');
|
||||
});
|
||||
|
||||
it('검색 화면 어디에도 전역 검색 상태가 남아 있지 않다', () => {
|
||||
const leftovers = searchFiles.filter((path) =>
|
||||
/_global\??\.search(ActiveTab|SortBy|BoardFilter|Page|Cursor)|"search(ActiveTab|SortBy|BoardFilter|Page|Cursor)"/.test(read(path))
|
||||
|
||||
@@ -46,9 +46,10 @@
|
||||
"id": "searchResults",
|
||||
"label_key": "$t:editor.data_source.searchResults",
|
||||
"type": "api",
|
||||
"comment": "탭·게시판필터·정렬·페이지·커서는 전역 상태가 아니라 URL 쿼리를 읽는다. 전역 상태에 두면 주소가 바뀌지 않아 새로고침·뒤로가기에서 전부 초기값으로 돌아가고 결과를 그대로 공유할 수도 없다. cursor 는 커서로 넘길 수 있는 정렬(최신순 등)에서 이전/다음 이동 시 채워진다. 비어 있으면 서버가 첫 페이지로 읽고 다음 커서를 발급한다 — 관련도순은 계산값 정렬이라 서버가 커서를 발급하지 않고 page 로만 움직인다",
|
||||
"comment": "탭·게시판필터·정렬·페이지·커서는 전역 상태가 아니라 URL 쿼리를 읽는다. 전역 상태에 두면 주소가 바뀌지 않아 새로고침·뒤로가기에서 전부 초기값으로 돌아가고 결과를 그대로 공유할 수도 없다. cursor 는 커서로 넘길 수 있는 정렬(최신순 등)에서 이전/다음 이동 시 채워진다. 비어 있으면 서버가 첫 페이지로 읽고 다음 커서를 발급한다 — 관련도순은 계산값 정렬이라 서버가 커서를 발급하지 않고 page 로만 움직인다. auth_mode=optional: 검색 라우트는 optional.sanctum 으로 로그인 회원의 열람 범위(회원 전용 게시판 포함)를 해석하므로, 토큰이 있으면 반드시 실어 보낸다 — 미선언(none)이면 로그인 회원도 비회원 범위로 검색되어 서버측 권한 해석이 무력화된다",
|
||||
"endpoint": "/api/search?q={{query?.q}}&type={{query?.type ?? 'all'}}&board_slug={{query?.board_slug ?? ''}}&sort={{query?.sort ?? 'relevance'}}&page={{query?.page ?? 1}}&cursor={{query?.cursor ?? ''}}&per_page={{(query?.type === 'products') ? 12 : 10}}",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"condition": "{{query?.q}}",
|
||||
@@ -169,4 +170,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class UserBulkStatusTest extends TestCase
|
||||
'description' => json_encode(['ko' => '사용자 정보 수정 권한', 'en' => 'Permission to update users']),
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
'type' => 'admin',
|
||||
]
|
||||
);
|
||||
|
||||
@@ -65,7 +65,7 @@ class UserBulkStatusTest extends TestCase
|
||||
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
'type' => 'admin',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
@@ -134,6 +134,28 @@ class UserBulkStatusTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 상태 변경 응답 메시지의 :count 플레이스홀더가 실제 처리 건수로 치환된다.
|
||||
*
|
||||
* 7.0.7 사전점검 브라우저 실측에서 응답 message 가
|
||||
* ":count명의 사용자 상태가 변경되었습니다." 원문 그대로 내려오는 결함 발견 —
|
||||
* 컨트롤러가 메시지 키만 넘기고 치환 파라미터를 생략한 회귀를 고정한다.
|
||||
*/
|
||||
public function test_bulk_status_message_substitutes_count_placeholder(): void
|
||||
{
|
||||
$users = User::factory()->count(2)->create(['status' => 'inactive']);
|
||||
|
||||
$response = $this->authRequest()->patchJson('/api/admin/users/bulk-status', [
|
||||
'ids' => $users->pluck('uuid')->toArray(),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$message = (string) $response->json('message');
|
||||
$this->assertStringNotContainsString(':count', $message, '응답 메시지에 미치환 플레이스홀더가 남아 있으면 안 됩니다');
|
||||
$this->assertStringContainsString('2', $message, '응답 메시지에 처리 건수가 치환되어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 사용자들을 비활성으로 일괄 변경할 수 있다.
|
||||
*/
|
||||
@@ -240,14 +262,14 @@ class UserBulkStatusTest extends TestCase
|
||||
$afterHookCalled = false;
|
||||
|
||||
// before_bulk_update 훅 등록
|
||||
app(\App\Extension\HookManager::class)->addAction('sirsoft-core.user.before_bulk_update', function ($hookIds, $hookStatus) use (&$beforeHookCalled, $ids) {
|
||||
app(HookManager::class)->addAction('sirsoft-core.user.before_bulk_update', function ($hookIds, $hookStatus) use (&$beforeHookCalled, $ids) {
|
||||
$beforeHookCalled = true;
|
||||
$this->assertEquals($ids, $hookIds);
|
||||
$this->assertEquals('active', $hookStatus);
|
||||
});
|
||||
|
||||
// after_bulk_update 훅 등록
|
||||
app(\App\Extension\HookManager::class)->addAction('sirsoft-core.user.after_bulk_update', function ($hookIds, $hookStatus, $hookUpdatedCount) use (&$afterHookCalled, $ids) {
|
||||
app(HookManager::class)->addAction('sirsoft-core.user.after_bulk_update', function ($hookIds, $hookStatus, $hookUpdatedCount) use (&$afterHookCalled, $ids) {
|
||||
$afterHookCalled = true;
|
||||
$this->assertEquals($ids, $hookIds);
|
||||
$this->assertEquals('active', $hookStatus);
|
||||
|
||||
@@ -24,6 +24,12 @@ effects:
|
||||
- manager_can_access_secret_post_attachment
|
||||
- normal_post_attachment_still_public
|
||||
- guest_can_preview_normal_post_attachment
|
||||
# 서명 preview URL 축 — <img> 무인증 렌더 경로 (게이트 약화 없는 자격 위임, 7.0.7 사전점검 실측 발견)
|
||||
- guest_with_valid_signature_passes_secret_preview_gate
|
||||
- guest_with_tampered_signature_still_blocked
|
||||
- guest_with_expired_signature_still_blocked
|
||||
- secret_post_detail_serializes_signed_preview_url
|
||||
- normal_post_detail_serializes_plain_preview_url
|
||||
- regular_user_gets_empty_comment_list_on_secret_post
|
||||
- guest_blocked_from_secret_post_comments
|
||||
- owner_sees_secret_post_comments
|
||||
|
||||
@@ -20,6 +20,13 @@ description: |
|
||||
(review_image 에는 대응하는 권한 분기가 없어, viewer 를 축으로 세우면 실재하지 않는 조합이
|
||||
cross product 에 생긴다).
|
||||
|
||||
브라우저 <img src> 는 Authorization 헤더를 실을 수 없어, 게이트 도입 후 관리자
|
||||
상세·미발행 미리보기 화면의 첨부 썸네일이 무인증 요청 → 404 로 깨졌다(7.0.7 사전점검
|
||||
브라우저 실측으로 발견). 게이트를 통과한 응답의 직렬화 시점(PageResource /
|
||||
PublicPageResource 미발행 분기)에 한시 서명 preview URL 을 발급하고, 서빙 엔드포인트가
|
||||
유효 서명을 무인증 허용한다. 무서명 게이트는 종전과 동일하며, 발행 페이지는 무서명
|
||||
공개 URL 을 유지한다(SEO·캐시 안정성).
|
||||
|
||||
axes:
|
||||
resource: [page_attachment, review_image]
|
||||
parent_state: [public, restricted]
|
||||
@@ -34,6 +41,12 @@ effects:
|
||||
- unpublished_page_attachment_download_allowed_with_pages_read
|
||||
- published_page_attachment_download_still_public
|
||||
- published_page_attachment_preview_still_public
|
||||
# 서명 preview URL 축 — <img> 무인증 렌더 경로 (게이트 약화 없는 자격 위임)
|
||||
- unpublished_page_attachment_preview_allowed_with_valid_signature
|
||||
- unpublished_page_attachment_preview_blocked_with_tampered_signature
|
||||
- unpublished_page_attachment_preview_blocked_with_expired_signature
|
||||
- admin_response_serializes_signed_preview_url_for_unpublished_page
|
||||
- published_page_serializes_plain_preview_url
|
||||
- hidden_review_image_download_blocked
|
||||
- visible_review_image_download_still_served
|
||||
|
||||
|
||||
Reference in New Issue
Block a user