feat(preview): Implement object preview functionality (#94)

* feat(preview): implement object preview functionality with token-based access

* docs: update README to include inline object preview feature

* test: add range read error handling and attachment content disposition tests
This commit is contained in:
Noste
2026-07-15 00:01:04 +02:00
committed by GitHub
parent 5d33382e30
commit c0976a1e1d
39 changed files with 3867 additions and 28 deletions
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { IconTile } from '@/components/ui/icon-tile';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { ObjectPreview } from '@/components/buckets/ObjectPreview';
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { downloadObject, formatBytes } from '@/lib/file-utils';
@@ -42,6 +43,7 @@ export function ObjectDetailsView() {
const bucket = buckets.find((b) => b.name === bucketName);
const canBucket = useBucketCan();
const canDelete = canBucket(bucket, 'object.delete');
const canRead = canBucket(bucket, 'object.read');
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -216,9 +218,19 @@ export function ObjectDetailsView() {
{/* Preview */}
<CardSection title="Preview">
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
No preview available for this object.
</div>
{canRead && bucketName && objectKey ? (
<ObjectPreview
bucket={bucketName}
objectKey={objectKey}
size={metadata.size}
contentType={metadata.contentType}
onDownload={handleDownload}
/>
) : (
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
No preview available for this object.
</div>
)}
</CardSection>
<ConfirmDialog
@@ -0,0 +1,165 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ObjectPreviewState } from '@/hooks/useObjectPreview';
import { useObjectPreview } from '@/hooks/useObjectPreview';
import { TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
import { ObjectPreview } from './ObjectPreview';
vi.mock('@/hooks/useObjectPreview', () => ({ useObjectPreview: vi.fn() }));
const mockedHook = vi.mocked(useObjectPreview);
function state(overrides: Partial<ObjectPreviewState>): ObjectPreviewState {
return {
kind: 'none',
status: 'unsupported',
objectUrl: null,
text: null,
mediaUrl: null,
retry: vi.fn(),
onMediaError: vi.fn(),
...overrides,
};
}
function renderPreview() {
// A .json key keeps the highlight language deterministic in the text test.
return render(
<ObjectPreview bucket="b" objectKey="k.json" size={100} contentType="text/plain" onDownload={vi.fn()} />,
);
}
afterEach(() => {
vi.clearAllMocks();
});
describe('ObjectPreview', () => {
it('shows the loading state', () => {
mockedHook.mockReturnValue(state({ kind: 'image', status: 'loading' }));
renderPreview();
expect(screen.getByText(/loading preview/i)).toBeInTheDocument();
});
it('renders an image from the object url', () => {
mockedHook.mockReturnValue(state({ kind: 'image', status: 'ready', objectUrl: 'blob:img' }));
renderPreview();
expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:img');
});
it('renders video with the media url', () => {
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
const { container } = renderPreview();
const video = container.querySelector('video');
expect(video).not.toBeNull();
expect(video).toHaveAttribute('src', '/u?pt=t');
expect(video).not.toHaveAttribute('autoplay');
});
it('renders audio with the media url', () => {
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
const { container } = renderPreview();
expect(container.querySelector('audio')).toHaveAttribute('src', '/u?pt=t');
});
it('renders pdf in an iframe', () => {
mockedHook.mockReturnValue(state({ kind: 'pdf', status: 'ready', objectUrl: 'blob:pdf' }));
renderPreview();
expect(screen.getByTitle('k.json')).toHaveAttribute('src', 'blob:pdf');
});
it('renders plain text immediately and highlighted text after the import resolves', async () => {
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
const { container } = renderPreview();
expect(container.querySelector('pre')).toHaveTextContent('{"a": 1}');
await waitFor(() => expect(container.querySelector('code [class*="hljs-"]')).not.toBeNull());
});
it('shows the too-large notice with a download action', () => {
mockedHook.mockReturnValue(state({ kind: 'text', status: 'too-large' }));
renderPreview();
expect(screen.getByText(/too large to preview/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument();
});
it('shows the unsupported notice', () => {
mockedHook.mockReturnValue(state({ kind: 'none', status: 'unsupported' }));
renderPreview();
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
});
it('shows the binary notice', () => {
mockedHook.mockReturnValue(state({ kind: 'text', status: 'binary' }));
renderPreview();
expect(screen.getByText(/doesn't appear to be text/i)).toBeInTheDocument();
});
it('shows the error state with retry wired to the hook', () => {
const retry = vi.fn();
mockedHook.mockReturnValue(state({ kind: 'image', status: 'error', retry }));
renderPreview();
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
expect(retry).toHaveBeenCalled();
});
// The tests below are additional to the brief's list. They were added to
// close coverage gaps (media error and resume handling, the highlight size
// guard, and the defensive fallback branch) found while verifying the
// 90 percent coverage requirement on this file.
it('skips highlighting for text over the highlight size limit', () => {
const bigText = 'a'.repeat(TEXT_HIGHLIGHT_MAX_BYTES + 1);
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: bigText }));
const { container } = renderPreview();
expect(container.querySelector('code [class*="hljs-"]')).toBeNull();
expect(container.querySelector('code')?.textContent).toHaveLength(bigText.length);
});
it('unmounting before the highlight import resolves is safe', async () => {
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
const { container, unmount } = renderPreview();
expect(() => unmount()).not.toThrow();
expect(container.innerHTML).toBe('');
// Let the pending dynamic import resolve after unmount. The cancelled
// guard means the resolved callback renders nothing back into the
// detached container, so it stays empty and no error is thrown.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(container.innerHTML).toBe('');
});
it('resumes the playback position after a media error and reload', () => {
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
const { container } = renderPreview();
const video = container.querySelector('video')!;
// Loading metadata with no prior error is a no-op (resume position is 0).
fireEvent.loadedMetadata(video);
Object.defineProperty(video, 'currentTime', { value: 42, writable: true, configurable: true });
fireEvent.error(video);
Object.defineProperty(video, 'currentTime', { value: 0, writable: true, configurable: true });
fireEvent.loadedMetadata(video);
expect(video.currentTime).toBe(42);
});
it('does not restore a position when the media element reported no currentTime', () => {
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
const { container } = renderPreview();
const audio = container.querySelector('audio')!;
// The element reports no currentTime, so the captured resume position
// falls back to 0 via the ?? 0 guard, which keeps the restore guard
// (resumeAtRef.current > 0) false. A later loadedmetadata must then leave
// the position untouched, unlike the sibling resume test above.
Object.defineProperty(audio, 'currentTime', { value: undefined, writable: true, configurable: true });
fireEvent.error(audio);
Object.defineProperty(audio, 'currentTime', { value: 99, writable: true, configurable: true });
fireEvent.loadedMetadata(audio);
expect(audio.currentTime).toBe(99);
});
it('falls back to the generic notice for an unhandled preview kind', () => {
mockedHook.mockReturnValue(state({ kind: 'none', status: 'ready' }));
renderPreview();
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,159 @@
import { useEffect, useRef, useState } from 'react';
import { Download, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useObjectPreview } from '@/hooks/useObjectPreview';
import { getHighlightLanguage, TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
import { formatBytes } from '@/lib/file-utils';
function Notice({
message,
onDownload,
onRetry,
}: {
message: string;
onDownload?: () => void;
onRetry?: () => void;
}) {
return (
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
<p>{message}</p>
{(onRetry || onDownload) && (
<div className="flex gap-2">
{onRetry && (
<Button variant="secondary" onClick={onRetry}>
<RefreshCw className="h-4 w-4" /> Retry
</Button>
)}
{onDownload && (
<Button variant="secondary" onClick={onDownload}>
<Download className="h-4 w-4" /> Download
</Button>
)}
</div>
)}
</div>
);
}
function CodeBlock({ text, objectKey }: { text: string; objectKey: string }) {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
// Highlighting is progressive enhancement. Large files and any import or
// highlight failure fall back to the plain text already on screen.
if (text.length > TEXT_HIGHLIGHT_MAX_BYTES) return;
let cancelled = false;
import('@/lib/highlight')
.then(({ highlight }) => {
if (!cancelled) setHtml(highlight(text, getHighlightLanguage(objectKey)));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [text, objectKey]);
return (
<pre className="overflow-x-auto px-5 py-4 font-mono text-[12.5px] leading-relaxed">
{html !== null ? <code dangerouslySetInnerHTML={{ __html: html }} /> : <code>{text}</code>}
</pre>
);
}
export function ObjectPreview({
bucket,
objectKey,
size,
contentType,
onDownload,
}: {
bucket: string;
objectKey: string;
size: number;
contentType?: string;
onDownload: () => void;
}) {
const preview = useObjectPreview(bucket, objectKey, size, contentType);
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
const resumeAtRef = useRef(0);
const handleMediaError = () => {
resumeAtRef.current = mediaRef.current?.currentTime ?? 0;
preview.onMediaError();
};
const handleLoadedMetadata = () => {
if (resumeAtRef.current > 0 && mediaRef.current) {
mediaRef.current.currentTime = resumeAtRef.current;
resumeAtRef.current = 0;
}
};
switch (preview.status) {
case 'unsupported':
return <Notice message="No preview available for this object." onDownload={onDownload} />;
case 'too-large':
return (
<Notice
message={`File is too large to preview (${formatBytes(size)}), download it instead.`}
onDownload={onDownload}
/>
);
case 'binary':
return <Notice message="This file doesn't appear to be text." onDownload={onDownload} />;
case 'error':
return <Notice message="Could not load the preview." onRetry={preview.retry} onDownload={onDownload} />;
case 'loading':
return (
<div className="flex items-center justify-center gap-2 px-5 py-10 text-[13px] text-[var(--muted-foreground)]">
<Loader2 className="h-4 w-4 animate-spin" /> Loading preview
</div>
);
}
switch (preview.kind) {
case 'image':
return (
<div className="flex justify-center bg-[var(--surface-sunken)] px-5 py-6">
<img src={preview.objectUrl!} alt={objectKey} className="h-auto max-w-full object-contain" />
</div>
);
case 'video':
return (
<div className="flex justify-center bg-black">
<video
ref={(el) => {
mediaRef.current = el;
}}
controls
preload="metadata"
src={preview.mediaUrl!}
onError={handleMediaError}
onLoadedMetadata={handleLoadedMetadata}
className="max-h-[85vh] w-full"
/>
</div>
);
case 'audio':
return (
<div className="px-5 py-6">
<audio
ref={(el) => {
mediaRef.current = el;
}}
controls
src={preview.mediaUrl!}
onError={handleMediaError}
onLoadedMetadata={handleLoadedMetadata}
className="w-full"
/>
</div>
);
case 'pdf':
return <iframe src={preview.objectUrl!} title={objectKey} className="h-[85vh] w-full" />;
case 'text':
return <CodeBlock text={preview.text!} objectKey={objectKey} />;
default:
return <Notice message="No preview available for this object." onDownload={onDownload} />;
}
}
@@ -0,0 +1,160 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { objectsApi } from '@/lib/api';
import { useObjectPreview } from './useObjectPreview';
vi.mock('@/lib/api', () => ({
objectsApi: {
get: vi.fn(),
getPreviewUrl: vi.fn(),
},
}));
const mockedGet = vi.mocked(objectsApi.get);
const mockedGetPreviewUrl = vi.mocked(objectsApi.getPreviewUrl);
beforeAll(() => {
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
// One client per test, created outside the component so rerenders reuse it.
function createWrapper() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
describe('useObjectPreview', () => {
it('fetches a blob and produces an object url for images', async () => {
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])]));
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.kind).toBe('image');
expect(result.current.objectUrl).toBe('blob:mock-url');
expect(mockedGet).toHaveBeenCalledWith('b', 'pic.png');
});
it('decodes text content', async () => {
mockedGet.mockResolvedValue(new Blob(['{"a": 1}']));
const { result } = renderHook(() => useObjectPreview('b', 'data.json', 8, 'application/json'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.text).toBe('{"a": 1}');
});
it('reports binary content pretending to be text', async () => {
mockedGet.mockResolvedValue(new Blob([new Uint8Array([0, 1, 2, 3, 4, 0, 1, 2, 3, 4])]));
const { result } = renderHook(() => useObjectPreview('b', 'weird.log', 10, undefined), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('binary'));
});
it('does not fetch when the object is over the limit', () => {
const { result } = renderHook(
() => useObjectPreview('b', 'big.txt', 6 * 1024 * 1024, 'text/plain'),
{ wrapper: createWrapper() },
);
expect(result.current.status).toBe('too-large');
expect(mockedGet).not.toHaveBeenCalled();
});
it('reports unsupported kinds without fetching', () => {
const { result } = renderHook(() => useObjectPreview('b', 'blob.bin', 10, undefined), { wrapper: createWrapper() });
expect(result.current.status).toBe('unsupported');
expect(mockedGet).not.toHaveBeenCalled();
});
it('mints a media url for video without fetching bytes', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/api/v1/buckets/b/objects/v.mp4?pt=tok', expiresAt: 'later' });
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10_000_000_000, 'video/mp4'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.mediaUrl).toBe('/api/v1/buckets/b/objects/v.mp4?pt=tok');
expect(mockedGet).not.toHaveBeenCalled();
});
it('re-mints once on media error, then reports error', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
expect(result.current.status).toBe('ready');
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.status).toBe('error'));
});
it('reports fetch failures and recovers on retry', async () => {
mockedGet.mockRejectedValueOnce(new Error('network down'));
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('error'));
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
act(() => result.current.retry());
await waitFor(() => expect(result.current.status).toBe('ready'));
});
it('revokes the object url on unmount', async () => {
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
const { result, unmount } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
unmount();
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
it('recovers when the target object changes after a media error', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/a?pt=1', expiresAt: 'later' });
const { result, rerender } = renderHook(
({ k }) => useObjectPreview('b', k, 10, 'video/mp4'),
{ wrapper: createWrapper(), initialProps: { k: 'a.mp4' } },
);
await waitFor(() => expect(result.current.status).toBe('ready'));
// Exhaust the single re-mint on object A, driving it into the error state.
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.status).toBe('ready'));
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.status).toBe('error'));
// Switch to object B on the same hook instance. The stale error from A
// must not leave B stuck: it should load and reach ready.
mockedGetPreviewUrl.mockResolvedValue({ url: '/b?pt=1', expiresAt: 'later' });
rerender({ k: 'b.mp4' });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.mediaUrl).toBe('/b?pt=1');
});
it('refetches the media url on retry', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
act(() => result.current.retry());
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
expect(result.current.status).toBe('ready');
});
it('ignores a pending text decode after unmount', async () => {
let resolveText: (value: string) => void = () => {};
const blob = new Blob(['hello']);
vi.spyOn(blob, 'text').mockReturnValue(new Promise<string>((res) => { resolveText = res; }));
mockedGet.mockResolvedValue(blob);
const { result, unmount } = renderHook(() => useObjectPreview('b', 'a.txt', 5, 'text/plain'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.objectUrl).toBe('blob:mock-url'));
// Unmount before the decode resolves, then resolve it. The cancelled
// guard must swallow the late result rather than set state.
unmount();
act(() => resolveText('hello'));
expect(result.current.text).toBeNull();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { objectsApi } from '@/lib/api';
import {
getPreviewKind,
getPreviewMime,
getPreviewSizeLimit,
looksBinary,
type PreviewKind,
} from '@/lib/preview-utils';
export interface ObjectPreviewState {
kind: PreviewKind;
status: 'loading' | 'ready' | 'too-large' | 'unsupported' | 'binary' | 'error';
objectUrl: string | null;
text: string | null;
mediaUrl: string | null;
retry: () => void;
onMediaError: () => void;
}
// Documents (image, pdf, text) are fetched as blobs through the normal
// authenticated API path. Media (video, audio) gets a short-lived tokenized
// URL instead, because media elements cannot send an Authorization header
// and must stream with Range requests rather than load fully.
export function useObjectPreview(
bucket: string,
objectKey: string,
size: number,
contentType?: string,
): ObjectPreviewState {
const kind = getPreviewKind(contentType, objectKey);
const sizeLimit = getPreviewSizeLimit(kind);
const isDocument = kind === 'image' || kind === 'pdf' || kind === 'text';
const isMedia = kind === 'video' || kind === 'audio';
const tooLarge = isDocument && sizeLimit !== null && size > sizeLimit;
const blobQuery = useQuery({
queryKey: ['object-preview', bucket, objectKey],
queryFn: () => objectsApi.get(bucket, objectKey),
enabled: isDocument && !tooLarge,
staleTime: Infinity,
gcTime: 0,
retry: false,
});
const urlQuery = useQuery({
queryKey: ['object-preview-url', bucket, objectKey],
queryFn: () => objectsApi.getPreviewUrl(bucket, objectKey),
enabled: isMedia,
staleTime: Infinity,
gcTime: 0,
retry: false,
});
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [text, setText] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
const [mediaFailed, setMediaFailed] = useState(false);
const remintedRef = useRef(false);
// Reset media error state when the target object changes, so a failure on
// one object does not leave a later object stuck in the error state. The
// hook instance is reused across navigation, it does not remount.
useEffect(() => {
setMediaFailed(false);
remintedRef.current = false;
}, [bucket, objectKey]);
useEffect(() => {
const blob = blobQuery.data;
if (!blob) return;
let cancelled = false;
const typed = new Blob([blob], { type: getPreviewMime(kind, contentType, objectKey) });
const url = URL.createObjectURL(typed);
setObjectUrl(url);
if (kind === 'text') {
blob.text().then((decoded) => {
if (cancelled) return;
if (looksBinary(decoded.slice(0, 4096))) setIsBinary(true);
else setText(decoded);
});
}
return () => {
cancelled = true;
URL.revokeObjectURL(url);
setObjectUrl(null);
setText(null);
setIsBinary(false);
};
}, [blobQuery.data, kind, contentType, objectKey]);
const onMediaError = () => {
if (remintedRef.current) {
setMediaFailed(true);
return;
}
remintedRef.current = true;
urlQuery.refetch();
};
const retry = () => {
remintedRef.current = false;
setMediaFailed(false);
if (isDocument) blobQuery.refetch();
if (isMedia) urlQuery.refetch();
};
let status: ObjectPreviewState['status'];
if (kind === 'none') status = 'unsupported';
else if (tooLarge) status = 'too-large';
else if (isBinary) status = 'binary';
else if (mediaFailed || blobQuery.isError || urlQuery.isError) status = 'error';
else if (isMedia) status = urlQuery.data ? 'ready' : 'loading';
else if (kind === 'text') status = text !== null ? 'ready' : 'loading';
else status = objectUrl ? 'ready' : 'loading';
return {
kind,
status,
objectUrl,
text,
mediaUrl: urlQuery.data?.url ?? null,
retry,
onMediaError,
};
}
+74
View File
@@ -161,3 +161,77 @@
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
}
}
/* Syntax highlighting tokens for the object preview. The dark overrides ride
the same root class the theme provider toggles. */
.hljs-comment,
.hljs-quote {
color: #6a737d;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-subst {
color: #d73a49;
}
.hljs-number,
.hljs-literal,
.hljs-variable,
.hljs-template-variable {
color: #005cc5;
}
.hljs-string,
.hljs-doctag,
.hljs-regexp {
color: #032f62;
}
.hljs-title,
.hljs-section,
.hljs-name {
color: #6f42c1;
}
.hljs-attr,
.hljs-attribute,
.hljs-built_in {
color: #005cc5;
}
.hljs-meta,
.hljs-selector-id,
.hljs-selector-class {
color: #e36209;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #8b949e;
}
.dark .hljs-keyword,
.dark .hljs-selector-tag,
.dark .hljs-subst {
color: #ff7b72;
}
.dark .hljs-number,
.dark .hljs-literal,
.dark .hljs-variable,
.dark .hljs-template-variable {
color: #79c0ff;
}
.dark .hljs-string,
.dark .hljs-doctag,
.dark .hljs-regexp {
color: #a5d6ff;
}
.dark .hljs-title,
.dark .hljs-section,
.dark .hljs-name {
color: #d2a8ff;
}
.dark .hljs-attr,
.dark .hljs-attribute,
.dark .hljs-built_in {
color: #79c0ff;
}
.dark .hljs-meta,
.dark .hljs-selector-id,
.dark .hljs-selector-class {
color: #ffa657;
}
+6
View File
@@ -395,6 +395,12 @@ export const objectsApi = {
});
return response.data.data.url;
},
getPreviewUrl: async (bucket: string, key: string): Promise<{ url: string; expiresAt: string }> => {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/preview-url`);
const data = response.data.data;
return { url: data.url, expiresAt: data.expires_at };
},
};
// Access Control API (Users/Keys)
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { highlight } from './highlight';
describe('highlight', () => {
it('highlights a known language', () => {
const html = highlight('{"a": 1}', 'json');
expect(html).toContain('hljs-');
});
it('escapes html in the source', () => {
const html = highlight('<script>alert(1)</script>', 'xml');
expect(html).not.toContain('<script>');
});
it('falls back to auto detection for null language', () => {
const html = highlight('SELECT * FROM t;', null);
expect(typeof html).toBe('string');
expect(html.length).toBeGreaterThan(0);
});
it('falls back to auto detection for an unregistered language', () => {
const html = highlight('plain words', 'klingon');
expect(typeof html).toBe('string');
});
});
+39
View File
@@ -0,0 +1,39 @@
// Loaded only via dynamic import() from the text preview renderer, so
// highlight.js never enters the main bundle.
import hljs from 'highlight.js/lib/core';
import bash from 'highlight.js/lib/languages/bash';
import css from 'highlight.js/lib/languages/css';
import dockerfile from 'highlight.js/lib/languages/dockerfile';
import go from 'highlight.js/lib/languages/go';
import ini from 'highlight.js/lib/languages/ini';
import javascript from 'highlight.js/lib/languages/javascript';
import json from 'highlight.js/lib/languages/json';
import markdown from 'highlight.js/lib/languages/markdown';
import python from 'highlight.js/lib/languages/python';
import sql from 'highlight.js/lib/languages/sql';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml';
import yaml from 'highlight.js/lib/languages/yaml';
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('css', css);
hljs.registerLanguage('dockerfile', dockerfile);
hljs.registerLanguage('go', go);
hljs.registerLanguage('ini', ini);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('json', json);
hljs.registerLanguage('markdown', markdown);
hljs.registerLanguage('python', python);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('yaml', yaml);
// Returns highlighted HTML for the source text. hljs escapes the input, so
// the output is safe to assign as innerHTML.
export function highlight(text: string, language: string | null): string {
if (language && hljs.getLanguage(language)) {
return hljs.highlight(text, { language }).value;
}
return hljs.highlightAuto(text).value;
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
getHighlightLanguage,
getPreviewKind,
getPreviewMime,
getPreviewSizeLimit,
looksBinary,
IMAGE_PREVIEW_MAX_BYTES,
PDF_PREVIEW_MAX_BYTES,
TEXT_PREVIEW_MAX_BYTES,
} from './preview-utils';
describe('getPreviewKind', () => {
it('trusts a specific content type first', () => {
expect(getPreviewKind('image/png', 'noext')).toBe('image');
expect(getPreviewKind('video/mp4', 'noext')).toBe('video');
expect(getPreviewKind('audio/mpeg', 'noext')).toBe('audio');
expect(getPreviewKind('application/pdf', 'noext')).toBe('pdf');
expect(getPreviewKind('text/plain', 'noext')).toBe('text');
expect(getPreviewKind('application/json', 'noext')).toBe('text');
});
it('ignores content type parameters', () => {
expect(getPreviewKind('text/plain; charset=utf-8', 'noext')).toBe('text');
});
it('falls back to the extension when the type is octet-stream', () => {
expect(getPreviewKind('application/octet-stream', 'photo.JPG')).toBe('image');
expect(getPreviewKind('application/octet-stream', 'clip.mp4')).toBe('video');
expect(getPreviewKind('application/octet-stream', 'song.flac')).toBe('audio');
expect(getPreviewKind('application/octet-stream', 'doc.pdf')).toBe('pdf');
expect(getPreviewKind('application/octet-stream', 'conf.yaml')).toBe('text');
expect(getPreviewKind(undefined, 'a/b/c.json')).toBe('text');
});
it('detects svg as image even though the backend rewrites its type', () => {
expect(getPreviewKind('application/octet-stream', 'logo.svg')).toBe('image');
});
it('detects Dockerfile without an extension', () => {
expect(getPreviewKind(undefined, 'build/Dockerfile')).toBe('text');
});
it('returns none for unknown files', () => {
expect(getPreviewKind('application/octet-stream', 'blob.bin')).toBe('none');
expect(getPreviewKind(undefined, 'noext')).toBe('none');
expect(getPreviewKind(undefined, 'archive.zip')).toBe('none');
});
it('does not treat a dotfile name as an extension', () => {
expect(getPreviewKind(undefined, '.gitignore')).toBe('none');
});
});
describe('getPreviewSizeLimit', () => {
it('caps documents per kind and leaves media unlimited', () => {
expect(getPreviewSizeLimit('text')).toBe(TEXT_PREVIEW_MAX_BYTES);
expect(getPreviewSizeLimit('image')).toBe(IMAGE_PREVIEW_MAX_BYTES);
expect(getPreviewSizeLimit('pdf')).toBe(PDF_PREVIEW_MAX_BYTES);
expect(getPreviewSizeLimit('video')).toBeNull();
expect(getPreviewSizeLimit('audio')).toBeNull();
expect(getPreviewSizeLimit('none')).toBe(0);
});
});
describe('getPreviewMime', () => {
it('restores the mime type the backend rewrote', () => {
expect(getPreviewMime('image', 'application/octet-stream', 'logo.svg')).toBe('image/svg+xml');
expect(getPreviewMime('pdf', 'application/octet-stream', 'doc.pdf')).toBe('application/pdf');
});
it('keeps a usable content type when there is no mapping', () => {
expect(getPreviewMime('text', 'text/plain', 'readme.txt')).toBe('text/plain');
expect(getPreviewMime('text', undefined, 'readme.weird')).toBe('application/octet-stream');
});
});
describe('getHighlightLanguage', () => {
it('maps common extensions', () => {
expect(getHighlightLanguage('a.json')).toBe('json');
expect(getHighlightLanguage('a.yml')).toBe('yaml');
expect(getHighlightLanguage('a.tsx')).toBe('typescript');
expect(getHighlightLanguage('Dockerfile')).toBe('dockerfile');
});
it('returns null for unmapped extensions', () => {
expect(getHighlightLanguage('a.log')).toBeNull();
});
});
describe('looksBinary', () => {
it('accepts ordinary text with newlines and tabs', () => {
expect(looksBinary('hello\n\tworld\r\n')).toBe(false);
expect(looksBinary('')).toBe(false);
});
it('flags replacement-heavy content', () => {
expect(looksBinary('ab')).toBe(true);
});
it('flags control-character-heavy content', () => {
expect(looksBinary('\x00\x01\x02abc')).toBe(true);
});
it('tolerates a small fraction of oddities', () => {
expect(looksBinary('a'.repeat(99) + '\x00')).toBe(false);
});
});
+126
View File
@@ -0,0 +1,126 @@
export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'none';
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
export const TEXT_HIGHLIGHT_MAX_BYTES = 1 * 1024 * 1024;
export const IMAGE_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
export const PDF_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
const imageMimeByExtension: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
ico: 'image/x-icon',
svg: 'image/svg+xml',
};
const videoExtensions = new Set(['mp4', 'webm', 'ogv', 'mov']);
const audioExtensions = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']);
const textExtensions = new Set([
'json', 'yaml', 'yml', 'ini', 'toml', 'xml', 'csv', 'md', 'log', 'txt',
'conf', 'cfg', 'env', 'sh', 'bash', 'py', 'js', 'ts', 'jsx', 'tsx',
'go', 'rs', 'java', 'c', 'h', 'cpp', 'hpp', 'sql', 'html', 'css', 'dockerfile',
]);
function getExtension(key: string): string {
const name = key.split('/').pop() ?? '';
if (name.toLowerCase() === 'dockerfile') return 'dockerfile';
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}
// Content-Type first, extension fallback. The fallback matters: Garage often
// stores application/octet-stream, and the backend rewrites unsafe types to
// it, so a generic type defers to the file extension.
export function getPreviewKind(contentType: string | undefined, key: string): PreviewKind {
const ct = (contentType ?? '').split(';')[0].trim().toLowerCase();
if (ct && ct !== 'application/octet-stream') {
if (ct.startsWith('image/')) return 'image';
if (ct.startsWith('video/')) return 'video';
if (ct.startsWith('audio/')) return 'audio';
if (ct === 'application/pdf') return 'pdf';
if (ct.startsWith('text/') || ct === 'application/json') return 'text';
}
const ext = getExtension(key);
if (ext in imageMimeByExtension) return 'image';
if (videoExtensions.has(ext)) return 'video';
if (audioExtensions.has(ext)) return 'audio';
if (ext === 'pdf') return 'pdf';
if (textExtensions.has(ext)) return 'text';
return 'none';
}
// Byte limit per kind. null means no limit because media streams with Range
// requests and is never fully loaded into memory.
export function getPreviewSizeLimit(kind: PreviewKind): number | null {
switch (kind) {
case 'text':
return TEXT_PREVIEW_MAX_BYTES;
case 'image':
return IMAGE_PREVIEW_MAX_BYTES;
case 'pdf':
return PDF_PREVIEW_MAX_BYTES;
case 'video':
case 'audio':
return null;
case 'none':
return 0;
}
}
// The backend rewrites unsafe Content-Types to application/octet-stream, so
// blob URLs need their MIME type restored for the SVG <img> case and the
// browser PDF viewer.
export function getPreviewMime(kind: PreviewKind, contentType: string | undefined, key: string): string {
if (kind === 'image') {
return imageMimeByExtension[getExtension(key)] ?? contentType ?? 'application/octet-stream';
}
if (kind === 'pdf') return 'application/pdf';
return contentType || 'application/octet-stream';
}
const languageByExtension: Record<string, string> = {
json: 'json',
yaml: 'yaml',
yml: 'yaml',
ini: 'ini',
toml: 'ini',
xml: 'xml',
md: 'markdown',
sh: 'bash',
bash: 'bash',
py: 'python',
js: 'javascript',
jsx: 'javascript',
ts: 'typescript',
tsx: 'typescript',
go: 'go',
sql: 'sql',
html: 'xml',
css: 'css',
dockerfile: 'dockerfile',
};
export function getHighlightLanguage(key: string): string | null {
return languageByExtension[getExtension(key)] ?? null;
}
// A text preview only makes sense for content that decodes as text. More
// than 10 percent replacement or non-whitespace control characters in the
// sample means the file is binary despite its name.
export function looksBinary(sample: string): boolean {
if (sample.length === 0) return false;
let suspicious = 0;
let total = 0;
for (const ch of sample) {
total++;
const code = ch.codePointAt(0) ?? 0;
if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) {
suspicious++;
}
}
return suspicious / total > 0.1;
}
+10
View File
@@ -0,0 +1,10 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
// Testing Library's automatic cleanup only self-registers when a global
// afterEach exists. This harness does not set test.globals, so register it
// explicitly to unmount rendered trees between tests.
afterEach(() => {
cleanup();
});