diff --git a/app/page.tsx b/app/page.tsx index f247310..b741df7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,29 +1,47 @@ 'use client'; import { useState } from 'react'; -import { Lock, Unlock, Shield } from 'lucide-react'; +import { Lock, Unlock, Shield, Archive } from 'lucide-react'; import { encryptStream, decryptStream } from '@/lib/crypto'; +import { createZipFromFiles } from '@/lib/zip'; import { ModeSelector, type Mode } from '@/components/mode-selector'; import { FileDropZone } from '@/components/file-drop-zone'; import { PasswordInput } from '@/components/password-input'; import { StatusMessage, type Status } from '@/components/status-message'; +import { ArchiveToggle, type MultiFileMode } from '@/components/archive-toggle'; import { Footer } from '@/components/footer'; +/** Trigger a browser download from a ReadableStream. */ +async function downloadStream(stream: ReadableStream, filename: string): Promise { + const blob = await new Response(stream).blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + export default function Home() { const [mode, setMode] = useState('encrypt'); - const [file, setFile] = useState(null); + const [files, setFiles] = useState([]); const [password, setPassword] = useState(''); const [processing, setProcessing] = useState(false); const [status, setStatus] = useState(null); + const [multiFileMode, setMultiFileMode] = useState('archive'); - const handleFileSelect = (selected: File) => { - setFile(selected); + const isMultiFile = files.length > 1; + + const handleFileSelect = (selected: File[]) => { + setFiles(selected); setStatus(null); }; const handleProcess = async () => { - if (!file || !password) { - setStatus({ type: 'error', message: 'Please select a file and enter a password' }); + if (files.length === 0 || !password) { + setStatus({ type: 'error', message: 'Please select at least one file and enter a password.' }); return; } @@ -31,48 +49,91 @@ export default function Home() { setStatus(null); try { - let outputStream: ReadableStream; - let newFileName: string; - if (mode === 'encrypt') { - outputStream = encryptStream(file.stream(), password); - newFileName = `${file.name}.encrypted`; + await handleEncrypt(); } else { - if (file.size < 33) { - throw new Error('Invalid encrypted file format'); - } - outputStream = decryptStream(file.stream(), password); - newFileName = file.name.endsWith('.encrypted') - ? file.name.slice(0, -10) - : `${file.name}.decrypted`; + await handleDecrypt(); } - - const blob = await new Response(outputStream).blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = newFileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - setStatus({ - type: 'success', - message: mode === 'encrypt' ? 'File encrypted successfully!' : 'File decrypted successfully!', - }); } catch { setStatus({ type: 'error', message: mode === 'decrypt' ? 'Decryption failed. Wrong password or corrupted file.' - : 'Encryption failed. Please try again.' + : 'Encryption failed. Please try again.', }); } finally { setProcessing(false); } }; + const handleEncrypt = async () => { + if (isMultiFile && multiFileMode === 'archive') { + // --- Archive mode: ZIP then encrypt --- + const zipBlob = await createZipFromFiles(files); + + const outputStream = encryptStream(zipBlob.stream(), password); + await downloadStream(outputStream, 'seal3d-archive.zip.encrypted'); + + setStatus({ type: 'success', message: `${files.length} files archived and encrypted successfully!` }); + } else if (isMultiFile && multiFileMode === 'individual') { + // --- Individual mode: encrypt each file separately --- + for (const file of files) { + const outputStream = encryptStream(file.stream(), password); + await downloadStream(outputStream, `${file.name}.encrypted`); + } + + setStatus({ type: 'success', message: `${files.length} files encrypted successfully!` }); + } else { + // --- Single file --- + const file = files[0]; + const outputStream = encryptStream(file.stream(), password); + await downloadStream(outputStream, `${file.name}.encrypted`); + setStatus({ type: 'success', message: 'File encrypted successfully!' }); + } + }; + + const handleDecrypt = async () => { + // Decrypt always operates on a single file (could be a .zip.encrypted) + const file = files[0]; + + if (file.size < 33) { + throw new Error('Invalid encrypted file format'); + } + + if (files.length > 1) { + // Decrypt multiple files individually, sequentially + for (let i = 0; i < files.length; i++) { + const f = files[i]; + if (f.size < 33) { + throw new Error(`Invalid encrypted file format: ${f.name}`); + } + + const outputStream = decryptStream(f.stream(), password); + const newFileName = f.name.endsWith('.encrypted') + ? f.name.slice(0, -10) + : `${f.name}.decrypted`; + await downloadStream(outputStream, newFileName); + } + + setStatus({ type: 'success', message: `${files.length} files decrypted successfully!` }); + } else { + const outputStream = decryptStream(file.stream(), password); + const newFileName = file.name.endsWith('.encrypted') + ? file.name.slice(0, -10) + : `${file.name}.decrypted`; + await downloadStream(outputStream, newFileName); + setStatus({ type: 'success', message: 'File decrypted successfully!' }); + } + }; + + const buttonLabel = () => { + if (processing) return null; // handled by spinner + if (mode === 'decrypt') return 'Decrypt & Save'; + if (isMultiFile && multiFileMode === 'archive') return 'Archive & Encrypt'; + if (isMultiFile && multiFileMode === 'individual') return `Encrypt ${files.length} Files`; + return 'Encrypt & Save'; + }; + return (
@@ -91,7 +152,12 @@ export default function Home() {
- + + + {isMultiFile && mode === 'encrypt' && ( + + )} + + +
+
+
+ ); +} diff --git a/components/file-drop-zone.tsx b/components/file-drop-zone.tsx index 2cbe574..043fc56 100644 --- a/components/file-drop-zone.tsx +++ b/components/file-drop-zone.tsx @@ -1,12 +1,18 @@ import { useRef, useCallback, useState } from 'react'; -import { Upload, FileCheck } from 'lucide-react'; +import { Upload, FileCheck, X, Files } from 'lucide-react'; interface FileDropZoneProps { - file: File | null; - onFileSelect: (file: File) => void; + files: File[]; + onFileSelect: (files: File[]) => void; } -export function FileDropZone({ file, onFileSelect }: FileDropZoneProps) { +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +export function FileDropZone({ files, onFileSelect }: FileDropZoneProps) { const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); @@ -23,19 +29,36 @@ export function FileDropZone({ file, onFileSelect }: FileDropZoneProps) { const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); - const droppedFile = e.dataTransfer.files[0]; - if (droppedFile) { - onFileSelect(droppedFile); + const droppedFiles = Array.from(e.dataTransfer.files); + if (droppedFiles.length > 0) { + onFileSelect(droppedFiles); } }, [onFileSelect]); const handleChange = (e: React.ChangeEvent) => { - const selectedFile = e.target.files?.[0]; - if (selectedFile) { - onFileSelect(selectedFile); + const selectedFiles = e.target.files ? Array.from(e.target.files) : []; + if (selectedFiles.length > 0) { + onFileSelect(selectedFiles); + } + }; + + const handleRemoveFile = (index: number, e: React.MouseEvent) => { + e.stopPropagation(); + const updated = files.filter((_, i) => i !== index); + onFileSelect(updated); + }; + + const handleClearAll = (e: React.MouseEvent) => { + e.stopPropagation(); + onFileSelect([]); + // Reset the file input so re-selecting the same files works + if (fileInputRef.current) { + fileInputRef.current.value = ''; } }; + const totalSize = files.reduce((sum, f) => sum + f.size, 0); + return (
- {file ? ( + {files.length === 0 ? ( + <> +
+ +
+
+

+ Drop your files here +

+

+ or click to browse — select one or multiple files +

+
+ + ) : files.length === 1 ? ( <>

- {file.name} + {files[0].name}

- {(file.size / 1024 / 1024).toFixed(2)} MB + {formatSize(files[0].size)}

+ ) : ( <> -
- +
+
-
-

- Drop your file here +

+

+ {files.length} files selected

- or click to browse + Total: {formatSize(totalSize)}

+
e.stopPropagation()}> + {files.map((f, i) => ( +
+ + {f.name} + +
+ + {formatSize(f.size)} + + +
+
+ ))} +
+ )}
diff --git a/lib/crypto.test.ts b/lib/crypto.test.ts index 51c95eb..bcee3d7 100644 --- a/lib/crypto.test.ts +++ b/lib/crypto.test.ts @@ -3,6 +3,7 @@ import { createReadStream, createWriteStream } from 'node:fs'; import { unlink, stat } from 'node:fs/promises'; import { Readable } from 'node:stream'; import { encryptFile, decryptFile, encryptStream, decryptStream } from './crypto'; +import { createZipFromFiles, extractZip, verifyZip } from './zip'; const CHUNK_SIZE = 5 * 1024 * 1024; const PASSWORD = 'test-password'; @@ -134,6 +135,163 @@ describe('streaming encrypt/decrypt (100 MB)', () => { }); }); +describe('multi-file archive encrypt/decrypt', () => { + /** Helper: create a browser-like File from a string. */ + function makeFile(name: string, content: string): File { + const encoded = new TextEncoder().encode(content); + return new File([encoded.buffer as ArrayBuffer], name); + } + + function makeFileFromBytes(name: string, bytes: Uint8Array): File { + return new File([bytes.buffer as ArrayBuffer], name); + } + + it('encrypt then decrypt a ZIP archive of multiple text files', async () => { + const files = [ + makeFile('a.txt', 'alpha content'), + makeFile('b.txt', 'beta content'), + makeFile('c.txt', 'gamma content'), + ]; + + // 1. Create ZIP + const zipBlob = await createZipFromFiles(files); + const zipBytes = new Uint8Array(await zipBlob.arrayBuffer()); + + // Verify it's a valid ZIP + const entries = verifyZip(zipBytes); + expect(entries.sort()).toEqual(['a.txt', 'b.txt', 'c.txt']); + + // 2. Encrypt the ZIP + const encrypted = await encryptFile(zipBytes, PASSWORD); + expect(encrypted.length).toBeGreaterThan(zipBytes.length); + + // 3. Decrypt the ZIP + const decrypted = await decryptFile(encrypted, PASSWORD); + expect(Buffer.from(decrypted).equals(Buffer.from(zipBytes))).toBe(true); + + // 4. Verify the decrypted data is still a valid ZIP with correct contents + const extracted = extractZip(decrypted); + expect(new TextDecoder().decode(extracted['a.txt'])).toBe('alpha content'); + expect(new TextDecoder().decode(extracted['b.txt'])).toBe('beta content'); + expect(new TextDecoder().decode(extracted['c.txt'])).toBe('gamma content'); + }); + + it('encrypt then decrypt a ZIP with binary data', async () => { + const binaryData = randomBytes(10_000); + const files = [ + makeFileFromBytes('binary.bin', binaryData), + makeFile('readme.txt', 'This archive contains binary data'), + ]; + + const zipBlob = await createZipFromFiles(files); + const zipBytes = new Uint8Array(await zipBlob.arrayBuffer()); + + const encrypted = await encryptFile(zipBytes, PASSWORD); + const decrypted = await decryptFile(encrypted, PASSWORD); + + const extracted = extractZip(decrypted); + expect(extracted['binary.bin']).toEqual(binaryData); + expect(new TextDecoder().decode(extracted['readme.txt'])).toBe( + 'This archive contains binary data', + ); + }); + + it('encrypt then decrypt a ZIP archive via streaming', async () => { + const files = [ + makeFile('file1.md', '# Heading\n\nSome markdown content.'), + makeFile('file2.json', '{"key": "value", "num": 42}'), + ]; + + const zipBlob = await createZipFromFiles(files); + + // Encrypt via streaming + const encStream = encryptStream(zipBlob.stream(), PASSWORD); + const encBlob = await new Response(encStream).blob(); + const encBytes = new Uint8Array(await encBlob.arrayBuffer()); + + // Decrypt via streaming + const decStream = decryptStream( + new ReadableStream({ + start(c) { + c.enqueue(encBytes); + c.close(); + }, + }), + PASSWORD, + ); + const decBlob = await new Response(decStream).blob(); + const decBytes = new Uint8Array(await decBlob.arrayBuffer()); + + // Verify + const extracted = extractZip(decBytes); + expect(new TextDecoder().decode(extracted['file1.md'])).toBe( + '# Heading\n\nSome markdown content.', + ); + expect(JSON.parse(new TextDecoder().decode(extracted['file2.json']))).toEqual({ + key: 'value', + num: 42, + }); + }); + + it('wrong password fails to decrypt a ZIP archive', async () => { + const files = [makeFile('secret.txt', 'top secret')]; + const zipBlob = await createZipFromFiles(files); + const zipBytes = new Uint8Array(await zipBlob.arrayBuffer()); + + const encrypted = await encryptFile(zipBytes, PASSWORD); + + await expect(decryptFile(encrypted, 'wrong-password')).rejects.toThrow(); + }); + + it('individual file encryption works for multiple files', async () => { + const files = [ + makeFile('doc1.txt', 'document one'), + makeFile('doc2.txt', 'document two'), + ]; + + // Encrypt each file individually + const encryptedFiles: Uint8Array[] = []; + for (const file of files) { + const data = new Uint8Array(await file.arrayBuffer()); + const encrypted = await encryptFile(data, PASSWORD); + encryptedFiles.push(encrypted); + } + + // Decrypt each individually + const decryptedContents: string[] = []; + for (const encrypted of encryptedFiles) { + const decrypted = await decryptFile(encrypted, PASSWORD); + decryptedContents.push(new TextDecoder().decode(decrypted)); + } + + expect(decryptedContents).toEqual(['document one', 'document two']); + }); + + it('large multi-file archive (multiple 5 MiB chunks)', { timeout: 30_000 }, async () => { + // Create files that total > 5 MiB to force multi-block encryption + const bigData = randomBytes(3 * 1024 * 1024); // 3 MiB + const files = [ + makeFileFromBytes('big1.bin', bigData), + makeFileFromBytes('big2.bin', randomBytes(3 * 1024 * 1024)), + ]; + + const zipBlob = await createZipFromFiles(files); + const zipBytes = new Uint8Array(await zipBlob.arrayBuffer()); + + // Must exceed 5 MiB to test multi-block + expect(zipBytes.length).toBeGreaterThan(CHUNK_SIZE); + + const encrypted = await encryptFile(zipBytes, PASSWORD); + const decrypted = await decryptFile(encrypted, PASSWORD); + + expect(Buffer.from(decrypted).equals(Buffer.from(zipBytes))).toBe(true); + + const extracted = extractZip(decrypted); + expect(extracted['big1.bin']).toEqual(bigData); + expect(extracted['big2.bin'].length).toBe(3 * 1024 * 1024); + }); +}); + describe('large-file streaming (opt-in)', () => { const skip = !process.env.RUN_LARGE_FILE_TEST; diff --git a/lib/zip.test.ts b/lib/zip.test.ts new file mode 100644 index 0000000..02905e1 --- /dev/null +++ b/lib/zip.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createZipFromFiles, listZipEntries, verifyZip, extractZip } from './zip'; +import { unzipSync } from 'fflate'; + +/** Helper: create a browser-like File object from a string. */ +function makeFile(name: string, content: string): File { + const encoded = new TextEncoder().encode(content); + return new File([encoded.buffer as ArrayBuffer], name); +} + +/** Helper: create a File from raw bytes. */ +function makeFileFromBytes(name: string, bytes: Uint8Array): File { + return new File([bytes.buffer as ArrayBuffer], name); +} + +describe('createZipFromFiles', () => { + it('throws on empty file list', async () => { + await expect(createZipFromFiles([])).rejects.toThrow( + 'Cannot create ZIP from an empty file list.', + ); + }); + + it('zips a single file and preserves content', async () => { + const file = makeFile('hello.txt', 'Hello, World!'); + const blob = await createZipFromFiles([file]); + + expect(blob.type).toBe('application/zip'); + expect(blob.size).toBeGreaterThan(0); + + // Verify round-trip + const data = new Uint8Array(await blob.arrayBuffer()); + const entries = unzipSync(data); + expect(Object.keys(entries)).toEqual(['hello.txt']); + expect(new TextDecoder().decode(entries['hello.txt'])).toBe('Hello, World!'); + }); + + it('zips multiple files and preserves all contents', async () => { + const files = [ + makeFile('a.txt', 'content-a'), + makeFile('b.txt', 'content-b'), + makeFile('c.txt', 'content-c'), + ]; + + const blob = await createZipFromFiles(files); + const data = new Uint8Array(await blob.arrayBuffer()); + const entries = unzipSync(data); + + expect(Object.keys(entries).sort()).toEqual(['a.txt', 'b.txt', 'c.txt']); + expect(new TextDecoder().decode(entries['a.txt'])).toBe('content-a'); + expect(new TextDecoder().decode(entries['b.txt'])).toBe('content-b'); + expect(new TextDecoder().decode(entries['c.txt'])).toBe('content-c'); + }); + + it('handles duplicate filenames by appending counter', async () => { + const files = [ + makeFile('doc.pdf', 'first'), + makeFile('doc.pdf', 'second'), + makeFile('doc.pdf', 'third'), + ]; + + const blob = await createZipFromFiles(files); + const data = new Uint8Array(await blob.arrayBuffer()); + const entries = unzipSync(data); + + const names = Object.keys(entries).sort(); + expect(names).toEqual(['doc (1).pdf', 'doc (2).pdf', 'doc.pdf']); + expect(new TextDecoder().decode(entries['doc.pdf'])).toBe('first'); + expect(new TextDecoder().decode(entries['doc (1).pdf'])).toBe('second'); + expect(new TextDecoder().decode(entries['doc (2).pdf'])).toBe('third'); + }); + + it('handles duplicate filenames without extension', async () => { + const files = [ + makeFile('README', 'first'), + makeFile('README', 'second'), + ]; + + const blob = await createZipFromFiles(files); + const data = new Uint8Array(await blob.arrayBuffer()); + const entries = unzipSync(data); + + const names = Object.keys(entries).sort(); + expect(names).toEqual(['README', 'README (1)']); + expect(new TextDecoder().decode(entries['README'])).toBe('first'); + expect(new TextDecoder().decode(entries['README (1)'])).toBe('second'); + }); + + it('preserves binary content', async () => { + const bytes = new Uint8Array(256); + for (let i = 0; i < 256; i++) bytes[i] = i; + const file = makeFileFromBytes('binary.bin', bytes); + + const blob = await createZipFromFiles([file]); + const data = new Uint8Array(await blob.arrayBuffer()); + const entries = unzipSync(data); + + expect(entries['binary.bin']).toEqual(bytes); + }); + + it('handles large files (1 MB)', async () => { + const size = 1024 * 1024; + const bytes = new Uint8Array(size); + // Fill in 64KB chunks (crypto.getRandomValues limit) + for (let off = 0; off < size; off += 65536) { + const len = Math.min(65536, size - off); + crypto.getRandomValues(bytes.subarray(off, off + len)); + } + const file = makeFileFromBytes('large.bin', bytes); + + const blob = await createZipFromFiles([file]); + const data = new Uint8Array(await blob.arrayBuffer()); + const entries = unzipSync(data); + + expect(entries['large.bin'].length).toBe(size); + expect(entries['large.bin']).toEqual(bytes); + }); + + it('calls onProgress callback for each file', async () => { + const files = [ + makeFile('a.txt', 'a'), + makeFile('b.txt', 'b'), + makeFile('c.txt', 'c'), + ]; + const onProgress = vi.fn(); + + await createZipFromFiles(files, onProgress); + + expect(onProgress).toHaveBeenCalledTimes(3); + expect(onProgress).toHaveBeenNthCalledWith(1, 1, 3); + expect(onProgress).toHaveBeenNthCalledWith(2, 2, 3); + expect(onProgress).toHaveBeenNthCalledWith(3, 3, 3); + }); + + it('does not call onProgress when not provided', async () => { + // Just verify no error is thrown + const file = makeFile('test.txt', 'test'); + const blob = await createZipFromFiles([file]); + expect(blob.size).toBeGreaterThan(0); + }); + + it('handles files with special characters in names', async () => { + const files = [ + makeFile('file with spaces.txt', 'spaces'), + makeFile('file-with-dashes.txt', 'dashes'), + makeFile('file_with_underscores.txt', 'underscores'), + ]; + + const blob = await createZipFromFiles(files); + const data = new Uint8Array(await blob.arrayBuffer()); + const entries = unzipSync(data); + + expect(Object.keys(entries)).toContain('file with spaces.txt'); + expect(Object.keys(entries)).toContain('file-with-dashes.txt'); + expect(Object.keys(entries)).toContain('file_with_underscores.txt'); + }); + + it('handles empty files', async () => { + const file = makeFile('empty.txt', ''); + const blob = await createZipFromFiles([file]); + const data = new Uint8Array(await blob.arrayBuffer()); + const entries = unzipSync(data); + + expect(entries['empty.txt'].length).toBe(0); + }); +}); + +describe('listZipEntries', () => { + it('returns filenames from a ZIP archive', async () => { + const files = [makeFile('x.txt', 'x'), makeFile('y.txt', 'y')]; + const blob = await createZipFromFiles(files); + const data = new Uint8Array(await blob.arrayBuffer()); + + const names = listZipEntries(data); + expect(names.sort()).toEqual(['x.txt', 'y.txt']); + }); + + it('throws on invalid data', () => { + const bad = new Uint8Array([0, 1, 2, 3]); + expect(() => listZipEntries(bad)).toThrow(); + }); +}); + +describe('verifyZip', () => { + it('returns entry names for valid ZIP', async () => { + const blob = await createZipFromFiles([makeFile('test.txt', 'test')]); + const data = new Uint8Array(await blob.arrayBuffer()); + + const names = verifyZip(data); + expect(names).toEqual(['test.txt']); + }); + + it('throws for corrupted ZIP', () => { + const bad = new Uint8Array(100); + expect(() => verifyZip(bad)).toThrow(); + }); + + it('throws for truncated ZIP', async () => { + const blob = await createZipFromFiles([makeFile('test.txt', 'test')]); + const data = new Uint8Array(await blob.arrayBuffer()); + const truncated = data.slice(0, 10); + + expect(() => verifyZip(truncated)).toThrow(); + }); +}); + +describe('extractZip', () => { + it('extracts all files from a ZIP', async () => { + const files = [ + makeFile('a.txt', 'alpha'), + makeFile('b.txt', 'beta'), + ]; + const blob = await createZipFromFiles(files); + const data = new Uint8Array(await blob.arrayBuffer()); + + const extracted = extractZip(data); + expect(new TextDecoder().decode(extracted['a.txt'])).toBe('alpha'); + expect(new TextDecoder().decode(extracted['b.txt'])).toBe('beta'); + }); + + it('throws on invalid data', () => { + expect(() => extractZip(new Uint8Array([99, 99, 99]))).toThrow(); + }); +}); diff --git a/lib/zip.ts b/lib/zip.ts new file mode 100644 index 0000000..61cc908 --- /dev/null +++ b/lib/zip.ts @@ -0,0 +1,67 @@ +import { zipSync, unzipSync } from 'fflate'; + +/** + * Create a ZIP archive from an array of Files. + * + * Uses store-only compression (level 0) since the output will be encrypted + * anyway — compressing before encryption adds CPU cost with negligible benefit + * for already-compressed file formats. + * + * Duplicate filenames are disambiguated by appending " (N)" before the + * extension (e.g. "photo.jpg" → "photo (1).jpg"). + */ +export async function createZipFromFiles( + files: File[], + onProgress?: (current: number, total: number) => void, +): Promise { + if (files.length === 0) { + throw new Error('Cannot create ZIP from an empty file list.'); + } + + const entries: Record = {}; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const buffer = await file.arrayBuffer(); + + let name = file.name; + if (entries[name]) { + const dotIdx = name.lastIndexOf('.'); + const base = dotIdx > 0 ? name.slice(0, dotIdx) : name; + const ext = dotIdx > 0 ? name.slice(dotIdx) : ''; + let counter = 1; + while (entries[`${base} (${counter})${ext}`]) counter++; + name = `${base} (${counter})${ext}`; + } + + entries[name] = new Uint8Array(buffer); + onProgress?.(i + 1, files.length); + } + + const zipped = zipSync(entries, { level: 0 }); + return new Blob([zipped.buffer as ArrayBuffer], { type: 'application/zip' }); +} + +/** + * List filenames contained in a ZIP archive (for display / verification). + */ +export function listZipEntries(data: Uint8Array): string[] { + const entries = unzipSync(data); + return Object.keys(entries); +} + +/** + * Verify that a Uint8Array is a valid ZIP by attempting to parse it. + * Returns the entry names on success, throws on invalid data. + */ +export function verifyZip(data: Uint8Array): string[] { + return Object.keys(unzipSync(data)); +} + +/** + * Extract all files from a ZIP archive and return them as a map of + * filename → Uint8Array. + */ +export function extractZip(data: Uint8Array): Record { + return unzipSync(data); +} diff --git a/package.json b/package.json index e932c8e..b6399a6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@opennextjs/cloudflare": "^1.16.5", + "fflate": "^0.8.2", "lucide-react": "^0.575.0", "next": "^16.1.6", "react": "^19.2.4",