content disposition

This commit is contained in:
2025-11-02 02:26:40 +01:00
parent b9c95437d2
commit b561f46d2c
6 changed files with 53 additions and 48 deletions
+12
View File
@@ -1113,10 +1113,13 @@ pub async fn get_document_asset(
let mut object_responses = Vec::with_capacity(objects.len()); let mut object_responses = Vec::with_capacity(objects.len());
for object in objects { for object in objects {
let response_disposition = presign_disposition_for_asset(&asset, &object);
let url = storage let url = storage
.presign_get_object( .presign_get_object(
&object.s3_key, &object.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
response_disposition.as_deref(),
) )
.await .await
.storage_context("failed to generate asset URL")?; .storage_context("failed to generate asset URL")?;
@@ -1135,6 +1138,14 @@ pub async fn get_document_asset(
Ok(Json(to_asset_detail_response(asset, object_responses))) Ok(Json(to_asset_detail_response(asset, object_responses)))
} }
fn presign_disposition_for_asset(
asset: &DocumentAsset,
object: &DocumentAssetObject,
) -> Option<String> {
let filename = format!("{}-{}", asset.asset_type, object.ordinal);
inline_content_disposition(&filename)
}
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/documents/{id}/versions", path = "/api/documents/{id}/versions",
@@ -1271,6 +1282,7 @@ pub async fn download_with_token(
.presign_get_object( .presign_get_object(
&version.s3_key, &version.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
None,
) )
.await .await
.storage_context("failed to generate download URL")?; .storage_context("failed to generate download URL")?;
+1
View File
@@ -320,6 +320,7 @@ async fn stream_document(
.presign_get_object( .presign_get_object(
&version.s3_key, &version.s3_key,
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS), Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
None,
) )
.await .await
.storage_context("failed to presign document download")?; .storage_context("failed to presign document download")?;
+27 -9
View File
@@ -20,7 +20,12 @@ pub trait ObjectStorage: Send + Sync + 'static {
content_disposition: Option<String>, content_disposition: Option<String>,
) -> Result<()>; ) -> Result<()>;
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String>; async fn presign_get_object(
&self,
key: &str,
expires_in: Duration,
response_content_disposition: Option<&str>,
) -> Result<String>;
async fn get_object(&self, key: &str) -> Result<Vec<u8>>; async fn get_object(&self, key: &str) -> Result<Vec<u8>>;
@@ -73,17 +78,23 @@ impl ObjectStorage for S3Storage {
Ok(()) Ok(())
} }
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> { async fn presign_get_object(
&self,
key: &str,
expires_in: Duration,
response_content_disposition: Option<&str>,
) -> Result<String> {
let presign_config = PresigningConfig::builder() let presign_config = PresigningConfig::builder()
.expires_in(expires_in) .expires_in(expires_in)
.build() .build()
.context("failed to build S3 presigning config")?; .context("failed to build S3 presigning config")?;
let presigned = self let mut request = self.client.get_object().bucket(&self.bucket).key(key);
.client if let Some(value) = response_content_disposition {
.get_object() request = request.response_content_disposition(value);
.bucket(&self.bucket) }
.key(key)
let presigned = request
.presigned(presign_config) .presigned(presign_config)
.await .await
.context("failed to generate presigned download URL")?; .context("failed to generate presigned download URL")?;
@@ -157,9 +168,16 @@ impl TenantStorage {
.await .await
} }
pub async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> { pub async fn presign_get_object(
&self,
key: &str,
expires_in: Duration,
response_content_disposition: Option<&str>,
) -> Result<String> {
let qualified = self.qualify(key); let qualified = self.qualify(key);
self.inner.presign_get_object(&qualified, expires_in).await self.inner
.presign_get_object(&qualified, expires_in, response_content_disposition)
.await
} }
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>> { pub async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
+1 -21
View File
@@ -6,7 +6,6 @@ import {
ChevronsRightIcon, ChevronsRightIcon,
AnalyzeIcon, AnalyzeIcon,
WindowMaximizeIcon, WindowMaximizeIcon,
TextScanIcon,
} from '../ui/icons'; } from '../ui/icons';
import PanelHeader from '../ui/PanelHeader'; import PanelHeader from '../ui/PanelHeader';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
@@ -169,7 +168,7 @@ const DetailPanel = ({
[selectedDocuments], [selectedDocuments],
); );
const { downloadHref: singleDownloadHref, hasOcr: singleHasOcr, openOcr } = useMemo( const { downloadHref: singleDownloadHref } = useMemo(
() => () =>
createDocumentActionState({ createDocumentActionState({
document: singleDoc, document: singleDoc,
@@ -851,7 +850,6 @@ const DetailPanel = ({
}; };
const isBulkSelection = selectedCount > 1; const isBulkSelection = selectedCount > 1;
const showOcrAction = Boolean(singleDoc && singleHasOcr);
const headerLeading = [ const headerLeading = [
( (
@@ -924,24 +922,6 @@ const DetailPanel = ({
); );
} }
if (showOcrAction) {
headerActions.push(
<button
key="ocr"
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
openOcr().catch(() => {});
}}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>,
);
}
if (singleDoc) { if (singleDoc) {
headerActions.push( headerActions.push(
<button <button
+8 -1
View File
@@ -270,13 +270,20 @@ const DocumentsPanel = ({
} }
if (entry.type === EntryType.folder) { if (entry.type === EntryType.folder) {
const hasModifier = Boolean(
event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey),
);
const isPrimaryClick = Boolean(event && event.type === 'click' && event.button === 0);
if (!hasModifier && isPrimaryClick && typeof onFolderSelect === 'function') {
onFolderSelect(entry.id);
}
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.focus({ preventScroll: true }); scrollRef.current.focus({ preventScroll: true });
} }
onFocusedRowChange?.(rowKey); onFocusedRowChange?.(rowKey);
} }
}, },
[onRowSelection, onOpenDetailPanel, onFocusedRowChange], [onRowSelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect],
); );
const handleDocumentClick = useCallback( const handleDocumentClick = useCallback(
+4 -17
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons'; import { DownloadIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
import DocumentSummarySection, { import DocumentSummarySection, {
buildCorrespondentOptions, buildCorrespondentOptions,
sortCorrespondents, sortCorrespondents,
@@ -249,9 +249,9 @@ const DocumentViewerPanel = ({
{ocrError} {ocrError}
</div> </div>
) : ocrUrl ? ( ) : ocrUrl ? (
<embed <iframe
src={ocrUrl} src={ocrUrl}
type="text/plain" title={`OCR content for ${document.title || document.original_name || 'document'}`}
className="document-viewer__object document-viewer__object--ocr" className="document-viewer__object document-viewer__object--ocr"
/> />
) : ( ) : (
@@ -293,7 +293,7 @@ export const createDocumentViewerHeaderActions = ({
return null; return null;
} }
const { downloadHref, hasOcr, openOcr } = actionState; const { downloadHref } = actionState;
return ( return (
<> <>
@@ -309,19 +309,6 @@ export const createDocumentViewerHeaderActions = ({
<DownloadIcon /> <DownloadIcon />
</a> </a>
) : null} ) : null}
{hasOcr ? (
<button
type="button"
className="icon-button"
onClick={() => {
openOcr().catch(() => {});
}}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
<button <button
type="button" type="button"
className="icon-button" className="icon-button"