// pages/api/chunk-upload.ts // Chunked upload endpoint for large files with encryption import { NextApiRequest, NextApiResponse } from 'next'; import { IncomingForm } from 'formidable'; import fs from 'fs'; import { promises as fsPromises } from 'fs'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { UPLOAD_DIR } from '@/lib/config'; import { prisma } from '@/lib/prisma'; import bcrypt from 'bcrypt'; import { PLAN_CONFIG } from '@/lib/subscription'; import { sendMailjetEmail } from '@/lib/mailjet'; import { getServerSession } from 'next-auth/next'; import { authOptions } from '@/lib/auth'; export const config = { api: { bodyParser: false, }, }; interface ChunkMetadata { uploadId: string; chunkIndex: number; totalChunks: number; chunkSize: number; } interface UploadSession { uploadId: string; totalSize: number; totalChunks: number; receivedChunks: Set; chunkSize: number; sender: string; recipient: string; password?: string; encryptionIv: string; originalFilename: string; message?: string; filenames?: string; createdAt: Date; } // Store active upload sessions in memory (in production, use Redis) const uploadSessions = new Map(); // Cleanup abandoned uploads every 5 minutes (default 24h timeout) const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours setInterval(() => { const now = Date.now(); for (const [uploadId, session] of uploadSessions.entries()) { if (now - session.createdAt.getTime() > SESSION_TIMEOUT) { uploadSessions.delete(uploadId); // Clean up temp files const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId); fsPromises.rm(chunkDir, { recursive: true }).catch(err => console.warn(`Failed to clean abandoned upload ${uploadId}:`, err) ); } } }, 5 * 60 * 1000); // Run every 5 minutes export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getServerSession(req, res, authOptions); if (!session?.user?.email) { return res.status(401).json({ success: false, message: 'Unauthorized' }); } const sessionEmail = session.user.email; if (req.method === 'POST') { return handleChunkUpload(req, res, sessionEmail); } else if (req.method === 'GET') { return handleStatusCheck(req, res, sessionEmail); } else if (req.method === 'PUT') { return handleChunkComplete(req, res, sessionEmail); } return res.status(405).json({ success: false, message: 'Method not allowed' }); } async function handleChunkUpload( req: NextApiRequest, res: NextApiResponse, sessionEmail: string ) { const form = new IncomingForm({ uploadDir: path.join(UPLOAD_DIR, '.tmp'), keepExtensions: true, }); // Ensure temp directory exists 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) => { if (err) { return resolve(res.status(400).json({ success: false, message: 'Parse error: ' + err.message })); } 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); if (!chunkFile || !uploadId || isNaN(chunkIndex) || isNaN(totalChunks)) { return resolve(res.status(400).json({ success: false, message: 'Missing chunk metadata' })); } // 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' })); } if (!session) { const sender = sessionEmail; const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email; const password = Array.isArray(fields.password) ? fields.password[0] : fields.password; const encryptionIv = Array.isArray(fields.encryptionIv) ? fields.encryptionIv[0] : fields.encryptionIv; 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; if (!sender || !recipient || !encryptionIv) { return resolve(res.status(400).json({ success: false, message: 'Missing required fields' })); } // Verify user exists const user = await prisma.user.findUnique({ where: { email: sender } }); if (!user) { return resolve(res.status(404).json({ success: false, message: 'User not found' })); } session = { uploadId, totalSize: chunkSize * totalChunks, totalChunks, receivedChunks: new Set(), chunkSize, sender, recipient, password, encryptionIv, originalFilename, message, filenames, createdAt: new Date(), }; // Check plan limits before accepting upload const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro'; const limits = PLAN_CONFIG[plan]; 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); } // Save chunk to temp location const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId); await fsPromises.mkdir(chunkDir, { recursive: true }); const chunkPath = path.join(chunkDir, `chunk-${chunkIndex}`); await new Promise((resolve, reject) => { const readStream = fs.createReadStream(chunkFile.filepath); const writeStream = fs.createWriteStream(chunkPath); readStream.on('error', reject); writeStream.on('error', reject); writeStream.on('finish', resolve); readStream.pipe(writeStream); }); // Clean up formidable temp file try { await fsPromises.unlink(chunkFile.filepath); } catch (err) { console.warn('Failed to clean up temp file:', err); } session.receivedChunks.add(chunkIndex); return resolve(res.status(200).json({ success: true, uploadId, 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 })); } }); }); } async function handleStatusCheck( req: NextApiRequest, res: NextApiResponse, sessionEmail: string ) { const { uploadId } = req.query; if (!uploadId || typeof uploadId !== 'string') { return res.status(400).json({ success: false, message: 'Missing uploadId' }); } const session = uploadSessions.get(uploadId); if (!session || session.sender !== sessionEmail) { return res.status(404).json({ success: false, message: 'Upload session not found' }); } return res.status(200).json({ success: true, uploadId, totalChunks: session.totalChunks, receivedChunks: Array.from(session.receivedChunks), isComplete: session.receivedChunks.size === session.totalChunks, }); } async function handleChunkComplete( req: NextApiRequest, res: NextApiResponse, sessionEmail: string ) { const { uploadId } = req.query; if (!uploadId || typeof uploadId !== 'string') { return res.status(400).json({ success: false, message: 'Missing uploadId' }); } const session = uploadSessions.get(uploadId); if (!session || session.sender !== sessionEmail) { return res.status(404).json({ success: false, message: 'Upload session not found' }); } // Check if all chunks received if (session.receivedChunks.size !== session.totalChunks) { return res.status(400).json({ success: false, message: `Missing chunks. Received ${session.receivedChunks.size}/${session.totalChunks}`, }); } try { // Reassemble chunks into final file 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 so a multi-GB reassembly flushes to disk instead of // queueing in the stream's internal buffer. const write = (buf: Buffer): Promise => new Promise((resolve, reject) => { if (writeStream.write(buf)) return resolve(); writeStream.once('drain', resolve); writeStream.once('error', reject); }); for (let i = 0; i < session.totalChunks; i++) { const chunkPath = path.join(chunkDir, `chunk-${i}`); // Retry reading with exponential backoff to handle file locks 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); } await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); writeStream.end(); }); // Get final file size const stats = await fsPromises.stat(finalPath); // Verify user and check monthly limits 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' }); } const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro'; const limits = PLAN_CONFIG[plan]; const startOfMonth = new Date(); startOfMonth.setDate(1); startOfMonth.setHours(0, 0, 0, 0); const transfersThisMonth = await prisma.transfer.findMany({ where: { senderEmail: session.sender, createdAt: { gte: startOfMonth }, }, }); const totalSizeSentThisMonth = transfersThisMonth.reduce((acc, t) => acc + BigInt(t.totalSize), BigInt(0)); if ( (limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth) || (limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(stats.size) > BigInt(limits.maxTransferSizePerMonth)) ) { await fsPromises.unlink(finalPath); return res.status(429).json({ success: false, message: 'Plan limits exceeded' }); } // Hash password const hash = session.password ? await bcrypt.hash(session.password, 10) : null; // Parse filenames let totalFiles = 1; if (session.filenames) { try { const filesArray = JSON.parse(session.filenames); if (Array.isArray(filesArray)) { totalFiles = filesArray.length; } } catch {} } const expiresAt = new Date(Date.now() + limits.maxExpiryMs); // Create transfer record const transfer = await prisma.transfer.create({ data: { senderEmail: session.sender, recipientEmail: session.recipient, passwordHash: hash, downloadUrl: uuidv4(), expiresAt, filenames: session.filenames || null, message: session.message || null, totalFiles, totalSize: stats.size, userId: user.id, }, }); // Create file record separately await prisma.file.create({ data: { name: session.originalFilename || 'file', path: finalPath, size: stats.size, transferId: transfer.id, }, }); // Update transfer with encryption IV in filenames field (temporary workaround) // TODO: Fix Prisma type generation for encryptionIv field if (session.encryptionIv) { // Store IV in a separate metadata approach or update via raw query await (prisma as any).$executeRaw` UPDATE "File" SET "encryptionIv" = ${session.encryptionIv} WHERE "transferId" = ${transfer.id} `; } // Send email const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`; await sendMailjetEmail({ to: session.recipient, subject: 'You\'ve received an encrypted file', text: ` ${session.sender} sent you an encrypted file via TransferTribe. Link: ${link} ${session.message ? `Message:\n${session.message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it. `.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); } return res.status(200).json({ success: true, message: 'Upload complete', downloadUrl: transfer.downloadUrl, }); } catch (err: any) { console.error('Chunk completion error:', err); return res.status(500).json({ success: false, message: 'Completion error: ' + err.message }); } }