fix(page): 페이지 첨부 썸네일·다운로드 미노출 수정
썸네일(<img>)·다운로드(<a>)는 브라우저 직접 GET 이라 토큰을 실을 수 없는데 커밋 d48c684 가 첨부 URL 을 auth:sanctum admin 라우트로 바꿔 401 회귀가 났다. 게시판·이커머스 표준으로 정렬한다. - 썸네일(preview): 공개 hash 서빙으로 단일화 (발행/미발행 무관 200) - 다운로드: 공개 hash 라우트 + 권한 게이트(발행=누구나 / 미발행=pages.read 관리자만) - 프론트 다운로드를 downloadAttachment 핸들러(토큰 fetch→blob)로 전환 - 다운로드 아이콘 arrow-down-tray(FA 미존재) → download - 사용자 페이지에도 이미지 첨부 썸네일 추가 (관리자 상세와 일관) - admin 첨부 preview/download 데드 라우트·메서드 제거 - iteration 안 정적 id 를 att_idx 동적 id 로 교정 (위생 정리)
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
- 페이지 등록 / 수정 화면에서 첨부파일을 한 번에 여러 개 올릴 때, 올린 순서대로 첨부가 저장되도록 정리했습니다. 이전에는 여러 파일을 동시에 올릴 때 순서가 뒤섞여 저장되는 경우가 있었습니다. 저장된 첨부의 순서 변경은 수정 화면에서 드래그로 즉시 반영됩니다.
|
||||
- 페이지를 수정하다가 새 페이지 등록 화면으로 이동했을 때, 직전 페이지의 첨부파일·제목·슬러그와 "사용 가능한 슬러그입니다" 안내가 등록 화면에 그대로 남아 보이던 문제를 수정했습니다.
|
||||
- 새 페이지 등록 화면에서 슬러그를 입력하기 전 주소 미리보기가 "/page/{{slug}}" 처럼 표시되던 문제를 수정했습니다. 이제 입력 전에는 "/page/example" 로 보이고, 입력하면 입력한 주소로 즉시 바뀝니다.
|
||||
- 관리자 화면에서 페이지 첨부파일의 다운로드 / 미리보기가 관리자 전용 주소로 열리도록 수정했습니다. 이전에는 관리자 화면에서도 공개용 주소가 사용되어 비공개 페이지의 첨부파일을 관리자가 열지 못하는 경우가 있었습니다.
|
||||
- 관리자 페이지 상세 화면에서 첨부 이미지 미리보기(썸네일)가 표시되지 않고 다운로드 버튼도 보이지 않던 문제를 수정했습니다. 이제 발행 여부와 관계없이 썸네일이 표시되고, 다운로드 버튼으로 첨부파일을 내려받을 수 있습니다. 미발행 페이지의 첨부파일 다운로드는 페이지 조회 권한이 있는 관리자에게만 허용되며, 비로그인 사용자와 일반 회원에게는 이전과 동일하게 차단됩니다.
|
||||
- 사용자 페이지 화면의 첨부파일 목록에서도 이미지 첨부는 썸네일로 미리 볼 수 있도록 했습니다. 이전에는 이미지도 파일 아이콘으로만 표시되었습니다.
|
||||
- 페이지를 수정·삭제하거나 이전 버전으로 복원한 직후 해당 페이지 상세 화면의 검색엔진용 캐시가 즉시 갱신되도록 수정했습니다. 이전에는 일부 캐시가 갱신되지 않아 변경 전 내용이 검색엔진에 남는 경우가 있었습니다.
|
||||
- 페이지 목록에서 여러 페이지를 한 번에 발행 / 미발행할 때 활동 이력에 발행 여부가 기록되지 않던 문제를 수정했습니다. 이제 일괄 처리에서도 페이지별로 발행 / 미발행 기록이 남습니다.
|
||||
- 페이지 관리 목록에서 검색 조건 '전체'로 슬러그를 검색해도 결과가 나오지 않던 문제를 수정했습니다 — '전체'는 제목과 슬러그를 함께 검색하고, '제목'을 고르면 제목만 검색됩니다.
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file admin-page-detail-attachment-download.test.tsx
|
||||
* @description 관리자 페이지 상세 첨부 카드가 토큰 동반 핸들러로 다운로드하고, 다운로드 아이콘이
|
||||
* 유효한 Font Awesome 이름을 쓰는지 회귀 (별건: 관리자 상세 첨부 썸네일·다운로드 미노출).
|
||||
*
|
||||
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존).
|
||||
*
|
||||
* 결함 A: 첨부 카드(id: "attachment_card")가 <A href="{{...download_url}}"> 브라우저 직접
|
||||
* 링크였다. 커밋 d48c684 로 download_url 이 auth:sanctum 보호 admin 라우트가 되어 <a>
|
||||
* 네비게이션에 토큰이 실리지 않아 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<string, unknown> & { 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('첨부 카드는 <A href> 직접 링크가 아니다 (토큰 미동반 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<string, unknown>;
|
||||
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<Record<string, unknown>>;
|
||||
const clickAction = actions.find((a) => a.type === 'click');
|
||||
|
||||
expect(clickAction).toBeDefined();
|
||||
expect(clickAction?.handler).toBe('downloadAttachment');
|
||||
|
||||
const params = (clickAction?.params ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown> | 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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-39
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-11
@@ -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;
|
||||
|
||||
/**
|
||||
* 공개 페이지 첨부파일 컨트롤러
|
||||
*
|
||||
* 발행된 페이지의 첨부파일 다운로드를 처리합니다.
|
||||
* 첨부파일 서빙(다운로드/미리보기)을 처리합니다. 썸네일 <img>·다운로드는 브라우저 직접
|
||||
* 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).
|
||||
*
|
||||
* 발행된 페이지의 이미지 첨부파일만 미리보기 가능합니다.
|
||||
* 썸네일 <img> 는 토큰을 실을 수 없으므로 발행/미발행 무관 공개 서빙합니다.
|
||||
* 미발행 콘텐츠의 썸네일은 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);
|
||||
|
||||
@@ -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<int, PageAttachment>|null $attachments 첨부파일 목록
|
||||
* @param string $context 'admin' 또는 'public'
|
||||
* @return array<int, self>
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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을 사용합니다.
|
||||
* 썸네일 <img>·다운로드는 브라우저 직접 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/*)로 단일화 —
|
||||
// 썸네일 <img>·다운로드는 토큰을 실을 수 없어 인증 라우트에 물리면 401 로 깨진다.
|
||||
|
||||
// 첨부파일 삭제
|
||||
Route::delete('/{id}', [PageAttachmentController::class, 'destroy'])
|
||||
|
||||
@@ -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 매트릭스로 이관.
|
||||
|
||||
// ─── 인증 차단 ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -315,13 +315,13 @@ class PageControllerTest extends FeatureTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 상세 조회 시 첨부 URL이 관리자 라우트를 가리키는지 확인
|
||||
* 관리자 상세 조회 시 첨부 URL이 공개 hash 라우트를 가리키는지 확인
|
||||
*
|
||||
* 첨부 URL이 공개 라우트(발행 가드 있음)를 가리키면 미발행 페이지의
|
||||
* 첨부가 관리자에게도 404로 차단된다. 관리자 응답은 발행 가드 없는
|
||||
* admin 라우트를 반환해야 한다.
|
||||
* 썸네일 <img>·다운로드는 토큰을 실을 수 없어 인증 라우트에 물리면 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']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 라우트인지 확인
|
||||
*
|
||||
* 썸네일 <img>·다운로드는 브라우저 직접 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을 반환한다.
|
||||
*
|
||||
* 썸네일 <img> 는 토큰을 실을 수 없으므로 공개 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);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -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])를 사용한다.
|
||||
- 썸네일 <img>·다운로드 <a> 는 토큰을 실을 수 없어 인증 라우트에 물리면 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
|
||||
|
||||
@@ -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 라우트로 바꿔, 토큰을 실을 수 없는 <img>/<a> 브라우저 직접 GET 이
|
||||
발행/미발행 무관 401 로 깨지던 회귀를 수정한다. 게시판·이커머스 표준으로 정렬.
|
||||
|
||||
서빙 정책:
|
||||
- preview(썸네일): 공개 hash 서빙. <img> 는 인증 수단이 없어 모든 모듈이 공개로 둔다.
|
||||
보안은 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 — 다운로드 <a href> → downloadAttachment 핸들러
|
||||
(토큰 fetch → blob) 전환. admin 다운로드 아이콘 arrow-down-tray(Heroicons·FA미존재) → download.
|
||||
- page/show.json — 이미지 첨부는 preview_url 썸네일(<Img>, 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건과 동일 운용).
|
||||
@@ -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
|
||||
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file page-attachment-download-handler.test.tsx
|
||||
* @description 사용자 페이지 상세(page/show)의 첨부 다운로드가 토큰 동반 핸들러로 전환됐는지 회귀
|
||||
* (별건: 관리자 상세 첨부 썸네일·다운로드 미노출 — 사용자 화면도 동일 표준으로 정렬).
|
||||
*
|
||||
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존).
|
||||
*
|
||||
* 결함: 다운로드가 <A href="{{att_item.download_url}}"> 브라우저 직접 링크였다.
|
||||
* download_url 은 권한 게이트(미발행)를 가진 공개 라우트이며, <a> 네비게이션에는
|
||||
* 토큰이 실리지 않아 권한 관리자의 미발행 첨부 다운로드가 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<string, unknown> & { 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 을 가리키는 <A href> 직접 링크가 아니다', () => {
|
||||
const anchors = collectNodes(
|
||||
pageShow,
|
||||
(n) =>
|
||||
n.name === 'A' &&
|
||||
typeof (n.props as Record<string, unknown> | undefined)?.href === 'string' &&
|
||||
String((n.props as Record<string, unknown>).href).includes('download_url'),
|
||||
);
|
||||
|
||||
expect(anchors.length).toBe(0);
|
||||
});
|
||||
|
||||
it('첨부 다운로드는 downloadAttachment 핸들러 click 액션을 사용한다', () => {
|
||||
const handlerActions = collectNodes(pageShow, (n) => {
|
||||
const actions = (n.actions ?? []) as Array<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
const clickAction = actions.find(
|
||||
(a) => a.type === 'click' && a.handler === 'downloadAttachment',
|
||||
);
|
||||
const params = (clickAction?.params ?? {}) as Record<string, unknown>;
|
||||
expect(params.url).toContain('download_url');
|
||||
expect(params.filename).toContain('original_filename');
|
||||
});
|
||||
});
|
||||
|
||||
describe('별건 — 사용자 페이지 상세(page/show) 이미지 첨부 썸네일', () => {
|
||||
it('이미지 첨부는 preview_url 을 src 로 하는 <Img> 썸네일을 렌더한다', () => {
|
||||
const thumbs = collectNodes(pageShow, (n) => {
|
||||
const props = (n.props ?? {}) as Record<string, unknown>;
|
||||
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 <Img> 를 감싸는 노드는 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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user