wip manage encrypted attachments

This commit is contained in:
Thomas Ramé
2026-03-03 10:09:12 +01:00
parent 54f2762e79
commit 431bec3970
6 changed files with 201 additions and 20 deletions
+17
View File
@@ -696,6 +696,7 @@ class FileUploadSerializer(serializers.Serializer):
"""Receive file upload requests."""
file = serializers.FileField()
is_encrypted = serializers.BooleanField(default=False, required=False)
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
@@ -706,6 +707,22 @@ class FileUploadSerializer(serializers.Serializer):
f"File size exceeds the maximum limit of {max_size:d} MB."
)
# For encrypted files, the content is ciphertext so MIME detection
# is not possible. Trust the original filename extension.
if self.initial_data.get("is_encrypted") in ("true", "True", True):
extension = (
file.name.rpartition(".")[-1] if "." in file.name else None
)
if extension is None or len(extension) > 5:
raise serializers.ValidationError(
"Could not determine file extension."
)
self.context["expected_extension"] = extension
self.context["content_type"] = "application/octet-stream"
self.context["is_unsafe"] = False
self.context["file_name"] = file.name
return file
extension = file.name.rpartition(".")[-1] if "." in file.name else None
# Read the first few bytes to determine the MIME type accurately
+25 -2
View File
@@ -1480,18 +1480,39 @@ class DocumentViewSet(
serializer = serializers.FileUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
is_file_encrypted = serializer.validated_data.get("is_encrypted", False)
# Encrypted attachments are only allowed on encrypted documents
if is_file_encrypted and not document.is_encrypted:
raise drf.exceptions.ValidationError({
"is_encrypted":
"Cannot upload encrypted attachments to a non-encrypted document."
})
# Generate a generic yet unique filename to store the image in object storage
file_id = uuid.uuid4()
ext = serializer.validated_data["expected_extension"]
# For encrypted files, set status to READY immediately since the server
# cannot inspect ciphertext for malware scanning.
initial_status = (
enums.DocumentAttachmentStatus.READY
if is_file_encrypted
else enums.DocumentAttachmentStatus.PROCESSING
)
# Prepare metadata for storage
extra_args = {
"Metadata": {
"owner": str(request.user.id),
"status": enums.DocumentAttachmentStatus.PROCESSING,
"status": initial_status,
},
"ContentType": serializer.validated_data["content_type"],
}
if is_file_encrypted:
extra_args["Metadata"]["is_encrypted"] = "true"
file_unsafe = ""
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
@@ -1521,7 +1542,9 @@ class DocumentViewSet(
document.attachments.append(key)
document.save()
malware_detection.analyse_file(key, document_id=document.id)
# Only run malware scan for unencrypted files
if not is_file_encrypted:
malware_detection.analyse_file(key, document_id=document.id)
url = reverse(
"documents-media-check",
@@ -12,7 +12,7 @@ import * as locales from '@blocknote/core/locales';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import type { Awareness } from 'y-protocols/awareness';
@@ -20,6 +20,7 @@ import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { decryptContent } from '@/docs/doc-collaboration/encryption';
import {
Doc,
SwitchableProvider,
@@ -114,7 +115,63 @@ export const BlockNoteEditor = ({
lang = 'en';
}
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const symmetricKey = documentEncryptionSettings?.documentSymmetricKey;
const { uploadFile, errorAttachment } = useUploadFile(doc.id, symmetricKey);
// Cache for decrypted blob URLs (URL → blob URL), persists across renders
const blobUrlCacheRef = useRef<Map<string, string>>(new Map());
const resolveFileUrl = useCallback(
async (url: string): Promise<string> => {
if (!symmetricKey) {
return url;
}
const cached = blobUrlCacheRef.current.get(url);
if (cached) {
return cached;
}
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) {
throw new Error(
`Failed to fetch encrypted attachment: ${response.status}`,
);
}
const encryptedBytes = new Uint8Array(await response.arrayBuffer());
const decryptedBytes = await decryptContent(encryptedBytes, symmetricKey);
const ext = url.split('.').pop()?.toLowerCase() || '';
const mimeMap: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
pdf: 'application/pdf',
};
const mime = mimeMap[ext] || 'application/octet-stream';
const blob = new Blob([decryptedBytes], { type: mime });
const blobUrl = URL.createObjectURL(blob);
blobUrlCacheRef.current.set(url, blobUrl);
return blobUrl;
},
[symmetricKey],
);
// Revoke blob URLs on unmount or when symmetric key changes
useEffect(() => {
return () => {
blobUrlCacheRef.current.forEach((blobUrl) =>
URL.revokeObjectURL(blobUrl),
);
blobUrlCacheRef.current.clear();
};
}, [symmetricKey]);
const collabName = user?.full_name || user?.email;
const cursorName = collabName || t('Anonymous');
@@ -214,9 +271,10 @@ export const BlockNoteEditor = ({
headers: true,
},
uploadFile,
resolveFileUrl: symmetricKey ? resolveFileUrl : undefined,
schema: blockNoteSchema,
},
[cursorName, lang, provider, uploadFile, threadStore, resolveUsers],
[cursorName, lang, provider, uploadFile, resolveFileUrl, symmetricKey, threadStore, resolveUsers],
);
useHeadings(editor);
@@ -92,13 +92,22 @@ export const FileDownloadButton = ({
return;
}
const fetchAndDownload = async (fileName: string) => {
if (editor.resolveFileUrl) {
// Encrypted: decrypt via resolveFileUrl then download the blob
const blobUrl = await editor.resolveFileUrl(url);
const blob = await fetch(blobUrl).then((r) => r.blob());
downloadFile(blob, fileName);
} else {
const blob = (await exportResolveFileUrl(url)) as Blob;
downloadFile(blob, fileName);
}
};
if (!url.includes('-unsafe')) {
const blob = (await exportResolveFileUrl(url)) as Blob;
downloadFile(blob, name || url.split('/').pop() || 'file');
await fetchAndDownload(name || url.split('/').pop() || 'file');
} else {
const onConfirm = async () => {
const blob = (await exportResolveFileUrl(url)) as Blob;
const baseName = name || url.split('/').pop() || 'file';
const regFindLastDot = /(\.[^/.]+)$/;
@@ -106,7 +115,7 @@ export const FileDownloadButton = ({
? baseName.replace(regFindLastDot, '-unsafe$1')
: baseName + '-unsafe';
downloadFile(blob, unsafeName);
await fetchAndDownload(unsafeName);
};
open(onConfirm);
@@ -13,11 +13,11 @@ import {
createReactBlockSpec,
} from '@blocknote/react';
import { TFunction } from 'i18next';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { Box, Icon, Loading } from '@/components';
import { Box, Button, Icon, Loading } from '@/components';
import { ANALYZE_URL } from '../../conf';
import { DocsBlockNoteEditor } from '../../types';
@@ -70,6 +70,9 @@ const PdfBlockComponent = ({
const [isPDFContent, setIsPDFContent] = useState<boolean | null>(null);
const [isPDFContentLoading, setIsPDFContentLoading] =
useState<boolean>(false);
const [resolvedPdfUrl, setResolvedPdfUrl] = useState<string | null>(null);
const isEncrypted = !!editor.resolveFileUrl;
useEffect(() => {
if (lang && locales[lang as keyof typeof locales]) {
@@ -86,8 +89,9 @@ const PdfBlockComponent = ({
}
}, [lang, t]);
// For non-encrypted docs, validate PDF content on mount (existing behavior)
useEffect(() => {
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
if (isEncrypted || !pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
return;
}
@@ -101,6 +105,7 @@ const PdfBlockComponent = ({
if (response.ok && contentType?.includes('application/pdf')) {
setIsPDFContent(true);
setResolvedPdfUrl(pdfUrl);
} else {
setIsPDFContent(false);
}
@@ -112,12 +117,63 @@ const PdfBlockComponent = ({
};
void validatePDFContent();
}, [pdfUrl]);
}, [pdfUrl, isEncrypted]);
// For encrypted docs, decrypt only when user clicks
const handleDecryptPdf = useCallback(async () => {
if (!editor.resolveFileUrl || !pdfUrl) {
return;
}
setIsPDFContentLoading(true);
try {
const blobUrl = await editor.resolveFileUrl(pdfUrl);
setResolvedPdfUrl(blobUrl);
setIsPDFContent(true);
} catch {
setIsPDFContent(false);
} finally {
setIsPDFContentLoading(false);
}
}, [editor, pdfUrl]);
const showEncryptedPlaceholder =
isEncrypted &&
isPDFContent === null &&
pdfUrl &&
!pdfUrl.includes(ANALYZE_URL);
return (
<Box ref={contentRef} className="bn-file-block-content-wrapper">
<PDFBlockStyle />
{isPDFContentLoading && <Loading />}
{!isEncrypted && isPDFContentLoading && <Loading />}
{showEncryptedPlaceholder && (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$height="300px"
$css={css`
text-align: center;
cursor: ${isPDFContentLoading ? 'wait' : 'pointer'};
`}
contentEditable={false}
onClick={() => !isPDFContentLoading && void handleDecryptPdf()}
>
{isPDFContentLoading ? (
<Loading />
) : (
<>
<Icon iconName="lock" $size="24px" />
<Box $margin={{ top: 'small' }}>
{t('Click to decrypt and view PDF')}
</Box>
</>
)}
</Box>
)}
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
<Box
$align="center"
@@ -142,7 +198,7 @@ const PdfBlockComponent = ({
block={block as unknown as FileBlockBlock}
editor={editor as unknown as FileBlockEditor}
>
{!isPDFContentLoading && isPDFContent && (
{!isPDFContentLoading && isPDFContent && resolvedPdfUrl && (
<Box
as="embed"
className="bn-visual-media"
@@ -150,7 +206,7 @@ const PdfBlockComponent = ({
$width="100%"
$height="450px"
type="application/pdf"
src={pdfUrl}
src={resolvedPdfUrl}
aria-label={block.props.name || t('PDF document')}
contentEditable={false}
draggable={false}
@@ -4,12 +4,16 @@ import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { backendUrl } from '@/api';
import { encryptContent } from '@/docs/doc-collaboration/encryption';
import { useCreateDocAttachment } from '../api';
import { ANALYZE_URL } from '../conf';
import { DocsBlockNoteEditor } from '../types';
export const useUploadFile = (docId: string) => {
export const useUploadFile = (
docId: string,
symmetricKey?: CryptoKey,
) => {
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
@@ -19,7 +23,21 @@ export const useUploadFile = (docId: string) => {
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
if (symmetricKey) {
const arrayBuffer = await file.arrayBuffer();
const encryptedBytes = await encryptContent(
new Uint8Array(arrayBuffer),
symmetricKey,
);
const encryptedFile = new File([encryptedBytes], file.name, {
type: 'application/octet-stream',
});
body.append('file', encryptedFile);
body.append('is_encrypted', 'true');
} else {
body.append('file', file);
}
const ret = await createDocAttachment({
docId,
@@ -28,7 +46,7 @@ export const useUploadFile = (docId: string) => {
return `${backendUrl()}${ret.file}`;
},
[createDocAttachment, docId],
[createDocAttachment, docId, symmetricKey],
);
return {