From f8d82e6c19536bbd9bd7d70a72efbf7ec163aeb4 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 18 Aug 2026 12:32:34 +0800 Subject: [PATCH] fix: render prompt museum media --- .../20260818-prompt-museum-client-4f7a.md | 57 +++++ electron/api/routes/image-prompt-museum.ts | 82 +++++++- shared/image-prompt-museum.ts | 2 +- src/lib/image-prompt-museum.ts | 35 ++++ src/pages/ImagePromptMuseum/index.tsx | 72 +++++-- .../e2e/image-workspace-conversations.spec.ts | 88 ++++++++ tests/unit/image-prompt-museum-api.test.ts | 35 ++++ tests/unit/image-prompt-museum-page.test.tsx | 77 +++++++ tests/unit/image-prompt-museum-route.test.ts | 198 ++++++++++++++++-- 9 files changed, 609 insertions(+), 37 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260818-prompt-museum-client-4f7a.md diff --git a/.project-docs/30-worklog/tasks/20260818-prompt-museum-client-4f7a.md b/.project-docs/30-worklog/tasks/20260818-prompt-museum-client-4f7a.md new file mode 100644 index 0000000..586369b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260818-prompt-museum-client-4f7a.md @@ -0,0 +1,57 @@ +# Task: Fix Makelore Prompt Museum media rendering + +## Identity + +- Task ID: 20260818-prompt-museum-client-4f7a +- Mode: Feature +- Branch: codex/20260818-prompt-museum-client-4f7a-prompt-museum-client +- Worktree: D:\mk-4f7a +- Base commit: 11b19832a35477d2136c6ea953dd9c408fd84816 +- Owner: developer +- Status: Ready for integration + +## Scope + +- Permit the server-controlled Prompt Museum media URL shape in strict list/detail DTO projection. +- Add an authenticated, fixed-path Main proxy for bounded Prompt Museum raster media. +- Resolve relative media through Main in the Renderer while keeping absolute HTTPS images direct and image failures card-local. +- Preserve real attribution sources whose optional URL is missing or null without creating an undefined Renderer link. +- Cover list, detail, media, invalid-path/response, API conversion, and page success/failure behavior. + +## Intent And Constraints + +- The only relative media URL allowed is `/api/image-prompt-museum/{entry}/media/{thumbnail|number}`, with entry IDs matching `[A-Za-z0-9][A-Za-z0-9._:-]{0,127}`. +- Works Bearer credentials remain Main-owned; the existing Host API IPC JSON protocol is unchanged. +- Media is limited to 10 MiB and trusted raster MIME types; upstream payloads and errors are not exposed directly. +- Absolute credential-free HTTPS images remain directly renderable. A failed image displays a placeholder without failing its card or detail view. +- Attribution source URLs are optional: undefined is omitted, null is retained, and present strings remain strict credential-free HTTPS. + +## Outcome + +- Main now accepts controlled relative media URLs in projected cards/details and proxies only the mirrored fixed local media route with Works Bearer refresh behavior. +- Main rejects non-raster or oversized responses and returns only `dataBase64` plus normalized trusted `mimeType` for successful media. +- Renderer converts relative media responses into data URLs, validates the JSON again, and preserves direct HTTPS rendering. +- Museum images load independently; pending and failed images use an accessible placeholder and do not affect card interaction. +- Source attribution without a URL renders as plain text; valid HTTPS sources remain links. + +## Verification + +- `pnpm vitest run tests/unit/image-prompt-museum-route.test.ts tests/unit/image-prompt-museum-api.test.ts tests/unit/image-prompt-museum-page.test.tsx`: PASS, 3 files / 27 tests, including missing/null source URL projection and no-link rendering. +- `pnpm typecheck`: PASS. +- Scoped ESLint across the eight owned source/test files: PASS. +- `pnpm build:vite`: PASS; existing dynamic-import and chunk-size warnings only. +- Targeted Electron E2E `tests/e2e/image-workspace-conversations.spec.ts --grep "keeps Prompt Museum cards usable when relative media fails"`: PASS, 1/1. The local fixture returned a controlled relative thumbnail and invalid media MIME; the failed-image placeholder remained visible, the card stayed enabled, and its detail sheet opened. +- `git diff --check`: PASS; Git emitted only existing LF-to-CRLF checkout warnings. + +## Follow-ups + +- Production validation still requires a real Works account and deployed media endpoint; local automation does not prove production content availability. + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md` and Prompt Museum evidence/commitment entries during integration. +- Proposal: record that protected relative Prompt Museum media is fetched through a Main-owned bounded authenticated proxy while HTTPS CDN media remains direct. +- Evidence: focused 27 tests, typecheck, scoped lint, and Vite/Main/Preload build passed. +- Future impact: future Prompt Museum media URL or MIME additions must update the server validator, Main proxy, and Renderer validator together. +- Semantic conflicts: none identified; this implements the existing Main-owned Museum boundary. +- Human confirmation required: no for integration of this behavior; production deployment/smoke remains an external release decision. diff --git a/electron/api/routes/image-prompt-museum.ts b/electron/api/routes/image-prompt-museum.ts index 14ebdc7..6408a5d 100644 --- a/electron/api/routes/image-prompt-museum.ts +++ b/electron/api/routes/image-prompt-museum.ts @@ -14,6 +14,16 @@ const MAX_FACET_ITEMS = 256; const MAX_CATEGORIES = 32; const MAX_IMAGES = 16; const MAX_VARIABLES = 64; +const MAX_MEDIA_BYTES = 10 * 1024 * 1024; +const MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/; +const LOCAL_MEDIA_PATH_PATTERN = /^\/api\/works\/image-prompt-museum\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/media\/(thumbnail|[0-9]+)$/; +const TRUSTED_MEDIA_MIME_TYPES = new Set([ + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); type PromptMuseumRouteDependencies = { fetchImpl?: typeof fetch; @@ -22,11 +32,17 @@ type PromptMuseumRouteDependencies = { }; function isMuseumPath(pathname: string): boolean { - return pathname === LOCAL_ROOT || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname); + return pathname === LOCAL_ROOT + || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname) + || LOCAL_MEDIA_PATH_PATTERN.test(pathname); } function upstreamPath(pathname: string): string | null { if (pathname === LOCAL_ROOT) return UPSTREAM_ROOT; + const mediaMatch = LOCAL_MEDIA_PATH_PATTERN.exec(pathname); + if (mediaMatch) { + return `${UPSTREAM_ROOT}/${mediaMatch[1]}/media/${mediaMatch[2]}`; + } const entryId = pathname.slice(`${LOCAL_ROOT}/`.length); if (!entryId) return null; return `${UPSTREAM_ROOT}/${encodeURIComponent(decodeURIComponent(entryId))}`; @@ -80,6 +96,12 @@ function httpsUrl(value: unknown): string { return parsed.toString(); } +function imageUrl(value: unknown): string { + const raw = boundedString(value, 2048); + if (MEDIA_URL_PATTERN.test(raw)) return raw; + return httpsUrl(raw); +} + function nullableHttpsUrl(value: unknown): string | null | undefined { if (value === undefined) return undefined; if (value === null) return null; @@ -116,13 +138,43 @@ function boundedArray(value: unknown, maximum: number): unknown[] { function projectImage(value: unknown): Record { const image = asRecord(value); return { - url: httpsUrl(image.url), + url: imageUrl(image.url), width: positiveInteger(image.width, 32_768), height: positiveInteger(image.height, 32_768), alt: boundedString(image.alt, 500), }; } +function mediaMimeType(response: Response): string | null { + const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null; +} + +async function readBoundedMedia(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_MEDIA_BYTES) { + await response.body?.cancel().catch(() => undefined); + return null; + } + if (!response.body) return null; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_MEDIA_BYTES) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + if (size === 0) return null; + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); +} + function projectCategory(value: unknown): Record { const category = asRecord(value); const group = boundedString(category.group, 32); @@ -142,6 +194,7 @@ function projectAttribution(value: unknown): Record { const source = asRecord(attribution.source); const license = asRecord(attribution.license); const authorUrl = nullableHttpsUrl(author.url); + const sourceUrl = nullableHttpsUrl(source.url); const licenseUrl = nullableHttpsUrl(license.url); return { author: { @@ -150,7 +203,7 @@ function projectAttribution(value: unknown): Record { }, source: { name: boundedString(source.name, 300), - url: httpsUrl(source.url), + ...(sourceUrl === undefined ? {} : { url: sourceUrl }), }, license: { name: boundedString(license.name, 300), @@ -310,6 +363,7 @@ export function createImagePromptMuseumRouteHandler( } try { + const isMediaRequest = LOCAL_MEDIA_PATH_PATTERN.test(url.pathname); const token = await getAccessToken({ fetchImpl }); if (!token) { sendJson(res, 401, { @@ -326,7 +380,7 @@ export function createImagePromptMuseumRouteHandler( { method: 'GET', headers: { - Accept: 'application/json', + Accept: isMediaRequest ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' : 'application/json', Authorization: `Bearer ${accessToken}`, }, redirect: 'manual', @@ -344,12 +398,30 @@ export function createImagePromptMuseumRouteHandler( response = await request(refreshed); } - const payload = await readPayload(response); if (!response.ok) { + await response.body?.cancel().catch(() => undefined); sendSafeError(res, response.status); return true; } + if (isMediaRequest) { + const mimeType = mediaMimeType(response); + if (!mimeType) { + await response.body?.cancel().catch(() => undefined); + sendInvalidResponse(res); + return true; + } + const bytes = await readBoundedMedia(response); + if (!bytes) { + sendInvalidResponse(res); + return true; + } + sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType }); + return true; + } + + const payload = await readPayload(response); + if (payload === null) { sendInvalidResponse(res); return true; diff --git a/shared/image-prompt-museum.ts b/shared/image-prompt-museum.ts index 61255c4..f57adae 100644 --- a/shared/image-prompt-museum.ts +++ b/shared/image-prompt-museum.ts @@ -40,7 +40,7 @@ export type PromptMuseumAuthor = { export type PromptMuseumSource = { name: string; - url: string; + url?: string | null; }; export type PromptMuseumLicense = { diff --git a/src/lib/image-prompt-museum.ts b/src/lib/image-prompt-museum.ts index af0e6dd..e772170 100644 --- a/src/lib/image-prompt-museum.ts +++ b/src/lib/image-prompt-museum.ts @@ -15,6 +15,21 @@ type PromptMuseumEnvelope = { data?: T; }; +type PromptMuseumMedia = { + dataBase64: string; + mimeType: string; +}; + +const PROMPT_MUSEUM_MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/; +const PROMPT_MUSEUM_MEDIA_MIME_TYPES = new Set([ + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); +const MAX_MEDIA_BASE64_LENGTH = Math.ceil((10 * 1024 * 1024) / 3) * 4; + export class PromptMuseumApiError extends Error { readonly status: number; readonly code: string; @@ -92,3 +107,23 @@ export async function fetchPromptMuseumPage(query: PromptMuseumListQuery = {}): export async function fetchPromptMuseumEntry(entryId: string): Promise { return await requestData(`${IMAGE_PROMPT_MUSEUM_API_PATH}/${encodeURIComponent(entryId)}`); } + +export async function fetchPromptMuseumMedia(mediaUrl: string): Promise { + if (!PROMPT_MUSEUM_MEDIA_URL_PATTERN.test(mediaUrl)) { + throw new PromptMuseumApiError(400, 'PROMPT_MUSEUM_INVALID_MEDIA_URL', '提示词图片地址无效'); + } + const localPath = mediaUrl.replace('/api/image-prompt-museum/', `${IMAGE_PROMPT_MUSEUM_API_PATH}/`); + const payload = await hostApiFetch(localPath); + const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : ''; + const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : ''; + if ( + !PROMPT_MUSEUM_MEDIA_MIME_TYPES.has(mimeType) + || !dataBase64 + || dataBase64.length > MAX_MEDIA_BASE64_LENGTH + || !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64) + || dataBase64.length % 4 === 1 + ) { + throw new PromptMuseumApiError(502, 'PROMPT_MUSEUM_INVALID_MEDIA_RESPONSE', '提示词图片返回了无效数据'); + } + return `data:${mimeType};base64,${dataBase64}`; +} diff --git a/src/pages/ImagePromptMuseum/index.tsx b/src/pages/ImagePromptMuseum/index.tsx index 6edca50..b95cd1e 100644 --- a/src/pages/ImagePromptMuseum/index.tsx +++ b/src/pages/ImagePromptMuseum/index.tsx @@ -26,6 +26,7 @@ import { } from '@/components/ui/sheet'; import { fetchPromptMuseumEntry, + fetchPromptMuseumMedia, fetchPromptMuseumPage, PromptMuseumApiError, } from '@/lib/image-prompt-museum'; @@ -84,6 +85,17 @@ function errorMessage(error: unknown): string { return '获取灵感暂时不可用,请稍后再试'; } +function directHttpsUrl(value: string): string | null { + try { + const parsed = new URL(value); + return parsed.protocol === 'https:' && !parsed.username && !parsed.password + ? parsed.toString() + : null; + } catch { + return null; + } +} + function MuseumImage({ image, className, @@ -93,22 +105,52 @@ function MuseumImage({ className?: string; sizes?: string; }) { - const [failed, setFailed] = useState(false); - if (failed || !image.url) { + const directSource = directHttpsUrl(image.url); + const [failedUrl, setFailedUrl] = useState(null); + const [mediaState, setMediaState] = useState<{ + imageUrl: string; + source: string | null; + failed: boolean; + }>({ imageUrl: '', source: null, failed: false }); + + useEffect(() => { + if (directHttpsUrl(image.url)) return; + let cancelled = false; + void fetchPromptMuseumMedia(image.url).then( + (dataUrl) => { + if (!cancelled) setMediaState({ imageUrl: image.url, source: dataUrl, failed: false }); + }, + () => { + if (!cancelled) setMediaState({ imageUrl: image.url, source: null, failed: true }); + }, + ); + return () => { + cancelled = true; + }; + }, [image.url]); + + const relativeState = mediaState.imageUrl === image.url ? mediaState : null; + const source = directSource ?? relativeState?.source ?? null; + const failed = failedUrl === image.url || relativeState?.failed === true; + + if (failed || !source) { return ( -
+
); } return ( {image.alt} setFailed(true)} + onError={() => setFailedUrl(image.url)} /> ); } @@ -227,15 +269,17 @@ function AttributionBlock({ entry }: { entry: PromptMuseumEntry }) {
来源
- - {entry.attribution.source.name} - + {entry.attribution.source.url ? ( + + {entry.attribution.source.name} + + ) : entry.attribution.source.name}
diff --git a/tests/e2e/image-workspace-conversations.spec.ts b/tests/e2e/image-workspace-conversations.spec.ts index 0886eb5..c39cbb6 100644 --- a/tests/e2e/image-workspace-conversations.spec.ts +++ b/tests/e2e/image-workspace-conversations.spec.ts @@ -1,6 +1,94 @@ import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; test.describe('AI Design workspace', () => { + test('keeps Prompt Museum cards usable when relative media fails', async ({ launchElectronApp }) => { + test.setTimeout(90_000); + const app = await launchElectronApp({ + imageWorkspaceMode: 'local', + skipSetup: true, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + await page.getByTestId('ai-module-option-painting').click(); + const imageSidebar = page.getByTestId('sidebar-image-workspace'); + await expect(imageSidebar.getByTestId('sidebar-open-image-prompt-museum')).toBeVisible(); + + await app.evaluate(({ ipcMain }) => { + const image = { + url: '/api/image-prompt-museum/e2e-entry/media/thumbnail', + width: 1200, + height: 900, + alt: 'E2E museum thumbnail', + }; + const card = { + id: 'e2e-entry', + slug: 'e2e-entry', + title: '相对媒体 E2E', + summary: '验证图片失败不会阻断卡片。', + thumbnail: image, + categories: [], + model: { id: 'e2e-model', name: 'E2E Model' }, + language: 'zh-CN', + attribution: { + author: { name: 'E2E 作者' }, + source: { name: 'E2E 来源' }, + license: { name: '测试许可', attributionText: 'E2E attribution' }, + }, + publishedAt: '2026-08-18T00:00:00.000Z', + updatedAt: '2026-08-18T00:00:00.000Z', + }; + const respond = (json: unknown) => ({ + ok: true, + data: { status: 200, ok: true, json }, + }); + + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => { + const path = request.path ?? ''; + if (path === '/api/works/image-prompt-museum') { + return respond({ + success: true, + data: { + items: [card], + facets: { useCases: [], styles: [], subjects: [] }, + nextCursor: null, + total: 1, + }, + }); + } + if (path === '/api/works/image-prompt-museum/e2e-entry') { + return respond({ + success: true, + data: { + ...card, + prompt: 'Create a resilient image card.', + variables: [], + images: [image], + requiresReferenceImages: false, + }, + }); + } + if (path === '/api/works/image-prompt-museum/e2e-entry/media/thumbnail') { + return respond({ mimeType: 'text/html', dataBase64: 'PGgxPnVuc2FmZTwvaDE+' }); + } + return { ok: false, error: { message: `Unexpected E2E Host API request: ${path}` } }; + }); + }); + + await imageSidebar.getByTestId('sidebar-open-image-prompt-museum').click(); + const cardButton = page.getByRole('button', { name: '查看 相对媒体 E2E' }); + await expect(cardButton).toBeVisible(); + await expect(page.getByLabel('E2E museum thumbnail加载失败')).toBeVisible(); + await expect(cardButton).toBeEnabled(); + await cardButton.click(); + await expect(page.getByRole('button', { name: '关闭详情' })).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); + test('keeps the conversation and generation task panes full height', async ({ launchElectronApp }) => { test.setTimeout(150_000); const app = await launchElectronApp({ diff --git a/tests/unit/image-prompt-museum-api.test.ts b/tests/unit/image-prompt-museum-api.test.ts index 438bb4d..288eaf2 100644 --- a/tests/unit/image-prompt-museum-api.test.ts +++ b/tests/unit/image-prompt-museum-api.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { hostApiFetch } from '@/lib/host-api'; import { fetchPromptMuseumEntry, + fetchPromptMuseumMedia, fetchPromptMuseumPage, PromptMuseumApiError, } from '@/lib/image-prompt-museum'; @@ -65,4 +66,38 @@ describe('Prompt Museum renderer API boundary', () => { ); }); + it('loads a controlled relative media path through Main and creates a data URL', async () => { + hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'image/webp', dataBase64: 'AQID' }); + + await expect(fetchPromptMuseumMedia( + '/api/image-prompt-museum/entry-1:en/media/thumbnail', + )).resolves.toBe('data:image/webp;base64,AQID'); + expect(hostApiFetchMock).toHaveBeenCalledWith( + '/api/works/image-prompt-museum/entry-1:en/media/thumbnail', + ); + }); + + it.each([ + 'https://cdn.example/image.webp', + '/api/image-prompt-museum/../media/thumbnail', + '/api/image-prompt-museum/entry/media/original', + ])('rejects unsafe media URL %s before IPC', async (url) => { + await expect(fetchPromptMuseumMedia(url)).rejects.toMatchObject({ + status: 400, + code: 'PROMPT_MUSEUM_INVALID_MEDIA_URL', + }); + expect(hostApiFetchMock).not.toHaveBeenCalled(); + }); + + it('rejects an untrusted Main media payload', async () => { + hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'text/html', dataBase64: 'PGgxPg==' }); + + await expect(fetchPromptMuseumMedia( + '/api/image-prompt-museum/entry/media/0', + )).rejects.toMatchObject({ + status: 502, + code: 'PROMPT_MUSEUM_INVALID_MEDIA_RESPONSE', + }); + }); + }); diff --git a/tests/unit/image-prompt-museum-page.test.tsx b/tests/unit/image-prompt-museum-page.test.tsx index 13de88b..fb30179 100644 --- a/tests/unit/image-prompt-museum-page.test.tsx +++ b/tests/unit/image-prompt-museum-page.test.tsx @@ -7,6 +7,7 @@ import type { PromptMuseumEntry, PromptMuseumPage } from '../../shared/image-pro const fetchPromptMuseumPageMock = vi.hoisted(() => vi.fn()); const fetchPromptMuseumEntryMock = vi.hoisted(() => vi.fn()); +const fetchPromptMuseumMediaMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/image-prompt-museum', async (importOriginal) => { const actual = await importOriginal(); @@ -14,6 +15,7 @@ vi.mock('@/lib/image-prompt-museum', async (importOriginal) => { ...actual, fetchPromptMuseumPage: (...args: unknown[]) => fetchPromptMuseumPageMock(...args), fetchPromptMuseumEntry: (...args: unknown[]) => fetchPromptMuseumEntryMock(...args), + fetchPromptMuseumMedia: (...args: unknown[]) => fetchPromptMuseumMediaMock(...args), }; }); @@ -66,6 +68,7 @@ describe('Prompt Museum page', () => { useImagePromptMuseumStore.getState().clearPendingPrompt(); fetchPromptMuseumPageMock.mockResolvedValue(pageFixture); fetchPromptMuseumEntryMock.mockResolvedValue(entryFixture); + fetchPromptMuseumMediaMock.mockResolvedValue('data:image/webp;base64,AQID'); }); it('keeps the inspiration filters compact and below the native title bar', async () => { @@ -126,4 +129,78 @@ describe('Prompt Museum page', () => { expect.objectContaining({ cursor: 'cursor-two' }), ); }); + + it('renders source attribution as text when the server omits its optional URL', async () => { + fetchPromptMuseumEntryMock.mockResolvedValueOnce({ + ...entryFixture, + attribution: { + ...entryFixture.attribution, + source: { name: 'PromptHero' }, + }, + }); + + render( + + + } /> + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '查看 编辑感产品海报' })); + expect(await screen.findByText('PromptHero')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'PromptHero' })).not.toBeInTheDocument(); + }); + + it('loads relative media through Main and renders its data URL', async () => { + fetchPromptMuseumPageMock.mockResolvedValueOnce({ + ...pageFixture, + items: [{ + ...pageFixture.items[0], + thumbnail: { + ...pageFixture.items[0].thumbnail, + url: '/api/image-prompt-museum/prompt-one/media/thumbnail', + }, + }], + }); + + render( + + + } /> + + , + ); + + const image = await screen.findByRole('img', { name: '编辑感产品海报示例' }); + expect(image).toHaveAttribute('src', 'data:image/webp;base64,AQID'); + expect(fetchPromptMuseumMediaMock).toHaveBeenCalledWith( + '/api/image-prompt-museum/prompt-one/media/thumbnail', + ); + }); + + it('keeps a card usable when its relative image fails', async () => { + fetchPromptMuseumMediaMock.mockRejectedValueOnce(new Error('image unavailable')); + fetchPromptMuseumPageMock.mockResolvedValueOnce({ + ...pageFixture, + items: [{ + ...pageFixture.items[0], + thumbnail: { + ...pageFixture.items[0].thumbnail, + url: '/api/image-prompt-museum/prompt-one/media/thumbnail', + }, + }], + }); + + render( + + + } /> + + , + ); + + expect(await screen.findByLabelText('编辑感产品海报示例加载失败')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '查看 编辑感产品海报' })).toBeEnabled(); + }); }); diff --git a/tests/unit/image-prompt-museum-route.test.ts b/tests/unit/image-prompt-museum-route.test.ts index 4d6f143..bd21aa4 100644 --- a/tests/unit/image-prompt-museum-route.test.ts +++ b/tests/unit/image-prompt-museum-route.test.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createImagePromptMuseumRouteHandler } from '@electron/api/routes/image-prompt-museum'; +import type { PromptMuseumCard } from '../../shared/image-prompt-museum'; function createResponse() { const chunks: string[] = []; @@ -19,6 +20,26 @@ function createResponse() { }; } +function card(imageUrl = '/api/image-prompt-museum/prompt-one/media/thumbnail'): PromptMuseumCard { + return { + id: 'prompt-one', + slug: 'prompt-one', + title: '示例', + summary: '示例摘要', + thumbnail: { url: imageUrl, width: 1200, height: 900, alt: '示例' }, + categories: [], + model: { id: 'image-model', name: 'Image Model' }, + language: 'zh-CN', + attribution: { + author: { name: '作者' }, + source: { name: '来源', url: 'https://example.com/source' }, + license: { name: 'CC BY 4.0', attributionText: '作者 / 来源 / CC BY 4.0' }, + }, + publishedAt: '2026-08-01T00:00:00Z', + updatedAt: '2026-08-01T00:00:00Z', + }; +} + describe('Prompt Museum Main route boundary', () => { const fetchImpl = vi.fn(); const getAccessToken = vi.fn(); @@ -74,6 +95,165 @@ describe('Prompt Museum Main route boundary', () => { expect(response.json).toMatchObject({ success: true }); }); + it('accepts controlled relative media URLs in list and detail DTOs', async () => { + getAccessToken.mockResolvedValue('works-token'); + fetchImpl + .mockResolvedValueOnce(new Response(JSON.stringify({ + success: true, + data: { + items: [card()], + facets: { useCases: [], styles: [], subjects: [] }, + nextCursor: null, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + success: true, + data: { + ...card(), + prompt: 'Create an image', + variables: [], + images: [{ url: '/api/image-prompt-museum/prompt-one/media/0', width: 1200, height: 900, alt: '详情' }], + requiresReferenceImages: false, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); + const listResponse = createResponse(); + const detailResponse = createResponse(); + + await handler( + { method: 'GET' } as IncomingMessage, + listResponse.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum'), + {} as never, + ); + await handler( + { method: 'GET' } as IncomingMessage, + detailResponse.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one'), + {} as never, + ); + + expect(listResponse.json).toMatchObject({ + success: true, + data: { items: [{ thumbnail: { url: '/api/image-prompt-museum/prompt-one/media/thumbnail' } }] }, + }); + expect(detailResponse.json).toMatchObject({ + success: true, + data: { images: [{ url: '/api/image-prompt-museum/prompt-one/media/0' }] }, + }); + }); + + it('accepts list attribution sources with a missing or null optional URL', async () => { + getAccessToken.mockResolvedValue('works-token'); + const itemWithoutUrl = card(); + itemWithoutUrl.attribution.source = { name: 'PromptHero' }; + const itemWithNullUrl = card(); + itemWithNullUrl.id = 'prompt-two'; + itemWithNullUrl.attribution.source = { name: 'Community archive', url: null }; + fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({ + success: true, + data: { + items: [itemWithoutUrl, itemWithNullUrl], + facets: { useCases: [], styles: [], subjects: [] }, + nextCursor: null, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); + const response = createResponse(); + + await handler( + { method: 'GET' } as IncomingMessage, + response.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum'), + {} as never, + ); + + expect(response.res.statusCode).toBe(200); + expect(response.json).toMatchObject({ + success: true, + data: { items: [ + { attribution: { source: { name: 'PromptHero' } } }, + { attribution: { source: { name: 'Community archive', url: null } } }, + ] }, + }); + const sources = (response.json.data as { items: Array<{ attribution: { source: unknown } }> }) + .items.map((item) => item.attribution.source); + expect(sources).toEqual([ + { name: 'PromptHero' }, + { name: 'Community archive', url: null }, + ]); + }); + + it('proxies a fixed media path with Bearer auth and returns only bounded raster data', async () => { + getAccessToken.mockResolvedValue('works-token'); + fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { 'Content-Type': 'image/png; charset=binary', 'Content-Length': '3' }, + })); + const handler = createImagePromptMuseumRouteHandler({ + fetchImpl, + getAccessToken, + apiBaseUrl: 'https://square.example', + }); + const response = createResponse(); + + await handler( + { method: 'GET' } as IncomingMessage, + response.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/entry-1:en/media/thumbnail'), + {} as never, + ); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://square.example/api/image-prompt-museum/entry-1:en/media/thumbnail', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer works-token' }), + redirect: 'manual', + }), + ); + expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/png' }); + }); + + it.each([ + ['non-image media', { 'Content-Type': 'text/html' }], + ['oversized media', { 'Content-Type': 'image/webp', 'Content-Length': String(10 * 1024 * 1024 + 1) }], + ])('rejects %s responses', async (_name, headers) => { + getAccessToken.mockResolvedValue('works-token'); + fetchImpl.mockResolvedValue(new Response('unsafe', { status: 200, headers })); + const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); + const response = createResponse(); + + await handler( + { method: 'GET' } as IncomingMessage, + response.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/0'), + {} as never, + ); + + expect(response.res.statusCode).toBe(502); + expect(response.json).toMatchObject({ code: 'PROMPT_MUSEUM_INVALID_RESPONSE' }); + }); + + it('does not claim unsafe or unknown media paths', async () => { + const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); + const unsafeResponse = createResponse(); + const unknownResponse = createResponse(); + + await expect(handler( + { method: 'GET' } as IncomingMessage, + unsafeResponse.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/../media/thumbnail'), + {} as never, + )).resolves.toBe(false); + await expect(handler( + { method: 'GET' } as IncomingMessage, + unknownResponse.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/original'), + {} as never, + )).resolves.toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('returns a stable auth error without calling the upstream service', async () => { getAccessToken.mockResolvedValue(null); const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); @@ -215,23 +395,7 @@ describe('Prompt Museum Main route boundary', () => { fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: { - items: [{ - id: 'prompt-one', - slug: 'prompt-one', - title: '示例', - summary: '示例摘要', - thumbnail: { url: 'http://internal.example/secret.png', width: 1200, height: 900, alt: '示例' }, - categories: [], - model: { id: 'image-model', name: 'Image Model' }, - language: 'zh-CN', - attribution: { - author: { name: '作者' }, - source: { name: '来源', url: 'https://example.com/source' }, - license: { name: 'CC BY 4.0', attributionText: '作者 / 来源 / CC BY 4.0' }, - }, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-01T00:00:00Z', - }], + items: [card('http://internal.example/secret.png')], facets: { useCases: [], styles: [], subjects: [] }, nextCursor: null, },