Address remaining audit findings
Multiple files per transfer - The UI accepted several files but only files[0] was ever uploaded, so recipients saw a list and received one file. Chunks now carry a fileIndex, the upload session tracks each file separately, and finalize assembles one encrypted payload and one File row per file. - The download page lists real files with per-file download buttons; the previously unused isFileDownloading/isFileDownloaded helpers now drive that state. /api/file/[id] accepts a fileId, scoped to the transfer so an id from another transfer cannot be fetched. Large downloads - Replace res.blob() with a helper that streams the response body to disk via the File System Access API where available, keeping peak memory at roughly one chunk instead of the whole file. Falls back to the blob path elsewhere. Password brute force - Rate limit /api/verify and /api/file/[id] to 10 attempts per transfer, per client, per 15 minutes; a correct password clears the counter. Per-process state, matching the existing local-disk storage model. Disk reclamation - Nothing ever deleted payloads, so every transfer stayed on disk regardless of expiresAt. Add a cleanup routine that marks lapsed transfers EXPIRED, deletes payloads for expired and soft-deleted transfers, and sweeps abandoned chunk directories. It refuses to unlink anything outside UPLOAD_DIR. Exposed as POST /api/cron/cleanup behind CRON_SECRET, failing closed when that is unset. Build hygiene - Stop ignoring ESLint during builds and fix all 70 resulting violations: unused imports and state, unescaped JSX entities, explicit any, and a missing useEffect dependency. Seven catch blocks discarded their error silently and now log it. - Delete dead code: two unused send components, the two legacy upload endpoints they called, the unused whole-file WebCrypto helpers, and a duplicate separator component differing only by a typo. Add .env.example documenting configuration, including CRON_SECRET. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+242
-226
@@ -1,8 +1,12 @@
|
||||
// pages/api/chunk-upload-v2.ts
|
||||
// Server-side encryption approach: Upload raw chunks, encrypt on server, store encrypted
|
||||
// Chunked upload with server-side encryption.
|
||||
//
|
||||
// Raw chunks are uploaded, encrypted individually on arrival, and reassembled
|
||||
// into one encrypted file per uploaded file. See lib/server-encryption.ts for
|
||||
// the on-disk format.
|
||||
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { IncomingForm } from 'formidable';
|
||||
import { IncomingForm, Fields, Files } from 'formidable';
|
||||
import fs from 'fs';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
@@ -23,28 +27,34 @@ export const config = {
|
||||
},
|
||||
};
|
||||
|
||||
interface UploadSession {
|
||||
uploadId: string;
|
||||
totalSize: number;
|
||||
interface UploadFile {
|
||||
name: string;
|
||||
totalChunks: number;
|
||||
receivedChunks: Set<number>;
|
||||
chunkSize: number;
|
||||
sender: string;
|
||||
recipient: string;
|
||||
password?: string;
|
||||
originalFilename: string;
|
||||
message?: string;
|
||||
filenames?: string;
|
||||
createdAt: Date;
|
||||
// Encryption metadata. There is deliberately no session-wide IV: each chunk
|
||||
// frame carries its own, generated at encryption time.
|
||||
salt: Buffer;
|
||||
encryptionKey: Buffer;
|
||||
receivedBytes: number;
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
// Store active upload sessions
|
||||
interface UploadSession {
|
||||
uploadId: string;
|
||||
sender: string;
|
||||
recipient: string;
|
||||
password: string;
|
||||
message?: string;
|
||||
filenames?: string;
|
||||
fileCount: number;
|
||||
/** Keyed by fileIndex. */
|
||||
files: Map<number, UploadFile>;
|
||||
createdAt: Date;
|
||||
// One salt/key per transfer. Sharing a key across files is safe because every
|
||||
// chunk frame carries its own unique IV.
|
||||
salt: Buffer;
|
||||
encryptionKey: Buffer;
|
||||
maxBytes: number;
|
||||
totalReceivedBytes: number;
|
||||
}
|
||||
|
||||
// NOTE: in-process state, matching the local-disk storage model. Running more
|
||||
// than one instance requires a shared store (Redis) plus shared object storage.
|
||||
const uploadSessions = new Map<string, UploadSession>();
|
||||
|
||||
// Cleanup abandoned uploads every 5 minutes
|
||||
@@ -55,13 +65,19 @@ setInterval(() => {
|
||||
if (now - session.createdAt.getTime() > SESSION_TIMEOUT) {
|
||||
uploadSessions.delete(uploadId);
|
||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
||||
fsPromises.rm(chunkDir, { recursive: true }).catch(err =>
|
||||
fsPromises.rm(chunkDir, { recursive: true, force: true }).catch((err) =>
|
||||
console.warn(`Failed to clean abandoned upload ${uploadId}:`, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
const firstValue = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
|
||||
const fileChunkDir = (uploadId: string, fileIndex: number) =>
|
||||
path.join(UPLOAD_DIR, '.tmp', uploadId, `f${fileIndex}`);
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const authSession = await getServerSession(req, res, authOptions);
|
||||
if (!authSession?.user?.email) {
|
||||
@@ -85,146 +101,155 @@ async function handleChunkUpload(
|
||||
res: NextApiResponse,
|
||||
sessionEmail: string
|
||||
) {
|
||||
await fsPromises.mkdir(path.join(UPLOAD_DIR, '.tmp'), { recursive: true }).catch((err) => {
|
||||
console.error('Failed to create temp directory:', err);
|
||||
});
|
||||
|
||||
const form = new IncomingForm({
|
||||
uploadDir: path.join(UPLOAD_DIR, '.tmp'),
|
||||
keepExtensions: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await fsPromises.mkdir(path.join(UPLOAD_DIR, '.tmp'), { recursive: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to create temp directory:', err);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
form.parse(req, async (err: Error | null, fields: any, files: any) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
form.parse(req, async (err: Error | null, fields: Fields, files: Files) => {
|
||||
if (err) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Parse error: ' + err.message }));
|
||||
res.status(400).json({ success: false, message: 'Parse error: ' + err.message });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
const chunkFile = Array.isArray(files.chunk) ? files.chunk[0] : files.chunk;
|
||||
const uploadId = Array.isArray(fields.uploadId) ? fields.uploadId[0] : fields.uploadId;
|
||||
const chunkIndex = parseInt(Array.isArray(fields.chunkIndex) ? fields.chunkIndex[0] : fields.chunkIndex);
|
||||
const totalChunks = parseInt(Array.isArray(fields.totalChunks) ? fields.totalChunks[0] : fields.totalChunks);
|
||||
const chunkSize = parseInt(Array.isArray(fields.chunkSize) ? fields.chunkSize[0] : fields.chunkSize);
|
||||
const uploadId = firstValue(fields.uploadId);
|
||||
const chunkIndex = parseInt(firstValue(fields.chunkIndex) ?? '', 10);
|
||||
const fileIndex = parseInt(firstValue(fields.fileIndex) ?? '0', 10);
|
||||
const fileChunks = parseInt(firstValue(fields.fileChunks) ?? '', 10);
|
||||
const fileCount = parseInt(firstValue(fields.fileCount) ?? '1', 10);
|
||||
const fileName = firstValue(fields.fileName);
|
||||
|
||||
if (!chunkFile || !uploadId || isNaN(chunkIndex) || isNaN(totalChunks)) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Missing chunk metadata' }));
|
||||
if (
|
||||
!chunkFile ||
|
||||
!uploadId ||
|
||||
Number.isNaN(chunkIndex) ||
|
||||
Number.isNaN(fileIndex) ||
|
||||
Number.isNaN(fileChunks) ||
|
||||
fileIndex < 0 ||
|
||||
chunkIndex < 0 ||
|
||||
chunkIndex >= fileChunks
|
||||
) {
|
||||
res.status(400).json({ success: false, message: 'Missing or invalid chunk metadata' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// Get or create upload session
|
||||
let session = uploadSessions.get(uploadId);
|
||||
if (session && session.sender !== sessionEmail) {
|
||||
return resolve(res.status(403).json({ success: false, message: 'Forbidden' }));
|
||||
res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
const sender = sessionEmail; // never trust a sender field from the body
|
||||
const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email;
|
||||
const password = Array.isArray(fields.password) ? fields.password[0] : fields.password;
|
||||
const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename;
|
||||
const message = Array.isArray(fields.message) ? fields.message[0] : fields.message;
|
||||
const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames;
|
||||
const recipient = firstValue(fields.email);
|
||||
const password = firstValue(fields.password);
|
||||
const message = firstValue(fields.message);
|
||||
const filenames = firstValue(fields.filenames);
|
||||
|
||||
if (!recipient) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Missing required fields' }));
|
||||
res.status(400).json({ success: false, message: 'Missing required fields' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// A password is mandatory: it is the key material. Without it every
|
||||
// file would be encrypted under a key derived from the empty string.
|
||||
if (!password) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'A password is required' }));
|
||||
res.status(400).json({ success: false, message: 'A password is required' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
const user = await prisma.user.findUnique({ where: { email: sender } });
|
||||
const user = await prisma.user.findUnique({ where: { email: sessionEmail } });
|
||||
if (!user) {
|
||||
return resolve(res.status(404).json({ success: false, message: 'User not found' }));
|
||||
res.status(404).json({ success: false, message: 'User not found' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// Generate salt and derive encryption key
|
||||
const salt = crypto.randomBytes(16);
|
||||
const encryptionKey = await deriveKeyFromPassword(password, salt);
|
||||
|
||||
const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
|
||||
const limits = PLAN_CONFIG[plan];
|
||||
const salt = crypto.randomBytes(16);
|
||||
|
||||
session = {
|
||||
uploadId,
|
||||
totalSize: chunkSize * totalChunks,
|
||||
totalChunks,
|
||||
receivedChunks: new Set(),
|
||||
chunkSize,
|
||||
sender,
|
||||
sender: sessionEmail,
|
||||
recipient,
|
||||
password,
|
||||
originalFilename,
|
||||
message,
|
||||
filenames,
|
||||
fileCount: Number.isNaN(fileCount) ? 1 : fileCount,
|
||||
files: new Map(),
|
||||
createdAt: new Date(),
|
||||
salt,
|
||||
encryptionKey,
|
||||
receivedBytes: 0,
|
||||
maxBytes: limits.maxFileSize,
|
||||
encryptionKey: await deriveKeyFromPassword(password, salt),
|
||||
maxBytes: PLAN_CONFIG[plan].maxFileSize,
|
||||
totalReceivedBytes: 0,
|
||||
};
|
||||
|
||||
// The advertised size is client-supplied, so this is only an early
|
||||
// reject; the real enforcement is the running receivedBytes check.
|
||||
if (limits.maxFileSize !== Infinity && session.totalSize > limits.maxFileSize) {
|
||||
return resolve(res.status(413).json({
|
||||
success: false,
|
||||
message: `File exceeds max size for your plan (${plan})`
|
||||
}));
|
||||
}
|
||||
|
||||
uploadSessions.set(uploadId, session);
|
||||
}
|
||||
|
||||
// Read and encrypt chunk into a self-contained frame (own IV + own tag)
|
||||
let entry = session.files.get(fileIndex);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
name: fileName || `file-${fileIndex}`,
|
||||
totalChunks: fileChunks,
|
||||
receivedChunks: new Set(),
|
||||
receivedBytes: 0,
|
||||
};
|
||||
session.files.set(fileIndex, entry);
|
||||
}
|
||||
|
||||
const chunkData = await fsPromises.readFile(chunkFile.filepath);
|
||||
await fsPromises.unlink(chunkFile.filepath).catch(() => {});
|
||||
|
||||
// Enforce the plan cap against bytes actually received, not the
|
||||
// client-declared size.
|
||||
const isNewChunk = !entry.receivedChunks.has(chunkIndex);
|
||||
if (
|
||||
isNewChunk &&
|
||||
session.maxBytes !== Infinity &&
|
||||
session.receivedBytes + chunkData.length > session.maxBytes
|
||||
session.totalReceivedBytes + chunkData.length > session.maxBytes
|
||||
) {
|
||||
await fsPromises.unlink(chunkFile.filepath).catch(() => {});
|
||||
return resolve(res.status(413).json({
|
||||
res.status(413).json({
|
||||
success: false,
|
||||
message: 'File exceeds max size for your plan',
|
||||
}));
|
||||
message: 'Transfer exceeds the maximum size for your plan',
|
||||
});
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const frame = encryptChunkFrame(chunkData, session.encryptionKey);
|
||||
const dir = fileChunkDir(uploadId, fileIndex);
|
||||
await fsPromises.mkdir(dir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
path.join(dir, `chunk-${chunkIndex}.enc`),
|
||||
encryptChunkFrame(chunkData, session.encryptionKey)
|
||||
);
|
||||
|
||||
// Save encrypted chunk to temp location
|
||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
||||
await fsPromises.mkdir(chunkDir, { recursive: true });
|
||||
const encryptedChunkPath = path.join(chunkDir, `chunk-${chunkIndex}.enc`);
|
||||
|
||||
await fsPromises.writeFile(encryptedChunkPath, frame);
|
||||
|
||||
// Clean up formidable temp file
|
||||
try {
|
||||
await fsPromises.unlink(chunkFile.filepath);
|
||||
} catch (err) {
|
||||
console.warn('Failed to clean up temp file:', err);
|
||||
if (isNewChunk) {
|
||||
entry.receivedBytes += chunkData.length;
|
||||
session.totalReceivedBytes += chunkData.length;
|
||||
}
|
||||
entry.receivedChunks.add(chunkIndex);
|
||||
|
||||
// Guard against a retried chunk double-counting toward the quota.
|
||||
if (!session.receivedChunks.has(chunkIndex)) {
|
||||
session.receivedBytes += chunkData.length;
|
||||
}
|
||||
session.receivedChunks.add(chunkIndex);
|
||||
|
||||
return resolve(res.status(200).json({
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
uploadId,
|
||||
fileIndex,
|
||||
chunkIndex,
|
||||
receivedChunks: Array.from(session.receivedChunks),
|
||||
}));
|
||||
} catch (err: any) {
|
||||
console.error('Chunk upload error:', err);
|
||||
return resolve(res.status(500).json({ success: false, message: 'Upload error: ' + err.message }));
|
||||
receivedChunks: entry.receivedChunks.size,
|
||||
totalChunks: entry.totalChunks,
|
||||
});
|
||||
return resolve();
|
||||
} catch (error: unknown) {
|
||||
console.error('Chunk upload error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Upload error: ' + (error as Error).message,
|
||||
});
|
||||
return resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -246,14 +271,22 @@ async function handleStatusCheck(
|
||||
return res.status(404).json({ success: false, message: 'Upload session not found' });
|
||||
}
|
||||
|
||||
const files = [...session.files.entries()].map(([fileIndex, entry]) => ({
|
||||
fileIndex,
|
||||
name: entry.name,
|
||||
receivedChunks: entry.receivedChunks.size,
|
||||
totalChunks: entry.totalChunks,
|
||||
isComplete: entry.receivedChunks.size === entry.totalChunks,
|
||||
}));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
uploadId,
|
||||
totalChunks: session.totalChunks,
|
||||
receivedChunks: Array.from(session.receivedChunks),
|
||||
isComplete: session.receivedChunks.size === session.totalChunks,
|
||||
receivedBytes: session.receivedBytes,
|
||||
totalBytes: session.totalSize,
|
||||
fileCount: session.fileCount,
|
||||
files,
|
||||
receivedBytes: session.totalReceivedBytes,
|
||||
isComplete:
|
||||
session.files.size === session.fileCount && files.every((f) => f.isComplete),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -273,89 +306,28 @@ async function handleChunkComplete(
|
||||
return res.status(404).json({ success: false, message: 'Upload session not found' });
|
||||
}
|
||||
|
||||
// Check if all chunks received
|
||||
if (session.receivedChunks.size !== session.totalChunks) {
|
||||
// Every file must be present and whole before anything is assembled.
|
||||
if (session.files.size !== session.fileCount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Missing chunks. Received ${session.receivedChunks.size}/${session.totalChunks}`,
|
||||
message: `Missing files. Received ${session.files.size}/${session.fileCount}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Reassemble encrypted chunks into final encrypted file
|
||||
console.log(`[${uploadId}] Starting reassembly of ${session.totalChunks} encrypted chunks`);
|
||||
|
||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
||||
const finalFilename = uuidv4() + '.enc';
|
||||
const finalPath = path.join(UPLOAD_DIR, finalFilename);
|
||||
|
||||
const writeStream = fs.createWriteStream(finalPath);
|
||||
|
||||
// Honour backpressure: createWriteStream queues in memory whenever write()
|
||||
// returns false, so a multi-GB reassembly that ignores the return value
|
||||
// grows the internal buffer instead of flushing to disk.
|
||||
const write = (buf: Buffer): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (writeStream.write(buf)) return resolve();
|
||||
writeStream.once('drain', resolve);
|
||||
writeStream.once('error', reject);
|
||||
for (const [fileIndex, entry] of session.files) {
|
||||
if (entry.receivedChunks.size !== entry.totalChunks) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `File ${fileIndex} incomplete: ${entry.receivedChunks.size}/${entry.totalChunks} chunks`,
|
||||
});
|
||||
|
||||
// Write the format header: magic (8) + salt (16). The per-chunk IVs live
|
||||
// in the frames themselves, so nothing needs storing in the DB.
|
||||
await write(buildHeader(session.salt));
|
||||
|
||||
// Assemble encrypted frames in order, with progress tracking
|
||||
let chunksAssembled = 0;
|
||||
for (let i = 0; i < session.totalChunks; i++) {
|
||||
const chunkPath = path.join(chunkDir, `chunk-${i}.enc`);
|
||||
|
||||
// Retry reading with exponential backoff
|
||||
let chunkData: Buffer | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
chunkData = await fsPromises.readFile(chunkPath);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (attempt < 2 && err.code === 'EACCES') {
|
||||
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A silently skipped chunk would corrupt the file, so fail loudly.
|
||||
if (!chunkData) {
|
||||
throw new Error(`Chunk ${i} missing during reassembly`);
|
||||
}
|
||||
|
||||
await write(chunkData);
|
||||
chunksAssembled++;
|
||||
|
||||
if (chunksAssembled % 10 === 0 || chunksAssembled === session.totalChunks) {
|
||||
console.log(`[${uploadId}] Reassembly progress: ${chunksAssembled}/${session.totalChunks} chunks`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${uploadId}] Waiting for write stream to finish...`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
writeStream.end();
|
||||
});
|
||||
const writtenPaths: string[] = [];
|
||||
|
||||
console.log(`[${uploadId}] Reassembly complete, validating file...`);
|
||||
|
||||
// Sizes recorded and metered are PLAINTEXT bytes actually received, not the
|
||||
// on-disk size, which is inflated by the header and per-frame IVs and tags.
|
||||
const plaintextSize = session.receivedBytes;
|
||||
|
||||
// Verify user and check monthly limits
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email: session.sender } });
|
||||
if (!user) {
|
||||
await fsPromises.unlink(finalPath);
|
||||
return res.status(404).json({ success: false, message: 'User not found' });
|
||||
}
|
||||
|
||||
@@ -367,38 +339,84 @@ async function handleChunkComplete(
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const transfersThisMonth = await prisma.transfer.findMany({
|
||||
where: {
|
||||
senderEmail: session.sender,
|
||||
createdAt: { gte: startOfMonth },
|
||||
},
|
||||
where: { senderEmail: session.sender, createdAt: { gte: startOfMonth } },
|
||||
});
|
||||
|
||||
const totalSizeSentThisMonth = transfersThisMonth.reduce((acc, t) => acc + BigInt(t.totalSize), BigInt(0));
|
||||
// Sizes are plaintext bytes received, not on-disk size, which is inflated
|
||||
// by the format header and each frame's IV and auth tag.
|
||||
const totalPlaintext = session.totalReceivedBytes;
|
||||
const sentThisMonth = transfersThisMonth.reduce(
|
||||
(acc, t) => acc + BigInt(t.totalSize),
|
||||
BigInt(0)
|
||||
);
|
||||
|
||||
if (
|
||||
(limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth) ||
|
||||
(limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(plaintextSize) > BigInt(limits.maxTransferSizePerMonth))
|
||||
(limits.maxTransfersPerMonth !== Infinity &&
|
||||
transfersThisMonth.length >= limits.maxTransfersPerMonth) ||
|
||||
(limits.maxTransferSizePerMonth !== Infinity &&
|
||||
sentThisMonth + BigInt(totalPlaintext) > BigInt(limits.maxTransferSizePerMonth))
|
||||
) {
|
||||
await fsPromises.unlink(finalPath);
|
||||
return res.status(429).json({ success: false, message: 'Plan limits exceeded' });
|
||||
}
|
||||
|
||||
// Hash password for verification (don't encrypt again)
|
||||
const hash = session.password ? await bcrypt.hash(session.password, 10) : null;
|
||||
// Assemble each file into its own encrypted payload.
|
||||
const assembled: { name: string; path: string; size: number }[] = [];
|
||||
|
||||
// Parse filenames
|
||||
let totalFiles = 1;
|
||||
if (session.filenames) {
|
||||
try {
|
||||
const filesArray = JSON.parse(session.filenames);
|
||||
if (Array.isArray(filesArray)) {
|
||||
totalFiles = filesArray.length;
|
||||
for (const [fileIndex, entry] of [...session.files.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const dir = fileChunkDir(uploadId, fileIndex);
|
||||
const finalPath = path.join(UPLOAD_DIR, uuidv4() + '.enc');
|
||||
writtenPaths.push(finalPath);
|
||||
|
||||
const writeStream = fs.createWriteStream(finalPath);
|
||||
// Honour backpressure so a multi-GB reassembly flushes to disk rather
|
||||
// than queueing in the stream's internal buffer.
|
||||
const write = (buf: Buffer): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (writeStream.write(buf)) return resolve();
|
||||
writeStream.once('drain', resolve);
|
||||
writeStream.once('error', reject);
|
||||
});
|
||||
|
||||
await write(buildHeader(session.salt));
|
||||
|
||||
for (let i = 0; i < entry.totalChunks; i++) {
|
||||
const chunkPath = path.join(dir, `chunk-${i}.enc`);
|
||||
let chunkData: Buffer | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
chunkData = await fsPromises.readFile(chunkPath);
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (attempt < 2 && code === 'EACCES') {
|
||||
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 100));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// A silently skipped chunk would corrupt the file, so fail loudly.
|
||||
if (!chunkData) {
|
||||
throw new Error(`Chunk ${i} of file ${fileIndex} missing during reassembly`);
|
||||
}
|
||||
|
||||
await write(chunkData);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
writeStream.end();
|
||||
});
|
||||
|
||||
assembled.push({ name: entry.name, path: finalPath, size: entry.receivedBytes });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(session.password, 10);
|
||||
const expiresAt = new Date(Date.now() + limits.maxExpiryMs);
|
||||
|
||||
// Create transfer record
|
||||
const transfer = await prisma.transfer.create({
|
||||
data: {
|
||||
senderEmail: session.sender,
|
||||
@@ -406,34 +424,27 @@ async function handleChunkComplete(
|
||||
passwordHash: hash,
|
||||
downloadUrl: uuidv4(),
|
||||
expiresAt,
|
||||
filenames: session.filenames || null,
|
||||
filenames: session.filenames || JSON.stringify(assembled.map((f) => f.name)),
|
||||
message: session.message || null,
|
||||
totalFiles,
|
||||
totalSize: plaintextSize,
|
||||
totalFiles: assembled.length,
|
||||
totalSize: totalPlaintext,
|
||||
userId: user.id,
|
||||
files: {
|
||||
create: assembled.map((f) => ({ name: f.name, path: f.path, size: f.size })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create file record with encryption metadata
|
||||
// Store salt and IV in the file path (prepended to encrypted data)
|
||||
// So we don't need separate DB fields
|
||||
await prisma.file.create({
|
||||
data: {
|
||||
name: session.originalFilename || 'file',
|
||||
path: finalPath,
|
||||
size: plaintextSize,
|
||||
transferId: transfer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email
|
||||
const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://transfertribe.com';
|
||||
const link = `${baseUrl}/download/${transfer.downloadUrl}`;
|
||||
const fileLine =
|
||||
assembled.length === 1 ? '1 file' : `${assembled.length} files`;
|
||||
|
||||
await sendMailjetEmail({
|
||||
to: session.recipient,
|
||||
subject: 'You\'ve received an encrypted file',
|
||||
subject: "You've received an encrypted file",
|
||||
text: `
|
||||
${session.sender} sent you an encrypted file via TransferTribe.
|
||||
${session.sender} sent you ${fileLine} via TransferTribe.
|
||||
|
||||
Link: ${link}
|
||||
|
||||
@@ -441,21 +452,26 @@ ${session.message ? `Message:\n${session.message}\n\n` : ''}Note: You'll need th
|
||||
`.trim(),
|
||||
});
|
||||
|
||||
// Clean up session and temp files
|
||||
uploadSessions.delete(uploadId);
|
||||
try {
|
||||
await fsPromises.rm(chunkDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.warn('Failed to clean up temp directory:', err);
|
||||
}
|
||||
await fsPromises
|
||||
.rm(path.join(UPLOAD_DIR, '.tmp', uploadId), { recursive: true, force: true })
|
||||
.catch((err) => console.warn('Failed to clean up temp directory:', err));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Upload complete',
|
||||
downloadUrl: transfer.downloadUrl,
|
||||
files: assembled.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Chunk completion error:', error);
|
||||
// Don't leave half-assembled payloads behind on failure.
|
||||
await Promise.all(
|
||||
writtenPaths.map((p) => fsPromises.unlink(p).catch(() => {}))
|
||||
);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Completion error: ' + (error as Error).message,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Chunk completion error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Completion error: ' + err.message });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user