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:
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight, Shield, Zap, Globe, Mail, Lock, Clock } from 'lucide-react';
|
||||
import { ArrowRight, Mail, Lock, Clock } from 'lucide-react';
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Share2,
|
||||
Trash2,
|
||||
File,
|
||||
Mail,
|
||||
Clock,
|
||||
Users,
|
||||
MoreVertical,
|
||||
@@ -64,7 +63,7 @@ export function MyTransfers() {
|
||||
)
|
||||
);
|
||||
|
||||
const fetchTransfers = async () => {
|
||||
const fetchTransfers = useCallback(async () => {
|
||||
if (!session?.user?.email) return;
|
||||
|
||||
setIsLoading(true);
|
||||
@@ -78,15 +77,16 @@ export function MyTransfers() {
|
||||
toast.error('Failed to fetch transfers');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to fetch transfers');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [session?.user?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransfers();
|
||||
}, [session]);
|
||||
}, [fetchTransfers]);
|
||||
|
||||
const handleResend = async (transferId: number) => {
|
||||
try {
|
||||
@@ -100,6 +100,7 @@ export function MyTransfers() {
|
||||
toast.error('Failed to resend transfer');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to resend transfer');
|
||||
}
|
||||
};
|
||||
@@ -117,6 +118,7 @@ export function MyTransfers() {
|
||||
toast.error('Failed to delete transfer');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to delete transfer');
|
||||
}
|
||||
};
|
||||
@@ -184,7 +186,7 @@ const copyShareLink = (transferId: number) => {
|
||||
No transfers found
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
You haven't sent any transfers yet
|
||||
You haven't sent any transfers yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -273,7 +275,7 @@ const copyShareLink = (transferId: number) => {
|
||||
{transfer.message && (
|
||||
<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">
|
||||
"{transfer.message}"
|
||||
“{transfer.message}”
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -307,7 +309,7 @@ const copyShareLink = (transferId: number) => {
|
||||
{filteredTransfers.length === 0 && searchTerm && transfers.length > 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
No transfers found matching "{searchTerm}"
|
||||
No transfers found matching “{searchTerm}”
|
||||
</p>
|
||||
</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 [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('');
|
||||
@@ -60,7 +59,6 @@ export default function SendPage() {
|
||||
const data = await res.json();
|
||||
setPlan(data.plan);
|
||||
setRemainingMB(data.remainingMB);
|
||||
setUsedMB(data.usedMB);
|
||||
}
|
||||
};
|
||||
if (session?.user?.email) fetchPlan();
|
||||
@@ -111,10 +109,11 @@ export default function SendPage() {
|
||||
|
||||
try {
|
||||
setUploadStep('Preparing files');
|
||||
const file = files[0]; // Upload first file for now
|
||||
|
||||
// Calculate chunks from raw file (no encryption)
|
||||
const totalChunksCount = Math.ceil(file.size / CHUNK_SIZE);
|
||||
// Chunk counts for every selected file, so progress spans the whole
|
||||
// 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);
|
||||
|
||||
// Generate upload ID
|
||||
@@ -122,44 +121,48 @@ export default function SendPage() {
|
||||
|
||||
// Upload raw chunks - server will encrypt each chunk
|
||||
setUploadStep('Uploading');
|
||||
for (let i = 0; i < totalChunksCount; i++) {
|
||||
setCurrentChunk(i + 1);
|
||||
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunkFile = file.slice(start, end);
|
||||
let uploadedChunks = 0;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('chunk', chunkFile);
|
||||
formData.append('uploadId', uploadId);
|
||||
formData.append('chunkIndex', i.toString());
|
||||
formData.append('totalChunks', totalChunksCount.toString());
|
||||
formData.append('chunkSize', CHUNK_SIZE.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);
|
||||
const filenames = files.map((f) => f.name);
|
||||
formData.append('filenames', JSON.stringify(filenames));
|
||||
formData.append('message', message);
|
||||
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
|
||||
const file = files[fileIndex];
|
||||
const fileChunks = perFileChunks[fileIndex];
|
||||
|
||||
for (let i = 0; i < fileChunks; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunkFile = file.slice(start, end);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('chunk', chunkFile);
|
||||
formData.append('uploadId', uploadId);
|
||||
formData.append('fileIndex', fileIndex.toString());
|
||||
formData.append('chunkIndex', i.toString());
|
||||
formData.append('fileChunks', fileChunks.toString());
|
||||
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
|
||||
@@ -169,24 +172,23 @@ export default function SendPage() {
|
||||
});
|
||||
|
||||
if (!finalRes.ok) {
|
||||
const error = await finalRes.json();
|
||||
const error = await finalRes.json().catch(() => ({}));
|
||||
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('');
|
||||
setMessage('');
|
||||
setUploadProgress(100);
|
||||
setTimeout(() => setUploadMessage(''), 3000);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
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 };
|
||||
Reference in New Issue
Block a user