wip manage encrypted attachments
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+61
-3
@@ -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);
|
||||
|
||||
+14
-5
@@ -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);
|
||||
|
||||
+63
-7
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user