diff --git a/api/__tests__/image-search-insertion.test.js b/api/__tests__/image-search-insertion.test.js new file mode 100644 index 0000000..231d047 --- /dev/null +++ b/api/__tests__/image-search-insertion.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Readable } from 'node:stream' + +vi.mock('../_imageSearch.js', () => ({ + ImageSearchError: class ImageSearchError extends Error {}, + imageSearchErrorBody: vi.fn(() => ({ error: 'Search failed' })), + searchImages: vi.fn(), + embedImage: vi.fn(), + resolveKeys: vi.fn(() => ({})), +})) + +import handler from '../image-search.js' +import { searchImages, embedImage } from '../_imageSearch.js' + +const images = [ + { id: 'one', url: 'https://images.example/one.jpg', thumbnailUrl: 'https://images.example/one-thumb.jpg', provider: 'openverse' }, + { id: 'two', url: 'https://images.example/two.jpg', thumbnailUrl: 'https://images.example/two-thumb.jpg', provider: 'openverse' }, +] + +async function post(body) { + const req = Readable.from([JSON.stringify(body)]) + req.method = 'POST' + const res = { + statusCode: 200, + setHeader: vi.fn(), + end: vi.fn(), + } + await handler(req, res) + return { status: res.statusCode, body: JSON.parse(res.end.mock.calls[0][0]) } +} + +describe('manual image selection', () => { + beforeEach(() => { + vi.resetAllMocks() + searchImages.mockResolvedValue(images) + embedImage.mockResolvedValue('data:image/jpeg;base64,YWJj') + }) + + it('embeds the image actually clicked, not the first search result', async () => { + const response = await post({ query: 'apple', provider: 'openverse', limit: 20, embedSelectedUrl: images[1].url }) + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ embedded: true, candidate: images[1] }) + expect(embedImage).toHaveBeenCalledExactlyOnceWith(images[1].url) + expect(searchImages).toHaveBeenCalledWith(expect.objectContaining({ query: 'apple', provider: 'openverse' })) + }) + + it('rejects an unverified URL without downloading it (SSRF guard)', async () => { + const response = await post({ query: 'apple', provider: 'openverse', embedSelectedUrl: 'http://127.0.0.1/private' }) + expect(response.status).toBe(409) + expect(response.body.code).toBe('image_selection_expired') + expect(embedImage).not.toHaveBeenCalled() + }) + + it('can embed the provider thumbnail if the original is blocked', async () => { + embedImage.mockRejectedValueOnce(new Error('403')).mockResolvedValueOnce('data:image/jpeg;base64,YWJj') + const response = await post({ query: 'apple', provider: 'openverse', embedSelectedUrl: images[0].url }) + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ embedded: true, thumbnailFallback: true }) + expect(embedImage).toHaveBeenNthCalledWith(2, images[0].thumbnailUrl) + }) + + it('reports download failures instead of pretending insertion succeeded', async () => { + embedImage.mockRejectedValue(new Error('403')) + const response = await post({ query: 'apple', provider: 'openverse', embedSelectedUrl: images[0].url }) + expect(response.status).toBe(422) + expect(response.body.code).toBe('image_download_failed') + expect(response.body).not.toHaveProperty('dataUrl') + }) + + it('preserves ordinary image search requests', async () => { + const response = await post({ query: 'apple', provider: 'openverse' }) + expect(response.status).toBe(200) + expect(response.body.results).toEqual(images) + expect(embedImage).not.toHaveBeenCalled() + }) +}) diff --git a/api/image-search.js b/api/image-search.js index 3f21d2c..c47bab3 100644 --- a/api/image-search.js +++ b/api/image-search.js @@ -43,6 +43,36 @@ export default async function handler(req, res) { : { query }, }) + // Manual selection: only embed a URL returned by this fresh provider search. + // Never fetch an arbitrary client-supplied URL (server-side request forgery risk). + if (body.embedSelectedUrl) { + const selectedUrl = String(body.embedSelectedUrl) + const selected = results.find((result) => result.url === selectedUrl) + if (!selected) { + return json(res, 409, { + error: 'The selected image is no longer in the search results. Search again and reselect it.', + code: 'image_selection_expired', + }) + } + try { + const dataUrl = await embedImage(selected.url) + return json(res, 200, { dataUrl, embedded: true, candidate: selected }) + } catch (originalError) { + // Some providers block hotlinking the original but allow their thumbnail. + if (selected.thumbnailUrl && selected.thumbnailUrl !== selected.url) { + try { + const dataUrl = await embedImage(selected.thumbnailUrl) + return json(res, 200, { dataUrl, embedded: true, candidate: selected, thumbnailFallback: true }) + } catch { /* Preserve the original failure below. */ } + } + return json(res, 422, { + error: 'This image cannot be downloaded. Choose another search result.', + code: 'image_download_failed', + details: originalError instanceof Error ? originalError.message : 'Image download failed', + }) + } + } + // Optional single-image embed (used by the agent Phase 2) if (body.embedImage && results.length > 0) { if (results[0].needsReview) { diff --git a/src/App.tsx b/src/App.tsx index 149254d..073628f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -908,6 +908,9 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog const [imageSearchQuery, setImageSearchQuery] = useState('') const [imageSearchResults, setImageSearchResults] = useState([]) const [imageSearchLoading, setImageSearchLoading] = useState(false) + const [imageSearchApplyLoading, setImageSearchApplyLoading] = useState(false) + const [imageSearchResultsQuery, setImageSearchResultsQuery] = useState('') + const [imageSearchApplyError, setImageSearchApplyError] = useState(null) const initialAgentDraft = loadAgentDraft(set.id) const [imageAgentOpen, setImageAgentOpen] = useState(false) const [flashcardAgentMode, setFlashcardAgentMode] = useState(initialAgentDraft.mode) @@ -1741,7 +1744,9 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog backText: card.backText, }) setImageSearchQuery(intent.query) + setImageSearchResultsQuery(intent.query) setImageSearchResults(getStoredImageCandidates(card, side)) + setImageSearchApplyError(null) } setImageSearchCardId(cardId) setImageSearchSide(side) @@ -1756,6 +1761,8 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog try { setImageSearchLoading(true) + setImageSearchApplyError(null) + setImageSearchResults([]) const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 30_000) try { @@ -1783,6 +1790,7 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog } const data = await response.json() as { results?: ImageCandidate[] } const results = data.results ?? [] + setImageSearchResultsQuery(imageSearchQuery.trim()) setImageSearchResults(results) if (results.length === 0) toast.message('No images found — try a different keyword or configure a provider in Image Search Settings') } catch (error) { @@ -1798,30 +1806,65 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog } } - function handleSelectWebImage(candidate: ImageCandidate) { - if (!imageSearchCardId) return - const asset = { - ...imageAssetFromCandidate(candidate), - candidates: imageSearchResults, - } - if (imageSearchSide === 'front') { - updateCard(imageSearchCardId, { - frontImageUrl: candidate.url, - frontImage: asset, - frontImageCandidates: undefined, - frontImagePlacement: DEFAULT_IMAGE_PLACEMENT, - }) - } else { - updateCard(imageSearchCardId, { - backImageUrl: candidate.url, - backImage: asset, - backImageCandidates: undefined, - backImagePlacement: DEFAULT_IMAGE_PLACEMENT, + async function handleSelectWebImage(candidate: ImageCandidate) { + if (!imageSearchCardId || imageSearchApplyLoading) return + setImageSearchApplyLoading(true) + setImageSearchApplyError(null) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 45_000) + try { + const response = await fetch('/api/image-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + body: JSON.stringify({ + query: imageSearchResultsQuery, + intent: { query: imageSearchResultsQuery, concepts: [imageSearchResultsQuery], style: 'neutral' }, + provider: candidate.provider, + braveApiKey: imageBraveKey.trim(), + pixabayApiKey: imagePixabayKey.trim(), + pexelsApiKey: imagePexelsKey.trim(), + googleApiKey: imageGoogleKey.trim() || googleImageApiKey.trim(), + googleCx: imageGoogleCx.trim() || googleImageSearchCx.trim(), + limit: 20, + embedSelectedUrl: candidate.url, + }), }) + const data = await response.json() as { dataUrl?: string; error?: string; thumbnailFallback?: boolean } + if (!response.ok || !data.dataUrl?.startsWith('data:image/')) { + throw new Error(data.error || 'The image could not be downloaded. Choose another result.') + } + // Decode/verify and compress before persisting. Remote URLs alone can be hotlink-blocked. + const imageUrl = await compressImage(data.dataUrl, 850, 0.72) + const asset = { ...imageAssetFromCandidate(candidate, imageUrl), candidates: imageSearchResults } + if (imageSearchSide === 'front') { + updateCard(imageSearchCardId, { + frontImageUrl: imageUrl, + frontImage: asset, + frontImageCandidates: undefined, + frontImagePlacement: DEFAULT_IMAGE_PLACEMENT, + }) + } else { + updateCard(imageSearchCardId, { + backImageUrl: imageUrl, + backImage: asset, + backImageCandidates: undefined, + backImagePlacement: DEFAULT_IMAGE_PLACEMENT, + }) + } + setImageSearchOpen(false) + setImageSearchResults([]) + toast.success(data.thumbnailFallback ? 'Image inserted (thumbnail quality)' : 'Image inserted') + } catch (error) { + const message = controller.signal.aborted + ? 'Image download timed out. Choose another result.' + : error instanceof Error ? error.message : 'Could not insert this image.' + setImageSearchApplyError(message) + toast.error(message) + } finally { + clearTimeout(timeout) + setImageSearchApplyLoading(false) } - setImageSearchOpen(false) - setImageSearchResults([]) - toast.success('Image inserted') } @@ -3502,7 +3545,7 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog - + { if (!imageSearchApplyLoading) setImageSearchOpen(open) }}> Search Web Images @@ -3521,7 +3564,7 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog } }} /> - @@ -3531,6 +3574,8 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog + {imageSearchApplyError &&

{imageSearchApplyError}

} + {imageSearchApplyLoading &&

Downloading and saving image…

} {imageSearchResults.length > 0 ? (
{imageSearchResults.map((result) => ( @@ -3538,6 +3583,7 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog key={result.id} className="text-left border rounded-lg overflow-hidden hover:border-primary transition-colors" onClick={() => handleSelectWebImage(result)} + disabled={imageSearchApplyLoading || imageSearchLoading} type="button" >