Fix authenticated media flows and upload preflight handling
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
270
backend/tests/test_upload_request_size_middleware.py
Normal file
270
backend/tests/test_upload_request_size_middleware.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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={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>
|
</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."
|
||||||
>
|
>
|
||||||
|
|||||||
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.
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user