Fix authenticated media flows and upload preflight handling

This commit is contained in:
2026-02-21 15:53:02 -03:00
parent 1cb6bfee58
commit c3f34b38b4
12 changed files with 619 additions and 35 deletions

View File

@@ -1,6 +1,8 @@
"""FastAPI entrypoint for the DMS backend service.""" """FastAPI entrypoint for the DMS backend service."""
from fastapi import FastAPI, Request from typing import Awaitable, Callable
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -14,6 +16,18 @@ from app.services.typesense_index import ensure_typesense_collection
settings = get_settings() settings = get_settings()
UPLOAD_ENDPOINT_PATH = "/api/v1/documents/upload"
UPLOAD_ENDPOINT_METHOD = "POST"
def _is_upload_size_guard_target(request: Request) -> bool:
"""Returns whether upload request-size enforcement applies to this request.
Upload-size validation is intentionally scoped to the upload POST endpoint so CORS
preflight OPTIONS requests can pass through CORSMiddleware.
"""
return request.method.upper() == UPLOAD_ENDPOINT_METHOD and request.url.path == UPLOAD_ENDPOINT_PATH
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -30,10 +44,13 @@ def create_app() -> FastAPI:
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")
@app.middleware("http") @app.middleware("http")
async def enforce_upload_request_size(request: Request, call_next): async def enforce_upload_request_size(
"""Rejects upload requests without deterministic length or exceeding configured limits.""" request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Rejects only POST upload bodies without deterministic length or with oversized request totals."""
if request.url.path.endswith("/api/v1/documents/upload"): if _is_upload_size_guard_target(request):
content_length = request.headers.get("content-length", "").strip() content_length = request.headers.get("content-length", "").strip()
if not content_length: if not content_length:
return JSONResponse( return JSONResponse(

View File

@@ -0,0 +1,270 @@
"""Regression tests for upload request-size middleware scope and preflight handling."""
from __future__ import annotations
import importlib
import sys
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Any, Awaitable, Callable
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
def _install_main_import_stubs() -> dict[str, ModuleType | None]:
"""Installs lightweight module stubs required for importing app.main in isolation."""
previous_modules: dict[str, ModuleType | None] = {
name: sys.modules.get(name)
for name in [
"fastapi",
"fastapi.middleware",
"fastapi.middleware.cors",
"fastapi.responses",
"app.api.router",
"app.core.config",
"app.db.base",
"app.services.app_settings",
"app.services.handwriting_style",
"app.services.storage",
"app.services.typesense_index",
]
}
fastapi_stub = ModuleType("fastapi")
class _Response:
"""Minimal response base class for middleware typing compatibility."""
class _FastAPI:
"""Captures middleware registration behavior used by app.main tests."""
def __init__(self, *_args: object, **_kwargs: object) -> None:
self.http_middlewares: list[Any] = []
def add_middleware(self, *_args: object, **_kwargs: object) -> None:
"""Accepts middleware registrations without side effects."""
def include_router(self, *_args: object, **_kwargs: object) -> None:
"""Accepts router registration without side effects."""
def middleware(
self,
middleware_type: str,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Registers request middleware functions for later invocation in tests."""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
if middleware_type == "http":
self.http_middlewares.append(func)
return func
return decorator
def on_event(
self,
*_args: object,
**_kwargs: object,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Returns no-op startup and shutdown decorators."""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
fastapi_stub.FastAPI = _FastAPI
fastapi_stub.Request = object
fastapi_stub.Response = _Response
sys.modules["fastapi"] = fastapi_stub
fastapi_middleware_stub = ModuleType("fastapi.middleware")
sys.modules["fastapi.middleware"] = fastapi_middleware_stub
fastapi_middleware_cors_stub = ModuleType("fastapi.middleware.cors")
class _CORSMiddleware:
"""Placeholder CORS middleware class accepted by FastAPI.add_middleware."""
fastapi_middleware_cors_stub.CORSMiddleware = _CORSMiddleware
sys.modules["fastapi.middleware.cors"] = fastapi_middleware_cors_stub
fastapi_responses_stub = ModuleType("fastapi.responses")
class _JSONResponse:
"""Simple JSONResponse stand-in exposing status code and payload fields."""
def __init__(self, *, status_code: int, content: dict[str, Any]) -> None:
self.status_code = status_code
self.content = content
fastapi_responses_stub.JSONResponse = _JSONResponse
sys.modules["fastapi.responses"] = fastapi_responses_stub
api_router_stub = ModuleType("app.api.router")
api_router_stub.api_router = object()
sys.modules["app.api.router"] = api_router_stub
config_stub = ModuleType("app.core.config")
def get_settings() -> SimpleNamespace:
"""Returns minimal settings consumed by app.main during test import."""
return SimpleNamespace(
cors_origins=["http://localhost:5173"],
max_upload_request_size_bytes=1024,
)
config_stub.get_settings = get_settings
sys.modules["app.core.config"] = config_stub
db_base_stub = ModuleType("app.db.base")
def init_db() -> None:
"""No-op database initializer for middleware scope tests."""
db_base_stub.init_db = init_db
sys.modules["app.db.base"] = db_base_stub
app_settings_stub = ModuleType("app.services.app_settings")
def ensure_app_settings() -> None:
"""No-op settings initializer for middleware scope tests."""
app_settings_stub.ensure_app_settings = ensure_app_settings
sys.modules["app.services.app_settings"] = app_settings_stub
handwriting_style_stub = ModuleType("app.services.handwriting_style")
def ensure_handwriting_style_collection() -> None:
"""No-op handwriting collection initializer for middleware scope tests."""
handwriting_style_stub.ensure_handwriting_style_collection = ensure_handwriting_style_collection
sys.modules["app.services.handwriting_style"] = handwriting_style_stub
storage_stub = ModuleType("app.services.storage")
def ensure_storage() -> None:
"""No-op storage initializer for middleware scope tests."""
storage_stub.ensure_storage = ensure_storage
sys.modules["app.services.storage"] = storage_stub
typesense_stub = ModuleType("app.services.typesense_index")
def ensure_typesense_collection() -> None:
"""No-op Typesense collection initializer for middleware scope tests."""
typesense_stub.ensure_typesense_collection = ensure_typesense_collection
sys.modules["app.services.typesense_index"] = typesense_stub
return previous_modules
def _restore_main_import_stubs(previous_modules: dict[str, ModuleType | None]) -> None:
"""Restores module table entries captured before installing app.main test stubs."""
for module_name, previous in previous_modules.items():
if previous is None:
sys.modules.pop(module_name, None)
else:
sys.modules[module_name] = previous
class UploadRequestSizeMiddlewareTests(unittest.IsolatedAsyncioTestCase):
"""Verifies upload request-size middleware ignores preflight and guards only upload POST."""
@classmethod
def setUpClass(cls) -> None:
"""Installs import stubs and imports app.main once for middleware extraction."""
cls._previous_modules = _install_main_import_stubs()
cls.main_module = importlib.import_module("app.main")
@classmethod
def tearDownClass(cls) -> None:
"""Removes imported module and restores pre-existing module table entries."""
sys.modules.pop("app.main", None)
_restore_main_import_stubs(cls._previous_modules)
def _http_middleware(
self,
) -> Callable[[object, Callable[[object], Awaitable[object]]], Awaitable[object]]:
"""Returns the registered HTTP middleware callable from the stubbed FastAPI app."""
return self.main_module.app.http_middlewares[0]
async def test_options_preflight_skips_upload_content_length_guard(self) -> None:
"""OPTIONS preflight requests for upload endpoint continue without Content-Length enforcement."""
request = SimpleNamespace(
method="OPTIONS",
url=SimpleNamespace(path="/api/v1/documents/upload"),
headers={},
)
expected_response = object()
call_next_count = 0
async def call_next(_request: object) -> object:
nonlocal call_next_count
call_next_count += 1
return expected_response
response = await self._http_middleware()(request, call_next)
self.assertIs(response, expected_response)
self.assertEqual(call_next_count, 1)
async def test_post_upload_without_content_length_is_rejected(self) -> None:
"""Upload POST requests remain blocked when Content-Length is absent."""
request = SimpleNamespace(
method="POST",
url=SimpleNamespace(path="/api/v1/documents/upload"),
headers={},
)
call_next_count = 0
async def call_next(_request: object) -> object:
nonlocal call_next_count
call_next_count += 1
return object()
response = await self._http_middleware()(request, call_next)
self.assertEqual(response.status_code, 411)
self.assertEqual(
response.content,
{"detail": "Content-Length header is required for document uploads"},
)
self.assertEqual(call_next_count, 0)
async def test_post_non_upload_path_skips_upload_content_length_guard(self) -> None:
"""Content-Length enforcement does not run for non-upload POST requests."""
request = SimpleNamespace(
method="POST",
url=SimpleNamespace(path="/api/v1/documents"),
headers={},
)
expected_response = object()
call_next_count = 0
async def call_next(_request: object) -> object:
nonlocal call_next_count
call_next_count += 1
return expected_response
response = await self._http_middleware()(request, call_next)
self.assertIs(response, expected_response)
self.assertEqual(call_next_count, 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -9,4 +9,4 @@ This directory contains technical documentation for DMS.
- `api-contract.md` - API endpoint contract grouped by route module, including token auth roles, upload limits, and settings or processing-log security constraints - `api-contract.md` - API endpoint contract grouped by route module, including token auth roles, upload limits, and settings or processing-log security constraints
- `data-model-reference.md` - database entity definitions and lifecycle states - `data-model-reference.md` - database entity definitions and lifecycle states
- `operations-and-configuration.md` - runtime operations, hardened compose defaults, security environment variables, and persisted settings configuration and read-sanitization behavior - `operations-and-configuration.md` - runtime operations, hardened compose defaults, security environment variables, and persisted settings configuration and read-sanitization behavior
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, processing-log timeline behavior, and settings helper-copy guidance - `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, authenticated media delivery under API token auth, processing-log timeline behavior, and settings helper-copy guidance

View File

@@ -89,7 +89,8 @@ Primary implementation modules:
- `ask`: returns `conflicts` if duplicate checksum is detected - `ask`: returns `conflicts` if duplicate checksum is detected
- `replace`: creates new document linked to replaced document id - `replace`: creates new document linked to replaced document id
- `duplicate`: creates additional document record - `duplicate`: creates additional document record
- request rejected with `411` when `Content-Length` is missing - upload `POST` request rejected with `411` when `Content-Length` is missing
- `OPTIONS /documents/upload` CORS preflight bypasses upload `Content-Length` enforcement
- request rejected with `413` when file count, per-file size, or total request size exceeds configured limits - request rejected with `413` when file count, per-file size, or total request size exceeds configured limits
## Search ## Search

View File

@@ -49,6 +49,13 @@ Do not hardcode new palette or spacing values in component styles when a token a
- Do not render queued headers before their animation starts, even when polling returns batched updates. - Do not render queued headers before their animation starts, even when polling returns batched updates.
- Preserve existing header content format and fold/unfold detail behavior as lines are revealed. - Preserve existing header content format and fold/unfold detail behavior as lines are revealed.
## Authenticated Media Delivery
- Document previews and thumbnails must load through authenticated fetch flows in `frontend/src/lib/api.ts`, then render via temporary object URLs.
- Direct `window.open` calls for protected media endpoints are not allowed because browser navigation requests do not include the API token header.
- Download actions for original files and markdown exports must use authenticated blob fetches plus controlled browser download triggers.
- Revoke all temporary object URLs after replacement, unmount, or completion to prevent browser memory leaks.
## Extension Checklist ## Extension Checklist
When adding or redesigning a UI area: When adding or redesigning a UI area:

View File

@@ -149,7 +149,8 @@ Retention settings are used by worker cleanup and by `POST /api/v1/processing/lo
- scheme restrictions (`https` by default) - scheme restrictions (`https` by default)
- local/private-network blocking and per-request DNS revalidation checks for outbound runtime calls - local/private-network blocking and per-request DNS revalidation checks for outbound runtime calls
- Upload and archive safety guards are enforced: - Upload and archive safety guards are enforced:
- multipart upload requires `Content-Length` and enforces file-count, per-file size, and total request size limits - `POST /api/v1/documents/upload` requires `Content-Length` and enforces file-count, per-file size, and total request size limits
- `OPTIONS /api/v1/documents/upload` CORS preflight is excluded from `Content-Length` enforcement
- ZIP member count, per-member uncompressed size, total decompressed size, and compression-ratio guards - ZIP member count, per-member uncompressed size, total decompressed size, and compression-ratio guards
- Processing logs redact sensitive payload and text fields, and trim endpoints enforce retention caps from runtime config. - Processing logs redact sensitive payload and text fields, and trim endpoints enforce retention caps from runtime config.
- Compose hardening defaults: - Compose hardening defaults:

View File

@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"test": "node --experimental-strip-types src/lib/api.test.ts",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173" "preview": "vite preview --host 0.0.0.0 --port 4173"
}, },

View File

@@ -14,6 +14,7 @@ import SettingsScreen from './components/SettingsScreen';
import UploadSurface from './components/UploadSurface'; import UploadSurface from './components/UploadSurface';
import { import {
clearProcessingLogs, clearProcessingLogs,
downloadBlobFile,
deleteDocument, deleteDocument,
exportContentsMarkdown, exportContentsMarkdown,
getAppSettings, 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 loadCatalogs = useCallback(async (): Promise<void> => {
const [tags, paths, types] = await Promise.all([listTags(true), listPaths(true), listTypes(true)]); const [tags, paths, types] = await Promise.all([listTags(true), listPaths(true), listTypes(true)]);
setKnownTags(tags); setKnownTags(tags);
@@ -465,13 +457,13 @@ export default function App(): JSX.Element {
only_trashed: documentView === 'trash', only_trashed: documentView === 'trash',
include_trashed: false, include_trashed: false,
}); });
downloadBlob(result.blob, result.filename); downloadBlobFile(result.blob, result.filename);
} catch (caughtError) { } catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to export selected markdown files'); setError(caughtError instanceof Error ? caughtError.message : 'Failed to export selected markdown files');
} finally { } finally {
setIsRunningBulkAction(false); setIsRunningBulkAction(false);
} }
}, [documentView, downloadBlob, selectedDocumentIds]); }, [documentView, selectedDocumentIds]);
const handleExportPath = useCallback(async (): Promise<void> => { const handleExportPath = useCallback(async (): Promise<void> => {
const trimmedPrefix = exportPathInput.trim(); const trimmedPrefix = exportPathInput.trim();
@@ -487,13 +479,13 @@ export default function App(): JSX.Element {
only_trashed: documentView === 'trash', only_trashed: documentView === 'trash',
include_trashed: false, include_trashed: false,
}); });
downloadBlob(result.blob, result.filename); downloadBlobFile(result.blob, result.filename);
} catch (caughtError) { } catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to export path markdown files'); setError(caughtError instanceof Error ? caughtError.message : 'Failed to export path markdown files');
} finally { } finally {
setIsRunningBulkAction(false); setIsRunningBulkAction(false);
} }
}, [documentView, downloadBlob, exportPathInput]); }, [documentView, exportPathInput]);
const handleSaveSettings = useCallback(async (payload: AppSettingsUpdate): Promise<void> => { const handleSaveSettings = useCallback(async (payload: AppSettingsUpdate): Promise<void> => {
setIsSavingSettings(true); setIsSavingSettings(true);

View File

@@ -1,12 +1,17 @@
/** /**
* Card view for displaying document summary, preview, and metadata. * 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 type { JSX } from 'react';
import { Download, FileText, Trash2 } from 'lucide-react'; import { Download, FileText, Trash2 } from 'lucide-react';
import type { DmsDocument } from '../types'; 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. * Defines properties accepted by the document card component.
@@ -79,12 +84,59 @@ export default function DocumentCard({
onFilterTag, onFilterTag,
}: DocumentCardProps): JSX.Element { }: DocumentCardProps): JSX.Element {
const [isTrashing, setIsTrashing] = useState<boolean>(false); 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 createdDate = new Date(document.created_at).toLocaleString();
const status = statusPresentation(document.status); const status = statusPresentation(document.status);
const compactPath = compactLogicalPath(document.logical_path, 180); const compactPath = compactLogicalPath(document.logical_path, 180);
const trashDisabled = isTrashView || document.status === 'trashed' || isTrashing; const trashDisabled = isTrashView || document.status === 'trashed' || isTrashing;
const trashTitle = trashDisabled ? 'Already in trash' : 'Move to trash'; 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 ( return (
<article <article
className={`document-card ${isSelected ? 'selected' : ''}`} className={`document-card ${isSelected ? 'selected' : ''}`}
@@ -119,8 +171,8 @@ export default function DocumentCard({
</label> </label>
</header> </header>
<div className="document-preview"> <div className="document-preview">
{document.preview_available ? ( {document.preview_available && thumbnailObjectUrl ? (
<img src={thumbnailUrl(document.id)} alt={document.original_filename} loading="lazy" /> <img src={thumbnailObjectUrl} alt={document.original_filename} loading="lazy" />
) : ( ) : (
<div className="document-preview-fallback">{document.extension || 'file'}</div> <div className="document-preview-fallback">{document.extension || 'file'}</div>
)} )}
@@ -173,7 +225,13 @@ export default function DocumentCard({
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); 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" /> <Download aria-hidden="true" />
@@ -186,7 +244,13 @@ export default function DocumentCard({
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); 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" /> <FileText aria-hidden="true" />

View File

@@ -1,14 +1,15 @@
/** /**
* Embedded document viewer panel for preview, metadata updates, and lifecycle actions. * 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 type { JSX } from 'react';
import { import {
contentMarkdownUrl, downloadBlobFile,
downloadDocumentContentMarkdown,
deleteDocument, deleteDocument,
getDocumentDetails, getDocumentDetails,
previewUrl, getDocumentPreviewBlob,
reprocessDocument, reprocessDocument,
restoreDocument, restoreDocument,
trashDocument, trashDocument,
@@ -44,6 +45,8 @@ export default function DocumentViewer({
requestConfirmation, requestConfirmation,
}: DocumentViewerProps): JSX.Element { }: DocumentViewerProps): JSX.Element {
const [documentDetail, setDocumentDetail] = useState<DmsDocumentDetail | null>(null); 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 [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
const [originalFilename, setOriginalFilename] = useState<string>(''); const [originalFilename, setOriginalFilename] = useState<string>('');
const [logicalPath, setLogicalPath] = useState<string>(''); const [logicalPath, setLogicalPath] = useState<string>('');
@@ -55,6 +58,7 @@ export default function DocumentViewer({
const [isDeleting, setIsDeleting] = useState<boolean>(false); const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isMetadataDirty, setIsMetadataDirty] = useState<boolean>(false); const [isMetadataDirty, setIsMetadataDirty] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const previewObjectUrlRef = useRef<string | null>(null);
/** /**
* Syncs editable metadata fields whenever selection changes. * Syncs editable metadata fields whenever selection changes.
@@ -62,6 +66,12 @@ export default function DocumentViewer({
useEffect(() => { useEffect(() => {
if (!document) { if (!document) {
setDocumentDetail(null); setDocumentDetail(null);
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current);
previewObjectUrlRef.current = null;
}
setPreviewObjectUrl(null);
setIsLoadingPreview(false);
setIsMetadataDirty(false); setIsMetadataDirty(false);
return; return;
} }
@@ -72,6 +82,57 @@ export default function DocumentViewer({
setError(null); setError(null);
}, [document?.id]); }, [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. * Refreshes editable metadata from list updates only while form is clean.
*/ */
@@ -418,10 +479,16 @@ export default function DocumentViewer({
<h2>{document.original_filename}</h2> <h2>{document.original_filename}</h2>
<p className="small">Status: {document.status}</p> <p className="small">Status: {document.status}</p>
<div className="viewer-preview"> <div className="viewer-preview">
{isImageDocument ? ( {previewObjectUrl ? (
<img src={previewUrl(document.id)} alt={document.original_filename} /> isImageDocument ? (
<img src={previewObjectUrl} alt={document.original_filename} />
) : ( ) : (
<iframe src={previewUrl(document.id)} title={document.original_filename} /> <iframe src={previewObjectUrl} title={document.original_filename} />
)
) : isLoadingPreview ? (
<p className="small">Loading preview...</p>
) : (
<p className="small">Preview unavailable for this document.</p>
)} )}
</div> </div>
<label> <label>
@@ -561,7 +628,16 @@ export default function DocumentViewer({
<button <button
type="button" type="button"
className="secondary-action" 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} disabled={isDeleting}
title="Downloads recognized/extracted content as markdown for this document." title="Downloads recognized/extracted content as markdown for this document."
> >

View 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();

View File

@@ -16,12 +16,12 @@ import type {
/** /**
* Resolves backend base URL from environment with localhost fallback. * 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. * 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 }; type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
@@ -78,6 +78,22 @@ function responseFilename(response: Response, fallback: string): string {
return match[1]; 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. * Loads documents from the backend list endpoint.
*/ */
@@ -376,6 +392,60 @@ export function contentMarkdownUrl(documentId: string): string {
return `${API_BASE}/documents/${documentId}/content-md`; 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. * Exports extracted content markdown files for selected documents or path filters.
*/ */