fix(desktop): load local images in the web UI and H5

A bare <img src> cannot send Authorization, and the server refuses a
credential-less cross-site subresource load, so the browser blocked the
response (net::ERR_BLOCKED_BY_ORB) and chat showed "unable to load
image". Only the Electron shell worked, because its main process injects
the credential for an allowlist of media routes.

When an <img> fails, retry once through the credentialed client and show
the result as a blob: URL; the failure notice appears only if that also
fails. Covers the inline image gallery, the lightbox, image generation
slots and Markdown images. The credential is only ever sent to the local
server's own origin.
This commit is contained in:
程序员阿江(Relakkes)
2026-10-01 15:14:25 +08:00
parent f12a3e71ec
commit 6dddf94db8
11 changed files with 462 additions and 19 deletions
@@ -0,0 +1,68 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
vi.mock('../../lib/authedImage', () => ({ fetchServerImageBlobUrl }))
import { AuthedImage } from './AuthedImage'
beforeEach(() => {
fetchServerImageBlobUrl.mockReset()
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
})
describe('AuthedImage', () => {
it('shows the bare URL until it fails', () => {
render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" />)
expect(screen.getByRole('img')).toHaveAttribute('src', 'http://127.0.0.1:1/a.png')
expect(fetchServerImageBlobUrl).not.toHaveBeenCalled()
})
it('swaps in the authenticated copy without reporting a failure', async () => {
fetchServerImageBlobUrl.mockResolvedValue('blob:x')
const onFailure = vi.fn()
render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" onFailure={onFailure} />)
fireEvent.error(screen.getByRole('img'))
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:x'))
expect(onFailure).not.toHaveBeenCalled()
})
it('reports a failure only after the authenticated attempt fails too', async () => {
fetchServerImageBlobUrl.mockRejectedValue(new Error('403'))
const onFailure = vi.fn()
render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" onFailure={onFailure} />)
fireEvent.error(screen.getByRole('img'))
await waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1))
})
it('does not apply a result that arrives after the image is gone, and frees it', async () => {
let finish!: (url: string) => void
fetchServerImageBlobUrl.mockReturnValue(new Promise<string>((resolve) => { finish = resolve }))
const { unmount } = render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" />)
fireEvent.error(screen.getByRole('img'))
unmount()
finish('blob:late')
await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:late'))
})
it('treats a new src as a new image: it gets its own retry', async () => {
fetchServerImageBlobUrl.mockResolvedValueOnce('blob:one').mockResolvedValueOnce('blob:two')
const { rerender } = render(<AuthedImage src="http://127.0.0.1:1/one.png" alt="a" />)
fireEvent.error(screen.getByRole('img'))
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:one'))
rerender(<AuthedImage src="http://127.0.0.1:1/two.png" alt="a" />)
expect(screen.getByRole('img')).toHaveAttribute('src', 'http://127.0.0.1:1/two.png')
fireEvent.error(screen.getByRole('img'))
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:two'))
})
})
@@ -0,0 +1,13 @@
import type { ImgHTMLAttributes } from 'react'
import { useAuthedImageFallback } from '../../lib/useAuthedImageFallback'
type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, 'onError'> & {
/** Runs once the image has failed even with the app's credential. */
onFailure?: () => void
}
/** An `<img>` for a local-server URL that also loads where a bare request is refused (web UI, H5). */
export function AuthedImage({ src, onFailure, alt = '', ...rest }: Props) {
const image = useAuthedImageFallback(src, onFailure)
return <img {...rest} alt={alt} src={image.src} onError={image.onError} />
}
@@ -2,7 +2,9 @@ import { useEffect } from 'react'
import { ExternalLink, X } from 'lucide-react'
import { IconButton } from '@/components/ui/IconButton'
import { Modal } from '@/components/ui/Modal'
import { ZoomableImage } from '@/components/ui/ZoomableImage'
import { ZoomableImage, type ZoomableImageProps } from '@/components/ui/ZoomableImage'
import { useAuthedImageFallback } from '../../lib/useAuthedImageFallback'
import { AuthedImage } from './AuthedImage'
import { getDesktopHost } from '@/lib/desktopHost'
import { isRootedLocalPath } from '@/lib/handlePreviewLink'
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
@@ -27,6 +29,12 @@ type Props = {
onSelect: (index: number) => void
}
/** The lightbox picture, which also loads where a bare request is refused (web UI, H5). */
function AuthedZoomableImage({ src, onError, ...props }: ZoomableImageProps) {
const image = useAuthedImageFallback(src, onError)
return <ZoomableImage {...props} src={image.src ?? src} onError={image.onError} />
}
export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect }: Props) {
const t = useTranslation()
const activeImage = images[activeIndex]
@@ -85,7 +93,7 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
</div>
<div className="relative flex min-h-0 flex-1 flex-col px-4 pb-4">
<ZoomableImage
<AuthedZoomableImage
// The size it measures belongs to one picture, and so does the zoom.
key={activeImage.src}
src={activeImage.src}
@@ -154,7 +162,7 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
: 'border-[var(--color-media-border)] opacity-55 hover:opacity-90'
}`}
>
<img src={image.src} alt={image.name} className="h-12 w-12 object-cover" />
<AuthedImage src={image.src} alt={image.name} className="h-12 w-12 object-cover" />
</button>
))}
</div>
@@ -4,6 +4,7 @@ import { ImageIcon, Maximize2, TriangleAlert } from 'lucide-react'
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton'
import { useTranslation } from '@/i18n'
import { localImageFileUrl } from '@/lib/attachmentImages'
import { AuthedImage } from './AuthedImage'
import { ImageGalleryModal } from './ImageGalleryModal'
type GeneratedImage = {
@@ -186,7 +187,7 @@ function ImageGenerationCollection({
onClick={() => setActiveIndex(slot.galleryIndex!)}
className="group/image relative aspect-square w-full overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border-separator)] bg-[var(--color-surface-container-low)] text-left shadow-[var(--shadow-card)] transition-[border-color,box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:border-[var(--color-outline)] hover:shadow-[var(--shadow-composer)] active:translate-y-0 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<img
<AuthedImage
src={src}
alt={t('tool.generatedImageAlt', { index: slot.galleryIndex + 1 })}
loading="lazy"
@@ -14,19 +14,30 @@ vi.mock('../../lib/desktopRuntime', () => ({
getServerBaseUrl: () => 'http://127.0.0.1:4321',
}))
// The authenticated fallback an <img> error falls back to. It rejects by default,
// which is what a missing or denied file does, so the failure notice shows.
const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
vi.mock('../../lib/authedImage', () => ({ fetchServerImageBlobUrl }))
import { InlineImageGallery } from './InlineImageGallery'
beforeEach(() => {
fetchServerImageBlobUrl.mockReset().mockRejectedValue(new Error('403'))
// jsdom ships no object-URL support.
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
})
function imgSrcs(): string[] {
return screen.getAllByRole('img').map((img) => (img as HTMLImageElement).getAttribute('src') ?? '')
}
describe('InlineImageGallery', () => {
it('shows a failed image notice and filename instead of hiding the gallery entry', () => {
it('shows a failed image notice and filename instead of hiding the gallery entry', async () => {
render(<InlineImageGallery text="See E:/test/denied.png" />)
fireEvent.error(screen.getByRole('img'))
const notice = screen.getByRole('alert')
const notice = await screen.findByRole('alert')
expect(notice).toBeVisible()
expect(notice).toHaveTextContent('Unable to load image')
expect(notice).toHaveTextContent('denied.png')
@@ -34,9 +45,10 @@ describe('InlineImageGallery', () => {
expect(screen.getByRole('button', { name: 'Retry' })).toBeVisible()
})
it('keeps other images usable and tracks failures by source when the list changes', () => {
it('keeps other images usable and tracks failures by source when the list changes', async () => {
const { rerender } = render(<InlineImageGallery text="See /tmp/denied.png and /tmp/allowed.png" />)
fireEvent.error(screen.getByRole('img', { name: 'denied.png' }))
await screen.findByRole('alert')
expect(screen.getByRole('img', { name: 'allowed.png' })).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: /allowed.png/ }))
@@ -49,21 +61,74 @@ describe('InlineImageGallery', () => {
expect(screen.queryByRole('img', { name: 'denied.png' })).not.toBeInTheDocument()
})
it('retries the same protected URL and keeps feedback if the retry fails', () => {
it('retries the same protected URL and keeps feedback if the retry fails', async () => {
render(<InlineImageGallery text="See /tmp/denied.png" />)
const source = screen.getByRole('img').getAttribute('src')
fireEvent.error(screen.getByRole('img'))
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
fireEvent.click(await screen.findByRole('button', { name: 'Retry' }))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('src', source)
fireEvent.error(screen.getByRole('img'))
expect(screen.getByRole('alert')).toBeVisible()
expect(await screen.findByRole('alert')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
fireEvent.load(screen.getByRole('img'))
expect(screen.getByRole('img')).toBeVisible()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('falls back to an authenticated fetch when the bare <img> is refused, as in the web UI', async () => {
fetchServerImageBlobUrl.mockResolvedValue('blob:http://localhost/chart')
render(<InlineImageGallery text="See /tmp/chart.png" />)
const source = screen.getByRole('img').getAttribute('src')!
fireEvent.error(screen.getByRole('img'))
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:http://localhost/chart'))
expect(fetchServerImageBlobUrl).toHaveBeenCalledWith(source)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('retries the full-size view with the credential too when the lightbox picture is refused', async () => {
render(<InlineImageGallery text="See /tmp/chart.png" />)
fireEvent.click(screen.getByRole('button', { name: /chart.png/ }))
const dialog = screen.getByRole('dialog')
const refused = dialog.querySelector('img')!.getAttribute('src')
fetchServerImageBlobUrl.mockResolvedValue('blob:http://localhost/chart-large')
fireEvent.error(dialog.querySelector('img')!)
await waitFor(() => expect(dialog.querySelector('img')).toHaveAttribute('src', 'blob:http://localhost/chart-large'))
expect(fetchServerImageBlobUrl).toHaveBeenCalledWith(refused)
})
it('tries the authenticated fetch only once per image: a broken blob is a real failure', async () => {
fetchServerImageBlobUrl.mockResolvedValue('blob:http://localhost/broken')
render(<InlineImageGallery text="See /tmp/broken.png" />)
fireEvent.error(screen.getByRole('img'))
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:http://localhost/broken'))
fireEvent.error(screen.getByRole('img'))
expect(await screen.findByRole('alert')).toHaveTextContent('broken.png')
expect(fetchServerImageBlobUrl).toHaveBeenCalledTimes(1)
})
it('ignores a late authenticated result that belongs to the previous session', async () => {
let finish!: (url: string) => void
fetchServerImageBlobUrl.mockReturnValue(new Promise<string>((resolve) => { finish = resolve }))
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const { rerender } = render(<InlineImageGallery text="See /tmp/chart.png" sessionId="old" workDir="/tmp/old" />)
const source = screen.getByRole('img').getAttribute('src')
fireEvent.error(screen.getByRole('img'))
rerender(<InlineImageGallery text="See /tmp/chart.png" sessionId="new" workDir="/tmp/old" />)
finish('blob:http://localhost/late')
await waitFor(() => expect(revoke).toHaveBeenCalledWith('blob:http://localhost/late'))
expect(screen.getByRole('img')).toHaveAttribute('src', source)
revoke.mockRestore()
})
it.each([
{ sessionId: 'new-session', workDir: '/tmp/old' },
{ sessionId: 'old-session', workDir: '/tmp/new' },
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'
import { ErrorState } from '@/components/ui/ErrorState'
import { useTranslation } from '@/i18n'
import { AuthedImage } from './AuthedImage'
import { ImageGalleryModal } from './ImageGalleryModal'
import { isManagedGeneratedImagePath, localImageFileUrl } from '../../lib/attachmentImages'
import {
@@ -184,22 +185,20 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
/>
) : (
<button
key={img.src}
key={`${sessionId ?? ''}|${workDir ?? ''}|${img.src}`}
type="button"
onClick={() => setActiveIndex(i)}
className="group/image relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-[var(--shadow-card)] transition-[border-color,box-shadow] duration-150 hover:shadow-[var(--shadow-composer)] hover:border-[var(--color-primary-fixed-dim)]"
>
<img
<AuthedImage
src={img.src}
alt={img.name}
loading="lazy"
className="w-full object-cover"
style={{ maxHeight: images.length === 1 ? 400 : 240 }}
onError={() => {
// img errors expose no HTTP status: a denied, missing or invalid
// image needs visible feedback without claiming a specific cause.
setFailureState((previous) => ({ ...previous, sources: new Set(previous.sources).add(img.src) }))
}}
// img errors expose no HTTP status: a denied, missing or invalid
// image needs visible feedback without claiming a specific cause.
onFailure={() => setFailureState((previous) => ({ ...previous, sources: new Set(previous.sources).add(img.src) }))}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all group-hover/image:bg-black/20 group-hover/image:opacity-100">
<span className="material-symbols-outlined rounded-full bg-white/90 p-2 text-[20px] text-[var(--color-text-primary)] shadow-lg">
@@ -0,0 +1,57 @@
import '@testing-library/jest-dom'
import { fireEvent, render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiGetBlob = vi.hoisted(() => vi.fn())
vi.mock('../../api/client', async (original) => ({
...(await original<Record<string, unknown>>()),
apiGetBlob,
getBaseUrl: () => 'http://127.0.0.1:3456',
}))
import { MarkdownRenderer } from './MarkdownRenderer'
const LOCAL = 'http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fchart.png'
beforeEach(() => {
apiGetBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:http://localhost/chart'), configurable: true, writable: true })
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
})
describe('MarkdownRenderer local images', () => {
it('retries a refused local image with the app credential', async () => {
const { container } = render(
<MarkdownRenderer content="![chart](chart.png)" resolveImageSrc={() => LOCAL} />,
)
const image = container.querySelector('img')!
expect(image).toHaveAttribute('src', LOCAL)
fireEvent.error(image)
await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', 'blob:http://localhost/chart'))
expect(apiGetBlob).toHaveBeenCalledWith('/api/filesystem/file?path=%2Ftmp%2Fchart.png')
})
it('also covers a document with code blocks, which render in separate parts', async () => {
const { container } = render(
<MarkdownRenderer content={'![chart](chart.png)\n\n```ts\nconst a = 1\n```'} resolveImageSrc={() => LOCAL} />,
)
fireEvent.error(container.querySelector('img')!)
await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', 'blob:http://localhost/chart'))
})
it('never sends the credential for a remote image', async () => {
const { container } = render(
<MarkdownRenderer content="![cat](https://example.com/cat.png)" resolveImageSrc={(src) => src} />,
)
fireEvent.error(container.querySelector('img')!)
await Promise.resolve()
expect(apiGetBlob).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,4 @@
import { memo, useMemo, useCallback } from 'react'
import { memo, useMemo, useCallback, useRef } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import DOMPurify from 'dompurify'
import katex from 'katex'
@@ -20,6 +20,7 @@ import { isSafeMarkdownImageSource, normalizeMarkdownImageDestination } from '@/
import { CodeViewer } from '../chat/CodeViewer'
import { MermaidRenderer } from '../chat/MermaidRenderer'
import { copyTextToClipboard } from '@/lib/clipboard'
import { attachAuthedImageFallback } from '@/lib/authedImage'
import { t } from '../../i18n'
type Props = {
@@ -715,9 +716,18 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
}, 1500)
}, [onImageClick, onLinkClick])
// Local images the server refuses to hand to a bare <img> (web UI, H5) get one
// authenticated retry; a callback ref keeps the listener on whichever div renders.
const detachImageFallback = useRef<(() => void) | null>(null)
const imageFallbackRef = useCallback((node: HTMLDivElement | null) => {
detachImageFallback.current?.()
detachImageFallback.current = node ? attachAuthedImageFallback(node) : null
}, [])
if (codeBlocks.length === 0) {
return (
<div
ref={imageFallbackRef}
className={proseClasses}
dangerouslySetInnerHTML={{ __html: parts[0]?.type === 'html' ? parts[0].content : '' }}
onClick={handleClick}
@@ -726,7 +736,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
}
return (
<div className={proseClasses} onClick={handleClick}>
<div ref={imageFallbackRef} className={proseClasses} onClick={handleClick}>
{parts.map((part, i) =>
part.type === 'html' ? (
<div key={i} dangerouslySetInnerHTML={{ __html: part.content }} />
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const apiGetBlob = vi.hoisted(() => vi.fn())
vi.mock('../api/client', () => ({
apiGetBlob,
getBaseUrl: () => 'http://127.0.0.1:3456',
}))
import { attachAuthedImageFallback, fetchServerImageBlobUrl } from './authedImage'
beforeEach(() => {
apiGetBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:http://localhost/x'), configurable: true, writable: true })
})
afterEach(() => {
Reflect.deleteProperty(URL, 'createObjectURL')
})
describe('fetchServerImageBlobUrl', () => {
it('fetches a local-server image through the credentialed client and returns an object URL', async () => {
const src = `http://127.0.0.1:3456/api/filesystem/file?path=${encodeURIComponent('/tmp/fti work/chart.png')}`
await expect(fetchServerImageBlobUrl(src)).resolves.toBe('blob:http://localhost/x')
// Path and query only: the client prepends its own base URL and credential.
expect(apiGetBlob).toHaveBeenCalledWith(`/api/filesystem/file?path=${encodeURIComponent('/tmp/fti work/chart.png')}`)
})
it('serves a session preview URL the same way', async () => {
await fetchServerImageBlobUrl('http://127.0.0.1:3456/preview-fs/s1/out/frame.png')
expect(apiGetBlob).toHaveBeenCalledWith('/preview-fs/s1/out/frame.png')
})
it.each([
'https://example.com/cat.png',
'http://127.0.0.1:9999/api/filesystem/file?path=%2Fetc%2Fhosts',
'http://localhost:3456/api/filesystem/file?path=%2Ftmp%2Fa.png',
])('never sends the credential to another origin (%s)', async (src) => {
await expect(fetchServerImageBlobUrl(src)).rejects.toThrow('Not a local-server image URL')
expect(apiGetBlob).not.toHaveBeenCalled()
})
it('propagates a refusal so the caller can show the failure notice', async () => {
apiGetBlob.mockRejectedValue(new Error('403'))
await expect(fetchServerImageBlobUrl('http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fa.png')).rejects.toThrow('403')
})
})
describe('attachAuthedImageFallback', () => {
const localSrc = 'http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fa.png'
function mount(src: string) {
const container = document.createElement('div')
container.innerHTML = `<p><img alt="a" src="${src}"></p>`
document.body.appendChild(container)
const detach = attachAuthedImageFallback(container)
return { container, image: container.querySelector('img')!, detach }
}
beforeEach(() => {
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
})
it('swaps a refused local image for an authenticated copy', async () => {
const { image, detach } = mount(localSrc)
image.dispatchEvent(new Event('error'))
await vi.waitFor(() => expect(image.getAttribute('src')).toBe('blob:http://localhost/x'))
detach()
})
it('retries each image only once, so a broken body does not loop', async () => {
const { image, detach } = mount(localSrc)
image.dispatchEvent(new Event('error'))
await vi.waitFor(() => expect(image.getAttribute('src')).toBe('blob:http://localhost/x'))
image.dispatchEvent(new Event('error'))
expect(apiGetBlob).toHaveBeenCalledTimes(1)
detach()
})
it.each(['https://example.com/cat.png', 'data:image/png;base64,AAAA'])('leaves %s alone', async (src) => {
const { image, detach } = mount(src)
image.dispatchEvent(new Event('error'))
await Promise.resolve()
expect(apiGetBlob).not.toHaveBeenCalled()
expect(image.getAttribute('src')).toBe(src)
detach()
})
it('frees its object URLs and stops listening once detached', async () => {
const { image, detach } = mount(localSrc)
image.dispatchEvent(new Event('error'))
await vi.waitFor(() => expect(image.getAttribute('src')).toBe('blob:http://localhost/x'))
detach()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/x')
const other = document.createElement('img')
other.setAttribute('src', localSrc)
image.parentElement!.appendChild(other)
other.dispatchEvent(new Event('error'))
expect(apiGetBlob).toHaveBeenCalledTimes(1)
})
})
+58
View File
@@ -0,0 +1,58 @@
import { apiGetBlob, getBaseUrl } from '../api/client'
/**
* Fetch a local-server image with the app's credential and hand back a `blob:` URL.
*
* A bare `<img src>` cannot send `Authorization`, and the server refuses a
* credential-less cross-site subresource load (it cannot tell the page from a
* hostile one). The packaged desktop shell works around that by injecting the
* header for an allowlist of media routes; a plain browser tab — the web UI, a LAN
* or remote H5 client — has no such hook, so its images go through this fetch.
*
* Only URLs on the local server's own origin are fetched: the credential must not
* follow an arbitrary image URL to another host.
*/
export async function fetchServerImageBlobUrl(src: string): Promise<string> {
const base = new URL(getBaseUrl())
const target = new URL(src, base)
if (target.origin !== base.origin) throw new Error('Not a local-server image URL')
const blob = await apiGetBlob(`${target.pathname}${target.search}`)
return URL.createObjectURL(blob)
}
/**
* The same fallback for images that arrive as sanitized HTML (rendered Markdown),
* where there is no component to hang `onError` on: one capturing listener on the
* container retries a failed `<img>` per element, once. Returns the detach function,
* which also frees the object URLs it handed out.
*/
export function attachAuthedImageFallback(container: HTMLElement): () => void {
const objectUrls: string[] = []
let detached = false
const onError = (event: Event) => {
const image = event.target
if (!(image instanceof HTMLImageElement) || image.dataset.authedFallback) return
const src = image.getAttribute('src')
if (!src || src.startsWith('blob:') || src.startsWith('data:')) return
image.dataset.authedFallback = '1'
void fetchServerImageBlobUrl(src).then((url) => {
if (detached) {
URL.revokeObjectURL(url)
return
}
objectUrls.push(url)
image.src = url
}).catch(() => {
// Not a local-server image, or refused: the browser's own broken-image state stands.
})
}
// `error` does not bubble, so the listener has to capture.
container.addEventListener('error', onError, true)
return () => {
detached = true
container.removeEventListener('error', onError, true)
for (const url of objectUrls) URL.revokeObjectURL(url)
}
}
+50
View File
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { fetchServerImageBlobUrl } from './authedImage'
/**
* Let an `<img>` that a plain request could not load try once more with the app's
* credential (see {@link fetchServerImageBlobUrl}).
*
* Spread `src` and `onError` onto the image. `onFailure` runs only when the
* authenticated attempt has also failed — a missing or denied file, or a body that
* is not an image — so callers keep their own failure notice for real failures and
* never flash it for a request that was merely missing a header.
*/
export function useAuthedImageFallback(src: string | undefined, onFailure?: () => void) {
const [resolved, setResolved] = useState<{ source: string; url: string } | null>(null)
const triedSource = useRef<string | undefined>(undefined)
const alive = useRef(true)
const objectUrls = useRef<string[]>([])
const onFailureRef = useRef(onFailure)
onFailureRef.current = onFailure
useEffect(() => {
alive.current = true
const owned = objectUrls.current
return () => {
alive.current = false
for (const url of owned) URL.revokeObjectURL(url)
owned.length = 0
}
}, [])
const onError = useCallback(() => {
if (!src || triedSource.current === src) {
onFailureRef.current?.()
return
}
triedSource.current = src
void fetchServerImageBlobUrl(src).then((url) => {
if (!alive.current) {
URL.revokeObjectURL(url)
return
}
objectUrls.current.push(url)
setResolved({ source: src, url })
}).catch(() => {
if (alive.current && triedSource.current === src) onFailureRef.current?.()
})
}, [src])
return { src: resolved && resolved.source === src ? resolved.url : src, onError }
}