From 431bec3970b98a8879d1cd99b1f3fe82cb38cd0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Tue, 3 Mar 2026 10:09:12 +0100 Subject: [PATCH] wip manage encrypted attachments --- src/backend/core/api/serializers.py | 17 +++++ src/backend/core/api/viewsets.py | 27 ++++++- .../doc-editor/components/BlockNoteEditor.tsx | 64 ++++++++++++++++- .../BlockNoteToolBar/FileDownloadButton.tsx | 19 +++-- .../components/custom-blocks/PdfBlock.tsx | 70 +++++++++++++++++-- .../docs/doc-editor/hook/useUploadFile.tsx | 24 ++++++- 6 files changed, 201 insertions(+), 20 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 6df12bf4..0e857090 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -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 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 9896fa6d..08e3a8b4 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -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", diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 08a3dcdd..48a65d94 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -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>(new Map()); + + const resolveFileUrl = useCallback( + async (url: string): Promise => { + 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 = { + 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); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx index 458a3c84..c7ed6711 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx @@ -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); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx index 1130d2b5..74523c4a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx @@ -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(null); const [isPDFContentLoading, setIsPDFContentLoading] = useState(false); + const [resolvedPdfUrl, setResolvedPdfUrl] = useState(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 ( - {isPDFContentLoading && } + {!isEncrypted && isPDFContentLoading && } + {showEncryptedPlaceholder && ( + !isPDFContentLoading && void handleDecryptPdf()} + > + {isPDFContentLoading ? ( + + ) : ( + <> + + + {t('Click to decrypt and view PDF')} + + + )} + + )} {!isPDFContentLoading && isPDFContent !== null && !isPDFContent && ( - {!isPDFContentLoading && isPDFContent && ( + {!isPDFContentLoading && isPDFContent && resolvedPdfUrl && ( { +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 {