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:
chym1217
2026-07-06 09:37:06 +09:00
parent 98989e3c2c
commit b98a536162
18 changed files with 622 additions and 172 deletions
+2 -1
View File
@@ -12,7 +12,8 @@
- 페이지 등록 / 수정 화면에서 첨부파일을 한 번에 여러 개 올릴 때, 올린 순서대로 첨부가 저장되도록 정리했습니다. 이전에는 여러 파일을 동시에 올릴 때 순서가 뒤섞여 저장되는 경우가 있었습니다. 저장된 첨부의 순서 변경은 수정 화면에서 드래그로 즉시 반영됩니다.
- 페이지를 수정하다가 새 페이지 등록 화면으로 이동했을 때, 직전 페이지의 첨부파일·제목·슬러그와 "사용 가능한 슬러그입니다" 안내가 등록 화면에 그대로 남아 보이던 문제를 수정했습니다.
- 새 페이지 등록 화면에서 슬러그를 입력하기 전 주소 미리보기가 "/page/{{slug}}" 처럼 표시되던 문제를 수정했습니다. 이제 입력 전에는 "/page/example" 로 보이고, 입력하면 입력한 주소로 즉시 바뀝니다.
- 관리자 화면에서 페이지 첨부파일의 다운로드 / 미리보기가 관리자 전용 주소로 열리도록 수정했습니다. 이전에는 관리자 화면에서도 공개용 주소가 사용되어 비공개 페이지의 첨부파일을 관리자가 열지 못하는 경우가 있었습니다.
- 관리자 페이지 상세 화면에서 첨부 이미지 미리보기(썸네일)가 표시되지 않고 다운로드 버튼도 보이지 않던 문제를 수정했습니다. 이제 발행 여부와 관계없이 썸네일이 표시되고, 다운로드 버튼으로 첨부파일을 내려받을 수 있습니다. 미발행 페이지의 첨부파일 다운로드는 페이지 조회 권한이 있는 관리자에게만 허용되며, 비로그인 사용자와 일반 회원에게는 이전과 동일하게 차단됩니다.
- 사용자 페이지 화면의 첨부파일 목록에서도 이미지 첨부는 썸네일로 미리 볼 수 있도록 했습니다. 이전에는 이미지도 파일 아이콘으로만 표시되었습니다.
- 페이지를 수정·삭제하거나 이전 버전으로 복원한 직후 해당 페이지 상세 화면의 검색엔진용 캐시가 즉시 갱신되도록 수정했습니다. 이전에는 일부 캐시가 갱신되지 않아 변경 전 내용이 검색엔진에 남는 경우가 있었습니다.
- 페이지 목록에서 여러 페이지를 한 번에 발행 / 미발행할 때 활동 이력에 발행 여부가 기록되지 않던 문제를 수정했습니다. 이제 일괄 처리에서도 페이지별로 발행 / 미발행 기록이 남습니다.
- 페이지 관리 목록에서 검색 조건 '전체'로 슬러그를 검색해도 결과가 나오지 않던 문제를 수정했습니다 — '전체'는 제목과 슬러그를 함께 검색하고, '제목'을 고르면 제목만 검색됩니다.
@@ -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"
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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
@@ -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",