diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e101d9e --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copy to .env and fill in. Never commit the real .env. + +# Postgres connection string +DATABASE_URL="postgresql://user:password@localhost:5432/transfertribe" + +# Where encrypted transfer payloads are written, relative to the project root. +UPLOAD_DIR="./uploads/files" + +# Mailjet credentials for transfer notification emails +MJ_API_KEY="" +MJ_SECRET_KEY="" + +# Public base URL, used to build download links in emails +NEXT_PUBLIC_APP_URL="http://localhost:3000" + +# NextAuth +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="" + +# Google OAuth +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" + +# Shared secret for POST /api/cron/cleanup, which deletes payloads belonging to +# expired and soft-deleted transfers. Without it that endpoint refuses to run +# and nothing ever reclaims disk. Generate with: openssl rand -hex 32 +CRON_SECRET="" diff --git a/.gitignore b/.gitignore index 0feaeac..bf070ab 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/next.config.ts b/next.config.ts index f917d61..b87b9b3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { eslint: { - ignoreDuringBuilds: true, + // Generated Prisma client is excluded in eslint.config.mjs; app code is linted. + dirs: ["src", "pages"], }, }; diff --git a/pages/api/chunk-upload-v2.ts b/pages/api/chunk-upload-v2.ts index 7e599dd..140c5c3 100644 --- a/pages/api/chunk-upload-v2.ts +++ b/pages/api/chunk-upload-v2.ts @@ -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; - 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; + 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(); // 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((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 => - 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((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 => + 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((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 }); } } diff --git a/pages/api/chunk-upload.ts b/pages/api/chunk-upload.ts deleted file mode 100644 index d7a11ce..0000000 --- a/pages/api/chunk-upload.ts +++ /dev/null @@ -1,423 +0,0 @@ -// 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 }); - } -} diff --git a/pages/api/send.ts b/pages/api/send.ts deleted file mode 100644 index 6364730..0000000 --- a/pages/api/send.ts +++ /dev/null @@ -1,211 +0,0 @@ -// pages/api/send.ts -import { prisma } from '@/lib/prisma'; -import { UPLOAD_DIR } from '@/lib/config'; -import { PLAN_CONFIG } from '@/lib/subscription'; -import { sendMailjetEmail } from '@/lib/mailjet'; -import { v4 as uuidv4 } from 'uuid'; -import bcrypt from 'bcrypt'; -import path from 'path'; -import { IncomingForm, File as FormidableFile } from 'formidable'; -import fs from 'fs'; -import { promises as fsPromises } from 'fs'; -import { NextApiRequest, NextApiResponse } from 'next'; -import { getServerSession } from 'next-auth/next'; -import { authOptions } from '@/lib/auth'; - -export const config = { - api: { - bodyParser: false, - }, -}; - -interface ParsedFields { - [key: string]: string | string[] | undefined; -} - -interface ParsedFiles { - [key: string]: FormidableFile | FormidableFile[] | undefined; -} - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'POST') { - return res.status(405).json({ success: false, message: 'Method not allowed' }); - } - - const session = await getServerSession(req, res, authOptions); - if (!session?.user?.email) { - return res.status(401).json({ success: false, message: 'Unauthorized' }); - } - - const form = new IncomingForm({ - uploadDir: UPLOAD_DIR, - keepExtensions: true, - maxFileSize: 100 * 1024 * 1024 * 1024, // 100GB - }); - - const parseForm = (): Promise<{ fields: ParsedFields; files: ParsedFiles }> => - new Promise((resolve, reject) => { - form.parse(req, (err: Error | null, fields: any, files: any) => { - if (err) return reject(err); - resolve({ fields, files }); - }); - }); - - try { - const { fields, files } = await parseForm(); - - // Extract form fields - const encryptedFile = Array.isArray(files.file) ? files.file[0] : files.file; - const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email; - const password = Array.isArray(fields.password) ? fields.password[0] : fields.password; - // Sender is taken from the session, never the request body, so a caller - // cannot post transfers (and consume quota) as another user. - const sender = session.user.email; - const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames; - const message = Array.isArray(fields.message) ? fields.message[0] : fields.message; - const encryptionIvStr = Array.isArray(fields.encryptionIv) ? fields.encryptionIv[0] : fields.encryptionIv; - const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename; - - if (!encryptedFile || !recipient || !sender) { - return res.status(400).json({ success: false, message: 'Missing required fields' }); - } - - // Ensure upload directory exists - if (!fs.existsSync(UPLOAD_DIR)) { - fs.mkdirSync(UPLOAD_DIR, { recursive: true }); - } - - // Generate final filename and move encrypted file - const finalFilename = uuidv4() + '.enc'; - const finalPath = path.join(UPLOAD_DIR, finalFilename); - - // Use streaming copy for large files to avoid permission issues - await new Promise((resolve, reject) => { - const readStream = fs.createReadStream(encryptedFile.filepath); - const writeStream = fs.createWriteStream(finalPath); - - readStream.on('error', reject); - writeStream.on('error', reject); - writeStream.on('finish', resolve); - - readStream.pipe(writeStream); - }); - - // Clean up temp file after successful copy - try { - await fsPromises.unlink(encryptedFile.filepath); - } catch (err) { - console.warn('Failed to clean up temp file:', err); - } - - // Verify user exists - const user = await prisma.user.findUnique({ where: { email: sender } }); - if (!user) { - fs.unlinkSync(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]; - - // Check file size against plan limits - const stats = fs.statSync(finalPath); - if (limits.maxFileSize !== Infinity && stats.size > limits.maxFileSize) { - fs.unlinkSync(finalPath); - return res.status(413).json({ - success: false, - message: `File exceeds max size for your plan (${plan})` - }); - } - - // Check monthly transfer limits - const startOfMonth = new Date(); - startOfMonth.setDate(1); - startOfMonth.setHours(0, 0, 0, 0); - - const transfersThisMonth = await prisma.transfer.findMany({ - where: { - senderEmail: 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) - ) { - fs.unlinkSync(finalPath); - return res.status(429).json({ success: false, message: 'Plan limits exceeded' }); - } - - // Hash password if provided - const hash = password ? await bcrypt.hash(password, 10) : null; - - // Parse filenames if provided - let totalFiles = 1; - if (filenames) { - try { - const filesArray = JSON.parse(filenames); - if (Array.isArray(filesArray)) { - totalFiles = filesArray.length; - } - } catch {} - } - - const expiresAt = new Date(Date.now() + limits.maxExpiryMs); - - // Create transfer record with encrypted file - const transfer = await prisma.transfer.create({ - data: { - senderEmail: sender, - recipientEmail: recipient, - passwordHash: hash, - downloadUrl: uuidv4(), - expiresAt, - filenames: filenames || null, - message: message || null, - totalFiles, - totalSize: stats.size, - userId: user?.id, - files: { - create: [ - { - name: originalFilename || 'file', - path: finalPath, - size: stats.size, - }, - ], - }, - }, - }); - - // Update file with encryption IV using raw query - if (encryptionIvStr) { - await (prisma as any).$executeRaw` - UPDATE "File" SET "encryptionIv" = ${encryptionIvStr} - WHERE "transferId" = ${transfer.id} - `; - } - - // Send email notification - const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`; - - await sendMailjetEmail({ - to: recipient, - subject: 'You\'ve received an encrypted file', - text: ` -${sender} sent you an encrypted file via TransferTribe. - -Link: ${link} - -${message ? `Message:\n${message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it. -`.trim(), - }); - - return res.status(200).json({ success: true }); - } catch (err: any) { - console.error('Upload failed:', err); - return res.status(500).json({ success: false, message: 'Upload failed: ' + err.message }); - } -} diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts new file mode 100644 index 0000000..c191e55 --- /dev/null +++ b/src/app/api/cron/cleanup/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server' +import crypto from 'crypto' +import { runCleanup } from '@/lib/cleanup' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function timingSafeEquals(a: string, b: string): boolean { + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) + if (bufA.length !== bufB.length) return false + return crypto.timingSafeEqual(bufA, bufB) +} + +/** + * Deletes payloads for expired and soft-deleted transfers. + * + * Intended to be called on a schedule (cron, Vercel Cron, systemd timer): + * curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://host/api/cron/cleanup + * + * Fails closed: without CRON_SECRET set, the endpoint refuses to run rather + * than exposing bulk deletion unauthenticated. + */ +export async function POST(req: NextRequest) { + const secret = process.env.CRON_SECRET + + if (!secret) { + console.error('CRON_SECRET is not configured; refusing to run cleanup') + return NextResponse.json({ error: 'Cleanup is not configured' }, { status: 503 }) + } + + const header = req.headers.get('authorization') || '' + const provided = header.startsWith('Bearer ') ? header.slice(7) : '' + + if (!provided || !timingSafeEquals(provided, secret)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const report = await runCleanup() + console.log('Cleanup report:', report) + return NextResponse.json({ success: true, ...report }) + } catch (err) { + console.error('Cleanup failed:', err) + return NextResponse.json({ error: 'Cleanup failed' }, { status: 500 }) + } +} diff --git a/src/app/api/file/[id]/route.ts b/src/app/api/file/[id]/route.ts index 80b3e0b..5942322 100644 --- a/src/app/api/file/[id]/route.ts +++ b/src/app/api/file/[id]/route.ts @@ -4,6 +4,11 @@ import bcrypt from 'bcrypt' import fs from 'fs/promises' import { TransferStatus } from '@prisma/client' import { decryptFileStream } from '@/lib/server-encryption' +import { rateLimit, resetRateLimit, clientKey } from '@/lib/rate-limit' + +// 10 password attempts per transfer, per client, per 15 minutes. +const ATTEMPT_LIMIT = 10 +const ATTEMPT_WINDOW_MS = 15 * 60 * 1000 // Node APIs (fs handles, crypto) — this route cannot run on the edge runtime. export const runtime = 'nodejs' @@ -33,9 +38,13 @@ export async function POST( } let password = '' + let requestedFileId: number | undefined try { const body = await req.json() password = typeof body?.password === 'string' ? body.password : '' + if (typeof body?.fileId === 'number' && Number.isInteger(body.fileId)) { + requestedFileId = body.fileId + } } catch { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) } @@ -44,6 +53,15 @@ export async function POST( return NextResponse.json({ error: 'Password required' }, { status: 401 }) } + const rateKey = `file:${id}:${clientKey(req.headers)}` + const limit = rateLimit(rateKey, ATTEMPT_LIMIT, ATTEMPT_WINDOW_MS) + if (!limit.allowed) { + return NextResponse.json( + { error: 'Too many attempts. Please try again later.' }, + { status: 429, headers: { 'Retry-After': String(limit.retryAfter) } } + ) + } + const transfer = await prisma.transfer.findUnique({ where: { downloadUrl: id }, include: { files: true }, @@ -79,7 +97,17 @@ export async function POST( return NextResponse.json({ error: 'Incorrect password' }, { status: 401 }) } - const file = transfer.files[0] + resetRateLimit(rateKey) + + // Pick the requested file, scoped to this transfer so a file id from another + // transfer cannot be fetched. Defaults to the first file. + const file = requestedFileId === undefined + ? transfer.files[0] + : transfer.files.find((f) => f.id === requestedFileId) + + if (!file) { + return NextResponse.json({ error: 'File not found in this transfer' }, { status: 404 }) + } try { await fs.access(file.path) @@ -103,9 +131,9 @@ export async function POST( try { const first = await frames.next() if (!first.done) firstFrame = first.value - } catch (err: any) { + } catch (err: unknown) { await frames.return(undefined as never).catch(() => {}) - if (err?.message === 'UNSUPPORTED_FORMAT') { + if (err instanceof Error && err.message === 'UNSUPPORTED_FORMAT') { console.error(`Legacy-format file for transfer ${transfer.id}: ${file.path}`) return NextResponse.json( { error: 'This transfer was created with an older, incompatible version and cannot be decrypted. Please ask the sender to resend it.' }, diff --git a/src/app/api/verify/route.ts b/src/app/api/verify/route.ts index 4f1a516..b41a088 100644 --- a/src/app/api/verify/route.ts +++ b/src/app/api/verify/route.ts @@ -3,6 +3,11 @@ import { prisma } from '@/lib/prisma' import { NextResponse } from 'next/server' import bcrypt from 'bcrypt' import { TransferStatus } from '@prisma/client' +import { rateLimit, resetRateLimit, clientKey } from '@/lib/rate-limit' + +// 10 password attempts per transfer, per client, per 15 minutes. +const ATTEMPT_LIMIT = 10 +const ATTEMPT_WINDOW_MS = 15 * 60 * 1000 export async function POST(req: Request) { const { id, password } = await req.json() @@ -11,6 +16,17 @@ export async function POST(req: Request) { return NextResponse.json({ success: false, message: 'Missing ID or password' }, { status: 400 }) } + // Keyed before the DB lookup so throttling costs an attacker a request + // regardless of whether the transfer exists. + const key = `verify:${id}:${clientKey(req.headers)}` + const limit = rateLimit(key, ATTEMPT_LIMIT, ATTEMPT_WINDOW_MS) + if (!limit.allowed) { + return NextResponse.json( + { success: false, message: 'Too many attempts. Please try again later.' }, + { status: 429, headers: { 'Retry-After': String(limit.retryAfter) } } + ) + } + const transfer = await prisma.transfer.findUnique({ where: { downloadUrl: id }, include: { files: true }, @@ -39,6 +55,9 @@ export async function POST(req: Request) { return NextResponse.json({ success: false, message: 'Incorrect password' }, { status: 401 }) } + // Correct password: clear the counter so an earlier typo does not throttle. + resetRateLimit(key) + // Explicit allow-list rather than spreading the row: `files` carries absolute // server paths, and the row carries passwordHash and the owning userId. const totalSize = transfer.files.reduce((sum, file) => sum + file.size, BigInt(0)) diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index f268653..0e50394 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { signIn, getSession } from 'next-auth/react'; +import { signIn } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; @@ -52,6 +52,7 @@ export default function SignInPage() { router.push('/'); } } catch (error) { + console.error(error); toast.error('Something went wrong'); } finally { setIsLoading(false); @@ -63,6 +64,7 @@ export default function SignInPage() { try { await signIn(provider, { callbackUrl: '/send' }); } catch (error) { + console.error(error); toast.error(`Failed to sign in with ${provider}`); setLoadingProvider(null); } @@ -213,7 +215,7 @@ export default function SignInPage() {

