FN-017: add authenticated image artifact viewer

Protect dashboard image artifacts while providing a dedicated preview experience.

- Fetch image media with header authentication and render revocable blob URLs.
- Add desktop and mobile viewer behavior across gallery, documents, task, and mailbox surfaces.
- Cover viewer, blob-loading, and updated artifact-surface behavior with tests and documentation.

Files changed:
 docs/dashboard-guide.md                            |   1 +
 .../app/components/ArtifactImageViewer.css         |   9 ++
 .../app/components/ArtifactImageViewer.tsx         | 100 ++++++++++++++++++
 .../dashboard/app/components/ArtifactMedia.tsx     |   8 +-
 .../dashboard/app/components/ArtifactsGallery.tsx  |  10 +-
 .../dashboard/app/components/DocumentsView.tsx     |   4 +-
 .../app/components/MailboxArtifactAttachment.tsx   |  29 +++---
 .../dashboard/app/components/TaskDocumentsTab.tsx  | 114 ++-------------------
 .../__tests__/ArtifactImageViewer.test.tsx         |  48 +++++++++
 .../__tests__/ArtifactsGallery.swipe-back.test.tsx |   7 +-
 .../components/__tests__/DocumentsView.test.tsx    |   9 +-
 .../__tests__/MailboxArtifactAttachment.test.tsx   |  34 ++++--
 .../components/__tests__/TaskDocumentsTab.test.tsx |  22 ++--
 .../hooks/__tests__/useArtifactImageBlob.test.tsx  |  92 +++++++++++++++++
 .../dashboard/app/hooks/useArtifactImageBlob.ts    |  59 +++++++++++
 15 files changed, 387 insertions(+), 159 deletions(-)

Fusion-Task-Id: FN-017
Fusion-Task-Lineage: 893c5a50-536a-4f54-a9a4-cba33ea111b5
Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 18:37:26 +00:00
parent da3c280672
commit a736843cd2
15 changed files with 387 additions and 159 deletions

View File

@@ -1129,6 +1129,7 @@ Features:
- Already-open global and task-detail artifact lists refresh live from the artifact registry event when an agent, dashboard chat session, user action, or system tool registers a new artifact, while preserving active search filters and task scoping
- Use the tab-count badges to see the current counts for Project Files, Task Documents, and Artifacts; the Artifacts badge reflects the loaded `GET /api/artifacts` result set, including active search filters
- Browse the category-driven gallery: artifacts are broken down into **Images**, **Docs**, **PDFs**, **Videos**, **Audio**, and **Other** content categories (PDFs are detected by MIME type/extension regardless of registry type). "All" renders one section per present category; the chip row filters to a single category, and chips only appear for categories that exist
- Open image artifacts in the dedicated authenticated dashboard viewer. It fetches media with the Authorization header and displays a temporary blob URL, so image links and browser navigation never expose a tokenized raw-media URL. The viewer remains a desktop floating window and uses the established mobile sheet and Back behavior.
- Each category has a tailored experience: Images/Videos use a visual-first tile grid with hover metadata and a full-size lightbox; Docs open a full document viewer with rendered markdown; PDFs open an embedded viewer with an open-in-new-tab action; Audio renders inline player rows; Other renders compact download rows
- Video artifacts (agent-registered recordings, `path`-ingested MP4/WebM/MOV, and bridged video attachments) play with working seek because the media route serves HTTP byte ranges
- HTML doc artifacts (`mimeType: text/html`) render as **live sandboxed previews** by default in the document viewer (scripts allowed, same-origin denied), with a Preview/Source toggle and the same Edit mode as other docs

View File

