Address remaining audit findings
Multiple files per transfer - The UI accepted several files but only files[0] was ever uploaded, so recipients saw a list and received one file. Chunks now carry a fileIndex, the upload session tracks each file separately, and finalize assembles one encrypted payload and one File row per file. - The download page lists real files with per-file download buttons; the previously unused isFileDownloading/isFileDownloaded helpers now drive that state. /api/file/[id] accepts a fileId, scoped to the transfer so an id from another transfer cannot be fetched. Large downloads - Replace res.blob() with a helper that streams the response body to disk via the File System Access API where available, keeping peak memory at roughly one chunk instead of the whole file. Falls back to the blob path elsewhere. Password brute force - Rate limit /api/verify and /api/file/[id] to 10 attempts per transfer, per client, per 15 minutes; a correct password clears the counter. Per-process state, matching the existing local-disk storage model. Disk reclamation - Nothing ever deleted payloads, so every transfer stayed on disk regardless of expiresAt. Add a cleanup routine that marks lapsed transfers EXPIRED, deletes payloads for expired and soft-deleted transfers, and sweeps abandoned chunk directories. It refuses to unlink anything outside UPLOAD_DIR. Exposed as POST /api/cron/cleanup behind CRON_SECRET, failing closed when that is unset. Build hygiene - Stop ignoring ESLint during builds and fix all 70 resulting violations: unused imports and state, unescaped JSX entities, explicit any, and a missing useEffect dependency. Seven catch blocks discarded their error silently and now log it. - Delete dead code: two unused send components, the two legacy upload endpoints they called, the unused whole-file WebCrypto helpers, and a duplicate separator component differing only by a typo. Add .env.example documenting configuration, including CRON_SECRET. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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=""
|
||||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
// Generated Prisma client is excluded in eslint.config.mjs; app code is linted.
|
||||||
|
dirs: ["src", "pages"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+242
-226
@@ -1,8 +1,12 @@
|
|||||||
// pages/api/chunk-upload-v2.ts
|
// 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 { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { IncomingForm } from 'formidable';
|
import { IncomingForm, Fields, Files } from 'formidable';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { promises as fsPromises } from 'fs';
|
import { promises as fsPromises } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -23,28 +27,34 @@ export const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UploadSession {
|
interface UploadFile {
|
||||||
uploadId: string;
|
name: string;
|
||||||
totalSize: number;
|
|
||||||
totalChunks: number;
|
totalChunks: number;
|
||||||
receivedChunks: Set<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;
|
receivedBytes: number;
|
||||||
maxBytes: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store active upload sessions
|
interface UploadSession {
|
||||||
|
uploadId: string;
|
||||||
|
sender: string;
|
||||||
|
recipient: string;
|
||||||
|
password: string;
|
||||||
|
message?: string;
|
||||||
|
filenames?: string;
|
||||||
|
fileCount: number;
|
||||||
|
/** Keyed by fileIndex. */
|
||||||
|
files: Map<number, UploadFile>;
|
||||||
|
createdAt: Date;
|
||||||
|
// One salt/key per transfer. Sharing a key across files is safe because every
|
||||||
|
// chunk frame carries its own unique IV.
|
||||||
|
salt: Buffer;
|
||||||
|
encryptionKey: Buffer;
|
||||||
|
maxBytes: number;
|
||||||
|
totalReceivedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: in-process state, matching the local-disk storage model. Running more
|
||||||
|
// than one instance requires a shared store (Redis) plus shared object storage.
|
||||||
const uploadSessions = new Map<string, UploadSession>();
|
const uploadSessions = new Map<string, UploadSession>();
|
||||||
|
|
||||||
// Cleanup abandoned uploads every 5 minutes
|
// Cleanup abandoned uploads every 5 minutes
|
||||||
@@ -55,13 +65,19 @@ setInterval(() => {
|
|||||||
if (now - session.createdAt.getTime() > SESSION_TIMEOUT) {
|
if (now - session.createdAt.getTime() > SESSION_TIMEOUT) {
|
||||||
uploadSessions.delete(uploadId);
|
uploadSessions.delete(uploadId);
|
||||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', 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)
|
console.warn(`Failed to clean abandoned upload ${uploadId}:`, err)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 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) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const authSession = await getServerSession(req, res, authOptions);
|
const authSession = await getServerSession(req, res, authOptions);
|
||||||
if (!authSession?.user?.email) {
|
if (!authSession?.user?.email) {
|
||||||
@@ -85,146 +101,155 @@ async function handleChunkUpload(
|
|||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
sessionEmail: string
|
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({
|
const form = new IncomingForm({
|
||||||
uploadDir: path.join(UPLOAD_DIR, '.tmp'),
|
uploadDir: path.join(UPLOAD_DIR, '.tmp'),
|
||||||
keepExtensions: true,
|
keepExtensions: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
return new Promise<void>((resolve) => {
|
||||||
await fsPromises.mkdir(path.join(UPLOAD_DIR, '.tmp'), { recursive: true });
|
form.parse(req, async (err: Error | null, fields: Fields, files: Files) => {
|
||||||
} 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) {
|
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 {
|
try {
|
||||||
const chunkFile = Array.isArray(files.chunk) ? files.chunk[0] : files.chunk;
|
const chunkFile = Array.isArray(files.chunk) ? files.chunk[0] : files.chunk;
|
||||||
const uploadId = Array.isArray(fields.uploadId) ? fields.uploadId[0] : fields.uploadId;
|
const uploadId = firstValue(fields.uploadId);
|
||||||
const chunkIndex = parseInt(Array.isArray(fields.chunkIndex) ? fields.chunkIndex[0] : fields.chunkIndex);
|
const chunkIndex = parseInt(firstValue(fields.chunkIndex) ?? '', 10);
|
||||||
const totalChunks = parseInt(Array.isArray(fields.totalChunks) ? fields.totalChunks[0] : fields.totalChunks);
|
const fileIndex = parseInt(firstValue(fields.fileIndex) ?? '0', 10);
|
||||||
const chunkSize = parseInt(Array.isArray(fields.chunkSize) ? fields.chunkSize[0] : fields.chunkSize);
|
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)) {
|
if (
|
||||||
return resolve(res.status(400).json({ success: false, message: 'Missing chunk metadata' }));
|
!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);
|
let session = uploadSessions.get(uploadId);
|
||||||
if (session && session.sender !== sessionEmail) {
|
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) {
|
if (!session) {
|
||||||
const sender = sessionEmail; // never trust a sender field from the body
|
const recipient = firstValue(fields.email);
|
||||||
const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email;
|
const password = firstValue(fields.password);
|
||||||
const password = Array.isArray(fields.password) ? fields.password[0] : fields.password;
|
const message = firstValue(fields.message);
|
||||||
const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename;
|
const filenames = firstValue(fields.filenames);
|
||||||
const message = Array.isArray(fields.message) ? fields.message[0] : fields.message;
|
|
||||||
const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames;
|
|
||||||
|
|
||||||
if (!recipient) {
|
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
|
// A password is mandatory: it is the key material. Without it every
|
||||||
// file would be encrypted under a key derived from the empty string.
|
// file would be encrypted under a key derived from the empty string.
|
||||||
if (!password) {
|
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: sessionEmail } });
|
||||||
const user = await prisma.user.findUnique({ where: { email: sender } });
|
|
||||||
if (!user) {
|
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 plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
|
||||||
const limits = PLAN_CONFIG[plan];
|
const salt = crypto.randomBytes(16);
|
||||||
|
|
||||||
session = {
|
session = {
|
||||||
uploadId,
|
uploadId,
|
||||||
totalSize: chunkSize * totalChunks,
|
sender: sessionEmail,
|
||||||
totalChunks,
|
|
||||||
receivedChunks: new Set(),
|
|
||||||
chunkSize,
|
|
||||||
sender,
|
|
||||||
recipient,
|
recipient,
|
||||||
password,
|
password,
|
||||||
originalFilename,
|
|
||||||
message,
|
message,
|
||||||
filenames,
|
filenames,
|
||||||
|
fileCount: Number.isNaN(fileCount) ? 1 : fileCount,
|
||||||
|
files: new Map(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
salt,
|
salt,
|
||||||
encryptionKey,
|
encryptionKey: await deriveKeyFromPassword(password, salt),
|
||||||
receivedBytes: 0,
|
maxBytes: PLAN_CONFIG[plan].maxFileSize,
|
||||||
maxBytes: limits.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);
|
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);
|
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 (
|
if (
|
||||||
|
isNewChunk &&
|
||||||
session.maxBytes !== Infinity &&
|
session.maxBytes !== Infinity &&
|
||||||
session.receivedBytes + chunkData.length > session.maxBytes
|
session.totalReceivedBytes + chunkData.length > session.maxBytes
|
||||||
) {
|
) {
|
||||||
await fsPromises.unlink(chunkFile.filepath).catch(() => {});
|
res.status(413).json({
|
||||||
return resolve(res.status(413).json({
|
|
||||||
success: false,
|
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
|
if (isNewChunk) {
|
||||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
entry.receivedBytes += chunkData.length;
|
||||||
await fsPromises.mkdir(chunkDir, { recursive: true });
|
session.totalReceivedBytes += chunkData.length;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
entry.receivedChunks.add(chunkIndex);
|
||||||
|
|
||||||
// Guard against a retried chunk double-counting toward the quota.
|
res.status(200).json({
|
||||||
if (!session.receivedChunks.has(chunkIndex)) {
|
|
||||||
session.receivedBytes += chunkData.length;
|
|
||||||
}
|
|
||||||
session.receivedChunks.add(chunkIndex);
|
|
||||||
|
|
||||||
return resolve(res.status(200).json({
|
|
||||||
success: true,
|
success: true,
|
||||||
uploadId,
|
uploadId,
|
||||||
|
fileIndex,
|
||||||
chunkIndex,
|
chunkIndex,
|
||||||
receivedChunks: Array.from(session.receivedChunks),
|
receivedChunks: entry.receivedChunks.size,
|
||||||
}));
|
totalChunks: entry.totalChunks,
|
||||||
} catch (err: any) {
|
});
|
||||||
console.error('Chunk upload error:', err);
|
return resolve();
|
||||||
return resolve(res.status(500).json({ success: false, message: 'Upload error: ' + err.message }));
|
} 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' });
|
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({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
uploadId,
|
uploadId,
|
||||||
totalChunks: session.totalChunks,
|
fileCount: session.fileCount,
|
||||||
receivedChunks: Array.from(session.receivedChunks),
|
files,
|
||||||
isComplete: session.receivedChunks.size === session.totalChunks,
|
receivedBytes: session.totalReceivedBytes,
|
||||||
receivedBytes: session.receivedBytes,
|
isComplete:
|
||||||
totalBytes: session.totalSize,
|
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' });
|
return res.status(404).json({ success: false, message: 'Upload session not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if all chunks received
|
// Every file must be present and whole before anything is assembled.
|
||||||
if (session.receivedChunks.size !== session.totalChunks) {
|
if (session.files.size !== session.fileCount) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: `Missing chunks. Received ${session.receivedChunks.size}/${session.totalChunks}`,
|
message: `Missing files. Received ${session.files.size}/${session.fileCount}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
for (const [fileIndex, entry] of session.files) {
|
||||||
// Reassemble encrypted chunks into final encrypted file
|
if (entry.receivedChunks.size !== entry.totalChunks) {
|
||||||
console.log(`[${uploadId}] Starting reassembly of ${session.totalChunks} encrypted chunks`);
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
message: `File ${fileIndex} incomplete: ${entry.receivedChunks.size}/${entry.totalChunks} chunks`,
|
||||||
const finalFilename = uuidv4() + '.enc';
|
|
||||||
const finalPath = path.join(UPLOAD_DIR, finalFilename);
|
|
||||||
|
|
||||||
const writeStream = fs.createWriteStream(finalPath);
|
|
||||||
|
|
||||||
// Honour backpressure: createWriteStream queues in memory whenever write()
|
|
||||||
// returns false, so a multi-GB reassembly that ignores the return value
|
|
||||||
// grows the internal buffer instead of flushing to disk.
|
|
||||||
const write = (buf: Buffer): Promise<void> =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
if (writeStream.write(buf)) return resolve();
|
|
||||||
writeStream.once('drain', resolve);
|
|
||||||
writeStream.once('error', reject);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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...`);
|
const writtenPaths: string[] = [];
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
writeStream.on('finish', resolve);
|
|
||||||
writeStream.on('error', reject);
|
|
||||||
writeStream.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[${uploadId}] Reassembly complete, validating file...`);
|
try {
|
||||||
|
|
||||||
// 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 } });
|
const user = await prisma.user.findUnique({ where: { email: session.sender } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await fsPromises.unlink(finalPath);
|
|
||||||
return res.status(404).json({ success: false, message: 'User not found' });
|
return res.status(404).json({ success: false, message: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,38 +339,84 @@ async function handleChunkComplete(
|
|||||||
startOfMonth.setHours(0, 0, 0, 0);
|
startOfMonth.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const transfersThisMonth = await prisma.transfer.findMany({
|
const transfersThisMonth = await prisma.transfer.findMany({
|
||||||
where: {
|
where: { senderEmail: session.sender, createdAt: { gte: startOfMonth } },
|
||||||
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 (
|
if (
|
||||||
(limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth) ||
|
(limits.maxTransfersPerMonth !== Infinity &&
|
||||||
(limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(plaintextSize) > BigInt(limits.maxTransferSizePerMonth))
|
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' });
|
return res.status(429).json({ success: false, message: 'Plan limits exceeded' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password for verification (don't encrypt again)
|
// Assemble each file into its own encrypted payload.
|
||||||
const hash = session.password ? await bcrypt.hash(session.password, 10) : null;
|
const assembled: { name: string; path: string; size: number }[] = [];
|
||||||
|
|
||||||
// Parse filenames
|
for (const [fileIndex, entry] of [...session.files.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
let totalFiles = 1;
|
const dir = fileChunkDir(uploadId, fileIndex);
|
||||||
if (session.filenames) {
|
const finalPath = path.join(UPLOAD_DIR, uuidv4() + '.enc');
|
||||||
try {
|
writtenPaths.push(finalPath);
|
||||||
const filesArray = JSON.parse(session.filenames);
|
|
||||||
if (Array.isArray(filesArray)) {
|
const writeStream = fs.createWriteStream(finalPath);
|
||||||
totalFiles = filesArray.length;
|
// Honour backpressure so a multi-GB reassembly flushes to disk rather
|
||||||
|
// than queueing in the stream's internal buffer.
|
||||||
|
const write = (buf: Buffer): Promise<void> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
if (writeStream.write(buf)) return resolve();
|
||||||
|
writeStream.once('drain', resolve);
|
||||||
|
writeStream.once('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
await write(buildHeader(session.salt));
|
||||||
|
|
||||||
|
for (let i = 0; i < entry.totalChunks; i++) {
|
||||||
|
const chunkPath = path.join(dir, `chunk-${i}.enc`);
|
||||||
|
let chunkData: Buffer | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
chunkData = await fsPromises.readFile(chunkPath);
|
||||||
|
break;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const code = (error as NodeJS.ErrnoException)?.code;
|
||||||
|
if (attempt < 2 && code === 'EACCES') {
|
||||||
|
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 100));
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
|
// A silently skipped chunk would corrupt the file, so fail loudly.
|
||||||
|
if (!chunkData) {
|
||||||
|
throw new Error(`Chunk ${i} of file ${fileIndex} missing during reassembly`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await write(chunkData);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
writeStream.on('finish', resolve);
|
||||||
|
writeStream.on('error', reject);
|
||||||
|
writeStream.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
assembled.push({ name: entry.name, path: finalPath, size: entry.receivedBytes });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(session.password, 10);
|
||||||
const expiresAt = new Date(Date.now() + limits.maxExpiryMs);
|
const expiresAt = new Date(Date.now() + limits.maxExpiryMs);
|
||||||
|
|
||||||
// Create transfer record
|
|
||||||
const transfer = await prisma.transfer.create({
|
const transfer = await prisma.transfer.create({
|
||||||
data: {
|
data: {
|
||||||
senderEmail: session.sender,
|
senderEmail: session.sender,
|
||||||
@@ -406,34 +424,27 @@ async function handleChunkComplete(
|
|||||||
passwordHash: hash,
|
passwordHash: hash,
|
||||||
downloadUrl: uuidv4(),
|
downloadUrl: uuidv4(),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
filenames: session.filenames || null,
|
filenames: session.filenames || JSON.stringify(assembled.map((f) => f.name)),
|
||||||
message: session.message || null,
|
message: session.message || null,
|
||||||
totalFiles,
|
totalFiles: assembled.length,
|
||||||
totalSize: plaintextSize,
|
totalSize: totalPlaintext,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
files: {
|
||||||
|
create: assembled.map((f) => ({ name: f.name, path: f.path, size: f.size })),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create file record with encryption metadata
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://transfertribe.com';
|
||||||
// Store salt and IV in the file path (prepended to encrypted data)
|
const link = `${baseUrl}/download/${transfer.downloadUrl}`;
|
||||||
// So we don't need separate DB fields
|
const fileLine =
|
||||||
await prisma.file.create({
|
assembled.length === 1 ? '1 file' : `${assembled.length} files`;
|
||||||
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({
|
await sendMailjetEmail({
|
||||||
to: session.recipient,
|
to: session.recipient,
|
||||||
subject: 'You\'ve received an encrypted file',
|
subject: "You've received an encrypted file",
|
||||||
text: `
|
text: `
|
||||||
${session.sender} sent you an encrypted file via TransferTribe.
|
${session.sender} sent you ${fileLine} via TransferTribe.
|
||||||
|
|
||||||
Link: ${link}
|
Link: ${link}
|
||||||
|
|
||||||
@@ -441,21 +452,26 @@ ${session.message ? `Message:\n${session.message}\n\n` : ''}Note: You'll need th
|
|||||||
`.trim(),
|
`.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up session and temp files
|
|
||||||
uploadSessions.delete(uploadId);
|
uploadSessions.delete(uploadId);
|
||||||
try {
|
await fsPromises
|
||||||
await fsPromises.rm(chunkDir, { recursive: true });
|
.rm(path.join(UPLOAD_DIR, '.tmp', uploadId), { recursive: true, force: true })
|
||||||
} catch (err) {
|
.catch((err) => console.warn('Failed to clean up temp directory:', err));
|
||||||
console.warn('Failed to clean up temp directory:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Upload complete',
|
message: 'Upload complete',
|
||||||
downloadUrl: transfer.downloadUrl,
|
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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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);
|
|
||||||
|
|
||||||
// Honour backpressure so a multi-GB reassembly flushes to disk instead of
|
|
||||||
// queueing in the stream's internal buffer.
|
|
||||||
const write = (buf: Buffer): Promise<void> =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
if (writeStream.write(buf)) return resolve();
|
|
||||||
writeStream.once('drain', resolve);
|
|
||||||
writeStream.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
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<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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,11 @@ import bcrypt from 'bcrypt'
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import { TransferStatus } from '@prisma/client'
|
import { TransferStatus } from '@prisma/client'
|
||||||
import { decryptFileStream } from '@/lib/server-encryption'
|
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.
|
// Node APIs (fs handles, crypto) — this route cannot run on the edge runtime.
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
@@ -33,9 +38,13 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let password = ''
|
let password = ''
|
||||||
|
let requestedFileId: number | undefined
|
||||||
try {
|
try {
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
password = typeof body?.password === 'string' ? body.password : ''
|
password = typeof body?.password === 'string' ? body.password : ''
|
||||||
|
if (typeof body?.fileId === 'number' && Number.isInteger(body.fileId)) {
|
||||||
|
requestedFileId = body.fileId
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
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 })
|
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({
|
const transfer = await prisma.transfer.findUnique({
|
||||||
where: { downloadUrl: id },
|
where: { downloadUrl: id },
|
||||||
include: { files: true },
|
include: { files: true },
|
||||||
@@ -79,7 +97,17 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Incorrect password' }, { status: 401 })
|
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 {
|
try {
|
||||||
await fs.access(file.path)
|
await fs.access(file.path)
|
||||||
@@ -103,9 +131,9 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const first = await frames.next()
|
const first = await frames.next()
|
||||||
if (!first.done) firstFrame = first.value
|
if (!first.done) firstFrame = first.value
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
await frames.return(undefined as never).catch(() => {})
|
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}`)
|
console.error(`Legacy-format file for transfer ${transfer.id}: ${file.path}`)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'This transfer was created with an older, incompatible version and cannot be decrypted. Please ask the sender to resend it.' },
|
{ error: 'This transfer was created with an older, incompatible version and cannot be decrypted. Please ask the sender to resend it.' },
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
import { TransferStatus } from '@prisma/client'
|
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) {
|
export async function POST(req: Request) {
|
||||||
const { id, password } = await req.json()
|
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 })
|
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({
|
const transfer = await prisma.transfer.findUnique({
|
||||||
where: { downloadUrl: id },
|
where: { downloadUrl: id },
|
||||||
include: { files: true },
|
include: { files: true },
|
||||||
@@ -39,6 +55,9 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ success: false, message: 'Incorrect password' }, { status: 401 })
|
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
|
// Explicit allow-list rather than spreading the row: `files` carries absolute
|
||||||
// server paths, and the row carries passwordHash and the owning userId.
|
// server paths, and the row carries passwordHash and the owning userId.
|
||||||
const totalSize = transfer.files.reduce((sum, file) => sum + file.size, BigInt(0))
|
const totalSize = transfer.files.reduce((sum, file) => sum + file.size, BigInt(0))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { signIn, getSession } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -52,6 +52,7 @@ export default function SignInPage() {
|
|||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
toast.error('Something went wrong');
|
toast.error('Something went wrong');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -63,6 +64,7 @@ export default function SignInPage() {
|
|||||||
try {
|
try {
|
||||||
await signIn(provider, { callbackUrl: '/send' });
|
await signIn(provider, { callbackUrl: '/send' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
toast.error(`Failed to sign in with ${provider}`);
|
toast.error(`Failed to sign in with ${provider}`);
|
||||||
setLoadingProvider(null);
|
setLoadingProvider(null);
|
||||||
}
|
}
|
||||||
@@ -213,7 +215,7 @@ export default function SignInPage() {
|
|||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link
|
<Link
|
||||||
href="/auth/signup"
|
href="/auth/signup"
|
||||||
className="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
className="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export default function SignUpPage() {
|
|||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
toast.error('Something went wrong');
|
toast.error('Something went wrong');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -91,6 +92,7 @@ export default function SignUpPage() {
|
|||||||
try {
|
try {
|
||||||
await signIn(provider, { callbackUrl: '/' });
|
await signIn(provider, { callbackUrl: '/' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
toast.error(`Failed to sign up with ${provider}`);
|
toast.error(`Failed to sign up with ${provider}`);
|
||||||
setLoadingProvider(null);
|
setLoadingProvider(null);
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-40
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { decryptBlob } from '@/lib/encryption';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -18,44 +17,55 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
File,
|
File,
|
||||||
FileText,
|
|
||||||
Image,
|
|
||||||
Music,
|
|
||||||
Video,
|
|
||||||
Archive,
|
|
||||||
Clock,
|
Clock,
|
||||||
Shield,
|
Shield,
|
||||||
User,
|
User,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
CheckCircle,
|
|
||||||
Lock,
|
Lock,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Mail,
|
|
||||||
HardDrive,
|
HardDrive,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { formatFileSize, formatTimeRemaining } from '@/lib/utils';
|
import { formatFileSize, formatTimeRemaining } from '@/lib/utils';
|
||||||
|
import { saveResponseToDisk, DownloadCancelled } from '@/lib/download';
|
||||||
|
|
||||||
|
interface TransferFile {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransferInfo {
|
||||||
|
senderEmail: string;
|
||||||
|
expiresAt: string;
|
||||||
|
// Pre-authentication shape
|
||||||
|
fileCount?: number;
|
||||||
|
totalSize: number;
|
||||||
|
requiresPassword?: boolean;
|
||||||
|
// Post-verification shape
|
||||||
|
message?: string | null;
|
||||||
|
totalFiles?: number;
|
||||||
|
files?: TransferFile[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function DownloadPage() {
|
export default function DownloadPage() {
|
||||||
const { id } = useParams() as { id: string };
|
const { id } = useParams() as { id: string };
|
||||||
|
|
||||||
const [transfer, setTransfer] = useState<any>(null);
|
const [transfer, setTransfer] = useState<TransferInfo | null>(null);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [passwordError, setPasswordError] = useState('');
|
const [passwordError, setPasswordError] = useState('');
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
const [downloadingFiles, setDownloadingFiles] = useState<Set<string>>(
|
const [downloadingFiles, setDownloadingFiles] = useState<Set<number>>(
|
||||||
new Set()
|
new Set()
|
||||||
);
|
);
|
||||||
const [downloadingAll, setDownloadingAll] = useState(false);
|
const [downloadingAll, setDownloadingAll] = useState(false);
|
||||||
const [downloadedFiles, setDownloadedFiles] = useState<Set<string>>(
|
const [downloadedFiles, setDownloadedFiles] = useState<Set<number>>(
|
||||||
new Set()
|
new Set()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,18 +92,17 @@ export default function DownloadPage() {
|
|||||||
loadTransfer();
|
loadTransfer();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleDecrypt = async () => {
|
const downloadFile = async (file: TransferFile) => {
|
||||||
try {
|
setDownloadingFiles((prev) => new Set(prev).add(file.id));
|
||||||
setDownloading(true);
|
setStatus(`Downloading ${file.name}...`);
|
||||||
setDownloadingAll(true);
|
|
||||||
setStatus('Downloading and decrypting file...');
|
|
||||||
|
|
||||||
|
try {
|
||||||
// POST so the password stays out of the URL (and therefore out of access
|
// POST so the password stays out of the URL (and therefore out of access
|
||||||
// logs and Referer headers).
|
// logs and Referer headers).
|
||||||
const res = await fetch(`/api/file/${encodeURIComponent(id)}`, {
|
const res = await fetch(`/api/file/${encodeURIComponent(id)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password, fileId: file.id }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -101,22 +110,42 @@ export default function DownloadPage() {
|
|||||||
throw new Error(err?.error || 'File not found or invalid password.');
|
throw new Error(err?.error || 'File not found or invalid password.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedBlob = await res.blob();
|
// Streams straight to disk where supported, so a multi-GB file is never
|
||||||
const url = URL.createObjectURL(decryptedBlob);
|
// held in browser memory.
|
||||||
const a = document.createElement('a');
|
await saveResponseToDisk(res, file.name.replace(/\.enc$/, '') || 'file');
|
||||||
a.href = url;
|
|
||||||
a.download = transfer.files?.[0]?.name?.replace(/\.enc$/, '') || 'file';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
setStatus('Download complete!');
|
setDownloadedFiles((prev) => new Set(prev).add(file.id));
|
||||||
} catch (err: any) {
|
setStatus('');
|
||||||
|
toast.success(`${file.name} downloaded`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof DownloadCancelled) {
|
||||||
|
setStatus('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error(err);
|
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 {
|
} finally {
|
||||||
setDownloading(false);
|
|
||||||
setDownloadingAll(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 isFileDownloading = (fileId: number) => downloadingFiles.has(fileId);
|
||||||
const isFileDownloaded = (fileId: string) => downloadedFiles.has(fileId);
|
const isFileDownloaded = (fileId: number) => downloadedFiles.has(fileId);
|
||||||
|
|
||||||
if (!transfer) {
|
if (!transfer) {
|
||||||
return (
|
return (
|
||||||
@@ -362,7 +391,7 @@ export default function DownloadPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<CardTitle className="text-xl mb-2 text-white">
|
<CardTitle className="text-xl mb-2 text-white">
|
||||||
Transfer from {transfer.senderName}
|
Transfer from {transfer.senderEmail}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-base">
|
<CardDescription className="text-base">
|
||||||
<div className="flex items-center gap-2 mb-1 text-gray-400">
|
<div className="flex items-center gap-2 mb-1 text-gray-400">
|
||||||
@@ -398,7 +427,10 @@ export default function DownloadPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-400">
|
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-400">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<File className="h-4 w-4" />
|
<File className="h-4 w-4" />
|
||||||
<span>{transfer.files.length} files</span>
|
<span>
|
||||||
|
{transfer.files?.length ?? 0}{' '}
|
||||||
|
{transfer.files?.length === 1 ? 'file' : 'files'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
@@ -406,7 +438,9 @@ export default function DownloadPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span>Expires {transfer.expiresAt}</span>
|
<span>
|
||||||
|
Expires {formatTimeRemaining(new Date(transfer.expiresAt))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -441,18 +475,51 @@ export default function DownloadPage() {
|
|||||||
<h2 className="text-lg font-semibold text-white mb-4">
|
<h2 className="text-lg font-semibold text-white mb-4">
|
||||||
Files in this transfer
|
Files in this transfer
|
||||||
</h2>
|
</h2>
|
||||||
{transfer.filenames?.map((name: string, index: number) => (
|
{transfer.files?.map((file) => (
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
key={file.id}
|
||||||
className="border-gray-800 bg-gray-900/60 backdrop-blur-xl hover:bg-gray-900/80 transition-all duration-200"
|
className="border-gray-800 bg-gray-900/60 backdrop-blur-xl hover:bg-gray-900/80 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<CardContent>
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
<h3 className="font-medium text-white truncate">{name}</h3>
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-medium text-white truncate">{file.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => downloadFile(file)}
|
||||||
|
disabled={isFileDownloading(file.id) || downloadingAll}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{isFileDownloading(file.id) ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-current border-t-transparent mr-2" />
|
||||||
|
Downloading
|
||||||
|
</>
|
||||||
|
) : isFileDownloaded(file.id) ? (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download again
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<p className="mt-4 text-sm text-gray-400 text-center">{status}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-12 text-center">
|
<div className="mt-12 text-center">
|
||||||
<div className="inline-flex items-center gap-2 text-sm text-gray-500 mb-4">
|
<div className="inline-flex items-center gap-2 text-sm text-gray-500 mb-4">
|
||||||
|
|||||||
+9
-14
@@ -8,17 +8,12 @@ import { HeroSection } from '@/components/hero-section';
|
|||||||
import { Stats } from '@/components/stats';
|
import { Stats } from '@/components/stats';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Shield,
|
Shield,
|
||||||
Zap,
|
Zap,
|
||||||
Globe,
|
Mail,
|
||||||
Mail,
|
Star
|
||||||
Lock,
|
|
||||||
Clock,
|
|
||||||
Users,
|
|
||||||
Star,
|
|
||||||
CheckCircle
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
@@ -186,7 +181,7 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
"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.”
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center text-white font-semibold">
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||||
@@ -208,7 +203,7 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
"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.”
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center text-white font-semibold">
|
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||||
@@ -230,7 +225,7 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
"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.”
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-600 rounded-full flex items-center justify-center text-white font-semibold">
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||||
|
|||||||
@@ -12,12 +12,9 @@ import {
|
|||||||
Crown,
|
Crown,
|
||||||
Gift,
|
Gift,
|
||||||
Upload,
|
Upload,
|
||||||
Clock,
|
|
||||||
Shield,
|
Shield,
|
||||||
Users,
|
Users,
|
||||||
Mail,
|
Mail
|
||||||
Settings,
|
|
||||||
Infinity
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface PricingTier {
|
interface PricingTier {
|
||||||
@@ -25,7 +22,7 @@ interface PricingTier {
|
|||||||
price: string;
|
price: string;
|
||||||
period: string;
|
period: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: React.ComponentType<any>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
popular?: boolean;
|
popular?: boolean;
|
||||||
features: {
|
features: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -147,7 +144,7 @@ export default function PricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-7xl mx-auto">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-7xl mx-auto">
|
||||||
{tiers.map((tier, index) => {
|
{tiers.map((tier) => {
|
||||||
const IconComponent = tier.icon;
|
const IconComponent = tier.icon;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -198,7 +195,7 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||||
What's included:
|
What's included:
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{tier.features.map((feature, featureIndex) => (
|
{tier.features.map((feature, featureIndex) => (
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Header } from '@/components/header';
|
import { Header } from '@/components/header';
|
||||||
import SendPage from '@/components/send-transfer-server-encrypted';
|
import SendPage from '@/components/send-transfer-server-encrypted';
|
||||||
import { MyTransfers } from '@/components/my-transfers';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import {
|
import {
|
||||||
Upload,
|
|
||||||
Send,
|
Send,
|
||||||
History,
|
History,
|
||||||
Files,
|
Files,
|
||||||
TrendingUp,
|
|
||||||
Download,
|
Download,
|
||||||
Clock,
|
Clock,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -23,14 +21,9 @@ export default function DashboardPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<'send' | 'transfers' | 'files'>(
|
const [activeTab, setActiveTab] = useState<'send' | 'transfers' | 'files'>(
|
||||||
'send'
|
'send'
|
||||||
);
|
);
|
||||||
const [userFiles, setUserFiles] = useState<any[]>([]);
|
const { data: session } = useSession();
|
||||||
const { data: session, status } = useSession();
|
|
||||||
const userName = session?.user?.name || 'friend';
|
const userName = session?.user?.name || 'friend';
|
||||||
|
|
||||||
const handleFileUpload = (files: any[]) => {
|
|
||||||
setUserFiles((prev) => [...prev, ...files]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
transfersSent: 0,
|
transfersSent: 0,
|
||||||
downloads: 0,
|
downloads: 0,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
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() {
|
export function HeroSection() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
Trash2,
|
Trash2,
|
||||||
File,
|
File,
|
||||||
Mail,
|
|
||||||
Clock,
|
Clock,
|
||||||
Users,
|
Users,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
@@ -64,7 +63,7 @@ export function MyTransfers() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchTransfers = async () => {
|
const fetchTransfers = useCallback(async () => {
|
||||||
if (!session?.user?.email) return;
|
if (!session?.user?.email) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -78,15 +77,16 @@ export function MyTransfers() {
|
|||||||
toast.error('Failed to fetch transfers');
|
toast.error('Failed to fetch transfers');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
toast.error('Failed to fetch transfers');
|
toast.error('Failed to fetch transfers');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [session?.user?.email]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTransfers();
|
fetchTransfers();
|
||||||
}, [session]);
|
}, [fetchTransfers]);
|
||||||
|
|
||||||
const handleResend = async (transferId: number) => {
|
const handleResend = async (transferId: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +100,7 @@ export function MyTransfers() {
|
|||||||
toast.error('Failed to resend transfer');
|
toast.error('Failed to resend transfer');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
toast.error('Failed to resend transfer');
|
toast.error('Failed to resend transfer');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -117,6 +118,7 @@ export function MyTransfers() {
|
|||||||
toast.error('Failed to delete transfer');
|
toast.error('Failed to delete transfer');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
toast.error('Failed to delete transfer');
|
toast.error('Failed to delete transfer');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -184,7 +186,7 @@ const copyShareLink = (transferId: number) => {
|
|||||||
No transfers found
|
No transfers found
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-slate-500 dark:text-slate-400">
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
You haven't sent any transfers yet
|
You haven't sent any transfers yet
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -273,7 +275,7 @@ const copyShareLink = (transferId: number) => {
|
|||||||
{transfer.message && (
|
{transfer.message && (
|
||||||
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-3 mb-4">
|
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-3 mb-4">
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
"{transfer.message}"
|
“{transfer.message}”
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -307,7 +309,7 @@ const copyShareLink = (transferId: number) => {
|
|||||||
{filteredTransfers.length === 0 && searchTerm && transfers.length > 0 && (
|
{filteredTransfers.length === 0 && searchTerm && transfers.length > 0 && (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-slate-500 dark:text-slate-400">
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
No transfers found matching "{searchTerm}"
|
No transfers found matching “{searchTerm}”
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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<File[]>([]);
|
|
||||||
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<HTMLDivElement>(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<HTMLInputElement>) => {
|
|
||||||
if (!e.target.files) return;
|
|
||||||
handleFileAdd(Array.from(e.target.files));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
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<HTMLInputElement>(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 (
|
|
||||||
<div className="w-full p-4 bg-slate-100 dark:bg-slate-800 rounded-lg shadow-md flex flex-col space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{showSpinner && (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
|
||||||
)}
|
|
||||||
<p
|
|
||||||
className={`text-sm font-medium ${
|
|
||||||
isError ? 'text-red-600' : 'text-slate-800 dark:text-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step}{currentChunk && totalChunks ? ` (${currentChunk}/${totalChunks})` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!isError && step === 'Uploading' && (
|
|
||||||
<>
|
|
||||||
<Progress value={progress} className="h-2" />
|
|
||||||
<p className="text-xs text-red-600 font-medium">
|
|
||||||
⚠️ Please do not close this page during upload!
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* File Upload Section */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
|
||||||
Upload Files
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
ref={dropRef}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center space-y-4">
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
|
|
||||||
<Upload className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
Drag & drop files here
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
or click to select files
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
onClick={triggerFileInput}
|
|
||||||
>
|
|
||||||
Select Files
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Files List */}
|
|
||||||
{files.length > 0 && (
|
|
||||||
<div className="mt-6 space-y-2">
|
|
||||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
Selected Files ({files.length})
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
|
||||||
{files.map((file, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
<File className="w-4 h-4 flex-shrink-0 text-slate-500" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
|
||||||
{file.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => removeFile(i)}
|
|
||||||
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-800 rounded"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4 text-slate-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Size Info */}
|
|
||||||
<div className="mt-4 text-sm text-slate-600 dark:text-slate-400">
|
|
||||||
<p>Total Size: {formatFileSize(totalSize)}</p>
|
|
||||||
{remainingMB !== Infinity && (
|
|
||||||
<p>Remaining: {remainingMB.toFixed(2)} MB</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transfer Details Section */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
|
||||||
Transfer Details
|
|
||||||
</h3>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="recipient" className="text-slate-700 dark:text-slate-300">
|
|
||||||
Recipient Email
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="recipient"
|
|
||||||
type="email"
|
|
||||||
value={recipient}
|
|
||||||
onChange={(e) => setRecipient(e.target.value)}
|
|
||||||
placeholder="recipient@example.com"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="password" className="text-slate-700 dark:text-slate-300">
|
|
||||||
Encryption Password
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Create a strong password"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="message" className="text-slate-700 dark:text-slate-300">
|
|
||||||
Message (optional)
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="message"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
placeholder="Add a message to your recipient..."
|
|
||||||
className="mt-1 resize-none"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOverLimit && (
|
|
||||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-700 dark:text-red-400 text-sm">
|
|
||||||
⚠️ File size exceeds your plan limits
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!files.length || isOverLimit || uploadStep !== ''}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Send className="w-4 h-4 mr-2" />
|
|
||||||
Send Transfer
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{uploadMessage && (
|
|
||||||
<UploadStatusToast
|
|
||||||
step={uploadStep}
|
|
||||||
progress={uploadProgress}
|
|
||||||
message={uploadMessage}
|
|
||||||
currentChunk={currentChunk}
|
|
||||||
totalChunks={totalChunks}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,6 @@ export default function SendPage() {
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [plan, setPlan] = useState<'free' | 'rookie' | 'pro'>('free');
|
const [plan, setPlan] = useState<'free' | 'rookie' | 'pro'>('free');
|
||||||
const [remainingMB, setRemainingMB] = useState(Infinity);
|
const [remainingMB, setRemainingMB] = useState(Infinity);
|
||||||
const [usedMB, setUsedMB] = useState(0);
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploadStep, setUploadStep] = useState('');
|
const [uploadStep, setUploadStep] = useState('');
|
||||||
const [uploadMessage, setUploadMessage] = useState('');
|
const [uploadMessage, setUploadMessage] = useState('');
|
||||||
@@ -60,7 +59,6 @@ export default function SendPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPlan(data.plan);
|
setPlan(data.plan);
|
||||||
setRemainingMB(data.remainingMB);
|
setRemainingMB(data.remainingMB);
|
||||||
setUsedMB(data.usedMB);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (session?.user?.email) fetchPlan();
|
if (session?.user?.email) fetchPlan();
|
||||||
@@ -111,10 +109,11 @@ export default function SendPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setUploadStep('Preparing files');
|
setUploadStep('Preparing files');
|
||||||
const file = files[0]; // Upload first file for now
|
|
||||||
|
|
||||||
// Calculate chunks from raw file (no encryption)
|
// Chunk counts for every selected file, so progress spans the whole
|
||||||
const totalChunksCount = Math.ceil(file.size / CHUNK_SIZE);
|
// transfer rather than just the first file.
|
||||||
|
const perFileChunks = files.map((f) => Math.max(1, Math.ceil(f.size / CHUNK_SIZE)));
|
||||||
|
const totalChunksCount = perFileChunks.reduce((a, b) => a + b, 0);
|
||||||
setTotalChunks(totalChunksCount);
|
setTotalChunks(totalChunksCount);
|
||||||
|
|
||||||
// Generate upload ID
|
// Generate upload ID
|
||||||
@@ -122,44 +121,48 @@ export default function SendPage() {
|
|||||||
|
|
||||||
// Upload raw chunks - server will encrypt each chunk
|
// Upload raw chunks - server will encrypt each chunk
|
||||||
setUploadStep('Uploading');
|
setUploadStep('Uploading');
|
||||||
for (let i = 0; i < totalChunksCount; i++) {
|
let uploadedChunks = 0;
|
||||||
setCurrentChunk(i + 1);
|
|
||||||
|
|
||||||
const start = i * CHUNK_SIZE;
|
|
||||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
|
||||||
const chunkFile = file.slice(start, end);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
|
||||||
formData.append('chunk', chunkFile);
|
const file = files[fileIndex];
|
||||||
formData.append('uploadId', uploadId);
|
const fileChunks = perFileChunks[fileIndex];
|
||||||
formData.append('chunkIndex', i.toString());
|
|
||||||
formData.append('totalChunks', totalChunksCount.toString());
|
for (let i = 0; i < fileChunks; i++) {
|
||||||
formData.append('chunkSize', CHUNK_SIZE.toString());
|
const start = i * CHUNK_SIZE;
|
||||||
|
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||||
// Only send metadata on first chunk
|
const chunkFile = file.slice(start, end);
|
||||||
if (i === 0) {
|
|
||||||
formData.append('email', recipient);
|
const formData = new FormData();
|
||||||
formData.append('password', password);
|
formData.append('chunk', chunkFile);
|
||||||
formData.append('sender', senderEmail);
|
formData.append('uploadId', uploadId);
|
||||||
formData.append('originalFilename', file.name);
|
formData.append('fileIndex', fileIndex.toString());
|
||||||
const filenames = files.map((f) => f.name);
|
formData.append('chunkIndex', i.toString());
|
||||||
formData.append('filenames', JSON.stringify(filenames));
|
formData.append('fileChunks', fileChunks.toString());
|
||||||
formData.append('message', message);
|
formData.append('fileCount', files.length.toString());
|
||||||
|
formData.append('fileName', file.name);
|
||||||
|
|
||||||
|
// Transfer-wide metadata travels with the very first chunk only.
|
||||||
|
if (fileIndex === 0 && i === 0) {
|
||||||
|
formData.append('email', recipient);
|
||||||
|
formData.append('password', password);
|
||||||
|
formData.append('filenames', JSON.stringify(files.map((f) => f.name)));
|
||||||
|
formData.append('message', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadRes = await fetch('/api/chunk-upload-v2', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
const error = await uploadRes.json().catch(() => ({}));
|
||||||
|
throw new Error(error.message || `Chunk ${i} of ${file.name} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedChunks++;
|
||||||
|
setCurrentChunk(uploadedChunks);
|
||||||
|
setUploadProgress(Math.round((uploadedChunks / totalChunksCount) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadRes = await fetch('/api/chunk-upload-v2', {
|
|
||||||
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
|
// Finalize upload
|
||||||
@@ -169,24 +172,23 @@ export default function SendPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!finalRes.ok) {
|
if (!finalRes.ok) {
|
||||||
const error = await finalRes.json();
|
const error = await finalRes.json().catch(() => ({}));
|
||||||
throw new Error(error.message || 'Failed to finalize upload');
|
throw new Error(error.message || 'Failed to finalize upload');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await finalRes.json();
|
|
||||||
|
|
||||||
setUploadStep('');
|
setUploadStep('');
|
||||||
setUploadMessage('Success! 🎉');
|
setUploadMessage('Success! 🎉');
|
||||||
toast.success('Your transfer has been sent successfully!');
|
toast.success('Your transfer has been sent successfully!');
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setRecipient('');
|
setRecipient('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
|
setMessage('');
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
setTimeout(() => setUploadMessage(''), 3000);
|
setTimeout(() => setUploadMessage(''), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setUploadStep('');
|
setUploadStep('');
|
||||||
setUploadMessage('Error: ' + err.message);
|
setUploadMessage('Error: ' + (err instanceof Error ? err.message : String(err)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,425 +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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SendPage() {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
|
||||||
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 dropRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
|
||||||
if (!e.target.files) return;
|
|
||||||
handleFileAdd(Array.from(e.target.files));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
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<HTMLInputElement>(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);
|
|
||||||
|
|
||||||
// Extract IV from encrypted blob (first 12 bytes)
|
|
||||||
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
|
||||||
const iv = encryptedBytes.slice(0, 12);
|
|
||||||
const ivBase64 = btoa(String.fromCharCode(...iv));
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', encryptedBlob, file.name + '.enc');
|
|
||||||
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);
|
|
||||||
|
|
||||||
setUploadProgress(0);
|
|
||||||
setUploadStep('Uploading');
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', '/api/send');
|
|
||||||
xhr.upload.onprogress = (event) => {
|
|
||||||
if (event.lengthComputable) {
|
|
||||||
const percent = Math.round((event.loaded / event.total) * 100);
|
|
||||||
setUploadProgress(percent);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
setUploadStep('');
|
|
||||||
setUploadMessage('Success! 🎉');
|
|
||||||
toast.success('Your transfer has been sent successfully!');
|
|
||||||
setFiles([]);
|
|
||||||
setRecipient('');
|
|
||||||
setPassword('');
|
|
||||||
setUploadProgress(100);
|
|
||||||
setTimeout(() => setUploadMessage(''), 3000);
|
|
||||||
} else {
|
|
||||||
setUploadStep('');
|
|
||||||
setUploadMessage(`Upload failed: ${xhr.statusText}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = () => {
|
|
||||||
setUploadStep('');
|
|
||||||
setUploadMessage('Upload error occurred');
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.send(formData);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
setUploadStep('');
|
|
||||||
setUploadMessage('Error: ' + err.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function UploadStatusToast({
|
|
||||||
step,
|
|
||||||
progress,
|
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
step: string;
|
|
||||||
progress: number;
|
|
||||||
message: string;
|
|
||||||
}) {
|
|
||||||
const showSpinner = ['Zipping', 'Encrypting', 'Uploading'].includes(step);
|
|
||||||
const isError =
|
|
||||||
message.toLowerCase().includes('error') || message.includes('fail');
|
|
||||||
|
|
||||||
if (!step && !message) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full p-4 bg-slate-100 dark:bg-slate-800 rounded-lg shadow-md flex flex-col space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{showSpinner && (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
|
||||||
)}
|
|
||||||
<p
|
|
||||||
className={`text-sm font-medium ${
|
|
||||||
isError ? 'text-red-600' : 'text-slate-800 dark:text-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step || message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!isError && step === 'Uploading' && (
|
|
||||||
<>
|
|
||||||
<Progress value={progress} className="h-2" />
|
|
||||||
<p className="text-xs text-red-600 font-medium">
|
|
||||||
⚠️ Please do not close this page during upload!
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* File Upload Section */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
|
||||||
Upload Files
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
ref={dropRef}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center space-y-4">
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
|
|
||||||
<Upload className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
Drag & drop files here
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
or click to select files (max 2GB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
onClick={triggerFileInput}
|
|
||||||
>
|
|
||||||
Select Files
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
id="file-upload"
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{files.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
Files ({files.length})
|
|
||||||
</h4>
|
|
||||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
<span>Total: {formatFileSize(totalSize)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
|
||||||
{files.map((file, idx) => (
|
|
||||||
<Card
|
|
||||||
key={idx}
|
|
||||||
className="bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm border-white/20 dark:border-slate-700/20"
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
|
||||||
<div className="w-10 h-10 bg-slate-100 dark:bg-slate-700 rounded-lg flex items-center justify-center">
|
|
||||||
<File className="w-5 h-5 text-slate-600 dark:text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
|
||||||
{file.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeFile(idx)}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Progress
|
|
||||||
value={
|
|
||||||
plan === 'free'
|
|
||||||
? Math.min(
|
|
||||||
(totalSizeMB / PLAN_LIMITS.free.maxFileSizeMB) *
|
|
||||||
100,
|
|
||||||
100
|
|
||||||
)
|
|
||||||
: Math.min(
|
|
||||||
((usedMB + totalSizeMB) /
|
|
||||||
PLAN_LIMITS[plan].maxMonthlyTransferSizeMB) *
|
|
||||||
100,
|
|
||||||
100
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
isOverLimit ? 'progress-error' : 'progress-success'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
className={`text-xs mt-1 ${
|
|
||||||
isOverLimit
|
|
||||||
? 'text-red-600 font-semibold'
|
|
||||||
: 'text-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Plan: <strong>{plan}</strong> — Total:{' '}
|
|
||||||
{totalSizeMB.toFixed(2)} MB /{' '}
|
|
||||||
{plan === 'free'
|
|
||||||
? `${PLAN_LIMITS.free.maxFileSizeMB} MB max per transfer`
|
|
||||||
: `${remainingMB.toFixed(2)} MB remaining this month`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Transfer Details Section */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
Transfer Details
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="recipientEmail">Recipient Email *</Label>
|
|
||||||
<Input
|
|
||||||
id="recipient"
|
|
||||||
type="email"
|
|
||||||
placeholder="recipient@example.com"
|
|
||||||
value={recipient}
|
|
||||||
onChange={(e) => setRecipient(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="message">Message (Optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="message"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
placeholder="Add a personal message..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="message">Password *</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter a secure password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
|
||||||
Transfer Summary
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-1 text-sm text-slate-600 dark:text-slate-400">
|
|
||||||
<p>Total Size: {formatFileSize(totalSize)}</p>
|
|
||||||
<p>Expires: 48 hours after sending</p>
|
|
||||||
<p>Security: Password protected</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4">
|
|
||||||
<UploadStatusToast
|
|
||||||
step={uploadStep}
|
|
||||||
progress={uploadProgress}
|
|
||||||
message={uploadMessage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isOverLimit}
|
|
||||||
className="w-full bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<Send className="w-4 h-4 mr-2" />
|
|
||||||
Send Transfer
|
|
||||||
</>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
|
||||||
ref
|
|
||||||
) => (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 bg-border',
|
|
||||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
|
||||||
|
|
||||||
export { Separator };
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Reclaims disk for transfers that are past their expiry or soft-deleted.
|
||||||
|
//
|
||||||
|
// Nothing previously removed payloads from UPLOAD_DIR, so every transfer ever
|
||||||
|
// sent stayed on disk indefinitely regardless of expiresAt.
|
||||||
|
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { UPLOAD_DIR } from '@/lib/config'
|
||||||
|
import { TransferStatus } from '@prisma/client'
|
||||||
|
|
||||||
|
export interface CleanupReport {
|
||||||
|
markedExpired: number
|
||||||
|
filesDeleted: number
|
||||||
|
bytesReclaimed: number
|
||||||
|
staleTempDirs: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abandoned chunk directories older than this are removed. */
|
||||||
|
const TEMP_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse to unlink anything that does not live under UPLOAD_DIR. Paths come
|
||||||
|
* from the database, so this is defence against a corrupted or tampered row
|
||||||
|
* turning cleanup into arbitrary file deletion.
|
||||||
|
*/
|
||||||
|
function isInsideUploadDir(target: string): boolean {
|
||||||
|
const relative = path.relative(UPLOAD_DIR, path.resolve(target))
|
||||||
|
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCleanup(): Promise<CleanupReport> {
|
||||||
|
const report: CleanupReport = {
|
||||||
|
markedExpired: 0,
|
||||||
|
filesDeleted: 0,
|
||||||
|
bytesReclaimed: 0,
|
||||||
|
staleTempDirs: 0,
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// 1. Flip lapsed ACTIVE transfers to EXPIRED.
|
||||||
|
const marked = await prisma.transfer.updateMany({
|
||||||
|
where: { status: TransferStatus.ACTIVE, expiresAt: { lte: now } },
|
||||||
|
data: { status: TransferStatus.EXPIRED },
|
||||||
|
})
|
||||||
|
report.markedExpired = marked.count
|
||||||
|
|
||||||
|
// 2. Delete payloads for transfers that are no longer downloadable. The rows
|
||||||
|
// are kept so senders retain their history; only the bytes go.
|
||||||
|
const reclaimable = await prisma.transfer.findMany({
|
||||||
|
where: { status: { in: [TransferStatus.EXPIRED, TransferStatus.DELETED] } },
|
||||||
|
include: { files: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const transfer of reclaimable) {
|
||||||
|
for (const file of transfer.files) {
|
||||||
|
if (!file.path) continue
|
||||||
|
|
||||||
|
if (!isInsideUploadDir(file.path)) {
|
||||||
|
report.errors.push(`Refusing to delete path outside upload dir: ${file.path}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(file.path)
|
||||||
|
await fs.unlink(file.path)
|
||||||
|
report.filesDeleted += 1
|
||||||
|
report.bytesReclaimed += stat.size
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = (err as NodeJS.ErrnoException)?.code
|
||||||
|
// Already gone is the expected steady state on repeat runs.
|
||||||
|
if (code !== 'ENOENT') {
|
||||||
|
report.errors.push(`${file.path}: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Drop abandoned chunk directories from uploads that never finished.
|
||||||
|
const tempRoot = path.join(UPLOAD_DIR, '.tmp')
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(tempRoot, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
const dir = path.join(tempRoot, entry.name)
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(dir)
|
||||||
|
if (now.getTime() - stat.mtimeMs > TEMP_DIR_MAX_AGE_MS) {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true })
|
||||||
|
report.staleTempDirs += 1
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
report.errors.push(`${dir}: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||||
|
report.errors.push(`temp sweep: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Client-side download helper.
|
||||||
|
//
|
||||||
|
// The naive approach — `await res.blob()` then createObjectURL — buffers the
|
||||||
|
// entire decrypted file in memory before anything is written. That is fine for
|
||||||
|
// small transfers and untenable for multi-gigabyte ones, which is exactly what
|
||||||
|
// this app exists to move.
|
||||||
|
//
|
||||||
|
// Where the File System Access API is available (Chromium), the response body
|
||||||
|
// is piped straight to a user-chosen file and peak memory stays at roughly one
|
||||||
|
// chunk. Everywhere else we fall back to the blob path.
|
||||||
|
|
||||||
|
interface SaveFilePickerOptions {
|
||||||
|
suggestedName?: string;
|
||||||
|
types?: { description: string; accept: Record<string, string[]> }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemWritable {
|
||||||
|
write(data: BufferSource | Blob | string): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
abort?(reason?: unknown): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemFileHandleLike {
|
||||||
|
createWritable(): Promise<FileSystemWritable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PickerWindow = Window & {
|
||||||
|
showSaveFilePicker?: (
|
||||||
|
options?: SaveFilePickerOptions
|
||||||
|
) => Promise<FileSystemFileHandleLike>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function supportsStreamingDownload(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof (window as PickerWindow).showSaveFilePicker === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when the user dismisses the save dialog. */
|
||||||
|
export class DownloadCancelled extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Download cancelled');
|
||||||
|
this.name = 'DownloadCancelled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write `response` to disk, streaming when the browser allows it.
|
||||||
|
* `onProgress` receives bytes written so far.
|
||||||
|
*/
|
||||||
|
export async function saveResponseToDisk(
|
||||||
|
response: Response,
|
||||||
|
filename: string,
|
||||||
|
onProgress?: (bytesWritten: number) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const picker = (window as PickerWindow).showSaveFilePicker;
|
||||||
|
|
||||||
|
if (picker && response.body) {
|
||||||
|
let handle: FileSystemFileHandleLike;
|
||||||
|
try {
|
||||||
|
handle = await picker({ suggestedName: filename });
|
||||||
|
} catch (err) {
|
||||||
|
// AbortError means the user closed the dialog; anything else is a real
|
||||||
|
// failure worth falling back for.
|
||||||
|
if ((err as DOMException)?.name === 'AbortError') {
|
||||||
|
throw new DownloadCancelled();
|
||||||
|
}
|
||||||
|
return saveViaBlob(response, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
let written = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
await writable.write(value);
|
||||||
|
written += value.byteLength;
|
||||||
|
onProgress?.(written);
|
||||||
|
}
|
||||||
|
await writable.close();
|
||||||
|
} catch (err) {
|
||||||
|
await writable.abort?.(err).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveViaBlob(response, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for browsers without the File System Access API. Buffers the whole
|
||||||
|
* body in memory, so very large transfers may fail here.
|
||||||
|
*/
|
||||||
|
async function saveViaBlob(response: Response, filename: string): Promise<void> {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
} finally {
|
||||||
|
// Give the browser a tick to start the download before revoking.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
export async function encryptFile(file: File, password: string) {
|
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
||||||
|
|
||||||
const keyMaterial = await getKeyMaterial(password)
|
|
||||||
const key = await deriveKey(keyMaterial, salt)
|
|
||||||
|
|
||||||
const fileBuffer = await file.arrayBuffer()
|
|
||||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, fileBuffer)
|
|
||||||
|
|
||||||
const encryptedBytes = new Uint8Array(encrypted)
|
|
||||||
|
|
||||||
// Combine salt + iv + encrypted data
|
|
||||||
const combined = new Uint8Array(salt.length + iv.length + encryptedBytes.length)
|
|
||||||
combined.set(salt, 0)
|
|
||||||
combined.set(iv, salt.length)
|
|
||||||
combined.set(encryptedBytes, salt.length + iv.length)
|
|
||||||
|
|
||||||
return {
|
|
||||||
encryptedBlob: new Blob([combined], { type: 'application/octet-stream' }),
|
|
||||||
metadata: {
|
|
||||||
salt: Array.from(salt),
|
|
||||||
iv: Array.from(iv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getKeyMaterial(password: string) {
|
|
||||||
const enc = new TextEncoder()
|
|
||||||
return crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
enc.encode(password),
|
|
||||||
{ name: 'PBKDF2' },
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// export async function decryptBlob(blob: Blob, password: string): Promise<Blob> {
|
|
||||||
// const combined = new Uint8Array(await blob.arrayBuffer())
|
|
||||||
|
|
||||||
// const salt = combined.slice(0, 16)
|
|
||||||
// const iv = combined.slice(16, 28)
|
|
||||||
// const data = combined.slice(28)
|
|
||||||
|
|
||||||
// const keyMaterial = await getKeyMaterial(password)
|
|
||||||
// const key = await deriveKey(keyMaterial, salt)
|
|
||||||
|
|
||||||
// const decrypted = await crypto.subtle.decrypt(
|
|
||||||
// { name: 'AES-GCM', iv },
|
|
||||||
// key,
|
|
||||||
// data
|
|
||||||
// )
|
|
||||||
|
|
||||||
// return new Blob([decrypted])
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
async function deriveKey(keyMaterial: CryptoKey, salt: Uint8Array) {
|
|
||||||
return crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt,
|
|
||||||
iterations: 100000,
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
keyMaterial,
|
|
||||||
{ name: 'AES-GCM', length: 256 },
|
|
||||||
false,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function encryptBlob(blob: Blob, password: string) {
|
|
||||||
const pwUtf8 = new TextEncoder().encode(password)
|
|
||||||
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey('raw', pwHash, 'AES-GCM', false, ['encrypt'])
|
|
||||||
const content = new Uint8Array(await blob.arrayBuffer())
|
|
||||||
|
|
||||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, content)
|
|
||||||
const encryptedBlob = new Blob([iv, new Uint8Array(encrypted)])
|
|
||||||
|
|
||||||
return { encryptedBlob }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function decryptBlob(encryptedBlob: Blob, password: string) {
|
|
||||||
const data = new Uint8Array(await encryptedBlob.arrayBuffer())
|
|
||||||
const iv = data.slice(0, 12)
|
|
||||||
const encrypted = data.slice(12)
|
|
||||||
|
|
||||||
const pwUtf8 = new TextEncoder().encode(password)
|
|
||||||
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)
|
|
||||||
const key = await crypto.subtle.importKey('raw', pwHash, 'AES-GCM', false, ['decrypt'])
|
|
||||||
|
|
||||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted)
|
|
||||||
return new Blob([decrypted])
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Fixed-window rate limiter for password attempts.
|
||||||
|
//
|
||||||
|
// SCOPE: this is per-process, in-memory state. It is effective for a single
|
||||||
|
// server instance, which is what the local-disk storage model already assumes.
|
||||||
|
// Running several instances behind a load balancer would give each its own
|
||||||
|
// counter, multiplying the effective limit by the instance count — moving to a
|
||||||
|
// shared store (Redis) is a prerequisite for scaling out.
|
||||||
|
|
||||||
|
type Bucket = { count: number; resetAt: number }
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>()
|
||||||
|
|
||||||
|
// Bound the map so a flood of distinct keys cannot grow it without limit.
|
||||||
|
const MAX_KEYS = 10000
|
||||||
|
|
||||||
|
function sweep(now: number) {
|
||||||
|
for (const [key, bucket] of buckets) {
|
||||||
|
if (bucket.resetAt <= now) buckets.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimitResult {
|
||||||
|
allowed: boolean
|
||||||
|
remaining: number
|
||||||
|
/** Seconds until the window resets. Suitable for a Retry-After header. */
|
||||||
|
retryAfter: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume one unit against `key`. Returns whether the caller is under the limit.
|
||||||
|
*/
|
||||||
|
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||||
|
const now = Date.now()
|
||||||
|
const existing = buckets.get(key)
|
||||||
|
|
||||||
|
if (!existing || existing.resetAt <= now) {
|
||||||
|
if (buckets.size >= MAX_KEYS) sweep(now)
|
||||||
|
const resetAt = now + windowMs
|
||||||
|
buckets.set(key, { count: 1, resetAt })
|
||||||
|
return { allowed: true, remaining: limit - 1, retryAfter: Math.ceil(windowMs / 1000) }
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.count += 1
|
||||||
|
const retryAfter = Math.max(1, Math.ceil((existing.resetAt - now) / 1000))
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: existing.count <= limit,
|
||||||
|
remaining: Math.max(0, limit - existing.count),
|
||||||
|
retryAfter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the counter for a key — call after a successful authentication so a
|
||||||
|
* legitimate user who mistyped a few times is not left throttled.
|
||||||
|
*/
|
||||||
|
export function resetRateLimit(key: string) {
|
||||||
|
buckets.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort client identifier. x-forwarded-for is only trustworthy behind a
|
||||||
|
* proxy that overwrites it; treat this as a speed bump, not an identity.
|
||||||
|
*/
|
||||||
|
export function clientKey(headers: Headers): string {
|
||||||
|
const forwarded = headers.get('x-forwarded-for')
|
||||||
|
if (forwarded) return forwarded.split(',')[0]!.trim()
|
||||||
|
return headers.get('x-real-ip') || 'unknown'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user