Fix authenticated media flows and upload preflight handling
This commit is contained in:
@@ -14,6 +14,7 @@ import SettingsScreen from './components/SettingsScreen';
|
||||
import UploadSurface from './components/UploadSurface';
|
||||
import {
|
||||
clearProcessingLogs,
|
||||
downloadBlobFile,
|
||||
deleteDocument,
|
||||
exportContentsMarkdown,
|
||||
getAppSettings,
|
||||
@@ -117,15 +118,6 @@ export default function App(): JSX.Element {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const downloadBlob = useCallback((blob: Blob, filename: string): void => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}, []);
|
||||
|
||||
const loadCatalogs = useCallback(async (): Promise<void> => {
|
||||
const [tags, paths, types] = await Promise.all([listTags(true), listPaths(true), listTypes(true)]);
|
||||
setKnownTags(tags);
|
||||
@@ -465,13 +457,13 @@ export default function App(): JSX.Element {
|
||||
only_trashed: documentView === 'trash',
|
||||
include_trashed: false,
|
||||
});
|
||||
downloadBlob(result.blob, result.filename);
|
||||
downloadBlobFile(result.blob, result.filename);
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to export selected markdown files');
|
||||
} finally {
|
||||
setIsRunningBulkAction(false);
|
||||
}
|
||||
}, [documentView, downloadBlob, selectedDocumentIds]);
|
||||
}, [documentView, selectedDocumentIds]);
|
||||
|
||||
const handleExportPath = useCallback(async (): Promise<void> => {
|
||||
const trimmedPrefix = exportPathInput.trim();
|
||||
@@ -487,13 +479,13 @@ export default function App(): JSX.Element {
|
||||
only_trashed: documentView === 'trash',
|
||||
include_trashed: false,
|
||||
});
|
||||
downloadBlob(result.blob, result.filename);
|
||||
downloadBlobFile(result.blob, result.filename);
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to export path markdown files');
|
||||
} finally {
|
||||
setIsRunningBulkAction(false);
|
||||
}
|
||||
}, [documentView, downloadBlob, exportPathInput]);
|
||||
}, [documentView, exportPathInput]);
|
||||
|
||||
const handleSaveSettings = useCallback(async (payload: AppSettingsUpdate): Promise<void> => {
|
||||
setIsSavingSettings(true);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/**
|
||||
* Card view for displaying document summary, preview, and metadata.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import { Download, FileText, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { DmsDocument } from '../types';
|
||||
import { contentMarkdownUrl, downloadUrl, thumbnailUrl } from '../lib/api';
|
||||
import {
|
||||
downloadBlobFile,
|
||||
downloadDocumentContentMarkdown,
|
||||
downloadDocumentFile,
|
||||
getDocumentThumbnailBlob,
|
||||
} from '../lib/api';
|
||||
|
||||
/**
|
||||
* Defines properties accepted by the document card component.
|
||||
@@ -79,12 +84,59 @@ export default function DocumentCard({
|
||||
onFilterTag,
|
||||
}: DocumentCardProps): JSX.Element {
|
||||
const [isTrashing, setIsTrashing] = useState<boolean>(false);
|
||||
const [thumbnailObjectUrl, setThumbnailObjectUrl] = useState<string | null>(null);
|
||||
const thumbnailObjectUrlRef = useRef<string | null>(null);
|
||||
const createdDate = new Date(document.created_at).toLocaleString();
|
||||
const status = statusPresentation(document.status);
|
||||
const compactPath = compactLogicalPath(document.logical_path, 180);
|
||||
const trashDisabled = isTrashView || document.status === 'trashed' || isTrashing;
|
||||
const trashTitle = trashDisabled ? 'Already in trash' : 'Move to trash';
|
||||
|
||||
/**
|
||||
* Loads thumbnail preview through authenticated fetch and revokes replaced object URLs.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const revokeThumbnailObjectUrl = (): void => {
|
||||
if (!thumbnailObjectUrlRef.current) {
|
||||
return;
|
||||
}
|
||||
URL.revokeObjectURL(thumbnailObjectUrlRef.current);
|
||||
thumbnailObjectUrlRef.current = null;
|
||||
};
|
||||
|
||||
if (!document.preview_available) {
|
||||
revokeThumbnailObjectUrl();
|
||||
setThumbnailObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadThumbnail = async (): Promise<void> => {
|
||||
try {
|
||||
const blob = await getDocumentThumbnailBlob(document.id);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
revokeThumbnailObjectUrl();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
thumbnailObjectUrlRef.current = objectUrl;
|
||||
setThumbnailObjectUrl(objectUrl);
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
revokeThumbnailObjectUrl();
|
||||
setThumbnailObjectUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
void loadThumbnail();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
revokeThumbnailObjectUrl();
|
||||
};
|
||||
}, [document.id, document.preview_available]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`document-card ${isSelected ? 'selected' : ''}`}
|
||||
@@ -119,8 +171,8 @@ export default function DocumentCard({
|
||||
</label>
|
||||
</header>
|
||||
<div className="document-preview">
|
||||
{document.preview_available ? (
|
||||
<img src={thumbnailUrl(document.id)} alt={document.original_filename} loading="lazy" />
|
||||
{document.preview_available && thumbnailObjectUrl ? (
|
||||
<img src={thumbnailObjectUrl} alt={document.original_filename} loading="lazy" />
|
||||
) : (
|
||||
<div className="document-preview-fallback">{document.extension || 'file'}</div>
|
||||
)}
|
||||
@@ -173,7 +225,13 @@ export default function DocumentCard({
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
window.open(downloadUrl(document.id), '_blank', 'noopener,noreferrer');
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const payload = await downloadDocumentFile(document.id);
|
||||
downloadBlobFile(payload.blob, payload.filename);
|
||||
} catch {
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Download aria-hidden="true" />
|
||||
@@ -186,7 +244,13 @@ export default function DocumentCard({
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer');
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const payload = await downloadDocumentContentMarkdown(document.id);
|
||||
downloadBlobFile(payload.blob, payload.filename);
|
||||
} catch {
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<FileText aria-hidden="true" />
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Embedded document viewer panel for preview, metadata updates, and lifecycle actions.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
import {
|
||||
contentMarkdownUrl,
|
||||
downloadBlobFile,
|
||||
downloadDocumentContentMarkdown,
|
||||
deleteDocument,
|
||||
getDocumentDetails,
|
||||
previewUrl,
|
||||
getDocumentPreviewBlob,
|
||||
reprocessDocument,
|
||||
restoreDocument,
|
||||
trashDocument,
|
||||
@@ -44,6 +45,8 @@ export default function DocumentViewer({
|
||||
requestConfirmation,
|
||||
}: DocumentViewerProps): JSX.Element {
|
||||
const [documentDetail, setDocumentDetail] = useState<DmsDocumentDetail | null>(null);
|
||||
const [previewObjectUrl, setPreviewObjectUrl] = useState<string | null>(null);
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState<boolean>(false);
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
||||
const [originalFilename, setOriginalFilename] = useState<string>('');
|
||||
const [logicalPath, setLogicalPath] = useState<string>('');
|
||||
@@ -55,6 +58,7 @@ export default function DocumentViewer({
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [isMetadataDirty, setIsMetadataDirty] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const previewObjectUrlRef = useRef<string | null>(null);
|
||||
|
||||
/**
|
||||
* Syncs editable metadata fields whenever selection changes.
|
||||
@@ -62,6 +66,12 @@ export default function DocumentViewer({
|
||||
useEffect(() => {
|
||||
if (!document) {
|
||||
setDocumentDetail(null);
|
||||
if (previewObjectUrlRef.current) {
|
||||
URL.revokeObjectURL(previewObjectUrlRef.current);
|
||||
previewObjectUrlRef.current = null;
|
||||
}
|
||||
setPreviewObjectUrl(null);
|
||||
setIsLoadingPreview(false);
|
||||
setIsMetadataDirty(false);
|
||||
return;
|
||||
}
|
||||
@@ -72,6 +82,57 @@ export default function DocumentViewer({
|
||||
setError(null);
|
||||
}, [document?.id]);
|
||||
|
||||
/**
|
||||
* Loads authenticated preview bytes and exposes a temporary object URL for iframe or image rendering.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const revokePreviewObjectUrl = (): void => {
|
||||
if (!previewObjectUrlRef.current) {
|
||||
return;
|
||||
}
|
||||
URL.revokeObjectURL(previewObjectUrlRef.current);
|
||||
previewObjectUrlRef.current = null;
|
||||
};
|
||||
|
||||
if (!document) {
|
||||
revokePreviewObjectUrl();
|
||||
setPreviewObjectUrl(null);
|
||||
setIsLoadingPreview(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingPreview(true);
|
||||
const loadPreview = async (): Promise<void> => {
|
||||
try {
|
||||
const blob = await getDocumentPreviewBlob(document.id);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
revokePreviewObjectUrl();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
previewObjectUrlRef.current = objectUrl;
|
||||
setPreviewObjectUrl(objectUrl);
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
revokePreviewObjectUrl();
|
||||
setPreviewObjectUrl(null);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingPreview(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadPreview();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
revokePreviewObjectUrl();
|
||||
};
|
||||
}, [document?.id]);
|
||||
|
||||
/**
|
||||
* Refreshes editable metadata from list updates only while form is clean.
|
||||
*/
|
||||
@@ -418,10 +479,16 @@ export default function DocumentViewer({
|
||||
<h2>{document.original_filename}</h2>
|
||||
<p className="small">Status: {document.status}</p>
|
||||
<div className="viewer-preview">
|
||||
{isImageDocument ? (
|
||||
<img src={previewUrl(document.id)} alt={document.original_filename} />
|
||||
{previewObjectUrl ? (
|
||||
isImageDocument ? (
|
||||
<img src={previewObjectUrl} alt={document.original_filename} />
|
||||
) : (
|
||||
<iframe src={previewObjectUrl} title={document.original_filename} />
|
||||
)
|
||||
) : isLoadingPreview ? (
|
||||
<p className="small">Loading preview...</p>
|
||||
) : (
|
||||
<iframe src={previewUrl(document.id)} title={document.original_filename} />
|
||||
<p className="small">Preview unavailable for this document.</p>
|
||||
)}
|
||||
</div>
|
||||
<label>
|
||||
@@ -561,7 +628,16 @@ export default function DocumentViewer({
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={() => window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer')}
|
||||
onClick={() => {
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const payload = await downloadDocumentContentMarkdown(document.id);
|
||||
downloadBlobFile(payload.blob, payload.filename);
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to download markdown');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
title="Downloads recognized/extracted content as markdown for this document."
|
||||
>
|
||||
|
||||
85
frontend/src/lib/api.test.ts
Normal file
85
frontend/src/lib/api.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// @ts-expect-error Node strip-types runtime requires explicit .ts extension in ESM imports.
|
||||
import { downloadDocumentContentMarkdown, downloadDocumentFile, getDocumentPreviewBlob, getDocumentThumbnailBlob } from './api.ts';
|
||||
|
||||
/**
|
||||
* Throws when a test condition is false.
|
||||
*/
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that async functions reject with an expected message fragment.
|
||||
*/
|
||||
async function assertRejects(action: () => Promise<unknown>, expectedMessage: string): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
assert(message.includes(expectedMessage), `Expected error containing "${expectedMessage}" but received "${message}"`);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Expected rejection containing "${expectedMessage}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs API helper tests for authenticated media and download flows.
|
||||
*/
|
||||
async function runApiTests(): Promise<void> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
const requestUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
requestUrls.push(typeof input === 'string' ? input : input.toString());
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const thumbnail = await getDocumentThumbnailBlob('doc-1');
|
||||
const preview = await getDocumentPreviewBlob('doc-1');
|
||||
|
||||
assert(await thumbnail.text() === 'preview-bytes', 'Thumbnail blob bytes mismatch');
|
||||
assert(await preview.text() === 'preview-bytes', 'Preview blob bytes mismatch');
|
||||
assert(
|
||||
requestUrls[0] === 'http://localhost:8000/api/v1/documents/doc-1/thumbnail',
|
||||
`Unexpected thumbnail URL ${requestUrls[0]}`,
|
||||
);
|
||||
assert(
|
||||
requestUrls[1] === 'http://localhost:8000/api/v1/documents/doc-1/preview',
|
||||
`Unexpected preview URL ${requestUrls[1]}`,
|
||||
);
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('file-bytes', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="invoice.pdf"',
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const fileResult = await downloadDocumentFile('doc-2');
|
||||
assert(fileResult.filename === 'invoice.pdf', `Unexpected download filename ${fileResult.filename}`);
|
||||
assert((await fileResult.blob.text()) === 'file-bytes', 'Original download bytes mismatch');
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('# markdown', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const markdownResult = await downloadDocumentContentMarkdown('doc-3');
|
||||
assert(markdownResult.filename === 'document-content.md', `Unexpected markdown filename ${markdownResult.filename}`);
|
||||
assert((await markdownResult.blob.text()) === '# markdown', 'Markdown bytes mismatch');
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('forbidden', { status: 401 });
|
||||
}) as typeof fetch;
|
||||
|
||||
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
await runApiTests();
|
||||
@@ -16,12 +16,12 @@ import type {
|
||||
/**
|
||||
* Resolves backend base URL from environment with localhost fallback.
|
||||
*/
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/v1';
|
||||
const API_BASE = import.meta.env?.VITE_API_BASE ?? 'http://localhost:8000/api/v1';
|
||||
|
||||
/**
|
||||
* Optional bearer token used for authenticated backend routes.
|
||||
*/
|
||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN?.trim();
|
||||
const API_TOKEN = import.meta.env?.VITE_API_TOKEN?.trim();
|
||||
|
||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||
|
||||
@@ -78,6 +78,22 @@ function responseFilename(response: Response, fallback: string): string {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser file download for blob payloads and releases temporary object URLs.
|
||||
*/
|
||||
export function downloadBlobFile(blob: Blob, filename: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads documents from the backend list endpoint.
|
||||
*/
|
||||
@@ -376,6 +392,60 @@ export function contentMarkdownUrl(documentId: string): string {
|
||||
return `${API_BASE}/documents/${documentId}/content-md`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads preview bytes for one document using centralized auth headers.
|
||||
*/
|
||||
export async function getDocumentPreviewBlob(documentId: string): Promise<Blob> {
|
||||
const response = await apiRequest(previewUrl(documentId));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load document preview');
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads thumbnail bytes for one document using centralized auth headers.
|
||||
*/
|
||||
export async function getDocumentThumbnailBlob(documentId: string): Promise<Blob> {
|
||||
const response = await apiRequest(thumbnailUrl(documentId));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load document thumbnail');
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the original document payload with backend-provided filename fallback.
|
||||
*/
|
||||
export async function downloadDocumentFile(documentId: string): Promise<{ blob: Blob; filename: string }> {
|
||||
const response = await apiRequest(downloadUrl(documentId));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download document');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
blob,
|
||||
filename: responseFilename(response, 'document-download'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads extracted markdown content for one document with backend-provided filename fallback.
|
||||
*/
|
||||
export async function downloadDocumentContentMarkdown(
|
||||
documentId: string,
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const response = await apiRequest(contentMarkdownUrl(documentId));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download document markdown');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
blob,
|
||||
filename: responseFilename(response, 'document-content.md'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports extracted content markdown files for selected documents or path filters.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user