@@ -0,0 +1,9 @@
/* FNXC:ArtifactImageSecurity 2026-08-19-18:08: The dedicated image viewer fills its shared FloatingWindow while preserving the existing mobile sheet behavior. */
.artifact-image-viewer { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; }
.artifact-image-viewer__header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); padding: var(--space-md); border-bottom: var(--border-width) solid var(--border); }
.artifact-image-viewer__title { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artifact-image-viewer__content { display: flex; flex: 1; min-height: 0; align-items: center; justify-content: center; padding: var(--space-md); overflow: auto; }
.artifact-image-viewer__image { display: block; max-width: 100%; max-height: 100%; object-fit: contain; }
.artifact-image-viewer__failure { display: flex; flex-direction: column; align-items: center; gap: var(--space-sm); }
.artifact-image-viewer__error { margin: 0; color: var(--color-error); }
@media (max-width: 768px) { .artifact-image-viewer__content { padding: var(--space-sm); } }

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef } from "react";
import { X } from "lucide-react";
import { FloatingWindow } from "./FloatingWindow";
import { useArtifactImageBlob } from "../hooks/useArtifactImageBlob";
import "./ArtifactImageViewer.css";
export interface ArtifactImageProps {
artifactId: string;
projectId?: string;
title: string;
className?: string;
loading?: "lazy" | "eager";
onError?: () => void;
}
/** A safe inline thumbnail: its image source is always a revocable blob URL. */
export function ArtifactImage({ artifactId, projectId, title, className, loading = "lazy", onError }: ArtifactImageProps) {
const { url, error } = useArtifactImageBlob(artifactId, projectId);
useEffect(() => { if (error) onError?.(); }, [error, onError]);
return url ? <img className={className} src={url} alt={title} loading={loading} /> : null;
}
export interface ArtifactImageViewerProps {
artifactId: string;
title: string;
projectId?: string;
taskId?: string;
onOpenTask?: (taskId: string) => void;
onClose: () => void;
}
/**
* FNXC:ArtifactImageSecurity 2026-08-19-18:08:
* One dashboard-owned viewer is the only image destination across artifact surfaces. It renders a
* revocable blob URL rather than a raw media link, preserving previews without exposing daemon
* credentials in copied URLs, browser history, or image attributes.
*/
export function ArtifactImageViewer({ artifactId, title, projectId, taskId, onOpenTask, onClose }: ArtifactImageViewerProps) {
const closeRef = useRef<HTMLButtonElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(document.activeElement instanceof HTMLElement ? document.activeElement : null);
const onCloseRef = useRef(onClose);
const { url, loading, error, reload } = useArtifactImageBlob(artifactId, projectId);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
closeRef.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCloseRef.current();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
returnFocusRef.current?.focus();
};
}, []);
return (
<FloatingWindow
windowKey={`artifact-media-${artifactId}`}
title={null}
modal
onClose={onClose}
hideHeader
dragHandleSelector=".artifact-image-viewer__header"
className="artifact-image-viewer-window artifacts-gallery-viewer"
ariaLabel="Artifact media preview"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
persistGeometryKey="fn-artifact-image-viewer-geometry"
defaultSize={{ width: 1024, height: 720 }}
minSize={{ width: 320, height: 280 }}
>
<section className="artifact-image-viewer" aria-label={`Image artifact: ${title}`}>
<header className="artifact-image-viewer__header">
<h3 className="artifact-image-viewer__title">{title}</h3>
{taskId && onOpenTask && <button className="btn btn-sm" type="button" onClick={() => onOpenTask(taskId)}>Open task</button>}
<button ref={closeRef} className="modal-close" type="button" onClick={onClose} aria-label="Close artifact preview">
<X size={20} />
</button>
</header>
<div className="artifact-image-viewer__content" aria-live="polite">
{loading && <p>Loading image artifact…</p>}
{error && (
<div className="artifact-image-viewer__failure" role="alert">
<p className="artifact-image-viewer__error">{error}</p>
<button className="btn btn-sm" type="button" onClick={reload}>Retry</button>
</div>
)}
{url && <img className="artifact-image-viewer__image" src={url} alt={title} />}
</div>
</section>
</FloatingWindow>
);
}

View File