- Don't have an account?{' '} + Don't have an account?{' '} (null); + const [transfer, setTransfer] = useState(null); const [password, setPassword] = useState(''); const [status, setStatus] = useState(''); - const [downloading, setDownloading] = useState(false); const [showPassword, setShowPassword] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false); const [passwordError, setPasswordError] = useState(''); const [isVerifying, setIsVerifying] = useState(false); - const [downloadingFiles, setDownloadingFiles] = useState>( + const [downloadingFiles, setDownloadingFiles] = useState>( new Set() ); const [downloadingAll, setDownloadingAll] = useState(false); - const [downloadedFiles, setDownloadedFiles] = useState>( + const [downloadedFiles, setDownloadedFiles] = useState>( new Set() ); @@ -82,18 +92,17 @@ export default function DownloadPage() { loadTransfer(); }, [id]); - const handleDecrypt = async () => { - try { - setDownloading(true); - setDownloadingAll(true); - setStatus('Downloading and decrypting file...'); + const downloadFile = async (file: TransferFile) => { + setDownloadingFiles((prev) => new Set(prev).add(file.id)); + setStatus(`Downloading ${file.name}...`); + try { // POST so the password stays out of the URL (and therefore out of access // logs and Referer headers). const res = await fetch(`/api/file/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }), + body: JSON.stringify({ password, fileId: file.id }), }); if (!res.ok) { @@ -101,22 +110,42 @@ export default function DownloadPage() { throw new Error(err?.error || 'File not found or invalid password.'); } - const decryptedBlob = await res.blob(); - const url = URL.createObjectURL(decryptedBlob); - const a = document.createElement('a'); - a.href = url; - a.download = transfer.files?.[0]?.name?.replace(/\.enc$/, '') || 'file'; - a.click(); - URL.revokeObjectURL(url); + // Streams straight to disk where supported, so a multi-GB file is never + // held in browser memory. + await saveResponseToDisk(res, file.name.replace(/\.enc$/, '') || 'file'); - setStatus('Download complete!'); - } catch (err: any) { + setDownloadedFiles((prev) => new Set(prev).add(file.id)); + setStatus(''); + toast.success(`${file.name} downloaded`); + } catch (err: unknown) { + if (err instanceof DownloadCancelled) { + setStatus(''); + return; + } console.error(err); - setStatus('Download failed: ' + err.message); + const detail = err instanceof Error ? err.message : String(err); + setStatus(`Download failed: ${detail}`); + toast.error(detail); + } finally { + setDownloadingFiles((prev) => { + const next = new Set(prev); + next.delete(file.id); + return next; + }); + } + }; + + const handleDecrypt = async () => { + if (!transfer?.files?.length) return; + setDownloadingAll(true); + try { + // Sequential: parallel downloads of multi-GB files would compete for + // bandwidth and memory, and each needs its own save prompt anyway. + for (const file of transfer.files) { + await downloadFile(file); + } } finally { - setDownloading(false); setDownloadingAll(false); - setDownloadedFiles(new Set(transfer.files.map((f: any) => f.id))); } }; @@ -151,8 +180,8 @@ export default function DownloadPage() { } }; - const isFileDownloading = (fileId: string) => downloadingFiles.has(fileId); - const isFileDownloaded = (fileId: string) => downloadedFiles.has(fileId); + const isFileDownloading = (fileId: number) => downloadingFiles.has(fileId); + const isFileDownloaded = (fileId: number) => downloadedFiles.has(fileId); if (!transfer) { return ( @@ -362,7 +391,7 @@ export default function DownloadPage() {

- Transfer from {transfer.senderName} + Transfer from {transfer.senderEmail}
@@ -398,7 +427,10 @@ export default function DownloadPage() {
- {transfer.files.length} files + + {transfer.files?.length ?? 0}{' '} + {transfer.files?.length === 1 ? 'file' : 'files'} +
@@ -406,7 +438,9 @@ export default function DownloadPage() {
- Expires {transfer.expiresAt} + + Expires {formatTimeRemaining(new Date(transfer.expiresAt))} +
@@ -441,18 +475,51 @@ export default function DownloadPage() {

Files in this transfer

- {transfer.filenames?.map((name: string, index: number) => ( + {transfer.files?.map((file) => ( - -

{name}

+ +
+

{file.name}

+

+ {formatFileSize(file.size)} +

+
+
))}
+ {status && ( +

{status}

+ )} + {/* Footer */}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 4e5faa7..33c9b86 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,17 +8,12 @@ import { HeroSection } from '@/components/hero-section'; import { Stats } from '@/components/stats'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { - ArrowRight, - Shield, - Zap, - Globe, - Mail, - Lock, - Clock, - Users, - Star, - CheckCircle +import { + ArrowRight, + Shield, + Zap, + Mail, + Star } from 'lucide-react'; import Link from 'next/link'; @@ -186,7 +181,7 @@ export default function Home() { ))}

- "Transfer Tribe has revolutionized how we share large design files with clients. The security features give us peace of mind." + “Transfer Tribe has revolutionized how we share large design files with clients. The security features give us peace of mind.”

@@ -208,7 +203,7 @@ export default function Home() { ))}

- "Simple, fast, and secure. Exactly what we needed for sharing confidential documents with our legal team." + “Simple, fast, and secure. Exactly what we needed for sharing confidential documents with our legal team.”

@@ -230,7 +225,7 @@ export default function Home() { ))}

- "The email integration is brilliant. Our clients love how easy it is to receive and download files." + “The email integration is brilliant. Our clients love how easy it is to receive and download files.”

diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index 0240984..e5f5dc2 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -12,12 +12,9 @@ import { Crown, Gift, Upload, - Clock, Shield, Users, - Mail, - Settings, - Infinity + Mail } from 'lucide-react'; interface PricingTier { @@ -25,7 +22,7 @@ interface PricingTier { price: string; period: string; description: string; - icon: React.ComponentType; + icon: React.ComponentType<{ className?: string }>; popular?: boolean; features: { name: string; @@ -147,7 +144,7 @@ export default function PricingPage() {
- {tiers.map((tier, index) => { + {tiers.map((tier) => { const IconComponent = tier.icon; return (

- What's included: + What's included:

    {tier.features.map((feature, featureIndex) => ( diff --git a/src/app/send/page.tsx b/src/app/send/page.tsx index 233a8a2..da804aa 100644 --- a/src/app/send/page.tsx +++ b/src/app/send/page.tsx @@ -5,15 +5,13 @@ import { useEffect, useState } from 'react'; import { Header } from '@/components/header'; import SendPage from '@/components/send-transfer-server-encrypted'; import { MyTransfers } from '@/components/my-transfers'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; import { - Upload, Send, History, Files, - TrendingUp, Download, Clock, Shield, @@ -23,14 +21,9 @@ export default function DashboardPage() { const [activeTab, setActiveTab] = useState<'send' | 'transfers' | 'files'>( 'send' ); - const [userFiles, setUserFiles] = useState([]); - const { data: session, status } = useSession(); + const { data: session } = useSession(); const userName = session?.user?.name || 'friend'; - const handleFileUpload = (files: any[]) => { - setUserFiles((prev) => [...prev, ...files]); - }; - const [stats, setStats] = useState({ transfersSent: 0, downloads: 0, diff --git a/src/components/hero-section.tsx b/src/components/hero-section.tsx index 266d3b7..6834380 100644 --- a/src/components/hero-section.tsx +++ b/src/components/hero-section.tsx @@ -1,5 +1,5 @@ import { Button } from '@/components/ui/button'; -import { ArrowRight, Shield, Zap, Globe, Mail, Lock, Clock } from 'lucide-react'; +import { ArrowRight, Mail, Lock, Clock } from 'lucide-react'; export function HeroSection() { return ( diff --git a/src/components/my-transfers.tsx b/src/components/my-transfers.tsx index 0b758a0..60a5939 100644 --- a/src/components/my-transfers.tsx +++ b/src/components/my-transfers.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useSession } from 'next-auth/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -12,7 +12,6 @@ import { Share2, Trash2, File, - Mail, Clock, Users, MoreVertical, @@ -64,7 +63,7 @@ export function MyTransfers() { ) ); - const fetchTransfers = async () => { + const fetchTransfers = useCallback(async () => { if (!session?.user?.email) return; setIsLoading(true); @@ -78,15 +77,16 @@ export function MyTransfers() { toast.error('Failed to fetch transfers'); } } catch (error) { + console.error(error); toast.error('Failed to fetch transfers'); } finally { setIsLoading(false); } - }; + }, [session?.user?.email]); useEffect(() => { fetchTransfers(); - }, [session]); + }, [fetchTransfers]); const handleResend = async (transferId: number) => { try { @@ -100,6 +100,7 @@ export function MyTransfers() { toast.error('Failed to resend transfer'); } } catch (error) { + console.error(error); toast.error('Failed to resend transfer'); } }; @@ -117,6 +118,7 @@ export function MyTransfers() { toast.error('Failed to delete transfer'); } } catch (error) { + console.error(error); toast.error('Failed to delete transfer'); } }; @@ -184,7 +186,7 @@ const copyShareLink = (transferId: number) => { No transfers found

    - You haven't sent any transfers yet + You haven't sent any transfers yet

)} @@ -273,7 +275,7 @@ const copyShareLink = (transferId: number) => { {transfer.message && (

- "{transfer.message}" + “{transfer.message}”

)} @@ -307,7 +309,7 @@ const copyShareLink = (transferId: number) => { {filteredTransfers.length === 0 && searchTerm && transfers.length > 0 && (

- No transfers found matching "{searchTerm}" + No transfers found matching “{searchTerm}”

)} diff --git a/src/components/send-transfer-chunked.tsx b/src/components/send-transfer-chunked.tsx deleted file mode 100644 index 89d14d2..0000000 --- a/src/components/send-transfer-chunked.tsx +++ /dev/null @@ -1,428 +0,0 @@ -'use client'; - -import { useSession } from 'next-auth/react'; -import { useState, useEffect, useRef } from 'react'; -import { encryptBlob } from '@/lib/encryption'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Progress } from '@/components/ui/progress'; -import { Textarea } from '@/components/ui/textarea'; -import { Card, CardContent } from '@/components/ui/card'; -import { Upload, File, X, Send } from 'lucide-react'; -import { toast } from 'sonner'; -import { formatFileSize } from '@/lib/utils'; -import { Loader2 } from 'lucide-react'; - -const PLAN_LIMITS = { - free: { - maxFileSizeMB: 2048, - maxMonthlyTransferSizeMB: 10240, - expiryText: '48 hours after sending', - }, - rookie: { - maxFileSizeMB: Infinity, - maxMonthlyTransferSizeMB: 30720, - expiryText: '7 days after sending', - }, - pro: { - maxFileSizeMB: Infinity, - maxMonthlyTransferSizeMB: 1024 * 1024, - expiryText: '30 days after sending', - }, -}; - -const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks - -export default function SendPage() { - const { data: session } = useSession(); - const [files, setFiles] = useState([]); - const [recipient, setRecipient] = useState(''); - const [password, setPassword] = useState(''); - const [message, setMessage] = useState(''); - const [plan, setPlan] = useState<'free' | 'rookie' | 'pro'>('free'); - const [remainingMB, setRemainingMB] = useState(Infinity); - const [usedMB, setUsedMB] = useState(0); - const [uploadProgress, setUploadProgress] = useState(0); - const [uploadStep, setUploadStep] = useState(''); - const [uploadMessage, setUploadMessage] = useState(''); - const [currentChunk, setCurrentChunk] = useState(0); - const [totalChunks, setTotalChunks] = useState(0); - const dropRef = useRef(null); - const totalSize = files.reduce((acc, file) => acc + file.size, 0); - const totalSizeMB = totalSize / 1024 / 1024; - const isOverLimit = - totalSizeMB > PLAN_LIMITS[plan].maxFileSizeMB || totalSizeMB > remainingMB; - - useEffect(() => { - const fetchPlan = async () => { - const res = await fetch('/api/usage'); - if (res.ok) { - const data = await res.json(); - setPlan(data.plan); - setRemainingMB(data.remainingMB); - setUsedMB(data.usedMB); - } - }; - if (session?.user?.email) fetchPlan(); - }, [session]); - - const handleFileAdd = (newFiles: File[]) => { - const unique = newFiles.filter( - (newFile) => - !files.find( - (existing) => - existing.name === newFile.name && existing.size === newFile.size - ) - ); - setFiles((prev) => [...prev, ...unique]); - }; - - const handleFileChange = (e: React.ChangeEvent) => { - if (!e.target.files) return; - handleFileAdd(Array.from(e.target.files)); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.dataTransfer.files) { - handleFileAdd(Array.from(e.dataTransfer.files)); - } - dropRef.current?.classList.remove('border-blue-500'); - }; - - const removeFile = (index: number) => { - setFiles((prev) => prev.filter((_, i) => i !== index)); - }; - - const fileInputRef = useRef(null); - const triggerFileInput = () => fileInputRef.current?.click(); - - const handleSubmit = async () => { - if (!files.length) - return setUploadMessage('Please select at least one file'); - if (!recipient || !password) - return setUploadMessage('All fields are required'); - if (isOverLimit) - return setUploadMessage('File size exceeds your plan limits'); - const senderEmail = session?.user?.email; - if (!senderEmail) - return setUploadMessage('Unable to get sender email. Please log in.'); - - try { - setUploadStep('Preparing files'); - const file = files[0]; // Upload first file for now - - setUploadStep('Encrypting'); - // Encrypt the file using the password - const { encryptedBlob } = await encryptBlob(file, password); - - // Convert blob to Uint8Array once to avoid multiple arrayBuffer() calls - // (which can cause "NotReadableError" on some browsers) - const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer()); - const iv = encryptedBytes.slice(0, 12); - const ivBase64 = btoa(String.fromCharCode(...iv)); - - // Calculate chunks - const CHUNK_SIZE_BYTES = CHUNK_SIZE; - const totalChunksCount = Math.ceil(encryptedBytes.length / CHUNK_SIZE_BYTES); - setTotalChunks(totalChunksCount); - - // Generate upload ID - const uploadId = crypto.randomUUID(); - - // Upload chunks - setUploadStep('Uploading'); - for (let i = 0; i < totalChunksCount; i++) { - setCurrentChunk(i + 1); - - const start = i * CHUNK_SIZE_BYTES; - const end = Math.min(start + CHUNK_SIZE_BYTES, encryptedBytes.length); - const chunkBytes = encryptedBytes.slice(start, end); - const chunkBlob = new Blob([chunkBytes]); - - const formData = new FormData(); - formData.append('chunk', chunkBlob); - formData.append('uploadId', uploadId); - formData.append('chunkIndex', i.toString()); - formData.append('totalChunks', totalChunksCount.toString()); - formData.append('chunkSize', CHUNK_SIZE_BYTES.toString()); - - // Only send metadata on first chunk - if (i === 0) { - formData.append('email', recipient); - formData.append('password', password); - formData.append('sender', senderEmail); - formData.append('originalFilename', file.name); - formData.append('encryptionIv', ivBase64); - const filenames = files.map((f) => f.name); - formData.append('filenames', JSON.stringify(filenames)); - formData.append('message', message); - } - - const uploadRes = await fetch('/api/chunk-upload', { - method: 'POST', - body: formData, - }); - - if (!uploadRes.ok) { - const error = await uploadRes.json(); - throw new Error(error.message || `Chunk ${i} upload failed`); - } - - // Update progress - const chunkProgress = ((i + 1) / totalChunksCount) * 100; - setUploadProgress(Math.round(chunkProgress)); - } - - // Finalize upload - setUploadStep('Finalizing'); - const finalRes = await fetch(`/api/chunk-upload?uploadId=${uploadId}`, { - method: 'PUT', - }); - - if (!finalRes.ok) { - const error = await finalRes.json(); - throw new Error(error.message || 'Failed to finalize upload'); - } - - const result = await finalRes.json(); - - setUploadStep(''); - setUploadMessage('Success! 🎉'); - toast.success('Your transfer has been sent successfully!'); - setFiles([]); - setRecipient(''); - setPassword(''); - setUploadProgress(100); - setTimeout(() => setUploadMessage(''), 3000); - } catch (err: any) { - console.error(err); - setUploadStep(''); - setUploadMessage('Error: ' + err.message); - } - }; - - function UploadStatusToast({ - step, - progress, - message, - currentChunk, - totalChunks, - }: { - step: string; - progress: number; - message: string; - currentChunk: number; - totalChunks: number; - }) { - const showSpinner = ['Encrypting', 'Uploading', 'Finalizing'].includes(step); - const isError = - message.toLowerCase().includes('error') || message.includes('fail'); - - if (!step && !message) return null; - - return ( -
-
- {showSpinner && ( - - )} -

- {step}{currentChunk && totalChunks ? ` (${currentChunk}/${totalChunks})` : ''} -

-
- {!isError && step === 'Uploading' && ( - <> - -

- ⚠️ Please do not close this page during upload! -

- - )} -
- ); - } - - return ( -
-
- {/* File Upload Section */} -
-
-

- Upload Files -

-
{ - e.preventDefault(); - dropRef.current?.classList.add('border-blue-500'); - }} - onDragLeave={() => - dropRef.current?.classList.remove('border-blue-500') - } - className="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all" - > -
-
- -
-
-

- Drag & drop files here -

-

- or click to select files -

-
- - -
-
- - {/* Files List */} - {files.length > 0 && ( -
-

- Selected Files ({files.length}) -

-
- {files.map((file, i) => ( -
-
- -
-

- {file.name} -

-

- {formatFileSize(file.size)} -

-
-
- -
- ))} -
-
- )} - - {/* Size Info */} -
-

Total Size: {formatFileSize(totalSize)}

- {remainingMB !== Infinity && ( -

Remaining: {remainingMB.toFixed(2)} MB

- )} -
-
-
- - {/* Transfer Details Section */} -
-
-

- Transfer Details -

- - -
- - setRecipient(e.target.value)} - placeholder="recipient@example.com" - className="mt-1" - /> -
- -
- - setPassword(e.target.value)} - placeholder="Create a strong password" - className="mt-1" - /> -
- -
- -