diff --git a/client/src/components/Home/MyFiles.tsx b/client/src/components/Home/MyFiles.tsx index 170c777..cbdd409 100644 --- a/client/src/components/Home/MyFiles.tsx +++ b/client/src/components/Home/MyFiles.tsx @@ -18,6 +18,7 @@ import { Download, Copy, Search, + Check, Filter, FileText, Image as ImageIcon, @@ -44,6 +45,7 @@ import { Play, AlertCircle, MoreVertical, + Shield, } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import { notify as toast } from "@/services/toastService"; @@ -124,6 +126,12 @@ const MyFiles: React.FC = () => { const [passwordValue, setPasswordValue] = useState(""); const [isPasswordModalLoading, setIsPasswordModalLoading] = useState(false); + // E2EE upload protection state + const [showE2eeUploadModal, setShowE2eeUploadModal] = useState(false); + const [e2eePassword, setE2eePassword] = useState(""); + const [isE2eeEnabled, setIsE2eeEnabled] = useState(false); + const [filesToUpload, setFilesToUpload] = useState([]); + // Share modal state const [shareModalOpen, setShareModalOpen] = useState(false); const [selectedFileForShare, setSelectedFileForShare] = useState<{ @@ -686,7 +694,6 @@ const MyFiles: React.FC = () => { const selectedFilesList = Array.from(e.target.files); const resumeSessionId = resumeSessionIdRef.current; - resumeSessionIdRef.current = null; if (resumeSessionId && selectedFilesList.length !== 1) { toast.error("Select the original file to resume an interrupted upload."); @@ -715,6 +722,14 @@ const MyFiles: React.FC = () => { return; } + setFilesToUpload(selectedFilesList); + setIsE2eeEnabled(false); + setE2eePassword(""); + setShowE2eeUploadModal(true); + e.target.value = ""; + }; + + const executeUploadFlow = async (files: File[], isE2ee: boolean, passwordVal: string) => { setIsUploading(true); setIsPaused(false); pauseRef.current = false; @@ -722,13 +737,17 @@ const MyFiles: React.FC = () => { setUploadProgressDetail(null); abortControllerRef.current = new AbortController(); - for (const [index, file] of selectedFilesList.entries()) { + const resumeSessionId = resumeSessionIdRef.current; + resumeSessionIdRef.current = null; + + for (const [index, file] of files.entries()) { try { const result = await uploadFileResumable({ file, sessionId: resumeSessionId || undefined, signal: abortControllerRef.current.signal, shouldPause: () => pauseRef.current, + encryptionPassword: isE2ee ? passwordVal : undefined, onDuplicateDetected: async () => { return new Promise((resolve) => { const useExisting = window.confirm( @@ -740,7 +759,7 @@ const MyFiles: React.FC = () => { }, onProgress: (state) => { const overallProgress = - ((index + state.progressPercent / 100) / selectedFilesList.length) * 100; + ((index + state.progressPercent / 100) / files.length) * 100; setUploadProgress(overallProgress); setUploadProgressDetail(state); }, @@ -754,14 +773,17 @@ const MyFiles: React.FC = () => { fileSizeBytes: file.size, checksum: result.checksum, folderId: currentFolderId, + isEncrypted: isE2ee, + wrappedKey: result.wrappedKey, + keySalt: result.keySalt, }); - } catch (err) { + } catch (err: any) { if (err instanceof DOMException && err.name === "AbortError") { toast.info("Upload cancelled."); break; } console.error("Upload failed for", file.name, err); - toast.error(`Failed to upload ${file.name}`); + toast.error(`Failed to upload ${file.name}: ${err.message || ""}`); } } @@ -776,7 +798,6 @@ const MyFiles: React.FC = () => { abortControllerRef.current = null; setUploadProgress(100); setTimeout(() => setUploadProgress(0), 1000); - e.target.value = ""; }; const handlePauseUpload = () => { @@ -2670,6 +2691,116 @@ formatFileSize )} + + {/* Client-Side E2EE Upload Modal */} + + {showE2eeUploadModal && ( + setShowE2eeUploadModal(false)} + > + e.stopPropagation()} + > +
+
+ +

+ Secure Share (E2EE) +

+
+ +
+ +
+

+ Configure security settings for your selected file(s). You can optionally encrypt files in your browser before they are uploaded. +

+ +
+ setIsE2eeEnabled(e.target.checked)} + className="w-4 h-4 text-[#3498db] border-gray-300 rounded focus:ring-[#3498db]" + /> + +
+ + {isE2eeEnabled && ( + +
+ + setE2eePassword(e.target.value)} + className="w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-950 border border-gray-300 dark:border-gray-700 + rounded-xl text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 + focus:ring-[#3498db] focus:border-transparent transition-all" + required + minLength={6} + /> +
+ +
+ āš ļø +
+ Zero-Knowledge Warning: This password encrypts your file. If you lose or forget it, no one (including SecureShare) can recover or decrypt your file. +
+
+
+ )} + +
+ + +
+
+
+
+ )} +
+ {/* Share Link Modal */} {/* Share Modal */} {shareModalOpen && selectedFileForShare && ( diff --git a/client/src/components/Share/SharePage.tsx b/client/src/components/Share/SharePage.tsx index 928649d..057a222 100644 --- a/client/src/components/Share/SharePage.tsx +++ b/client/src/components/Share/SharePage.tsx @@ -15,6 +15,7 @@ import { Cloud, Sun, Moon, + Shield, } from "lucide-react"; import { motion } from "framer-motion"; import { notify as toast } from "@/services/toastService"; @@ -36,6 +37,11 @@ const SharePage: React.FC = () => { const [isDownloading, setIsDownloading] = useState(null); // tracks current download file version/id const [errorMsg, setErrorMsg] = useState(null); + // E2EE decryption state + const [decryptionPassword, setDecryptionPassword] = useState(""); + const [unwrappedDek, setUnwrappedDek] = useState(null); + const [isDecryptingKey, setIsDecryptingKey] = useState(false); + // View tracking lock (ensure we only track view once per load/unlock) const viewTrackedRef = useRef(false); @@ -107,7 +113,7 @@ const SharePage: React.FC = () => { } }; - // 3. Verify password handler + // 3. Verify password handler (for server-side link password) const handleVerifyPassword = async (e: React.FormEvent) => { e.preventDefault(); if (!id || !password) return; @@ -134,8 +140,8 @@ const SharePage: React.FC = () => { } }; - // 4. Download file handler - const handleDownload = async (url: string, fileName: string, versionNum?: number) => { + // 4. Download file handler with E2EE local chunk decryption capability + const handleDownload = async (url: string, fileName: string, versionNum?: number, dekOverride?: CryptoKey) => { if (!id) return; const downloadKey = versionNum ? `v${versionNum}` : "current"; setIsDownloading(downloadKey); @@ -161,7 +167,45 @@ const SharePage: React.FC = () => { // Download file blob const response = await fetch(url); - const blob = await response.blob(); + + let blob: Blob; + const activeDek = dekOverride || unwrappedDek; + + if (fileDetails?.isEncrypted && activeDek) { + toast.info("Decrypting file chunks locally..."); + const encryptedBuffer = await response.arrayBuffer(); + + const originalChunkSize = 5 * 1024 * 1024; // 5MB + const encryptedChunkSize = originalChunkSize + 28; + const totalEncryptedBytes = encryptedBuffer.byteLength; + + const decryptedChunks: Uint8Array[] = []; + let offset = 0; + let chunkIndex = 0; + + while (offset < totalEncryptedBytes) { + const end = Math.min(offset + encryptedChunkSize, totalEncryptedBytes); + const encryptedChunkCombined = new Uint8Array(encryptedBuffer.slice(offset, end)); + + const iv = encryptedChunkCombined.slice(0, 12); + const ciphertext = encryptedChunkCombined.slice(12); + + const decryptedBuffer = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + activeDek, + ciphertext + ); + + decryptedChunks.push(new Uint8Array(decryptedBuffer)); + offset += encryptedChunkSize; + chunkIndex++; + } + + blob = new Blob(decryptedChunks, { type: fileDetails.fileType || "application/octet-stream" }); + } else { + blob = await response.blob(); + } + const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = downloadUrl; @@ -171,15 +215,82 @@ const SharePage: React.FC = () => { document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); - toast.success("Download started!"); - } catch (err) { - console.error("Download failed:", err); - toast.error("Download failed. Please try again."); + toast.success("Download completed and decrypted successfully!"); + } catch (err: any) { + console.error("Download or decryption failed:", err); + toast.error("Download/decryption failed: " + (err.message || "Please check your decryption password.")); } finally { setIsDownloading(null); } }; + // 5. E2EE Local Decryption Handler (KEK derivation & DEK unwrapping) + const handleDecryptAndDownload = async (e: React.FormEvent) => { + e.preventDefault(); + if (!fileUrl || !fileDetails || !fileDetails.wrappedKey || !fileDetails.keySalt) { + toast.error("File details or encrypted keys are missing."); + return; + } + + setIsDecryptingKey(true); + try { + const base64ToArrayBuffer = (base64: string): ArrayBuffer => { + const binaryString = window.atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; + }; + + const wrappedKeyCombined = new Uint8Array(base64ToArrayBuffer(fileDetails.wrappedKey)); + const wrapIv = wrappedKeyCombined.slice(0, 12); + const wrappedDekCiphertext = wrappedKeyCombined.slice(12); + + const keySaltBytes = new Uint8Array(base64ToArrayBuffer(fileDetails.keySalt)); + const passwordBytes = new TextEncoder().encode(decryptionPassword); + const baseKey = await window.crypto.subtle.importKey("raw", passwordBytes, "PBKDF2", false, ["deriveKey"]); + const kek = await window.crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: keySaltBytes, + iterations: 100000, + hash: "SHA-256" + }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"] + ); + + const rawDekBuffer = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv: wrapIv }, + kek, + wrappedDekCiphertext + ); + + const dek = await window.crypto.subtle.importKey( + "raw", + rawDekBuffer, + "AES-GCM", + true, + ["decrypt"] + ); + + setUnwrappedDek(dek); + toast.success("Decryption key unlocked! šŸ”‘"); + + // Immediately start downloading & decrypting file + await handleDownload(fileUrl, fileDetails.fileName, undefined, dek); + } catch (err: any) { + console.error("E2EE local decryption key derive failed:", err); + toast.error("Incorrect decryption password. Please check and try again."); + } finally { + setIsDecryptingKey(false); + } + }; + // Helper icons and styling const getFileTypeColor = (type: string) => { switch (type?.toLowerCase()) { @@ -254,7 +365,7 @@ const SharePage: React.FC = () => {
@@ -344,26 +455,82 @@ const SharePage: React.FC = () => {
- {/* Primary Download Button */} + {/* Primary Download Button or E2EE Passphrase Form */} {fileUrl && ( - ) : ( - <> - - Download Active File - - )} - +
+
+ + End-to-End Encrypted File +
+

+ This file was encrypted in the client browser prior to upload. Enter the decryption password to decrypt it locally. +

+
+ setDecryptionPassword(e.target.value)} + className="w-full px-4 py-2.5 bg-white dark:bg-gray-950 border border-gray-300 dark:border-gray-800 + rounded-xl text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 + focus:ring-[#3498db] focus:border-transparent transition-all font-mono" + required + disabled={isDecryptingKey} + /> +
+ +
+ ) + ) : ( + + ) )} {/* Versions History Dropdown */} - {versions && versions.length > 0 && ( + {versions && versions.length > 0 && (!fileDetails.isEncrypted || unwrappedDek) && (

Previous Versions diff --git a/client/src/services/__tests__/e2ee.test.ts b/client/src/services/__tests__/e2ee.test.ts new file mode 100644 index 0000000..a69c849 --- /dev/null +++ b/client/src/services/__tests__/e2ee.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { prepareE2EEncryption, encryptChunk } from "../resumableUpload"; +import { webcrypto } from "crypto"; + +// Polyfill window.crypto and base64 encoders for Node-based Vitest environment +beforeAll(() => { + if (typeof window === "undefined") { + (global as any).window = {}; + } + (global as any).window.crypto = webcrypto; + (global as any).window.btoa = (str: string) => Buffer.from(str, "binary").toString("base64"); + (global as any).window.atob = (str: string) => Buffer.from(str, "base64").toString("binary"); +}); + +describe("Client-Side E2EE WebCrypto Unit Tests", () => { + const testPassword = "secure-passphrase-123"; + const testData = new TextEncoder().encode("Hello, this is a secret chunk payload to encrypt!"); + + it("should derive key salt and wrapped DEK correctly", async () => { + const result = await prepareE2EEncryption(testPassword); + + expect(result.dek).toBeDefined(); + expect(result.rawDekBytes).toBeDefined(); + expect(result.rawDekBytes.length).toBe(32); // 256-bit AES DEK + expect(result.wrappedKeyBase64).toBeDefined(); + expect(result.keySaltBase64).toBeDefined(); + + // Verify key salt is 16 bytes base64 decoded + const saltBytes = Buffer.from(result.keySaltBase64, "base64"); + expect(saltBytes.length).toBe(16); + + // Verify wrapped key base64 decoded is 12 bytes IV + 32 bytes DEK + 16 bytes Tag = 60 bytes + const wrappedBytes = Buffer.from(result.wrappedKeyBase64, "base64"); + expect(wrappedBytes.length).toBe(60); + }); + + it("should encrypt and decrypt a chunk slice matching original data", async () => { + const { dek, rawDekBytes } = await prepareE2EEncryption(testPassword); + + const chunkBlob = new Blob([testData], { type: "text/plain" }); + const chunkIndex = 0; + + // Encrypt + const encryptedChunkBlob = await encryptChunk(chunkBlob, chunkIndex, dek, rawDekBytes); + expect(encryptedChunkBlob.size).toBe(testData.length + 28); // 12B IV + 16B GCM tag + original data length + + // Decrypt (simulate SharePage.tsx decoding flow) + const encryptedBuffer = await encryptedChunkBlob.arrayBuffer(); + const encryptedCombined = new Uint8Array(encryptedBuffer); + + // Extract IV (first 12 bytes) and ciphertext (rest) + const iv = encryptedCombined.slice(0, 12); + const ciphertext = encryptedCombined.slice(12); + + expect(iv.length).toBe(12); + expect(ciphertext.length).toBe(testData.length + 16); // ciphertext + 16B GCM tag + + // Decrypt using WebCrypto AES-GCM + const decryptedBuffer = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + dek, + ciphertext + ); + + const decryptedText = new TextDecoder().decode(decryptedBuffer); + expect(decryptedText).toBe("Hello, this is a secret chunk payload to encrypt!"); + }); +}); diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 5847950..c5b8f1c 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -79,6 +79,9 @@ export const fileApi = { fileSizeBytes?: number; checksum?: string; folderId?: string | null; + isEncrypted?: boolean; + wrappedKey?: string; + keySalt?: string; }) => { const response = await api.post("/api/files/save-info", data); return response.data; @@ -262,6 +265,9 @@ export const uploadApi = { expectedChecksum?: string; sessionId?: string; forceUpload?: boolean; + isEncrypted?: boolean; + wrappedKey?: string; + keySalt?: string; }) => { const response = await api.post("/api/files/upload/init", data); return response.data as { diff --git a/client/src/services/resumableUpload.ts b/client/src/services/resumableUpload.ts index d8979e8..dafe07e 100644 --- a/client/src/services/resumableUpload.ts +++ b/client/src/services/resumableUpload.ts @@ -15,13 +15,110 @@ import { type UploadProgressState, type UploadSessionInfo, } from "./uploadTypes"; +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; export type { UploadProgressState, UploadSessionInfo, ResumableUploadOptions }; +export interface EncryptionResult { + dek: CryptoKey; + rawDekBytes: Uint8Array; + wrappedKeyBase64: string; + keySaltBase64: string; +} + +/** + * Generates a unique DEK, derives a KEK using PBKDF2, and wraps the DEK with AES-GCM. + */ +export async function prepareE2EEncryption(password: string): Promise { + const dek = await window.crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + const rawDekBytes = new Uint8Array(await window.crypto.subtle.exportKey("raw", dek)); + const keySaltBytes = window.crypto.getRandomValues(new Uint8Array(16)); + + const passwordBytes = new TextEncoder().encode(password); + const baseKey = await window.crypto.subtle.importKey("raw", passwordBytes, "PBKDF2", false, ["deriveKey"]); + const kek = await window.crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: keySaltBytes, + iterations: 100000, + hash: "SHA-256" + }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + + const wrapIv = window.crypto.getRandomValues(new Uint8Array(12)); + const wrappedDekCiphertext = await window.crypto.subtle.encrypt({ name: "AES-GCM", iv: wrapIv }, kek, rawDekBytes); + + const wrappedKeyCombined = new Uint8Array(12 + wrappedDekCiphertext.byteLength); + wrappedKeyCombined.set(wrapIv, 0); + wrappedKeyCombined.set(new Uint8Array(wrappedDekCiphertext), 12); + + const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + let binary = ""; + const bytes = new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + }; + + const wrappedKeyBase64 = arrayBufferToBase64(wrappedKeyCombined.buffer); + const keySaltBase64 = arrayBufferToBase64(keySaltBytes.buffer); + + return { + dek, + rawDekBytes, + wrappedKeyBase64, + keySaltBase64, + }; +} + +/** + * Encrypts a single slice of file using the DEK with AES-GCM and prepends the 12-byte IV. + */ +export async function encryptChunk( + chunk: Blob, + chunkIndex: number, + dek: CryptoKey, + rawDekBytes: Uint8Array, +): Promise { + const chunkBuffer = await chunk.arrayBuffer(); + + const indexBytes = new TextEncoder().encode(String(chunkIndex)); + const combined = new Uint8Array(rawDekBytes.length + indexBytes.length); + combined.set(rawDekBytes, 0); + combined.set(indexBytes, rawDekBytes.length); + + const hashBuffer = await window.crypto.subtle.digest("SHA-256", combined); + const iv = new Uint8Array(hashBuffer).slice(0, 12); + + const ciphertextBuffer = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + dek, + chunkBuffer + ); + + const result = new Uint8Array(12 + ciphertextBuffer.byteLength); + result.set(iv, 0); + result.set(new Uint8Array(ciphertextBuffer), 12); + + return new Blob([result], { type: chunk.type }); +} + export interface UploadResult { fileUrl: string; sessionId: string; checksum: string; + wrappedKey?: string; + keySalt?: string; } async function uploadChunkWithRetry( @@ -75,6 +172,7 @@ export async function uploadFileResumable( onProgress, shouldPause, signal, + encryptionPassword, } = options; const totalChunks = calculateTotalChunks(file.size, chunkSize); @@ -92,15 +190,58 @@ export async function uploadFileResumable( }); }; - report({ status: "hashing", progressPercent: 0 }); + let checksum = ""; + let dek: CryptoKey | null = null; + let rawDekBytes = new Uint8Array(0); + let wrappedKeyBase64 = ""; + let keySaltBase64 = ""; - const checksum = await computeIncrementalSha256(file, chunkSize, (processed) => { - report({ - status: "hashing", - uploadedBytes: processed, - progressPercent: Math.round((processed / file.size) * 10), + const fileSizeBytesToUpload = encryptionPassword + ? file.size + (totalChunks * 28) + : file.size; + + if (encryptionPassword) { + report({ status: "hashing", progressPercent: 0 }); + try { + const cryptoResult = await prepareE2EEncryption(encryptionPassword); + dek = cryptoResult.dek; + rawDekBytes = cryptoResult.rawDekBytes; + wrappedKeyBase64 = cryptoResult.wrappedKeyBase64; + keySaltBase64 = cryptoResult.keySaltBase64; + + // Compute checksum on encrypted chunks + const hasher = sha256.create(); + let offset = 0; + let chunkIndex = 0; + while (offset < file.size) { + const chunk = file.slice(offset, offset + chunkSize); + const encryptedChunkBlob = await encryptChunk(chunk, chunkIndex, dek, rawDekBytes); + const buffer = await encryptedChunkBlob.arrayBuffer(); + hasher.update(new Uint8Array(buffer)); + + offset += chunkSize; + chunkIndex++; + report({ + status: "hashing", + uploadedBytes: Math.min(offset, file.size), + progressPercent: Math.round((Math.min(offset, file.size) / file.size) * 10), + }); + } + checksum = bytesToHex(hasher.digest()); + } catch (err: any) { + console.error("Encryption initialization failed:", err); + throw new Error("Failed to initialize client-side encryption: " + err.message); + } + } else { + report({ status: "hashing", progressPercent: 0 }); + checksum = await computeIncrementalSha256(file, chunkSize, (processed) => { + report({ + status: "hashing", + uploadedBytes: processed, + progressPercent: Math.round((processed / file.size) * 10), + }); }); - }); + } if (signal?.aborted) { throw new DOMException("Upload aborted", "AbortError"); @@ -108,22 +249,24 @@ export async function uploadFileResumable( let session: UploadSessionInfo; + const initData = { + fileName: file.name, + fileSizeBytes: fileSizeBytesToUpload, + mimeType: file.type, + expectedChecksum: checksum, + isEncrypted: !!encryptionPassword, + wrappedKey: wrappedKeyBase64 || undefined, + keySalt: keySaltBase64 || undefined, + }; + if (existingSessionId) { const resumed = await uploadApi.initUpload({ - fileName: file.name, - fileSizeBytes: file.size, - mimeType: file.type, - expectedChecksum: checksum, + ...initData, sessionId: existingSessionId, }); session = resumed.session!; } else { - const created = await uploadApi.initUpload({ - fileName: file.name, - fileSizeBytes: file.size, - mimeType: file.type, - expectedChecksum: checksum, - }); + const created = await uploadApi.initUpload(initData); if (created.duplicateExists && created.existingFileUrl) { if (options.onDuplicateDetected) { @@ -139,16 +282,15 @@ export async function uploadFileResumable( fileUrl: created.existingFileUrl, sessionId: "", // No session needed for linked file checksum, + wrappedKey: wrappedKeyBase64 || undefined, + keySalt: keySaltBase64 || undefined, }; } } // If no callback, or choice was 'upload', force upload const forced = await uploadApi.initUpload({ - fileName: file.name, - fileSizeBytes: file.size, - mimeType: file.type, - expectedChecksum: checksum, + ...initData, forceUpload: true, }); session = forced.session!; @@ -199,7 +341,11 @@ export async function uploadFileResumable( const start = chunkIndex * session.chunkSizeBytes; const end = Math.min(start + session.chunkSizeBytes, file.size); - const chunkBlob = file.slice(start, end); + let chunkBlob = file.slice(start, end); + + if (encryptionPassword && dek) { + chunkBlob = await encryptChunk(chunkBlob, chunkIndex, dek, rawDekBytes); + } report({ status: "uploading", @@ -246,6 +392,8 @@ export async function uploadFileResumable( fileUrl: session.fileUrl, sessionId: session.sessionId, checksum, + wrappedKey: wrappedKeyBase64 || undefined, + keySalt: keySaltBase64 || undefined, }; } } @@ -264,6 +412,8 @@ export async function uploadFileResumable( fileUrl: finalStatus.session.fileUrl, sessionId: session.sessionId, checksum, + wrappedKey: wrappedKeyBase64 || undefined, + keySalt: keySaltBase64 || undefined, }; } diff --git a/client/src/services/uploadTypes.ts b/client/src/services/uploadTypes.ts index 9bcd510..94feff0 100644 --- a/client/src/services/uploadTypes.ts +++ b/client/src/services/uploadTypes.ts @@ -39,6 +39,7 @@ export interface ResumableUploadOptions { shouldPause?: () => boolean; signal?: AbortSignal; onDuplicateDetected?: () => Promise<'link' | 'upload'>; + encryptionPassword?: string; } export function calculateTotalChunks(fileSizeBytes: number, chunkSize: number) { diff --git a/server/controllers/fileController.js b/server/controllers/fileController.js index 25f3f69..65a541a 100644 --- a/server/controllers/fileController.js +++ b/server/controllers/fileController.js @@ -27,7 +27,7 @@ export const getUserFiles = async (req, res, next) => { const userId = req.user.id; const { folderId, page = 1, limit = 50 } = req.query; // 'null' string or objectId - const query = { userId, isDeleted: false }; + const query = { userId, isDeleted: false, status: { $ne: "PENDING" } }; if (folderId !== undefined) { query.folderId = folderId === "null" || folderId === "" ? null : folderId; @@ -96,6 +96,9 @@ export const saveFileInfo = async (req, res, next) => { tags, password, folderId, + isEncrypted, + wrappedKey, + keySalt, } = req.body; if (!fileName || !fileUrl) { @@ -110,6 +113,7 @@ if (checksum) { const duplicateFile = await File.findOne({ checksum, userId, + status: { $ne: "PENDING" }, }); if (duplicateFile) { @@ -125,7 +129,57 @@ if (checksum) { } } - const existingFile = await File.findOne({ fileName, userId }); + // Try to find a pending file to complete + const pendingFile = await File.findOne({ + fileName, + userId, + status: "PENDING", + }).sort({ updatedAt: -1 }); + + if (pendingFile) { + let hashedPassword = null; + if (password) { + const salt = await bcrypt.genSalt(10); + hashedPassword = await bcrypt.hash(password, salt); + } + + // Update the pending file with final completed info + pendingFile.fileUrl = fileUrl; + pendingFile.fileType = fileType || pendingFile.fileType; + pendingFile.fileSize = fileSize || pendingFile.fileSize; + pendingFile.fileSizeBytes = fileSizeBytes || pendingFile.fileSizeBytes; + pendingFile.checksum = checksum || pendingFile.checksum; + pendingFile.status = "COMPLETED"; + pendingFile.folderId = folderId || pendingFile.folderId; + pendingFile.tags = Array.isArray(tags) ? tags : []; + pendingFile.password = hashedPassword; + if (isEncrypted !== undefined) pendingFile.isEncrypted = !!isEncrypted; + if (wrappedKey !== undefined) pendingFile.wrappedKey = wrappedKey; + if (keySalt !== undefined) pendingFile.keySalt = keySalt; + + await pendingFile.save(); + + // Enqueue file for malware scanning and document indexing + try { + await enqueueScan(pendingFile._id); + await enqueueIndex(pendingFile._id); + } catch (queueErr) { + console.error("Failed to enqueue background jobs:", queueErr); + } + + const io = req.app.get("io"); + if (io) io.to(`user_${userId}`).emit("FILE_UPLOADED", pendingFile); + + await invalidateDashboardStats(userId); + + return res.status(201).json({ + success: true, + message: "File info saved successfully", + file: pendingFile, + }); + } + + const existingFile = await File.findOne({ fileName, userId, status: { $ne: "PENDING" } }); if (existingFile) { // Archive the current state into versions array @@ -186,6 +240,9 @@ if (checksum) { currentVersion: 1, tags: Array.isArray(tags) ? tags : [], password: hashedPassword, + isEncrypted: !!isEncrypted, + wrappedKey: wrappedKey || null, + keySalt: keySalt || null, shareCount: 0, downloadCount: 0, viewCount: 0, @@ -583,9 +640,9 @@ export const getFileStats = async (req, res, next) => { } } - const totalFiles = await File.countDocuments({ userId }); + const totalFiles = await File.countDocuments({ userId, status: { $ne: "PENDING" } }); const aggregations = await File.aggregate([ - { $match: { userId } }, + { $match: { userId, status: { $ne: "PENDING" } } }, { $group: { _id: null, @@ -750,7 +807,7 @@ export const getFavoriteFiles = async (req, res, next) => { const { page = 1, limit = 50 } = req.query; const skip = (parseInt(page) - 1) * parseInt(limit); - const query = { userId, isFavorite: true }; + const query = { userId, isFavorite: true, status: { $ne: "PENDING" } }; const files = await File.find(query) .sort({ updatedAt: -1 }) .skip(skip) @@ -816,6 +873,7 @@ export const getFilesByTag = async (req, res, next) => { const files = await File.find({ userId, + status: { $ne: "PENDING" }, tags: { $in: [tag] }, }).sort({ createdAt: -1 }); @@ -1007,6 +1065,7 @@ export const searchFiles = async (req, res, next) => { const query = { userId, isDeleted: false, + status: { $ne: "PENDING" }, fileName: { $regex: q, $options: "i" } }; diff --git a/server/controllers/folderController.js b/server/controllers/folderController.js index e930cf5..5b24994 100644 --- a/server/controllers/folderController.js +++ b/server/controllers/folderController.js @@ -83,7 +83,7 @@ export const getFolderContents = async (req, res, next) => { const subfolders = await Folder.find({ parentId: folderId, userId }).sort({ name: 1 }); const skip = (parseInt(page) - 1) * parseInt(limit); - const query = { folderId: folderId, userId, isDeleted: false }; + const query = { folderId: folderId, userId, isDeleted: false, status: { $ne: "PENDING" } }; const files = await File.find(query) .sort({ createdAt: -1 }) diff --git a/server/controllers/shareController.js b/server/controllers/shareController.js index 372c4f9..70e234a 100644 --- a/server/controllers/shareController.js +++ b/server/controllers/shareController.js @@ -60,6 +60,9 @@ export const createShareLink = async (req, res, next) => { expiresAt: expiresAt ? new Date(expiresAt) : null, maxAccessCount: maxAccessCount || null, password: hashedPassword, + isEncrypted: file.isEncrypted || false, + wrappedKey: file.wrappedKey || null, + keySalt: file.keySalt || null, }); await shareLink.save(); @@ -336,6 +339,8 @@ export const accessSharedFile = async (req, res, next) => { return res.json({ success: true, isPasswordProtected: true, + isEncrypted: share.isEncrypted || false, + keySalt: share.keySalt || null, share: { _id: share._id, expiresAt: share.expiresAt, @@ -382,6 +387,9 @@ export const accessSharedFile = async (req, res, next) => { fileUrl: share.fileId?.fileUrl, fileType: share.fileId?.fileType, fileSize: share.fileId?.fileSize, + isEncrypted: share.isEncrypted || false, + wrappedKey: share.wrappedKey || null, + keySalt: share.keySalt || null, }, share: { _id: share._id, @@ -451,6 +459,9 @@ export const verifyShareLinkPassword = async (req, res, next) => { fileUrl: share.fileId?.fileUrl, fileType: share.fileId?.fileType, fileSize: share.fileId?.fileSize, + isEncrypted: share.isEncrypted || false, + wrappedKey: share.wrappedKey || null, + keySalt: share.keySalt || null, }, share: { _id: share._id, diff --git a/server/controllers/uploadController.js b/server/controllers/uploadController.js index 479edcc..b30a850 100644 --- a/server/controllers/uploadController.js +++ b/server/controllers/uploadController.js @@ -105,9 +105,19 @@ async function loadUploadSessionForChunk(req, res, next) { } } -export const initUpload = async (req, res) => { +export const initUpload = async (req, res, next) => { try { - const { fileName, fileSizeBytes, mimeType, expectedChecksum, sessionId, forceUpload } = req.body; + const { + fileName, + fileSizeBytes, + mimeType, + expectedChecksum, + sessionId, + forceUpload, + isEncrypted, + wrappedKey, + keySalt, + } = req.body; const userId = req.user.id; if (!fileName || !fileSizeBytes) { @@ -165,6 +175,22 @@ export const initUpload = async (req, res) => { const totalChunks = calculateTotalChunks(Number(fileSizeBytes)); const expiresAt = new Date(Date.now() + UPLOAD_SESSION_TTL_HOURS * 60 * 60 * 1000); + // Create the File record in PENDING state + const pendingFile = new File({ + fileName, + fileUrl: `${req.protocol || "http"}://${(typeof req.get === "function" ? req.get("host") : null) || "localhost"}/pending/${uploadId}`, + fileType: mimeType ? (mimeType.split("/")[0] || "application") : "application", + fileSize: "0 KB", + fileSizeBytes: Number(fileSizeBytes), + userId, + status: "PENDING", + folderId: req.body.folderId || null, + isEncrypted: !!isEncrypted, + wrappedKey: wrappedKey || null, + keySalt: keySalt || null, + }); + await pendingFile.save(); + const session = await UploadSession.create({ userId, uploadId, @@ -177,6 +203,7 @@ export const initUpload = async (req, res) => { tempDir, expiresAt, status: "uploading", + fileId: pendingFile._id, }); res.status(201).json({ @@ -188,7 +215,7 @@ export const initUpload = async (req, res) => { } }; -export const getUploadStatus = async (req, res) => { +export const getUploadStatus = async (req, res, next) => { try { const session = await UploadSession.findOne({ _id: req.params.sessionId, @@ -216,7 +243,7 @@ export const getUploadStatus = async (req, res) => { } }; -export const getResumableUploads = async (req, res) => { +export const getResumableUploads = async (req, res, next) => { try { const sessions = await UploadSession.find({ userId: req.user.id, @@ -252,17 +279,28 @@ export const uploadChunk = async (req, res) => { } else { chunkSize = await writeChunk(session.tempDir, chunkIndex, req.file.path); + // Check if this session is encrypted E2EE + let isEncrypted = false; + if (session.fileId) { + const associatedFile = await File.findById(session.fileId).select("isEncrypted"); + if (associatedFile) { + isEncrypted = associatedFile.isEncrypted; + } + } + + const baseChunkSize = isEncrypted ? (session.chunkSizeBytes + 28) : session.chunkSizeBytes; + const expectedStart = chunkIndex * session.chunkSizeBytes; const expectedEnd = Math.min( expectedStart + session.chunkSizeBytes, - session.fileSizeBytes, + isEncrypted ? (session.fileSizeBytes - (session.totalChunks * 28)) : session.fileSizeBytes, ); - const expectedSize = expectedEnd - expectedStart; + const expectedSize = (expectedEnd - expectedStart) + (isEncrypted ? 28 : 0); - if (chunkIndex < session.totalChunks - 1 && chunkSize !== session.chunkSizeBytes) { + if (chunkIndex < session.totalChunks - 1 && chunkSize !== baseChunkSize) { await fs.unlink(getChunkPath(session.tempDir, chunkIndex)).catch(() => {}); return res.status(400).json({ - error: `Invalid chunk size. Expected ${session.chunkSizeBytes} bytes`, + error: `Invalid chunk size. Expected ${baseChunkSize} bytes`, }); } @@ -279,8 +317,11 @@ export const uploadChunk = async (req, res) => { session.fileSizeBytes, session.receivedChunks.reduce((total, index) => { const start = index * session.chunkSizeBytes; - const end = Math.min(start + session.chunkSizeBytes, session.fileSizeBytes); - return total + (end - start); + const end = Math.min( + start + session.chunkSizeBytes, + isEncrypted ? (session.fileSizeBytes - (session.totalChunks * 28)) : session.fileSizeBytes, + ); + return total + (end - start) + (isEncrypted ? 28 : 0); }, 0), ); session.status = "uploading"; diff --git a/server/index.js b/server/index.js index fb20cb6..f3a4471 100644 --- a/server/index.js +++ b/server/index.js @@ -21,6 +21,7 @@ import { startWebhookWorker } from "./jobs/webhookWorker.js"; import webhookRoutes from "./routes/webhooks.js"; import { startUploadSessionCleanupJob } from "./jobs/uploadSessionCleanup.js"; import { initQuotaResetJob } from "./jobs/quotaResetJob.js"; +import { initUploadCleanupWorker } from "./jobs/uploadCleanupWorker.js"; import { connectRedis } from "./config/redis.js"; import { ensureUploadTempRoot } from "./utils/chunkStorage.js"; import { @@ -53,6 +54,7 @@ startIndexWorker(); startWebhookWorker(); startUploadSessionCleanupJob(); initQuotaResetJob(); +initUploadCleanupWorker(); ensureUploadTempRoot().catch(console.error); app.use(express.json()); diff --git a/server/jobs/indexQueue.js b/server/jobs/indexQueue.js index c0f499b..9187e7f 100644 --- a/server/jobs/indexQueue.js +++ b/server/jobs/indexQueue.js @@ -11,6 +11,10 @@ const connection = { export const indexQueue = new Queue("document-indexing", { connection }); export const enqueueIndex = async (fileId) => { + if (process.env.ENABLE_DOCUMENT_INDEXING === "false") { + console.log("Document indexing is disabled. Skipping for file:", fileId); + return; + } console.log(`[Queue] Enqueuing document indexing job for file: ${fileId}`); await indexQueue.add("index-file", { fileId }, { attempts: 3, diff --git a/server/jobs/uploadCleanupWorker.js b/server/jobs/uploadCleanupWorker.js new file mode 100644 index 0000000..7276026 --- /dev/null +++ b/server/jobs/uploadCleanupWorker.js @@ -0,0 +1,47 @@ +import cron from "node-cron"; +import File from "../models/File.js"; +import UploadSession from "../models/UploadSession.js"; +import { removeSessionTempDir } from "../utils/chunkStorage.js"; + +/** + * Initializes the background cron worker to clean up stale PENDING uploads. + * Runs daily at midnight (0 0 * * *). + */ +export const initUploadCleanupWorker = () => { + cron.schedule("0 0 * * *", async () => { + const thresholdTime = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 Hours ago + console.log(`[Cleanup Worker] Starting sweep of stale PENDING uploads older than ${thresholdTime.toISOString()}`); + + try { + // Find all incomplete files older than 24 hours + const staleUploads = await File.find({ + status: "PENDING", + updatedAt: { $lt: thresholdTime } + }); + + let clearedCount = 0; + for (const file of staleUploads) { + try { + // Find matching UploadSession to cleanup temp directory + const session = await UploadSession.findOne({ fileId: file._id }); + if (session) { + if (session.tempDir) { + await removeSessionTempDir(session.tempDir); + } + await UploadSession.findByIdAndDelete(session._id); + } + + // Delete the stale File document from MongoDB + await File.findByIdAndDelete(file._id); + clearedCount++; + } catch (fileErr) { + console.error(`[Cleanup Worker] Failed to clean up file ${file._id}:`, fileErr); + } + } + + console.log(`[Cleanup Worker] Successfully cleared ${clearedCount} stale uploads.`); + } catch (error) { + console.error("[Cleanup Worker Error]:", error); + } + }); +}; diff --git a/server/models/File.js b/server/models/File.js index c7ac6ec..a7e3a8a 100644 --- a/server/models/File.js +++ b/server/models/File.js @@ -170,6 +170,24 @@ const fileSchema = new mongoose.Schema( enum: ['pending', 'processing', 'completed', 'failed', 'skipped'], default: 'pending', }, + status: { + type: String, + enum: ['PENDING', 'COMPLETED'], + default: 'PENDING', + index: true, + }, + isEncrypted: { + type: Boolean, + default: false, + }, + wrappedKey: { + type: String, + default: null, + }, + keySalt: { + type: String, + default: null, + }, }, { timestamps: true, diff --git a/server/models/ShareLink.js b/server/models/ShareLink.js index ad44e44..d2ea5ce 100644 --- a/server/models/ShareLink.js +++ b/server/models/ShareLink.js @@ -27,6 +27,9 @@ const shareLinkSchema = new mongoose.Schema({ dailyBandwidth: { type: Number, default: 0 }, bandwidthLimit: { type: Number, default: 524288000 }, // 500 MB in bytes isSuspended: { type: Boolean, default: false }, + isEncrypted: { type: Boolean, default: false }, + wrappedKey: { type: String, default: null }, + keySalt: { type: String, default: null }, }, { timestamps: true }); shareLinkSchema.index({ fileId: 1, userId: 1 }); diff --git a/server/models/UploadSession.js b/server/models/UploadSession.js index e52816c..4893293 100644 --- a/server/models/UploadSession.js +++ b/server/models/UploadSession.js @@ -54,6 +54,11 @@ const uploadSessionSchema = new mongoose.Schema( type: String, required: true, }, + fileId: { + type: mongoose.Schema.Types.ObjectId, + ref: "File", + default: null, + }, fileUrl: { type: String, default: null, diff --git a/server/node_modules/.package-lock.json b/server/node_modules/.package-lock.json index c33f806..ca1c7ff 100644 --- a/server/node_modules/.package-lock.json +++ b/server/node_modules/.package-lock.json @@ -1256,6 +1256,8 @@ }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", "cpu": [ "x64" ], diff --git a/server/package-lock.json b/server/package-lock.json index f309119..13bdeaa 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -490,40 +490,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/@hapi/address/-/address-5.1.1.tgz", @@ -1219,17 +1185,6 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -1343,25 +1298,6 @@ "dev": true, "license": "ISC" }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.12.2", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", diff --git a/server/test-cleanup-worker.js b/server/test-cleanup-worker.js new file mode 100644 index 0000000..8f613aa --- /dev/null +++ b/server/test-cleanup-worker.js @@ -0,0 +1,315 @@ +process.env.ENABLE_DOCUMENT_INDEXING = "false"; +process.env.ENABLE_MALWARE_SCANNING = "false"; + +import mongoose from "mongoose"; +import File from "./models/File.js"; +import UploadSession from "./models/UploadSession.js"; +import { initUpload } from "./controllers/uploadController.js"; +import { saveFileInfo } from "./controllers/fileController.js"; +import { initUploadCleanupWorker } from "./jobs/uploadCleanupWorker.js"; + +// Mock database storage +const filesDb = new Map(); +const sessionsDb = new Map(); +let cronCallback = null; + +// Monkey patch ObjectId validation for testing +mongoose.Types.ObjectId.isValid = () => true; + +// Mock node-cron +import cron from "node-cron"; +cron.schedule = (pattern, callback) => { + cronCallback = callback; + console.log(`[Mock Cron] Scheduled pattern: ${pattern}`); + return { start: () => {} }; +}; + +const createMockQuery = (result) => { + const queryObj = { + select: function() { return queryObj; }, + populate: function() { return queryObj; }, + sort: function() { return queryObj; }, + then: function(onFulfilled, onRejected) { + return Promise.resolve(result).then(onFulfilled, onRejected); + }, + catch: function(onRejected) { + return Promise.resolve(result).catch(onRejected); + } + }; + return queryObj; +}; + +// Setup Mocking for File model +File.findOne = (query) => { + console.log("[Mock DB File.findOne] Query:", query); + for (const [id, doc] of filesDb.entries()) { + let match = true; + for (let key in query) { + if (key === "status" && typeof query[key] === "object") { + if (query[key].$ne && doc.status === query[key].$ne) match = false; + } else { + const docVal = doc[key] && typeof doc[key] === 'object' && doc[key].toString ? doc[key].toString() : doc[key]; + const queryVal = query[key] && typeof query[key] === 'object' && query[key].toString ? query[key].toString() : query[key]; + if (docVal !== queryVal) { + match = false; + } + } + } + console.log(`- Comparing with doc ${id} (name: ${doc.fileName}, status: ${doc.status}, userId: ${doc.userId}) -> match: ${match}`); + if (match) { + doc.save = async function() { + filesDb.set(this._id.toString(), this); + return this; + }; + return createMockQuery(doc); + } + } + return createMockQuery(null); +}; + +File.find = async (query) => { + const matched = []; + for (const [id, doc] of filesDb.entries()) { + let match = true; + for (let key in query) { + if (key === "updatedAt" && query[key].$lt) { + if (new Date(doc.updatedAt) >= new Date(query[key].$lt)) match = false; + } else if (key === "status" && typeof query[key] === "object") { + if (query[key].$ne && doc.status === query[key].$ne) match = false; + } else { + const docVal = doc[key] && typeof doc[key] === 'object' && doc[key].toString ? doc[key].toString() : doc[key]; + const queryVal = query[key] && typeof query[key] === 'object' && query[key].toString ? query[key].toString() : query[key]; + if (docVal !== queryVal) { + match = false; + } + } + } + if (match) matched.push(doc); + } + return matched; +}; + +File.findByIdAndDelete = async (id) => { + console.log(`[Mock DB] Deleting File ID: ${id}`); + filesDb.delete(id.toString()); + return true; +}; + +// We mock the constructor and save methods on File prototype +File.prototype.save = async function() { + if (!this._id) { + this._id = new mongoose.Types.ObjectId().toString(); + } + this.createdAt = this.createdAt || new Date(); + this.updatedAt = new Date(); + filesDb.set(this._id.toString(), this); + return this; +}; + +// Setup Mocking for UploadSession model +UploadSession.create = async (data) => { + const session = { + _id: new mongoose.Types.ObjectId().toString(), + ...data, + receivedChunks: [], + uploadedBytes: 0, + expiresAt: new Date(Date.now() + 3600000), + save: async function() { + sessionsDb.set(this._id, this); + return this; + } + }; + sessionsDb.set(session._id, session); + return session; +}; + +UploadSession.findOne = (query) => { + for (const [id, doc] of sessionsDb.entries()) { + let match = true; + for (let key in query) { + if (key === "status" && typeof query[key] === "object") { + if (query[key].$in && !query[key].$in.includes(doc.status)) match = false; + } else if (doc[key] && doc[key].toString() !== query[key].toString()) { + match = false; + } + } + if (match) return createMockQuery(doc); + } + return createMockQuery(null); +}; + +UploadSession.findByIdAndDelete = async (id) => { + console.log(`[Mock DB] Deleting UploadSession ID: ${id}`); + sessionsDb.delete(id.toString()); + return true; +}; + +// Mock Express req/res +const mockRes = () => { + const res = { statusCode: 200 }; + res.status = (code) => { + res.statusCode = code; + return res; + }; + res.json = (data) => { + res.body = data; + return res; + }; + return res; +}; + +const mockNext = (err) => { + if (err) throw err; +}; + +async function runTests() { + console.log("--- Starting Mock Tests for Stale Upload Cleanup ---"); + + try { + // Enable cleanup worker registration (registers mock cron) + initUploadCleanupWorker(); + + console.log("\n--- Test Case 1: Initialize Upload ---"); + let req1 = { + user: { id: "60c72b2f9b1d8e1f5c8b4567" }, + get: (header) => "localhost", + body: { + fileName: "large_movie.mp4", + fileSizeBytes: 104857600, // 100 MB + mimeType: "video/mp4" + } + }; + let res1 = mockRes(); + await initUpload(req1, res1, mockNext); + + console.log("initUpload Response status:", res1.statusCode); + console.log("initUpload Response session ID:", res1.body.session.sessionId); + + // Verify a PENDING File document was created + const pendingFile = Array.from(filesDb.values()).find(f => f.status === "PENDING"); + if (!pendingFile) throw new Error("Test 1 Failed: No PENDING file was created!"); + console.log(`āœ… PENDING File document created successfully. ID: ${pendingFile._id}`); + + // Verify session references the pending file + const activeSession = sessionsDb.get(res1.body.session.sessionId); + if (!activeSession || activeSession.fileId.toString() !== pendingFile._id.toString()) { + throw new Error("Test 1 Failed: UploadSession does not reference the PENDING file ID."); + } + console.log(`āœ… UploadSession successfully linked to PENDING File ID.`); + + console.log("\n--- Test Case 2: Save File Info (Transitions to COMPLETED) ---"); + let req2 = { + user: { id: "60c72b2f9b1d8e1f5c8b4567" }, + body: { + fileName: "large_movie.mp4", + fileUrl: "https://cloudinary.com/secureshare/large_movie.mp4", + fileType: "video", + fileSize: "100 MB", + fileSizeBytes: 104857600, + checksum: "sha256checksumvalue" + }, + app: { + get: () => ({ to: () => ({ emit: () => {} }) }) // mock socket.io + } + }; + let res2 = mockRes(); + await saveFileInfo(req2, res2, mockNext); + + console.log("saveFileInfo Response status:", res2.statusCode); + const completedFile = filesDb.get(pendingFile._id.toString()); + if (!completedFile || completedFile.status !== "COMPLETED") { + throw new Error("Test 2 Failed: File status did not transition to COMPLETED!"); + } + if (completedFile.fileUrl !== req2.body.fileUrl) { + throw new Error("Test 2 Failed: File URL was not updated correctly!"); + } + console.log("āœ… File status transitioned to COMPLETED and data updated successfully."); + + console.log("\n--- Test Case 3: Sweep Stale PENDING Uploads via Cron Worker ---"); + // Clear DB and create one stale pending file and one recent pending file + filesDb.clear(); + sessionsDb.clear(); + + // Stale upload (25 hours ago) + const staleFileId = new mongoose.Types.ObjectId().toString(); + const staleFile = { + _id: staleFileId, + fileName: "abandoned.txt", + fileUrl: "http://temp/abandoned", + fileType: "text", + fileSize: "5 KB", + fileSizeBytes: 5120, + userId: "60c72b2f9b1d8e1f5c8b4567", + status: "PENDING", + createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000), + updatedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) + }; + filesDb.set(staleFileId, staleFile); + + const staleSessionId = new mongoose.Types.ObjectId().toString(); + const staleSession = { + _id: staleSessionId, + userId: "60c72b2f9b1d8e1f5c8b4567", + fileName: "abandoned.txt", + tempDir: "/tmp/upload-stale-dir", + fileId: staleFileId, + status: "uploading" + }; + sessionsDb.set(staleSessionId, staleSession); + + // Recent upload (1 hour ago) + const recentFileId = new mongoose.Types.ObjectId().toString(); + const recentFile = { + _id: recentFileId, + fileName: "active.txt", + fileUrl: "http://temp/active", + fileType: "text", + fileSize: "2 KB", + fileSizeBytes: 2048, + userId: "60c72b2f9b1d8e1f5c8b4567", + status: "PENDING", + createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000), + updatedAt: new Date(Date.now() - 1 * 60 * 60 * 1000) + }; + filesDb.set(recentFileId, recentFile); + + const recentSessionId = new mongoose.Types.ObjectId().toString(); + const recentSession = { + _id: recentSessionId, + userId: "60c72b2f9b1d8e1f5c8b4567", + fileName: "active.txt", + tempDir: "/tmp/upload-active-dir", + fileId: recentFileId, + status: "uploading" + }; + sessionsDb.set(recentSessionId, recentSession); + + console.log(`Initial DB state: ${filesDb.size} files, ${sessionsDb.size} sessions.`); + + // Trigger cron sweep callback + if (!cronCallback) throw new Error("Test 3 Failed: Cron callback not captured!"); + await cronCallback(); + + console.log(`Post-sweep DB state: ${filesDb.size} files, ${sessionsDb.size} sessions.`); + + // Check if stale upload is gone + if (filesDb.has(staleFileId) || sessionsDb.has(staleSessionId)) { + throw new Error("Test 3 Failed: Stale file or session was not deleted!"); + } + + // Check if recent upload is kept + if (!filesDb.has(recentFileId) || !sessionsDb.has(recentSessionId)) { + throw new Error("Test 3 Failed: Recent pending file or session was deleted!"); + } + + console.log("āœ… Cron sweep cleared stale uploads, keeping active ones."); + console.log("\nšŸŽ‰ ALL CLEANUP WORKER TESTS PASSED SUCCESSFULLY!"); + process.exit(0); + + } catch (error) { + console.error("\nāŒ Test Case Failed:", error.message || error); + process.exit(1); + } +} + +runTests(); diff --git a/server/test.js b/server/test.js index 26c6b09..5003bf1 100644 --- a/server/test.js +++ b/server/test.js @@ -1,3 +1,6 @@ +process.env.ENABLE_DOCUMENT_INDEXING = "false"; +process.env.ENABLE_MALWARE_SCANNING = "false"; + import { saveFileInfo, getFileVersions, restoreFileVersion, updateFilePassword, getSharedFileById, verifySharedFilePassword } from './controllers/fileController.js'; import File from './models/File.js'; import mongoose from 'mongoose';