2e688c8e52
The application source was untracked, so this commit brings it under version control together with fixes for the issues found while auditing it. Notable fixes: Authorization - Require a session and scope to senderEmail on /api/transfers/[id] (GET, DELETE) and .../resend. These were unauthenticated over an autoincrement id, so the ids could be walked to soft-delete any transfer, read sender/recipient metadata, or make the app mail arbitrary recipients. - Require a session on the legacy /api/send and /api/chunk-upload endpoints, and take the sender from the session rather than a request field so transfers cannot be posted as another user. Encryption - Replace the chunk encryption scheme. Every chunk was encrypted under one shared session IV with its auth tag discarded, which reuses the AES-GCM keystream (XORing two ciphertexts recovers plaintext without the key) and left the stored file undecryptable, surfacing to users as a wrong-password error. Chunks are now self-contained frames carrying their own random IV and auth tag, behind a magic+salt header. - Files written by the previous format now report UNSUPPORTED_FORMAT instead of a misleading password error. Download - Verify the password against the stored bcrypt hash before serving a file, and enforce expiresAt and DELETED/EXPIRED status. - Move the password from the query string into a POST body so it stays out of access logs and Referer headers. - Record a download only after successful authentication. - Decrypt frame by frame through a stream instead of buffering the whole file, and encode the Content-Disposition filename per RFC 5987. Data exposure - /api/download ran before the password prompt and returned the full transfer row, including absolute server file paths. It now returns only what the pre-password screen renders; filenames, message and recipient are withheld until /api/verify succeeds. Correctness - Fix BigInt handling that made /api/transfers and /api/transfers/[id] fail unconditionally (JSON.stringify cannot serialize BigInt, and seeding a BigInt reduce with 0 throws). - Fail loudly on a missing chunk during reassembly rather than silently writing a corrupt file. - Meter plan usage in plaintext bytes rather than on-disk encrypted size. Ignore /uploads: it holds runtime transfer payloads, not source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
'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>
|
||
);
|
||
}
|