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>
209 lines
9.1 KiB
TypeScript
209 lines
9.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Moon, Sun, Menu, X, Upload } from 'lucide-react';
|
|
import { useTheme } from 'next-themes';
|
|
import { useSession, signOut } from 'next-auth/react';
|
|
import Link from 'next/link';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
|
|
export function Header() {
|
|
const { theme, setTheme } = useTheme();
|
|
const { data: session, status } = useSession();
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
|
|
const handleSignOut = () => {
|
|
signOut({ callbackUrl: '/' });
|
|
};
|
|
|
|
return (
|
|
<header className="sticky top-0 z-50 w-full border-b bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm border-slate-200 dark:border-slate-800">
|
|
<div className="container mx-auto px-4">
|
|
<div className="flex h-16 items-center justify-between">
|
|
<div className="flex items-center space-x-4">
|
|
<Link href={session ? "/send" : "/"} className="flex items-center space-x-2">
|
|
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
|
<Upload className="w-4 h-4 text-white" />
|
|
</div>
|
|
<span className="text-xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
|
Transfer Tribe
|
|
</span>
|
|
</Link>
|
|
</div>
|
|
|
|
<nav className="hidden md:flex items-center space-x-6">
|
|
{session ? (
|
|
<>
|
|
<Link href="/send" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
|
Dashboard
|
|
</Link>
|
|
<Link href="/pricing" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
|
Pricing
|
|
</Link>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link href="/" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
|
Home
|
|
</Link>
|
|
<Link href="/pricing" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
|
Pricing
|
|
</Link>
|
|
<a href="#features" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
|
Features
|
|
</a>
|
|
<a href="#about" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
|
About
|
|
</a>
|
|
</>
|
|
)}
|
|
</nav>
|
|
|
|
<div className="flex items-center space-x-4">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
className="w-9 h-9"
|
|
>
|
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
<span className="sr-only">Toggle theme</span>
|
|
</Button>
|
|
|
|
{status === 'loading' ? (
|
|
<div className="w-8 h-8 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse" />
|
|
) : session ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={session.user?.image || ''} alt={session.user?.name || ''} />
|
|
<AvatarFallback>
|
|
{session.user?.name?.charAt(0) || session.user?.email?.charAt(0) || 'U'}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
|
<div className="flex items-center justify-start gap-2 p-2">
|
|
<div className="flex flex-col space-y-1 leading-none">
|
|
{session.user?.name && (
|
|
<p className="font-medium">{session.user.name}</p>
|
|
)}
|
|
{session.user?.email && (
|
|
<p className="w-[200px] truncate text-sm text-muted-foreground">
|
|
{session.user.email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/send">Dashboard</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/settings">Settings</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={handleSignOut}>
|
|
Sign out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
<>
|
|
<Link href="/auth/signin">
|
|
<Button variant="outline" size="sm" className="hidden sm:flex">
|
|
Sign In
|
|
</Button>
|
|
</Link>
|
|
|
|
<Link href="/auth/signup">
|
|
<Button size="sm" className="hidden sm:flex bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700">
|
|
Get Started
|
|
</Button>
|
|
</Link>
|
|
</>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="md:hidden"
|
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
>
|
|
{isMenuOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isMenuOpen && (
|
|
<div className="md:hidden py-4 border-t border-slate-200 dark:border-slate-800">
|
|
<nav className="flex flex-col space-y-4">
|
|
{session ? (
|
|
<>
|
|
<Link href="/send" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
|
Dashboard
|
|
</Link>
|
|
<Link href="/pricing" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
|
Pricing
|
|
</Link>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link href="/" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
|
Home
|
|
</Link>
|
|
<Link href="/pricing" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
|
Pricing
|
|
</Link>
|
|
<a href="#features" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
|
Features
|
|
</a>
|
|
<a href="#about" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
|
About
|
|
</a>
|
|
</>
|
|
)}
|
|
<div className="flex flex-col space-y-2 pt-4 border-t border-slate-200 dark:border-slate-800">
|
|
{session ? (
|
|
<>
|
|
<div className="px-2 py-1">
|
|
<p className="text-sm font-medium">{session.user?.name}</p>
|
|
<p className="text-xs text-slate-500">{session.user?.email}</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={handleSignOut}>
|
|
Sign Out
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link href="/auth/signin">
|
|
<Button variant="outline" size="sm" className="w-full">
|
|
Sign In
|
|
</Button>
|
|
</Link>
|
|
<Link href="/">
|
|
<Button size="sm" className="w-full bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700">
|
|
Get Started
|
|
</Button>
|
|
</Link>
|
|
</>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
);
|
|
} |