@@ -1,6 +1,7 @@
import { FileText, Package } from "lucide-react";
import type { TFunction } from "i18next";
import type { ArtifactType, ArtifactWithTask } from "@fusion/core";
import { ArtifactImage } from "./ArtifactImageViewer";
export function getArtifactTypeLabel(t: TFunction<"app">, type: ArtifactType): string {
switch (type) {
@@ -18,8 +19,9 @@ export function getArtifactTypeLabel(t: TFunction<"app">, type: ArtifactType): s
}
interface ArtifactMediaProps {
artifact: Pick<ArtifactWithTask, "type">;
artifact: Pick<ArtifactWithTask, "id" | "type">;
mediaUrl: string;
projectId?: string;
title: string;
preview?: string;
t: TFunction<"app">;
@@ -29,10 +31,10 @@ interface ArtifactMediaProps {
* FNXC:ArtifactRegistry 2026-06-21-21:31:
* The global Documents gallery and the per-task Artifacts tab must share one media renderer so image, video, audio, document, and generic artifact previews cannot drift across dashboard surfaces.
*/
export function ArtifactMedia({ artifact, mediaUrl, title, preview, t }: ArtifactMediaProps) {
export function ArtifactMedia({ artifact, mediaUrl, projectId, title, preview, t }: ArtifactMediaProps) {
switch (artifact.type) {
case "image":
return <img className="documents-artifact-media" src={mediaUrl} alt={title} loading="lazy" />;
return <ArtifactImage className="documents-artifact-media" artifactId={artifact.id} projectId={projectId} title={title} />;
case "video":
return <video className="documents-artifact-media" controls src={mediaUrl} aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })} />;
case "audio":

View File

@@ -22,6 +22,7 @@ import { artifactMediaUrl, artifactMediaUrlWithToken, fetchArtifact, updateArtif
import { withTokenHeader } from "../auth";
import { FileEditor } from "./FileEditor";
import { FloatingWindow } from "./FloatingWindow";
import { ArtifactImage, ArtifactImageViewer } from "./ArtifactImageViewer";
import { NavigationHistoryContext } from "../hooks/useNavigationHistory";
/*
@@ -212,7 +213,10 @@ export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onO
);
})}
{viewer && viewer.kind === "media" && (
{viewer && viewer.kind === "media" && viewer.artifact.type === "image" && (
<ArtifactImageViewer artifactId={viewer.artifact.id} title={viewer.artifact.title || t("documents.untitledArtifact", "Untitled artifact")} projectId={projectId} taskId={viewer.artifact.taskId} onOpenTask={onOpenTask} onClose={dismissViewer} />
)}
{viewer && viewer.kind === "media" && viewer.artifact.type !== "image" && (
<MediaLightbox artifact={viewer.artifact} projectId={projectId} t={t} onClose={dismissViewer} onOpenTask={onOpenTask} />
)}
{viewer && viewer.kind === "pdf" && (
@@ -289,7 +293,7 @@ interface TileProps {
function VisualTile({ artifact, category, projectId, t, onOpen }: TileProps) {
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
const mediaUrl = artifactMediaUrlWithToken(artifact.id, projectId);
const mediaUrl = category === "image" ? "" : artifactMediaUrlWithToken(artifact.id, projectId);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
@@ -308,7 +312,7 @@ function VisualTile({ artifact, category, projectId, t, onOpen }: TileProps) {
onKeyDown={handleKeyDown}
>
{category === "image" ? (
<img src={mediaUrl} alt={title} loading="lazy" />
<ArtifactImage artifactId={artifact.id} projectId={projectId} title={title} />
) : (
<video src={mediaUrl} muted preload="metadata" aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })} />
)}

View File

@@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm";
import type { Artifact, ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { artifactMediaUrlWithToken, fetchArtifact, fetchTaskDetail, fetchTaskDocument, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api";
import { ArtifactImage, ArtifactImageViewer } from "./ArtifactImageViewer";
import { useArtifacts } from "../hooks/useArtifacts";
import { useDocuments } from "../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
@@ -149,6 +150,7 @@ interface TaskArtifactInlineViewerProps {
function TaskArtifactInlineViewer({ artifact, projectId, content, loading, error, renderMarkdown, onToggleMarkdown, onOpenTask, t }: TaskArtifactInlineViewerProps) {
const [mediaError, setMediaError] = useState<string | null>(null);
const [imageViewerOpen, setImageViewerOpen] = useState(false);
const category = getArtifactCategory(artifact);
const categoryLabel = getTaskArtifactCategoryLabel(t, category);
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
@@ -163,7 +165,7 @@ function TaskArtifactInlineViewer({ artifact, projectId, content, loading, error
const body = (() => {
if (category === "image") {
return <>{mediaErrorNode}<img className="documents-task-artifact-media" src={mediaUrl} alt={title} onError={() => setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} /></>;
return <>{mediaErrorNode}<button type="button" className="documents-task-artifact-image-button" onClick={() => setImageViewerOpen(true)} aria-label={t("documents.expandImageArtifact", "Expand image artifact {{title}}", { title })}><ArtifactImage className="documents-task-artifact-media" artifactId={artifact.id} projectId={projectId} title={title} onError={() => setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} /></button>{imageViewerOpen && <ArtifactImageViewer artifactId={artifact.id} projectId={projectId} title={title} onClose={() => setImageViewerOpen(false)} />}</>;
}
if (category === "video") {
return <>{mediaErrorNode}<video className="documents-task-artifact-media" controls src={mediaUrl} aria-label={t("documents.videoArtifactLabel", "Video artifact: {{title}}", { title })} onError={() => setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} /></>;

View File

@@ -2,6 +2,7 @@ import { memo, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { ArtifactType } from "@fusion/core";
import { artifactMediaUrlWithToken } from "../api";
import { ArtifactImage, ArtifactImageViewer } from "./ArtifactImageViewer";
export interface MailboxArtifactAttachmentProps {
artifactId?: unknown;
@@ -49,18 +50,17 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment
const mediaMimeType = readString(mimeType);
const task = readString(taskId);
const [imageFailed, setImageFailed] = useState(false);
const mediaUrl = useMemo(() => id ? artifactMediaUrlWithToken(id, projectId) : "", [id, projectId]);
const [imageViewerOpen, setImageViewerOpen] = useState(false);
const mediaUrl = useMemo(() => id && type !== "image" ? artifactMediaUrlWithToken(id, projectId) : "", [id, projectId, type]);
if (!id) return null;
const openLink = (
<a
className="mailbox-artifact-attachment__link btn"
href={mediaUrl}
target="_blank"
rel="noreferrer"
aria-label={t("mailbox.openArtifactAria", "Open artifact: {{label}}", { label })}
>
const openLink = type === "image" ? (
<button type="button" className="mailbox-artifact-attachment__link btn" onClick={() => setImageViewerOpen(true)} aria-label={t("mailbox.openArtifactAria", "Open artifact: {{label}}", { label })}>
{t("mailbox.openArtifact", "Open artifact")}
</button>
) : (
<a className="mailbox-artifact-attachment__link btn" href={mediaUrl} target="_blank" rel="noreferrer" aria-label={t("mailbox.openArtifactAria", "Open artifact: {{label}}", { label })}>
{t("mailbox.openArtifact", "Open artifact")}
</a>
);
@@ -79,13 +79,9 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment
let preview: ReactNode = null;
if (type === "image" && !imageFailed) {
preview = (
<img
className="mailbox-artifact-attachment__media mailbox-artifact-attachment__image"
src={mediaUrl}
alt={label}
loading="lazy"
onError={() => setImageFailed(true)}
/>
<button type="button" className="mailbox-artifact-attachment__image-button" onClick={() => setImageViewerOpen(true)} aria-label={t("mailbox.openArtifactAria", "Open artifact: {{label}}", { label })}>
<ArtifactImage className="mailbox-artifact-attachment__media mailbox-artifact-attachment__image" artifactId={id} projectId={projectId} title={label} onError={() => setImageFailed(true)} />
</button>
);
} else if (type === "video") {
preview = (
@@ -123,6 +119,7 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment
{openLink}
{taskLink}
</div>
{imageViewerOpen && type === "image" && <ArtifactImageViewer artifactId={id} projectId={projectId} title={label} onClose={() => setImageViewerOpen(false)} />}
</div>
);
});

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History, X } from "lucide-react";
import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History } from "lucide-react";
import "./DocumentsView.css";
import "./TaskDocumentsTab.css";
import ReactMarkdown from "react-markdown";
@@ -18,6 +18,7 @@ import {
import { useArtifacts } from "../hooks/useArtifacts";
import { LoadingSpinner } from "./LoadingSpinner";
import { ArtifactMedia, getArtifactTypeLabel } from "./ArtifactMedia";
import { ArtifactImageViewer } from "./ArtifactImageViewer";
// Document key validation: alphanumeric, hyphens, underscores, 1-64 chars
const DOCUMENT_KEY_REGEX = /^[a-zA-Z0-9_-]{1,64}$/;
@@ -96,12 +97,12 @@ function TaskArtifactCard({ artifact, projectId, onExpandImage }: TaskArtifactCa
onClick={() => onExpandImage(artifact)}
aria-label={t("documents.expandImageArtifact", "Expand image artifact {{title}}", { title })}
>
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} projectId={projectId} title={title} preview={preview} t={t} />
<span className="documents-artifact-expand-hint">{t("documents.expandArtifactHint", "Click to expand")}</span>
</button>
) : (
<div className="documents-artifact-preview">
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} projectId={projectId} title={title} preview={preview} t={t} />
</div>
)}
<div className="documents-artifact-body">
@@ -162,9 +163,6 @@ export function TaskDocumentsTab({
* Task detail image artifacts must be viewable in-place from the task modal. Keep the expand target image-only so document, audio, video, and generic cards retain their current non-lightbox behavior without empty controls.
*/
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactWithTask | null>(null);
const lightboxDialogRef = useRef<HTMLDivElement>(null);
const lightboxCloseRef = useRef<HTMLButtonElement>(null);
const lightboxReturnFocusRef = useRef<HTMLElement | null>(null);
const loadedTaskIdRef = useRef(taskId);
const documentKeysRef = useRef<Set<string>>(new Set());
const { artifacts, loading: artifactsLoading, error: artifactsError } = useArtifacts({ projectId, taskId });
@@ -381,79 +379,8 @@ export function TaskDocumentsTab({
setEditContent("");
}
const handleExpandArtifactImage = useCallback((artifact: ArtifactWithTask) => {
lightboxReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setLightboxArtifact(artifact);
}, []);
const handleCloseLightbox = useCallback(() => {
setLightboxArtifact(null);
lightboxReturnFocusRef.current?.focus();
lightboxReturnFocusRef.current = null;
}, []);
useEffect(() => {
if (!lightboxArtifact) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
lightboxCloseRef.current?.focus();
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
handleCloseLightbox();
return;
}
if (event.key !== "Tab") {
return;
}
/*
* FNXC:ArtifactRegistry 2026-06-29-17:08:
* The artifact preview declares an aria-modal dialog, so keyboard focus must stay inside the lightbox until Escape, overlay click, or the close button dismisses it. Cycle Tab/Shift+Tab over current focusable controls instead of letting focus escape into the task-detail modal behind the overlay.
*/
const dialog = lightboxDialogRef.current;
const focusableElements = Array.from(dialog?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
) ?? []).filter((element) => element.getAttribute("aria-hidden") !== "true");
if (!dialog || focusableElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey && activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
} else if (!dialog.contains(activeElement)) {
event.preventDefault();
firstElement.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", handleKeyDown);
};
}, [handleCloseLightbox, lightboxArtifact]);
const handleLightboxOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
handleCloseLightbox();
}
}, [handleCloseLightbox]);
const handleExpandArtifactImage = useCallback((artifact: ArtifactWithTask) => setLightboxArtifact(artifact), []);
const handleCloseLightbox = useCallback(() => setLightboxArtifact(null), []);
if (loading || artifactsLoading) {
return (
@@ -749,32 +676,7 @@ export function TaskDocumentsTab({
)}
</section>
{lightboxArtifact && (
<div
ref={lightboxDialogRef}
className="modal-overlay open documents-artifact-lightbox-overlay"
role="dialog"
aria-modal="true"
aria-label={t("documents.lightboxLabel", "Artifact media preview")}
onClick={handleLightboxOverlayClick}
>
<div className="documents-artifact-lightbox" onClick={(event) => event.stopPropagation()}>
<div className="documents-artifact-lightbox-header">
<h3 className="documents-artifact-lightbox-title">{lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}</h3>
<button ref={lightboxCloseRef} className="modal-close" onClick={handleCloseLightbox} aria-label={t("documents.closeLightbox", "Close artifact preview")}>
<X size={20} />
</button>
</div>
<div className="documents-artifact-lightbox-media-frame">
<img
className="documents-artifact-lightbox-media"
src={artifactMediaUrlWithToken(lightboxArtifact.id, projectId)}
alt={lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}
/>
</div>
</div>
</div>
)}
{lightboxArtifact && <ArtifactImageViewer artifactId={lightboxArtifact.id} title={lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")} projectId={projectId} onClose={handleCloseLightbox} />}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ArtifactImageViewer } from "../ArtifactImageViewer";
import { useArtifactImageBlob } from "../../hooks/useArtifactImageBlob";
vi.mock("../../hooks/useArtifactImageBlob", () => ({ useArtifactImageBlob: vi.fn() }));
vi.mock("../FloatingWindow", () => ({
FloatingWindow: ({ children, ariaLabel }: { children: ReactNode; ariaLabel?: string }) => <div role="dialog" aria-label={ariaLabel}>{children}</div>,
}));
const mockUseArtifactImageBlob = vi.mocked(useArtifactImageBlob);
describe("ArtifactImageViewer", () => {
afterEach(() => vi.restoreAllMocks());
it("renders a blob image without a raw media link and closes with Escape while restoring focus", () => {
mockUseArtifactImageBlob.mockReturnValue({ url: "blob:secure-preview", loading: false, error: null, reload: vi.fn() });
const onClose = vi.fn();
const trigger = document.createElement("button");
trigger.textContent = "Open";
document.body.append(trigger);
trigger.focus();
const { unmount } = render(<ArtifactImageViewer artifactId="image-1" title="Secure image" onClose={onClose} />);
const image = screen.getByRole("img", { name: "Secure image" });
expect(image).toHaveAttribute("src", "blob:secure-preview");
expect(image.getAttribute("src")).not.toContain("fn_token");
expect(screen.queryByRole("link")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
unmount();
expect(trigger).toHaveFocus();
trigger.remove();
});
it("exposes an accessible error and retry action without an unsafe image URL", () => {
const reload = vi.fn();
mockUseArtifactImageBlob.mockReturnValue({ url: null, loading: false, error: "Failed to load image artifact.", reload });
render(<ArtifactImageViewer artifactId="image-1" title="Broken image" onClose={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load image artifact.");
expect(screen.queryByRole("img")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(reload).toHaveBeenCalledOnce();
});
});

View File

@@ -15,6 +15,9 @@ vi.mock("../../api", () => ({
vi.mock("../FloatingWindow", () => ({
FloatingWindow: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../../hooks/useArtifactImageBlob", () => ({
useArtifactImageBlob: vi.fn(() => ({ url: "blob:secure-preview", loading: false, error: null, reload: vi.fn() })),
}));
const artifacts: ArtifactWithTask[] = [
{
@@ -69,7 +72,7 @@ function openDocumentViewer() {
}
function expectViewerOpen(container: HTMLElement) {
expect(container.querySelector(".artifacts-gallery-viewer")).not.toBeNull();
expect(container.querySelector(".artifacts-gallery-viewer, .artifact-image-viewer")).not.toBeNull();
}
describe("ArtifactsGallery mobile viewer navigation history", () => {
@@ -104,7 +107,7 @@ describe("ArtifactsGallery mobile viewer navigation history", () => {
await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull());
expect(screen.getByRole("button", { name: "Expand Image artifact" })).toBeInTheDocument();
dispatchPopState(0);
expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull();
expect(container.querySelector(".artifact-image-viewer")).toBeNull();
});
it("routes Android native Back through popstate before dismissing the document viewer", async () => {

View File

@@ -43,6 +43,9 @@ vi.mock("../../hooks/useArtifacts", () => ({
vi.mock("../../hooks/useProjectMarkdownFiles", () => ({
useProjectMarkdownFiles: vi.fn(),
}));
vi.mock("../../hooks/useArtifactImageBlob", () => ({
useArtifactImageBlob: vi.fn(() => ({ url: "blob:secure-preview", loading: false, error: null, reload: vi.fn() })),
}));
const mockUseDocuments = vi.mocked(useDocuments);
const mockUseArtifacts = vi.mocked(useArtifacts);
@@ -699,7 +702,7 @@ describe("DocumentsView", () => {
const imageEntry = screen.getByRole("button", { name: "Open KB-001 artifact Task screenshot" });
fireEvent.click(imageEntry);
expect(imageEntry).toHaveAttribute("aria-current", "true");
expect(screen.getByRole("img", { name: "Task screenshot" })).toHaveAttribute("src", "/api/artifacts/task-artifact-image/media?fn_token=daemon-token");
expect(screen.getByRole("img", { name: "Task screenshot" })).toHaveAttribute("src", "blob:secure-preview");
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
@@ -936,7 +939,7 @@ describe("DocumentsView", () => {
fireEvent.click(artifactsTab);
expect(screen.getByRole("tab", { name: /show artifacts/i })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "blob:secure-preview");
expect(screen.getByRole("button", { name: "Expand Image artifact" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Expand Video artifact" })).toBeInTheDocument();
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
@@ -991,7 +994,7 @@ describe("DocumentsView", () => {
*/
fireEvent.click(screen.getByRole("button", { name: "Expand Image artifact" }));
let dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
expect(within(dialog).getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
expect(within(dialog).getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "blob:secure-preview");
expect(screen.getByTestId("floating-window-artifact-media-artifact-image")).toBeInTheDocument();
expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();

View File

@@ -1,5 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MailboxArtifactAttachment } from "../MailboxArtifactAttachment";
import { artifactMediaUrlWithToken } from "../../api";
@@ -7,10 +7,21 @@ vi.mock("../../api", () => ({
artifactMediaUrlWithToken: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}&` : "?"}fn_token=daemon-token`),
}));
vi.mock("../ArtifactImageViewer", () => ({
ArtifactImage: ({ title, onError }: { title: string; onError?: () => void }) => <img alt={title} src="blob:secure-preview" onError={onError} />,
ArtifactImageViewer: ({ title, onClose }: { title: string; onClose: () => void }) => <div role="dialog"><img alt={title} src="blob:secure-preview" /><button type="button" aria-label="Close image artifact preview" onClick={onClose}>Close</button></div>,
}));
const mockArtifactMediaUrlWithToken = vi.mocked(artifactMediaUrlWithToken);
describe("MailboxArtifactAttachment", () => {
it("renders image artifacts inline with the project-scoped media URL", () => {
beforeEach(() => {
cleanup();
mockArtifactMediaUrlWithToken.mockClear();
});
afterEach(() => cleanup());
it("renders image artifacts through a blob preview and viewer without a tokenized link", () => {
render(
<MailboxArtifactAttachment
artifactId="art-image"
@@ -21,10 +32,13 @@ describe("MailboxArtifactAttachment", () => {
/>,
);
expect(mockArtifactMediaUrlWithToken).toHaveBeenCalledWith("art-image", "proj-1");
expect(mockArtifactMediaUrlWithToken).not.toHaveBeenCalledWith("art-image", "proj-1");
const image = screen.getByRole("img", { name: "Screenshot" });
expect(image).toHaveAttribute("src", "/api/artifacts/art-image/media?projectId=proj-1&fn_token=daemon-token");
expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toHaveAttribute("href", "/api/artifacts/art-image/media?projectId=proj-1&fn_token=daemon-token");
expect(image).toHaveAttribute("src", "blob:secure-preview");
fireEvent.click(screen.getAllByRole("button", { name: "Open artifact: Screenshot" }).at(-1)!);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(document.body.innerHTML).not.toContain("fn_token");
expect(document.body.innerHTML).not.toContain("daemon-token");
});
it("renders a View task affordance when task metadata and a handler are present", () => {
@@ -49,14 +63,14 @@ describe("MailboxArtifactAttachment", () => {
render(<MailboxArtifactAttachment artifactId="art-image" artifactType="image" title="Screenshot" taskId="FN-1234" />);
expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull();
expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: "Open artifact: Screenshot" })).toHaveLength(2);
});
it("does not render a View task affordance without task metadata", () => {
render(<MailboxArtifactAttachment artifactId="art-image" artifactType="image" title="Screenshot" onOpenTask={vi.fn()} />);
expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull();
expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: "Open artifact: Screenshot" })).toHaveLength(2);
});
it.each([
@@ -86,13 +100,13 @@ describe("MailboxArtifactAttachment", () => {
expect(screen.queryByTestId("mailbox-artifact-attachment")).toBeNull();
});
it("degrades image load failures to the open artifact link", () => {
it("keeps the safe viewer action available after an image load failure", () => {
render(<MailboxArtifactAttachment artifactId="art-broken" artifactType="image" title="Broken screenshot" taskId="FN-1234" onOpenTask={vi.fn()} />);
fireEvent.error(screen.getByRole("img", { name: "Broken screenshot" }));
expect(screen.queryByRole("img", { name: "Broken screenshot" })).toBeNull();
expect(screen.getByRole("link", { name: "Open artifact: Broken screenshot" })).toHaveAttribute("href", "/api/artifacts/art-broken/media?fn_token=daemon-token");
expect(screen.getByRole("button", { name: "Open artifact: Broken screenshot" })).toBeInTheDocument();
expect(screen.getByTestId("mailbox-artifact-view-task")).toBeInTheDocument();
});
});

View File

@@ -21,6 +21,9 @@ vi.mock("../../api", () => ({
vi.mock("../../hooks/useArtifacts", () => ({
useArtifacts: vi.fn(),
}));
vi.mock("../../hooks/useArtifactImageBlob", () => ({
useArtifactImageBlob: vi.fn(() => ({ url: "blob:secure-preview", loading: false, error: null, reload: vi.fn() })),
}));
const mockFetchTaskDocuments = vi.mocked(fetchTaskDocuments);
const mockFetchTaskDocumentRevisions = vi.mocked(fetchTaskDocumentRevisions);
@@ -238,7 +241,7 @@ describe("TaskDocumentsTab", () => {
expect(screen.getByRole("heading", { name: "Media artifacts" })).toBeInTheDocument();
});
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "blob:secure-preview");
expect(screen.getByRole("button", { name: "Expand image artifact Image artifact" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Expand image artifact Video artifact/ })).not.toBeInTheDocument();
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
@@ -269,7 +272,7 @@ describe("TaskDocumentsTab", () => {
const dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
expect(dialog).toBeInTheDocument();
expect(screen.getAllByRole("img", { name: "Image artifact" })[1]).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
expect(screen.getAllByRole("img", { name: "Image artifact" })[1]).toHaveAttribute("src", "blob:secure-preview");
fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" }));
@@ -300,7 +303,7 @@ describe("TaskDocumentsTab", () => {
});
});
it("traps keyboard focus inside the image artifact lightbox", async () => {
it("moves focus to the shared viewer close control", async () => {
mockUseArtifacts.mockReturnValue({
artifacts: mockArtifacts,
loading: false,
@@ -311,18 +314,7 @@ describe("TaskDocumentsTab", () => {
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
fireEvent.click(await screen.findByRole("button", { name: "Expand image artifact Image artifact" }));
const closeButton = screen.getByRole("button", { name: "Close artifact preview" });
expect(closeButton).toHaveFocus();
fireEvent.keyDown(document, { key: "Tab" });
expect(closeButton).toHaveFocus();
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
expect(closeButton).toHaveFocus();
screen.getAllByRole("button", { name: "Collapse" })[0].focus();
fireEvent.keyDown(document, { key: "Tab" });
expect(closeButton).toHaveFocus();
expect(screen.getByRole("button", { name: "Close artifact preview" })).toHaveFocus();
});
it("surfaces artifact fetch errors", async () => {

View File

@@ -0,0 +1,92 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useArtifactImageBlob } from "../useArtifactImageBlob";
import { artifactMediaUrl } from "../../api";
import { withTokenHeader } from "../../auth";
vi.mock("../../api", () => ({
artifactMediaUrl: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}` : ""}`),
}));
vi.mock("../../auth", () => ({ withTokenHeader: vi.fn(() => ({ Authorization: "Bearer dashboard-secret" })) }));
const mockArtifactMediaUrl = vi.mocked(artifactMediaUrl);
const mockWithTokenHeader = vi.mocked(withTokenHeader);
const createObjectUrl = vi.fn(() => "blob:artifact-preview");
const revokeObjectUrl = vi.fn();
function imageResponse() {
return {
ok: true,
headers: new Headers({ "content-type": "image/png" }),
blob: async () => new Blob(["png"], { type: "image/png" }),
} as Response;
}
function textResponse() {
return {
ok: true,
headers: new Headers({ "content-type": "text/plain" }),
blob: async () => new Blob(["not an image"], { type: "text/plain" }),
} as Response;
}
describe("useArtifactImageBlob", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.stubGlobal("fetch", vi.fn());
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
createObjectUrl.mockClear();
revokeObjectUrl.mockClear();
});
it("fetches a token-free media URL with Authorization and returns only a blob URL", async () => {
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
const fetchMock = vi.fn().mockResolvedValue(imageResponse());
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useArtifactImageBlob("image-1", "project-1"));
await waitFor(() => expect(result.current.url).toBe("blob:artifact-preview"));
expect(mockArtifactMediaUrl).toHaveBeenCalledWith("image-1", "project-1");
expect(mockWithTokenHeader).toHaveBeenCalledWith();
expect(fetchMock).toHaveBeenCalledWith("/api/artifacts/image-1/media?projectId=project-1", expect.objectContaining({ headers: { Authorization: "Bearer dashboard-secret" } }));
expect(result.current.url).not.toContain("fn_token");
expect(result.current.url).not.toContain("dashboard-secret");
});
it("aborts and revokes the previous object URL when the artifact changes or unmounts", async () => {
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
const fetchMock = vi.fn().mockResolvedValue(imageResponse());
vi.stubGlobal("fetch", fetchMock);
const { result, rerender, unmount } = renderHook(({ id }) => useArtifactImageBlob(id), { initialProps: { id: "image-1" } });
await waitFor(() => expect(result.current.url).toBe("blob:artifact-preview"));
rerender({ id: "image-2" });
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ signal: expect.any(AbortSignal) });
await waitFor(() => expect(revokeObjectUrl).toHaveBeenCalledWith("blob:artifact-preview"));
unmount();
expect(revokeObjectUrl.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("keeps non-image and failed responses out of DOM URLs", async () => {
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(textResponse()));
const { result } = renderHook(() => useArtifactImageBlob("not-image"));
await waitFor(() => expect(result.current.error).toBe("Failed to load image artifact."));
expect(result.current.url).toBeNull();
expect(createObjectUrl).not.toHaveBeenCalled();
});
it("retries a failed request without exposing its source URL", async () => {
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
const fetchMock = vi.fn().mockRejectedValueOnce(new Error("network")).mockResolvedValueOnce(imageResponse());
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useArtifactImageBlob("retry-image"));
await waitFor(() => expect(result.current.error).not.toBeNull());
act(() => result.current.reload());
await waitFor(() => expect(result.current.url).toBe("blob:artifact-preview"));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from "react";
import { artifactMediaUrl } from "../api";
import { withTokenHeader } from "../auth";
export interface ArtifactImageBlobState {
url: string | null;
loading: boolean;
error: string | null;
reload: () => void;
}
/**
* FNXC:ArtifactImageSecurity 2026-08-19-18:08:
* Registered images must never hand browser elements a tokenized media URL. Fetch through the
* existing header-authenticated endpoint and expose only a short-lived object URL, revoking it
* whenever its artifact view is replaced or closed so credentials cannot enter DOM URLs.
*/
export function useArtifactImageBlob(artifactId?: string, projectId?: string): ArtifactImageBlobState {
const [reloadKey, setReloadKey] = useState(0);
const reload = useCallback(() => setReloadKey((current) => current + 1), []);
const [state, setState] = useState<Omit<ArtifactImageBlobState, "reload">>({ url: null, loading: Boolean(artifactId), error: null });
useEffect(() => {
if (!artifactId) {
setState({ url: null, loading: false, error: null });
return;
}
const controller = new AbortController();
let objectUrl: string | null = null;
setState({ url: null, loading: true, error: null });
void fetch(artifactMediaUrl(artifactId, projectId), {
headers: withTokenHeader(),
signal: controller.signal,
}).then(async (response) => {
const contentType = response.headers.get("content-type") ?? "";
if (!response.ok || !contentType.toLowerCase().startsWith("image/")) {
throw new Error("Artifact media is unavailable or is not an image.");
}
const blob = await response.blob();
if (controller.signal.aborted) return;
if (!blob.type.toLowerCase().startsWith("image/")) {
throw new Error("Artifact media is not an image.");
}
objectUrl = URL.createObjectURL(blob);
if (!controller.signal.aborted) setState({ url: objectUrl, loading: false, error: null });
}).catch(() => {
if (!controller.signal.aborted) setState({ url: null, loading: false, error: "Failed to load image artifact." });
});
return () => {
controller.abort();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [artifactId, projectId, reloadKey]);
return { ...state, reload };
}