diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 687cc3a8..ae57b225 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -12,7 +12,8 @@ - 페이지 등록 / 수정 화면에서 첨부파일을 한 번에 여러 개 올릴 때, 올린 순서대로 첨부가 저장되도록 정리했습니다. 이전에는 여러 파일을 동시에 올릴 때 순서가 뒤섞여 저장되는 경우가 있었습니다. 저장된 첨부의 순서 변경은 수정 화면에서 드래그로 즉시 반영됩니다. - 페이지를 수정하다가 새 페이지 등록 화면으로 이동했을 때, 직전 페이지의 첨부파일·제목·슬러그와 "사용 가능한 슬러그입니다" 안내가 등록 화면에 그대로 남아 보이던 문제를 수정했습니다. - 새 페이지 등록 화면에서 슬러그를 입력하기 전 주소 미리보기가 "/page/{{slug}}" 처럼 표시되던 문제를 수정했습니다. 이제 입력 전에는 "/page/example" 로 보이고, 입력하면 입력한 주소로 즉시 바뀝니다. -- 관리자 화면에서 페이지 첨부파일의 다운로드 / 미리보기가 관리자 전용 주소로 열리도록 수정했습니다. 이전에는 관리자 화면에서도 공개용 주소가 사용되어 비공개 페이지의 첨부파일을 관리자가 열지 못하는 경우가 있었습니다. +- 관리자 페이지 상세 화면에서 첨부 이미지 미리보기(썸네일)가 표시되지 않고 다운로드 버튼도 보이지 않던 문제를 수정했습니다. 이제 발행 여부와 관계없이 썸네일이 표시되고, 다운로드 버튼으로 첨부파일을 내려받을 수 있습니다. 미발행 페이지의 첨부파일 다운로드는 페이지 조회 권한이 있는 관리자에게만 허용되며, 비로그인 사용자와 일반 회원에게는 이전과 동일하게 차단됩니다. +- 사용자 페이지 화면의 첨부파일 목록에서도 이미지 첨부는 썸네일로 미리 볼 수 있도록 했습니다. 이전에는 이미지도 파일 아이콘으로만 표시되었습니다. - 페이지를 수정·삭제하거나 이전 버전으로 복원한 직후 해당 페이지 상세 화면의 검색엔진용 캐시가 즉시 갱신되도록 수정했습니다. 이전에는 일부 캐시가 갱신되지 않아 변경 전 내용이 검색엔진에 남는 경우가 있었습니다. - 페이지 목록에서 여러 페이지를 한 번에 발행 / 미발행할 때 활동 이력에 발행 여부가 기록되지 않던 문제를 수정했습니다. 이제 일괄 처리에서도 페이지별로 발행 / 미발행 기록이 남습니다. - 페이지 관리 목록에서 검색 조건 '전체'로 슬러그를 검색해도 결과가 나오지 않던 문제를 수정했습니다 — '전체'는 제목과 슬러그를 함께 검색하고, '제목'을 고르면 제목만 검색됩니다. diff --git a/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-detail-attachment-download.test.tsx b/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-detail-attachment-download.test.tsx new file mode 100644 index 00000000..f42ebe5b --- /dev/null +++ b/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-detail-attachment-download.test.tsx @@ -0,0 +1,89 @@ +/** + * @file admin-page-detail-attachment-download.test.tsx + * @description 관리자 페이지 상세 첨부 카드가 토큰 동반 핸들러로 다운로드하고, 다운로드 아이콘이 + * 유효한 Font Awesome 이름을 쓰는지 회귀 (별건: 관리자 상세 첨부 썸네일·다운로드 미노출). + * + * 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존). + * + * 결함 A: 첨부 카드(id: "attachment_card")가 브라우저 직접 + * 링크였다. 커밋 d48c684 로 download_url 이 auth:sanctum 보호 admin 라우트가 되어 + * 네비게이션에 토큰이 실리지 않아 401 로 깨졌다. + * 결함 B: 다운로드 아이콘 name="arrow-down-tray"(Heroicons)는 FA 에 없어 조용히 미렌더. + * + * 수정: 카드를 Div + actions:[{click → downloadAttachment}] 로 전환(토큰 동반), + * 아이콘 name 을 유효한 FA 이름 "download" 로 교체. + * + * @scenario surface=admin_page_detail + * @effects download_card_uses_handler_not_anchor + * @effects download_icon_valid_fa_name + */ + +import { describe, it, expect } from 'vitest'; + +import adminPageDetail from '../../../layouts/admin/admin_page_detail.json'; + +type Node = Record & { children?: unknown; actions?: unknown }; + +function collectNodes(node: unknown, predicate: (n: Node) => boolean): Node[] { + const result: Node[] = []; + const walk = (cur: unknown): void => { + if (Array.isArray(cur)) { + cur.forEach(walk); + return; + } + if (cur && typeof cur === 'object') { + const obj = cur as Node; + if (predicate(obj)) { + result.push(obj); + } + Object.values(obj).forEach(walk); + } + }; + walk(node); + return result; +} + +// Heroicons 계열 이름(FA 미존재) — 조용히 미렌더 회귀 차단용 블랙리스트 +const HEROICON_NAMES = ['arrow-down-tray', 'arrow-up-tray', 'arrow-down-circle']; + +describe('별건 — 관리자 페이지 상세 첨부 다운로드 카드', () => { + it('첨부 카드는 직접 링크가 아니다 (토큰 미동반 401 회귀 차단)', () => { + const cards = collectNodes(adminPageDetail, (n) => typeof n.id === 'string' && n.id.startsWith('attachment_card')); + expect(cards.length).toBeGreaterThan(0); + + for (const card of cards) { + const props = (card.props ?? {}) as Record; + const isAnchorHref = card.name === 'A' && typeof props.href === 'string'; + expect(isAnchorHref).toBe(false); + } + }); + + it('첨부 카드는 click 액션으로 downloadAttachment 핸들러를 호출한다', () => { + const cards = collectNodes(adminPageDetail, (n) => typeof n.id === 'string' && n.id.startsWith('attachment_card')); + const card = cards[0]; + + const actions = (card.actions ?? []) as Array>; + const clickAction = actions.find((a) => a.type === 'click'); + + expect(clickAction).toBeDefined(); + expect(clickAction?.handler).toBe('downloadAttachment'); + + const params = (clickAction?.params ?? {}) as Record; + expect(params.url).toContain('download_url'); + expect(params.filename).toContain('original_filename'); + }); + + it('다운로드 아이콘은 유효한 Font Awesome 이름을 쓴다 (arrow-down-tray 미렌더 회귀 차단)', () => { + const icons = collectNodes(adminPageDetail, (n) => n.name === 'Icon'); + const iconNames = icons + .map((n) => (n.props as Record | undefined)?.name) + .filter((v): v is string => typeof v === 'string'); + + for (const blacklisted of HEROICON_NAMES) { + expect(iconNames).not.toContain(blacklisted); + } + + // 다운로드 아이콘이 실제로 존재하고 FA 유효 이름("download")을 쓴다 + expect(iconNames).toContain('download'); + }); +}); diff --git a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_detail.json b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_detail.json index db7380d0..56c8c186 100644 --- a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_detail.json +++ b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_detail.json @@ -1310,7 +1310,7 @@ ] }, { - "id": "attachments_list", + "id": "attachments_list_{{att_idx}}", "type": "basic", "name": "Div", "if": "{{_local.showAttachments}}", @@ -1324,13 +1324,22 @@ }, "children": [ { - "id": "attachment_card", + "id": "attachment_card_{{att_idx}}", "type": "basic", - "name": "A", + "name": "Div", "props": { - "href": "{{attachment_item?.download_url}}", - "className": "flex-center group gap-3 px-4 py-2.5 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50 transition-colors cursor-pointer no-underline" + "className": "flex-center group gap-3 px-4 py-2.5 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50 transition-colors cursor-pointer" }, + "actions": [ + { + "type": "click", + "handler": "downloadAttachment", + "params": { + "url": "{{attachment_item?.download_url}}", + "filename": "{{attachment_item?.original_filename}}" + } + } + ], "children": [ { "comment": "이미지 썸네일 (이미지 파일인 경우)", @@ -1400,7 +1409,7 @@ "type": "basic", "name": "Icon", "props": { - "name": "arrow-down-tray", + "name": "download", "className": "text-gray-400 dark:text-gray-500 group-hover:text-blue-500 dark:text-blue-400 dark:group-hover:text-blue-400 transition-colors" } } diff --git a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php index ec7602d4..472a177d 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php +++ b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php @@ -3,17 +3,18 @@ namespace Modules\Sirsoft\Page\Http\Controllers\Admin; use App\Http\Controllers\Api\Base\AdminBaseController; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Modules\Sirsoft\Page\Http\Requests\ReorderPageAttachmentsRequest; use Modules\Sirsoft\Page\Http\Requests\UploadPageAttachmentRequest; use Modules\Sirsoft\Page\Http\Resources\PageAttachmentResource; use Modules\Sirsoft\Page\Services\PageAttachmentService; -use Symfony\Component\HttpFoundation\StreamedResponse; /** * 관리자 페이지 첨부파일 컨트롤러 * - * 페이지 첨부파일 업로드/삭제/다운로드/순서변경을 처리합니다. + * 페이지 첨부파일 업로드/삭제/순서변경을 처리합니다. + * 파일 서빙(다운로드/미리보기)은 공개 hash 라우트(PublicPageAttachmentController)로 단일화됨. */ class PageAttachmentController extends AdminBaseController { @@ -71,7 +72,7 @@ class PageAttachmentController extends AdminBaseController } return $this->success('sirsoft-page::messages.attachment.delete_success'); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + } catch (ModelNotFoundException $e) { return $this->notFound('sirsoft-page::messages.attachment.not_found'); } catch (\Exception $e) { return $this->error('sirsoft-page::messages.attachment.delete_failed', 500, $e->getMessage()); @@ -99,40 +100,4 @@ class PageAttachmentController extends AdminBaseController return $this->error('sirsoft-page::messages.attachment.reorder_failed', 500, $e->getMessage()); } } - - /** - * 첨부파일을 다운로드합니다 (해시 기반). - * - * @param string $hash 첨부파일 해시 (12자) - * @return StreamedResponse|JsonResponse 파일 스트리밍 응답 또는 오류 - */ - public function download(string $hash): StreamedResponse|JsonResponse - { - $attachment = $this->attachmentService->getByHash($hash); - if (! $attachment) { - return $this->notFound('sirsoft-page::messages.attachment.not_found'); - } - - $response = $this->attachmentService->download($attachment); - - return $response ?: $this->error('sirsoft-page::messages.attachment.file_not_found', 404); - } - - /** - * 이미지 첨부파일을 미리봅니다 (해시 기반, inline). - * - * @param string $hash 첨부파일 해시 (12자) - * @return StreamedResponse|JsonResponse 파일 스트리밍 응답 또는 오류 - */ - public function preview(string $hash): StreamedResponse|JsonResponse - { - $attachment = $this->attachmentService->getByHash($hash); - if (! $attachment) { - return $this->notFound('sirsoft-page::messages.attachment.not_found'); - } - - $response = $this->attachmentService->preview($attachment); - - return $response ?: $this->error('sirsoft-page::messages.attachment.file_not_found', 404); - } } diff --git a/modules/_bundled/sirsoft-page/src/Http/Controllers/User/PublicPageAttachmentController.php b/modules/_bundled/sirsoft-page/src/Http/Controllers/User/PublicPageAttachmentController.php index 86bd6c47..15970557 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Controllers/User/PublicPageAttachmentController.php +++ b/modules/_bundled/sirsoft-page/src/Http/Controllers/User/PublicPageAttachmentController.php @@ -2,15 +2,20 @@ namespace Modules\Sirsoft\Page\Http\Controllers\User; +use App\Enums\PermissionType; use App\Http\Controllers\Api\Base\PublicBaseController; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Modules\Sirsoft\Page\Services\PageAttachmentService; use Symfony\Component\HttpFoundation\StreamedResponse; /** * 공개 페이지 첨부파일 컨트롤러 * - * 발행된 페이지의 첨부파일 다운로드를 처리합니다. + * 첨부파일 서빙(다운로드/미리보기)을 처리합니다. 썸네일 ·다운로드는 브라우저 직접 + * GET 이라 토큰을 실을 수 없으므로 공개 hash 라우트로 단일화합니다. + * - 미리보기(썸네일): 공개 서빙 (미발행 콘텐츠도 hash 보유 시 조회 가능 — 트레이드오프 수용) + * - 다운로드: 발행 첨부는 누구나, 미발행 첨부는 pages.read 관리자만 (파일 보호) */ class PublicPageAttachmentController extends PublicBaseController { @@ -21,20 +26,29 @@ class PublicPageAttachmentController extends PublicBaseController /** * 첨부파일을 다운로드합니다 (해시 기반). * - * 발행된 페이지의 첨부파일만 다운로드 가능합니다. + * 발행된 페이지의 첨부는 누구나, 미발행 페이지의 첨부는 페이지 조회 권한 + * (sirsoft-page.pages.read) 관리자만 다운로드할 수 있습니다. * + * @param Request $request HTTP 요청 객체 (다운로드 권한 판정용) * @param string $hash 첨부파일 해시 (12자) * @return StreamedResponse|JsonResponse 파일 스트리밍 응답 또는 오류 */ - public function download(string $hash): StreamedResponse|JsonResponse + // audit:allow controller-base-request-injection reason: GET 파일 서빙. 인증 사용자만 read-only 참조($request->user())해 미발행 첨부 다운로드 권한을 판정. 검증할 body 없음(hash 는 라우트 파라미터) + public function download(Request $request, string $hash): StreamedResponse|JsonResponse { $attachment = $this->attachmentService->getByHash($hash); if (! $attachment) { return $this->notFound('sirsoft-page::messages.attachment.not_found'); } - // 발행된 페이지의 첨부파일만 다운로드 가능 - if (! $attachment->page || ! $attachment->page->published) { + // 미발행 페이지의 첨부는 페이지 조회 권한 관리자만 다운로드 가능 + $published = $attachment->page && $attachment->page->published; + $canReadUnpublished = $request->user()?->hasPermission( + 'sirsoft-page.pages.read', + PermissionType::Admin + ) ?? false; + + if (! $published && ! $canReadUnpublished) { return $this->notFound('sirsoft-page::messages.attachment.not_found'); } @@ -46,7 +60,9 @@ class PublicPageAttachmentController extends PublicBaseController /** * 이미지 첨부파일을 미리봅니다 (해시 기반, inline). * - * 발행된 페이지의 이미지 첨부파일만 미리보기 가능합니다. + * 썸네일 는 토큰을 실을 수 없으므로 발행/미발행 무관 공개 서빙합니다. + * 미발행 콘텐츠의 썸네일은 hash 를 보유해야만 조회 가능하며(비추측성), 파일 + * 다운로드는 download() 의 권한 게이트로 보호됩니다. * * @param string $hash 첨부파일 해시 (12자) * @return StreamedResponse|JsonResponse 파일 스트리밍 응답 또는 오류 @@ -58,11 +74,6 @@ class PublicPageAttachmentController extends PublicBaseController return $this->notFound('sirsoft-page::messages.attachment.not_found'); } - // 발행된 페이지의 첨부파일만 미리보기 가능 - if (! $attachment->page || ! $attachment->page->published) { - return $this->notFound('sirsoft-page::messages.attachment.not_found'); - } - $response = $this->attachmentService->preview($attachment); return $response ?: $this->error('sirsoft-page::messages.attachment.file_not_found', 404); diff --git a/modules/_bundled/sirsoft-page/src/Http/Resources/PageAttachmentResource.php b/modules/_bundled/sirsoft-page/src/Http/Resources/PageAttachmentResource.php index cd0a2d9a..69005827 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Resources/PageAttachmentResource.php +++ b/modules/_bundled/sirsoft-page/src/Http/Resources/PageAttachmentResource.php @@ -13,43 +13,18 @@ use Modules\Sirsoft\Page\Models\PageAttachment; class PageAttachmentResource extends BaseApiResource { /** - * URL 컨텍스트 ('admin' 또는 'public'). - * - * 공개 라우트는 발행 가드가 있어 미발행 페이지 첨부를 차단하므로, - * 관리자 응답은 'admin' 컨텍스트로 발행 가드 없는 라우트 URL을 사용한다. - */ - protected string $urlContext = 'public'; - - /** - * URL 컨텍스트를 지정한 리소스 배열을 생성합니다. - * - * 각 첨부파일을 컨텍스트가 주입된 개별 리소스로 변환합니다. - * (컬렉션 헬퍼는 개별 item에 컨텍스트를 전달하지 못하므로 직접 매핑) + * 첨부파일 목록을 리소스 배열로 변환합니다. * * @param iterable|null $attachments 첨부파일 목록 - * @param string $context 'admin' 또는 'public' * @return array */ - public static function collectionFor($attachments, string $context): array + public static function collectionFor($attachments): array { return Collection::make($attachments ?? []) - ->map(fn ($attachment) => (new self($attachment))->withContext($context)) + ->map(fn ($attachment) => new self($attachment)) ->all(); } - /** - * URL 컨텍스트를 설정합니다. - * - * @param string $context 'admin' 또는 'public' - * @return $this - */ - public function withContext(string $context): self - { - $this->urlContext = $context; - - return $this; - } - /** * 리소스를 배열로 변환합니다. * @@ -67,8 +42,8 @@ class PageAttachmentResource extends BaseApiResource 'collection' => $this->collection, 'order' => $this->order, 'is_image' => $this->isImage(), - 'download_url' => $this->resource->downloadUrlFor($this->urlContext), - 'preview_url' => $this->resource->previewUrlFor($this->urlContext), + 'download_url' => $this->resource->downloadUrl(), + 'preview_url' => $this->resource->previewUrl(), 'created_at' => $this->created_at ? $this->formatDateTimeStringForUser($this->created_at) : null, diff --git a/modules/_bundled/sirsoft-page/src/Http/Resources/PageResource.php b/modules/_bundled/sirsoft-page/src/Http/Resources/PageResource.php index 134da95f..fa24f200 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Resources/PageResource.php +++ b/modules/_bundled/sirsoft-page/src/Http/Resources/PageResource.php @@ -40,7 +40,7 @@ class PageResource extends BaseApiResource ]), 'attachments' => $this->whenLoaded( 'attachments', - fn () => PageAttachmentResource::collectionFor($this->attachments, 'admin') + fn () => PageAttachmentResource::collectionFor($this->attachments) ), ...$this->formatTimestamps(), ...$this->resourceMeta($request), diff --git a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php index 7f5f90a1..d6320dad 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php +++ b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php @@ -61,10 +61,7 @@ class PublicPageResource extends BaseApiResource 'current_version' => $this->current_version, 'attachments' => $this->whenLoaded( 'attachments', - fn () => PageAttachmentResource::collectionFor( - $this->attachments, - $this->preview ? 'admin' : 'public' - ) + fn () => PageAttachmentResource::collectionFor($this->attachments) ), 'created_at' => $this->created_at ? $this->formatDateTimeStringForUser($this->created_at) diff --git a/modules/_bundled/sirsoft-page/src/Models/PageAttachment.php b/modules/_bundled/sirsoft-page/src/Models/PageAttachment.php index 970787f7..7f7cb1b7 100644 --- a/modules/_bundled/sirsoft-page/src/Models/PageAttachment.php +++ b/modules/_bundled/sirsoft-page/src/Models/PageAttachment.php @@ -122,55 +122,50 @@ class PageAttachment extends Model } /** - * 다운로드 URL을 반환합니다 (공개 라우트 — 기본값). + * 다운로드 URL을 반환합니다 (공개 hash 라우트). * * @return string 다운로드 URL */ public function getDownloadUrlAttribute(): string { - return $this->downloadUrlFor('public'); + return $this->downloadUrl(); } /** - * 이미지 미리보기 URL을 반환합니다 (공개 라우트 — 기본값). + * 이미지 미리보기 URL을 반환합니다 (공개 hash 라우트). * * @return string|null 미리보기 URL (이미지가 아니면 null) */ public function getPreviewUrlAttribute(): ?string { - return $this->previewUrlFor('public'); + return $this->previewUrl(); } /** - * 컨텍스트별 다운로드 URL을 반환합니다. + * 다운로드 URL을 반환합니다. * - * 공개 라우트는 발행 가드가 있어 미발행 페이지의 첨부를 차단하므로, - * 관리자 응답에는 발행 가드가 없는 admin 라우트 URL을 사용합니다. + * 썸네일 ·다운로드는 브라우저 직접 GET 이라 토큰을 실을 수 없으므로, + * 게시판·이커머스 표준과 동일하게 공개 hash 라우트로 단일화한다. + * 미발행 콘텐츠 다운로드 차단은 공개 라우트 내부의 권한 게이트가 담당한다. * - * @param string $context 'admin' 또는 'public' * @return string 다운로드 URL */ - public function downloadUrlFor(string $context = 'public'): string + public function downloadUrl(): string { - return $context === 'admin' - ? '/api/modules/sirsoft-page/admin/attachments/download/'.$this->hash - : '/api/modules/sirsoft-page/pages/attachment/'.$this->hash; + return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash; } /** - * 컨텍스트별 이미지 미리보기 URL을 반환합니다. + * 이미지 미리보기 URL을 반환합니다 (공개 hash 라우트). * - * @param string $context 'admin' 또는 'public' * @return string|null 미리보기 URL (이미지가 아니면 null) */ - public function previewUrlFor(string $context = 'public'): ?string + public function previewUrl(): ?string { if (! $this->isImage()) { return null; } - return $context === 'admin' - ? '/api/modules/sirsoft-page/admin/attachments/preview/'.$this->hash - : '/api/modules/sirsoft-page/pages/attachment/'.$this->hash.'/preview'; + return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash.'/preview'; } } diff --git a/modules/_bundled/sirsoft-page/src/routes/api.php b/modules/_bundled/sirsoft-page/src/routes/api.php index 2d543b3a..c41e1e29 100644 --- a/modules/_bundled/sirsoft-page/src/routes/api.php +++ b/modules/_bundled/sirsoft-page/src/routes/api.php @@ -105,17 +105,8 @@ Route::prefix('admin/attachments')->middleware(['auth:sanctum', 'throttle:600,1' ->middleware('permission:admin,sirsoft-page.pages.update') ->name('reorder'); - // 첨부파일 다운로드 (해시 기반) - Route::get('/download/{hash}', [PageAttachmentController::class, 'download']) - ->middleware('permission:admin,sirsoft-page.pages.read') - ->where('hash', '[a-zA-Z0-9]{12}') - ->name('download'); - - // 첨부파일 이미지 미리보기 (해시 기반, inline) - Route::get('/preview/{hash}', [PageAttachmentController::class, 'preview']) - ->middleware('permission:admin,sirsoft-page.pages.read') - ->where('hash', '[a-zA-Z0-9]{12}') - ->name('preview'); + // 다운로드/미리보기는 공개 hash 라우트(pages/attachment/*)로 단일화 — + // 썸네일 ·다운로드는 토큰을 실을 수 없어 인증 라우트에 물리면 401 로 깨진다. // 첨부파일 삭제 Route::delete('/{id}', [PageAttachmentController::class, 'destroy']) diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageAttachmentControllerTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageAttachmentControllerTest.php index afa34550..b5c06188 100644 --- a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageAttachmentControllerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageAttachmentControllerTest.php @@ -258,29 +258,9 @@ class PageAttachmentControllerTest extends FeatureTestCase ->assertJsonValidationErrors(['order']); } - // ─── 다운로드/미리보기 ───────────────────────────── - - /** - * 존재하지 않는 해시로 다운로드 시 404를 반환하는지 확인 - */ - public function test_download_nonexistent_hash_returns_404(): void - { - $response = $this->actingAs($this->adminUser) - ->getJson('/api/modules/sirsoft-page/admin/attachments/download/abcdefghijkl'); - - $response->assertStatus(404); - } - - /** - * 존재하지 않는 해시로 미리보기 시 404를 반환하는지 확인 - */ - public function test_preview_nonexistent_hash_returns_404(): void - { - $response = $this->actingAs($this->adminUser) - ->getJson('/api/modules/sirsoft-page/admin/attachments/preview/abcdefghijkl'); - - $response->assertStatus(404); - } + // 첨부 다운로드/미리보기는 공개 hash 라우트(pages/attachment/*)로 단일화되어 + // 관리자 전용 preview/download 라우트가 제거됨. 해당 서빙 경로 테스트는 + // PublicPageControllerTest 의 preview/download 매트릭스로 이관. // ─── 인증 차단 ───────────────────────────────────── diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php index 3cd7f155..7a60b6e4 100644 --- a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php @@ -315,13 +315,13 @@ class PageControllerTest extends FeatureTestCase } /** - * 관리자 상세 조회 시 첨부 URL이 관리자 라우트를 가리키는지 확인 + * 관리자 상세 조회 시 첨부 URL이 공개 hash 라우트를 가리키는지 확인 * - * 첨부 URL이 공개 라우트(발행 가드 있음)를 가리키면 미발행 페이지의 - * 첨부가 관리자에게도 404로 차단된다. 관리자 응답은 발행 가드 없는 - * admin 라우트를 반환해야 한다. + * 썸네일 ·다운로드는 토큰을 실을 수 없어 인증 라우트에 물리면 401 로 깨진다. + * 게시판·이커머스 표준과 동일하게 공개 hash 라우트로 단일화하고, 미발행 콘텐츠 + * 다운로드 차단은 공개 라우트 내부의 권한 게이트가 담당한다. */ - public function test_admin_show_returns_admin_attachment_urls(): void + public function test_admin_show_returns_public_attachment_urls(): void { $page = Page::factory()->create([ 'slug' => 'test-admin-attach-url', @@ -341,11 +341,11 @@ class PageControllerTest extends FeatureTestCase $response->assertStatus(200); $att = $response->json('data.attachments.0'); $this->assertSame( - "/api/modules/sirsoft-page/admin/attachments/preview/{$attachment->hash}", + "/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview", $att['preview_url'] ); $this->assertSame( - "/api/modules/sirsoft-page/admin/attachments/download/{$attachment->hash}", + "/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}", $att['download_url'] ); } diff --git a/modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php b/modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php index 95edf8b7..74252a1a 100644 --- a/modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php @@ -9,7 +9,9 @@ use App\Contracts\Extension\StorageInterface; use Mockery; use Modules\Sirsoft\Page\Models\Page; use Modules\Sirsoft\Page\Models\PageAttachment; +use Modules\Sirsoft\Page\Services\PageAttachmentService; use Modules\Sirsoft\Page\Tests\FeatureTestCase; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * 공개 페이지 API 테스트 @@ -37,8 +39,18 @@ class PublicPageControllerTest extends FeatureTestCase $storageMock->shouldReceive('deleteDirectory')->andReturn(true); $storageMock->shouldReceive('getDisk')->andReturn('local'); $storageMock->shouldReceive('url')->andReturn(null); - $storageMock->shouldReceive('response')->andReturn(null); + // 파일 서빙(다운로드/미리보기)은 200 스트리밍 응답을 반환 — 권한 게이트 검증용 + $storageMock->shouldReceive('response')->andReturnUsing( + fn () => new StreamedResponse(fn () => print ('file content'), 200) + ); $this->app->instance(StorageInterface::class, $storageMock); + + // PageAttachmentService 는 contextual binding 으로 모듈 도메인 Storage 를 받으므로 + // (AbstractExtensionServiceProvider::$storageServices), 전역 instance 만으로는 + // 오버라이드되지 않는다. 해당 서비스에도 mock 을 명시적으로 주입한다. + $this->app->when(PageAttachmentService::class) + ->needs(StorageInterface::class) + ->give(fn () => $storageMock); } /** @@ -133,9 +145,13 @@ class PublicPageControllerTest extends FeatureTestCase } /** - * 미발행 미리보기 응답의 첨부 URL이 관리자용(발행 가드 없는) 라우트인지 확인 + * 미발행 미리보기 응답의 첨부 URL이 공개 hash 라우트인지 확인 + * + * 썸네일 ·다운로드는 브라우저 직접 GET 이라 토큰을 실을 수 없어 인증 라우트로 + * 두면 401 로 깨진다. 게시판·이커머스 표준과 동일하게 공개 hash 라우트로 단일화하고, + * 미발행 콘텐츠 보안은 다운로드 권한 게이트(공개 라우트 내부)로 담당한다. */ - public function test_admin_preview_unpublished_page_attachments_use_admin_url(): void + public function test_admin_preview_unpublished_page_attachments_use_public_url(): void { $admin = $this->createAdminUser(['sirsoft-page.pages.read']); @@ -166,8 +182,10 @@ class PublicPageControllerTest extends FeatureTestCase $response->assertStatus(200); $downloadUrl = $response->json('data.attachments.0.download_url'); $previewUrl = $response->json('data.attachments.0.preview_url'); - $this->assertStringContainsString('/admin/attachments/download/', $downloadUrl); - $this->assertStringContainsString('/admin/attachments/preview/', $previewUrl); + $this->assertStringContainsString('/pages/attachment/', $downloadUrl); + $this->assertStringContainsString('/pages/attachment/', $previewUrl); + $this->assertStringNotContainsString('/admin/attachments/', $downloadUrl); + $this->assertStringNotContainsString('/admin/attachments/', $previewUrl); } /** @@ -339,4 +357,204 @@ class PublicPageControllerTest extends FeatureTestCase $response->assertStatus(404); } + + // ─── 썸네일(preview) 공개 서빙 매트릭스 (별건: 관리자 상세 썸네일 401 회귀) ─── + + /** + * 미발행 페이지의 이미지 첨부 미리보기(썸네일)는 비로그인이어도 200을 반환한다. + * + * 썸네일 는 토큰을 실을 수 없으므로 공개 hash 서빙으로 둔다. + * 보안은 hash 비추측성 + 파일 다운로드 권한 게이트가 담당한다(트레이드오프 수용). + */ + public function test_public_preview_unpublished_image_returns_200_for_guest(): void + { + $admin = $this->createAdminUser([]); + + $page = Page::factory()->create([ + 'slug' => 'test-draft-preview-guest', + 'published' => false, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + ]); + + $attachment = PageAttachment::create([ + 'page_id' => $page->id, + 'original_filename' => 'draft.png', + 'stored_filename' => 'stored-draft.png', + 'disk' => 'local', + 'path' => 'test/draft.png', + 'mime_type' => 'image/png', + 'size' => 1024, + 'collection' => 'attachments', + 'order' => 1, + 'created_by' => $admin->id, + ]); + + $response = $this->getJson("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview"); + + $response->assertStatus(200); + } + + /** + * 발행 페이지의 이미지 첨부 미리보기(썸네일)는 200을 반환한다. + */ + public function test_public_preview_published_image_returns_200(): void + { + $admin = $this->createAdminUser([]); + + $page = Page::factory()->published()->create([ + 'slug' => 'test-pub-preview', + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + ]); + + $attachment = PageAttachment::create([ + 'page_id' => $page->id, + 'original_filename' => 'pub.png', + 'stored_filename' => 'stored-pub.png', + 'disk' => 'local', + 'path' => 'test/pub.png', + 'mime_type' => 'image/png', + 'size' => 1024, + 'collection' => 'attachments', + 'order' => 1, + 'created_by' => $admin->id, + ]); + + $response = $this->getJson("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview"); + + $response->assertStatus(200); + } + + // ─── 다운로드 권한 게이트 매트릭스 (별건: 관리자 상세 다운로드 401 회귀) ─── + + /** + * 미발행 페이지 첨부 다운로드는 비로그인에게 404로 차단된다. + */ + public function test_download_unpublished_attachment_blocked_for_guest(): void + { + $admin = $this->createAdminUser([]); + + $page = Page::factory()->create([ + 'slug' => 'test-dl-guest', + 'published' => false, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + ]); + + $attachment = PageAttachment::create([ + 'page_id' => $page->id, + 'original_filename' => 'secret.pdf', + 'stored_filename' => 'stored-secret.pdf', + 'disk' => 'local', + 'path' => 'test/secret.pdf', + 'mime_type' => 'application/pdf', + 'size' => 1024, + 'collection' => 'attachments', + 'order' => 1, + 'created_by' => $admin->id, + ]); + + $response = $this->getJson("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}"); + + $response->assertStatus(404); + } + + /** + * 미발행 페이지 첨부 다운로드는 권한 없는 일반 회원에게 404로 차단된다. + */ + public function test_download_unpublished_attachment_blocked_for_member(): void + { + $admin = $this->createAdminUser([]); + $member = $this->createUser(); + + $page = Page::factory()->create([ + 'slug' => 'test-dl-member', + 'published' => false, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + ]); + + $attachment = PageAttachment::create([ + 'page_id' => $page->id, + 'original_filename' => 'secret.pdf', + 'stored_filename' => 'stored-secret.pdf', + 'disk' => 'local', + 'path' => 'test/secret.pdf', + 'mime_type' => 'application/pdf', + 'size' => 1024, + 'collection' => 'attachments', + 'order' => 1, + 'created_by' => $admin->id, + ]); + + $response = $this->actingAs($member) + ->getJson("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}"); + + $response->assertStatus(404); + } + + /** + * 미발행 페이지 첨부 다운로드는 pages.read 권한 관리자에게 200(스트리밍)을 허용한다. + */ + public function test_download_unpublished_attachment_allowed_for_admin_with_read(): void + { + $admin = $this->createAdminUser(['sirsoft-page.pages.read']); + + $page = Page::factory()->create([ + 'slug' => 'test-dl-admin', + 'published' => false, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + ]); + + $attachment = PageAttachment::create([ + 'page_id' => $page->id, + 'original_filename' => 'draft.pdf', + 'stored_filename' => 'stored-draft.pdf', + 'disk' => 'local', + 'path' => 'test/draft.pdf', + 'mime_type' => 'application/pdf', + 'size' => 1024, + 'collection' => 'attachments', + 'order' => 1, + 'created_by' => $admin->id, + ]); + + $response = $this->actingAs($admin) + ->get("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}"); + + $response->assertStatus(200); + } + + /** + * 발행 페이지 첨부 다운로드는 누구나 200(스트리밍)을 허용한다. + */ + public function test_download_published_attachment_allowed_for_guest(): void + { + $admin = $this->createAdminUser([]); + + $page = Page::factory()->published()->create([ + 'slug' => 'test-dl-pub', + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + ]); + + $attachment = PageAttachment::create([ + 'page_id' => $page->id, + 'original_filename' => 'pub.pdf', + 'stored_filename' => 'stored-pub.pdf', + 'disk' => 'local', + 'path' => 'test/pub.pdf', + 'mime_type' => 'application/pdf', + 'size' => 1024, + 'collection' => 'attachments', + 'order' => 1, + 'created_by' => $admin->id, + ]); + + $response = $this->get("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}"); + + $response->assertStatus(200); + } } diff --git a/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-order-and-seo-cache.yaml b/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-order-and-seo-cache.yaml index fb24ec17..3a243bf9 100644 --- a/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-order-and-seo-cache.yaml +++ b/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-order-and-seo-cache.yaml @@ -20,10 +20,11 @@ feature: 페이지 첨부 컨텍스트 URL / 다중 업로드 최종 순서 저 description: | 페이지 검수 오류 회귀를 한 매트릭스로 묶어 보장한다. - 6-1 (첨부 컨텍스트 URL): - - 관리자 화면은 관리자 전용 다운로드/미리보기 URL, 공개 화면은 공개 URL 을 사용해야 한다. - - 이전에는 관리자 화면에서도 공개 URL 이 노출되어 비공개 페이지 첨부를 관리자가 열지 못했다. - - PageAttachmentResource 가 urlContext(public|admin) 를 받아 URL 을 분기. + 6-1 (첨부 URL — 공개 hash 단일화): + - 관리자/공개 화면 모두 공개 hash 라우트(/pages/attachment/{hash}[/preview])를 사용한다. + - 썸네일 ·다운로드 는 토큰을 실을 수 없어 인증 라우트에 물리면 401 로 깨진다. + 게시판·이커머스 표준과 동일하게 공개 hash 라우트로 단일화하고, 미발행 첨부 파일 다운로드 + 차단은 공개 라우트 내부 권한 게이트가 담당한다. (별건 page-attachment-serving.yaml 이 SSoT.) 6-2 (다중 업로드 / 첨부 순서 — 이커머스 상품 이미지와 동일 패턴): - 생성 모드: temp_key 로 업로드된 첨부를 저장 시 업로드(조회) 순서대로 order 1..N 재부여 @@ -58,7 +59,7 @@ description: | 잔존 → after_restore 를 onPageChange 로 구독 추가(수정/삭제와 동일 무효화 정책). axes: - url_context: [public, admin] + url_context: [public] form_mode: [create, edit] upload_count: [single, multiple] page_change: [create, update, delete, restore] @@ -66,8 +67,7 @@ axes: upload_validation: [size_exceeded, ext_not_allowed, count_exceeded, valid] effects: - - admin_context_returns_admin_download_and_preview_url - - public_context_returns_public_download_and_preview_url + - admin_detail_and_public_both_return_public_hash_url - temp_attachments_linked_in_upload_order_1_to_n - edit_mode_drag_persists_via_reorder_api - create_form_clears_stale_attachments_via_form_attachments_binding diff --git a/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-serving.yaml b/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-serving.yaml new file mode 100644 index 00000000..3283efb3 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-serving.yaml @@ -0,0 +1,75 @@ +# audit:allow test-scenario-coverage reason: | +# 본 매니페스트는 첨부 서빙(preview 썸네일 / download 파일) 별건 결함의 serving_type × +# published × viewer cross product 를 기록한다. preview 는 모든 조합에서 공개 서빙(200), +# download 는 published=200 무조건 / unpublished 는 page_reader admin 만 200·나머지 404 로 +# 비대칭이다. 실제 회귀 가드는 test_files 의 Feature/레이아웃 테스트가 담당하므로 docblock +# @scenario 전수 마킹 대신 통과 테스트로 커버한다. +# E2E: 관리자 상세(admin_page_detail) 썸네일·다운로드는 페이지 모듈 Playwright 인프라 +# 보강 전까지 test.describe.skip placeholder 로 운용한다(기존 admin spec 3건과 동일 운용). +feature: 페이지 첨부 서빙 (썸네일 공개 hash 서빙 + 다운로드 권한 게이트) + +description: | + 별건(관리자 상세 첨부 썸네일·다운로드 미노출): 커밋 d48c684 가 관리자 화면 첨부 URL 을 + auth:sanctum 보호 admin 라우트로 바꿔, 토큰을 실을 수 없는 / 브라우저 직접 GET 이 + 발행/미발행 무관 401 로 깨지던 회귀를 수정한다. 게시판·이커머스 표준으로 정렬. + + 서빙 정책: + - preview(썸네일): 공개 hash 서빙. 는 인증 수단이 없어 모든 모듈이 공개로 둔다. + 보안은 hash(12자 랜덤) 비추측성 + 미발행 페이지 미노출에 의존. 발행/미발행 무관 200. + - download(파일): 권한 게이트. 발행 첨부는 누구나 200, 미발행 첨부는 + sirsoft-page.pages.read(admin) 보유자만 200, 그 외(비로그인/일반회원/무권한 admin) 404. + + 구현: + - PageAttachment::downloadUrl()/previewUrl() — 공개 hash URL 단일화(admin 분기 제거). + - PageAttachmentResource — urlContext/withContext 컨텍스트 인프라 제거(공개 URL 고정). + - PublicPageAttachmentController::preview — published 가드 제거(공개 서빙). + - PublicPageAttachmentController::download — Request 주입 + + published || user()?->hasPermission('sirsoft-page.pages.read', admin) 게이트. + - admin 첨부 preview/download 라우트·컨트롤러 메서드 제거(공개 라우트로 단일화 후 데드코드). + - admin_page_detail.json / page/show.json — 다운로드 → downloadAttachment 핸들러 + (토큰 fetch → blob) 전환. admin 다운로드 아이콘 arrow-down-tray(Heroicons·FA미존재) → download. + - page/show.json — 이미지 첨부는 preview_url 썸네일(, is_image 가드)로 표시(관리자 상세와 + 일관). 일반 파일은 파일 아이콘 유지. preview_url 이 공개 서빙이 되었으므로 백엔드 변경 불요. + + 수용된 트레이드오프: + - 썸네일 공개 → hash 보유 시 비로그인도 미발행 이미지 썸네일 조회 가능. 파일 다운로드는 + 권한으로 보호. 게시판·이커머스와 동일 수위(PO 수용). + +axes: + serving_type: [preview, download] + published: [published, unpublished] + viewer: [guest, member, page_reader_admin] + +effects: + - preview_published_returns_200_for_any_viewer + - preview_unpublished_returns_200_for_guest + - preview_nonexistent_hash_returns_404 + - download_published_returns_200_for_guest + - download_unpublished_blocked_404_for_guest + - download_unpublished_blocked_404_for_member + - download_unpublished_allowed_200_for_page_reader_admin + - attachment_urls_are_public_hash_route_even_for_admin_detail + - download_card_uses_downloadAttachment_handler_not_anchor + - download_icon_uses_valid_fa_name_not_heroicon + - user_page_image_attachment_renders_preview_thumbnail + - user_page_non_image_attachment_renders_file_icon + +test_files: + - modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php + - modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php + - modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-detail-attachment-download.test.tsx + - templates/_bundled/sirsoft-basic/__tests__/layouts/page-attachment-download-handler.test.tsx + +notes: | + 회귀 가드 담당: + - preview/download × published × viewer 게이트 → PublicPageControllerTest(Feature). + - 관리자 상세 응답 첨부 URL 이 공개 hash 라우트인지 → PageControllerTest(Feature). + - admin_page_detail 다운로드 카드가 downloadAttachment 핸들러 + 아이콘 name=download → + admin-page-detail-attachment-download.test.tsx(레이아웃 렌더). + - page/show 다운로드가 downloadAttachment 핸들러 + 이미지 첨부 preview_url 썸네일(is_image 가드) + → page-attachment-download-handler.test.tsx. + + E2E: 관리자 상세 썸네일·다운로드 브라우저 검증은 Chrome MCP 실측으로 대체 확인 + (id=96 미발행 썸네일 3건 200 + 다운로드 아이콘 4건 렌더 + 다운로드 토큰동반 200, + genuinely unpublished(page_id=10) guest download 404 / preview 200). 페이지 모듈 + Playwright 인프라 보강 전까지 전용 spec 은 미작성(기존 admin spec 3건과 동일 운용). diff --git a/modules/_bundled/sirsoft-page/tests/scenarios/page-unpublished-admin-preview.yaml b/modules/_bundled/sirsoft-page/tests/scenarios/page-unpublished-admin-preview.yaml index 396e39b7..4b56eafb 100644 --- a/modules/_bundled/sirsoft-page/tests/scenarios/page-unpublished-admin-preview.yaml +++ b/modules/_bundled/sirsoft-page/tests/scenarios/page-unpublished-admin-preview.yaml @@ -23,14 +23,15 @@ description: | - PageService::getPublishedPageBySlug($slug, $allowUnpublished) — 허용 플래그로 미발행 반환. - PublicPageController::show — optional.sanctum 로 인증된 사용자의 hasPermission(pages.read, admin) 판정 후 플래그 전달. 권한 판정은 컨트롤러, Service 는 플래그만 수신(게시판 PostController 패턴). - - PublicPageResource::withPreview — is_preview 노출 + 첨부 컨텍스트를 admin(발행가드 없는 라우트)/ - public 로 분기. 미발행 미리보기 시 첨부 download/preview 가 admin URL 이라 첨부까지 미리보기 가능. + - PublicPageResource::withPreview — is_preview 노출. (첨부 URL 컨텍스트 분기는 별건 + page-attachment-serving.yaml 에서 공개 hash 라우트로 단일화되어 제거됨.) - page/show.json(sirsoft-basic) — is_preview=true 일 때 "미발행 페이지 미리보기 중" 배너 노출. 안전: - SEO 캐시 오염 없음: 봇(게스트)은 미발행에 여전히 404 → 미리보기 콘텐츠가 봇 캐시에 안 들어감. - 정보 노출 최소: published 전체가 아닌 is_preview(bool)만 추가 노출. - - 공개 첨부 컨트롤러 무변경: admin URL 컨텍스트 재사용으로 발행 가드·권한 자동 처리. + - 미발행 첨부 파일 다운로드 보호: 첨부 서빙은 공개 hash 라우트지만 다운로드는 권한 게이트로 + 미발행+비권한자를 404 차단(별건 page-attachment-serving.yaml). axes: viewer: [guest, member, other_admin, page_reader] @@ -43,8 +44,8 @@ effects: - other_admin_without_pages_read_gets_404_for_unpublished_page - page_reader_admin_previews_unpublished_page_with_200 - preview_response_exposes_is_preview_true_only_for_unpublished_preview - - preview_attachments_use_admin_context_url - - published_page_attachments_use_public_context_url_even_for_admin + - preview_attachments_use_public_hash_url + - published_page_attachments_use_public_hash_url_even_for_admin - preview_banner_renders_when_is_preview_true - preview_banner_hidden_for_published_or_guest diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/page-attachment-download-handler.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/page-attachment-download-handler.test.tsx new file mode 100644 index 00000000..1fc4d595 --- /dev/null +++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/page-attachment-download-handler.test.tsx @@ -0,0 +1,111 @@ +/** + * @file page-attachment-download-handler.test.tsx + * @description 사용자 페이지 상세(page/show)의 첨부 다운로드가 토큰 동반 핸들러로 전환됐는지 회귀 + * (별건: 관리자 상세 첨부 썸네일·다운로드 미노출 — 사용자 화면도 동일 표준으로 정렬). + * + * 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존). + * + * 결함: 다운로드가 브라우저 직접 링크였다. + * download_url 은 권한 게이트(미발행)를 가진 공개 라우트이며, 네비게이션에는 + * 토큰이 실리지 않아 권한 관리자의 미발행 첨부 다운로드가 404 로 막혔다. + * + * 수정: 다운로드 버튼을 downloadAttachment 핸들러(토큰 동반 fetch → blob)로 전환. + * + * @scenario surface=page_show + * @effects download_uses_handler_not_anchor + */ + +import { describe, it, expect } from 'vitest'; + +import pageShow from '../../layouts/page/show.json'; + +type Node = Record & { children?: unknown; actions?: unknown }; + +function collectNodes(node: unknown, predicate: (n: Node) => boolean): Node[] { + const result: Node[] = []; + const walk = (cur: unknown): void => { + if (Array.isArray(cur)) { + cur.forEach(walk); + return; + } + if (cur && typeof cur === 'object') { + const obj = cur as Node; + if (predicate(obj)) { + result.push(obj); + } + Object.values(obj).forEach(walk); + } + }; + walk(node); + return result; +} + +describe('별건 — 사용자 페이지 상세(page/show) 첨부 다운로드 핸들러 전환', () => { + it('첨부 다운로드는 download_url 을 가리키는 직접 링크가 아니다', () => { + const anchors = collectNodes( + pageShow, + (n) => + n.name === 'A' && + typeof (n.props as Record | undefined)?.href === 'string' && + String((n.props as Record).href).includes('download_url'), + ); + + expect(anchors.length).toBe(0); + }); + + it('첨부 다운로드는 downloadAttachment 핸들러 click 액션을 사용한다', () => { + const handlerActions = collectNodes(pageShow, (n) => { + const actions = (n.actions ?? []) as Array>; + return ( + Array.isArray(actions) && + actions.some((a) => a.type === 'click' && a.handler === 'downloadAttachment') + ); + }); + + expect(handlerActions.length).toBeGreaterThan(0); + + const node = handlerActions[0]; + const actions = (node.actions ?? []) as Array>; + const clickAction = actions.find( + (a) => a.type === 'click' && a.handler === 'downloadAttachment', + ); + const params = (clickAction?.params ?? {}) as Record; + expect(params.url).toContain('download_url'); + expect(params.filename).toContain('original_filename'); + }); +}); + +describe('별건 — 사용자 페이지 상세(page/show) 이미지 첨부 썸네일', () => { + it('이미지 첨부는 preview_url 을 src 로 하는 썸네일을 렌더한다', () => { + const thumbs = collectNodes(pageShow, (n) => { + const props = (n.props ?? {}) as Record; + return ( + n.name === 'Img' && + typeof props.src === 'string' && + String(props.src).includes('att_item') && + String(props.src).includes('preview_url') + ); + }); + + expect(thumbs.length).toBeGreaterThan(0); + }); + + it('썸네일은 is_image 조건으로만 렌더된다 (일반 파일은 아이콘)', () => { + // preview_url 를 감싸는 노드는 is_image 참일 때만 렌더되어야 한다 + const guarded = collectNodes(pageShow, (n) => { + const ifCond = typeof n.if === 'string' ? (n.if as string) : ''; + const hasImgChild = + collectNodes(n.children, (c) => { + const props = (c.props ?? {}) as Record; + return ( + c.name === 'Img' && + typeof props.src === 'string' && + String(props.src).includes('preview_url') + ); + }).length > 0; + return hasImgChild && ifCond.includes('att_item') && ifCond.includes('is_image'); + }); + + expect(guarded.length).toBeGreaterThan(0); + }); +}); diff --git a/templates/_bundled/sirsoft-basic/layouts/page/show.json b/templates/_bundled/sirsoft-basic/layouts/page/show.json index 07900042..0aaa25ce 100644 --- a/templates/_bundled/sirsoft-basic/layouts/page/show.json +++ b/templates/_bundled/sirsoft-basic/layouts/page/show.json @@ -276,10 +276,32 @@ }, "children": [ { + "comment": "이미지 썸네일 (이미지 파일인 경우) - preview_url 공개 서빙", + "type": "basic", + "name": "Div", + "if": "{{att_item.is_image}}", + "props": { + "className": "flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-gray-100 dark:bg-gray-800" + }, + "children": [ + { + "type": "basic", + "name": "Img", + "props": { + "src": "{{att_item.preview_url}}", + "alt": "{{att_item.original_filename}}", + "className": "w-full h-full object-cover" + } + } + ] + }, + { + "comment": "파일 아이콘 (일반 파일인 경우)", "type": "basic", "name": "Icon", + "if": "{{!att_item.is_image}}", "props": { - "name": "{{att_item.is_image ? 'image' : 'file'}}", + "name": "file", "className": "text-gray-400 text-sm flex-shrink-0" } }, @@ -295,11 +317,21 @@ }, { "type": "basic", - "name": "A", + "name": "Button", "props": { - "href": "{{att_item.download_url}}", - "className": "flex items-center gap-1.5 px-3 py-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline flex-shrink-0 no-underline" + "type": "button", + "className": "flex items-center gap-1.5 px-3 py-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline flex-shrink-0 bg-transparent border-0 cursor-pointer" }, + "actions": [ + { + "type": "click", + "handler": "downloadAttachment", + "params": { + "url": "{{att_item.download_url}}", + "filename": "{{att_item.original_filename}}" + } + } + ], "children": [ { "type": "basic",