Add TransferTribe app and fix critical transfer security flaws
The application source was untracked, so this commit brings it under version control together with fixes for the issues found while auditing it. Notable fixes: Authorization - Require a session and scope to senderEmail on /api/transfers/[id] (GET, DELETE) and .../resend. These were unauthenticated over an autoincrement id, so the ids could be walked to soft-delete any transfer, read sender/recipient metadata, or make the app mail arbitrary recipients. - Require a session on the legacy /api/send and /api/chunk-upload endpoints, and take the sender from the session rather than a request field so transfers cannot be posted as another user. Encryption - Replace the chunk encryption scheme. Every chunk was encrypted under one shared session IV with its auth tag discarded, which reuses the AES-GCM keystream (XORing two ciphertexts recovers plaintext without the key) and left the stored file undecryptable, surfacing to users as a wrong-password error. Chunks are now self-contained frames carrying their own random IV and auth tag, behind a magic+salt header. - Files written by the previous format now report UNSUPPORTED_FORMAT instead of a misleading password error. Download - Verify the password against the stored bcrypt hash before serving a file, and enforce expiresAt and DELETED/EXPIRED status. - Move the password from the query string into a POST body so it stays out of access logs and Referer headers. - Record a download only after successful authentication. - Decrypt frame by frame through a stream instead of buffering the whole file, and encode the Content-Disposition filename per RFC 5987. Data exposure - /api/download ran before the password prompt and returned the full transfer row, including absolute server file paths. It now returns only what the pre-password screen renders; filenames, message and recipient are withheld until /api/verify succeeds. Correctness - Fix BigInt handling that made /api/transfers and /api/transfers/[id] fail unconditionally (JSON.stringify cannot serialize BigInt, and seeding a BigInt reduce with 0 throws). - Fail loudly on a missing chunk during reassembly rather than silently writing a corrupt file. - Meter plan usage in plaintext bytes rather than on-disk encrypted size. Ignore /uploads: it holds runtime transfer payloads, not source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
// pages/api/chunk-upload-v2.ts
|
||||
// Server-side encryption approach: Upload raw chunks, encrypt on server, store encrypted
|
||||
|
||||
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 { deriveKeyFromPassword, encryptChunkFrame, buildHeader } from '@/lib/server-encryption';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
interface UploadSession {
|
||||
uploadId: string;
|
||||
totalSize: number;
|
||||
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
|
||||
const uploadSessions = new Map<string, UploadSession>();
|
||||
|
||||
// Cleanup abandoned uploads every 5 minutes
|
||||
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);
|
||||
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);
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const authSession = await getServerSession(req, res, authOptions);
|
||||
if (!authSession?.user?.email) {
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
}
|
||||
const sessionEmail = authSession.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,
|
||||
});
|
||||
|
||||
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; // 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;
|
||||
|
||||
if (!recipient) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Missing required fields' }));
|
||||
}
|
||||
|
||||
// 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' }));
|
||||
}
|
||||
|
||||
// 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' }));
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
session = {
|
||||
uploadId,
|
||||
totalSize: chunkSize * totalChunks,
|
||||
totalChunks,
|
||||
receivedChunks: new Set(),
|
||||
chunkSize,
|
||||
sender,
|
||||
recipient,
|
||||
password,
|
||||
originalFilename,
|
||||
message,
|
||||
filenames,
|
||||
createdAt: new Date(),
|
||||
salt,
|
||||
encryptionKey,
|
||||
receivedBytes: 0,
|
||||
maxBytes: limits.maxFileSize,
|
||||
};
|
||||
|
||||
// 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)
|
||||
const chunkData = await fsPromises.readFile(chunkFile.filepath);
|
||||
|
||||
if (
|
||||
session.maxBytes !== Infinity &&
|
||||
session.receivedBytes + chunkData.length > session.maxBytes
|
||||
) {
|
||||
await fsPromises.unlink(chunkFile.filepath).catch(() => {});
|
||||
return resolve(res.status(413).json({
|
||||
success: false,
|
||||
message: 'File exceeds max size for your plan',
|
||||
}));
|
||||
}
|
||||
|
||||
const frame = 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);
|
||||
}
|
||||
|
||||
// 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({
|
||||
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,
|
||||
receivedBytes: session.receivedBytes,
|
||||
totalBytes: session.totalSize,
|
||||
});
|
||||
}
|
||||
|
||||
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 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);
|
||||
|
||||
// Write the format header: magic (8) + salt (16). The per-chunk IVs live
|
||||
// in the frames themselves, so nothing needs storing in the DB.
|
||||
writeStream.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`);
|
||||
}
|
||||
|
||||
writeStream.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();
|
||||
});
|
||||
|
||||
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
|
||||
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(plaintextSize) > 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;
|
||||
|
||||
// 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: plaintextSize,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 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}`;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// 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<number>;
|
||||
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<string, UploadSession>();
|
||||
|
||||
// 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<void>((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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chunkData) {
|
||||
writeStream.write(chunkData);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// 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<void>((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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user