Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Create a `.env` file in the root of the project and set the following:
```env
DATABASE_URL=postgresql://admin:admin@localhost:5432/vaultz
UPLOADTHING_TOKEN=<your_uploadthing_token>
VAULTZ_ACCESS_CODE=<shared passphrase>
VAULTZ_ACCESS_SECRET=<random signing secret>
```

3. **Start the local database:**
Expand Down
44 changes: 44 additions & 0 deletions app/access/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { AccessForm } from '@/components/access-form';
import { Card, CardContent } from '@/components/ui/card';

function isSafeRedirectPath(path: string): boolean {
return path.startsWith('/') && !path.startsWith('//');
}

export default async function AccessPage({
searchParams,
}: {
searchParams: Promise<{ from?: string }>;
}) {
const { from } = await searchParams;
const redirectTo = from && isSafeRedirectPath(from) ? from : '/';

return (
<main className="flex min-h-screen w-full items-center justify-center px-4">
<div className="flex w-full max-w-sm flex-col items-center gap-6">
<div className="flex flex-col items-center gap-3">
<div className="bg-primary flex size-16 items-center justify-center rounded-2xl shadow-md">
<svg
className="size-9 text-white"
viewBox="0 0 348 287"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M332.486 -0.000976562C336.258 -0.000978208 338.671 4.01546 336.901 7.3457L217.45 232H342.677C346.449 232 348.863 236.017 347.091 239.348L323.157 284.348C322.289 285.98 320.591 287 318.743 287H159.744C159.687 287.001 159.631 287.003 159.574 287.003H154.801C151.843 287.003 149.127 285.371 147.738 282.759L144.361 276.407C144.331 276.351 144.304 276.292 144.275 276.235L1.30536 7.34863C-0.465343 4.01842 1.94781 0.00123224 5.71943 0.000976562H57.8522C59.7072 0.00109416 61.41 1.02803 62.2751 2.66895L169.118 205.331L248.35 55H166.12C162.118 54.9997 159.737 50.5313 161.97 47.21L192.225 2.20996C193.154 0.82846 194.71 0.000100612 196.375 0H280.325C280.335 -5.73296e-05 280.344 -0.000976174 280.354 -0.000976562H332.486Z"
fill="currentColor"
/>
</svg>
</div>
<h1 className="text-xl font-bold tracking-tight">VaultZ</h1>
</div>

<Card className="w-full">
<CardContent>
<AccessForm from={redirectTo} />
</CardContent>
</Card>
</div>
</main>
);
}
27 changes: 27 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextSSRPlugin } from '@uploadthing/react/next-ssr-plugin';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { headers } from 'next/headers';
import { NuqsAdapter } from 'nuqs/adapters/next/app';

import { extractRouterConfig } from 'uploadthing/server';
Expand Down Expand Up @@ -32,6 +33,32 @@ export const metadata: Metadata = {
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
// The passphrase gate (see proxy.ts) tags every request with the current
// pathname so /access can render a minimal shell instead of the full app
// chrome + data fetches below.
const isAccessPage = (await headers()).get('x-pathname') === '/access';

if (isAccessPage) {
return (
<html lang="en">
<body className={cn('w-full font-sans antialiased', inter.variable)}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster
richColors
toastOptions={{ classNames: { description: 'line-clamp-2' } }}
/>
</ThemeProvider>
</body>
</html>
);
}

const [firstDesignation, activeYear, activePeriod] = await Promise.all([
getFirstDesignation(),
getActiveYear(),
Expand Down
59 changes: 59 additions & 0 deletions components/access-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';

import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { z } from 'zod/v4';

import { verifyAccessCode } from '@/lib/actions/access';

import { Button } from '@/components/ui/button';
import { FormInput } from '@/components/ui/form-input';

const schema = z.object({
passphrase: z.string().min(1, 'Passphrase is required'),
});

type FormData = z.infer<typeof schema>;

export function AccessForm({ from }: { from: string }) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { passphrase: '' },
});

async function onSubmit(data: FormData) {
setIsSubmitting(true);
try {
const result = await verifyAccessCode({ ...data, from });
if (result?.error) {
form.setError('passphrase', { message: result.error });
form.resetField('passphrase');
}
} finally {
setIsSubmitting(false);
}
}

return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormInput<FormData>
name="passphrase"
label="Passphrase"
type="password"
autoFocus
placeholder="Enter passphrase"
/>

<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" />}
Enter
</Button>
</form>
</FormProvider>
);
}
54 changes: 54 additions & 0 deletions lib/access-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { createHash, createHmac, timingSafeEqual } from 'crypto';

// Single shared passphrase gate — not real authentication. It has no
// per-user identity or permissions, it only keeps casual/unauthorized
// visitors out of a shared link. Replace this with proper per-user auth
// once that exists.

export const ACCESS_COOKIE_NAME = 'vaultz_access';
export const ACCESS_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;

function getAccessSecret(): string {
const secret = process.env.VAULTZ_ACCESS_SECRET;
if (!secret)
throw new Error('VAULTZ_ACCESS_SECRET environment variable is not set');
return secret;
}

function getAccessCode(): string {
const code = process.env.VAULTZ_ACCESS_CODE;
if (!code)
throw new Error('VAULTZ_ACCESS_CODE environment variable is not set');
return code;
}

function sign(payload: string): string {
return createHmac('sha256', getAccessSecret())
.update(payload)
.digest('base64url');
}

export function signAccessCookieValue(): string {
const expiresAt = String(Date.now() + ACCESS_COOKIE_MAX_AGE_SECONDS * 1000);
return `${expiresAt}.${sign(expiresAt)}`;
}

export function verifyAccessCookieValue(raw: string | undefined): boolean {
if (!raw) return false;

const [expiresAt, signature] = raw.split('.');
if (!expiresAt || !signature) return false;

const expected = Buffer.from(sign(expiresAt));
const actual = Buffer.from(signature);
if (expected.length !== actual.length) return false;
if (!timingSafeEqual(expected, actual)) return false;

return Number(expiresAt) > Date.now();
}

export function verifyPassphrase(candidate: string): boolean {
const candidateHash = createHash('sha256').update(candidate).digest();
const expectedHash = createHash('sha256').update(getAccessCode()).digest();
return timingSafeEqual(candidateHash, expectedHash);
}
34 changes: 34 additions & 0 deletions lib/actions/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use server';

import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

import {
ACCESS_COOKIE_MAX_AGE_SECONDS,
ACCESS_COOKIE_NAME,
signAccessCookieValue,
verifyPassphrase,
} from '@/lib/access-gate';

function isSafeRedirectPath(path: string): boolean {
return path.startsWith('/') && !path.startsWith('//');
}

export async function verifyAccessCode(input: {
passphrase: string;
from: string;
}) {
if (!verifyPassphrase(input.passphrase)) {
return { error: 'Incorrect passphrase' };
}

(await cookies()).set(ACCESS_COOKIE_NAME, signAccessCookieValue(), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: ACCESS_COOKIE_MAX_AGE_SECONDS,
path: '/',
});

redirect(isSafeRedirectPath(input.from) ? input.from : '/');
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vaultz",
"version": "2.7.4",
"version": "2.7.5",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
Expand Down
31 changes: 31 additions & 0 deletions proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

import { ACCESS_COOKIE_NAME, verifyAccessCookieValue } from '@/lib/access-gate';

// Gates every route behind a single shared passphrase cookie. This is a
// temporary stopgap (no per-user identity or permissions) meant to keep
// casual/unauthorized visitors out until real per-user auth is built.
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;

const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-pathname', pathname);
const passThrough = { request: { headers: requestHeaders } };

if (pathname === '/access') return NextResponse.next(passThrough);

if (verifyAccessCookieValue(request.cookies.get(ACCESS_COOKIE_NAME)?.value)) {
return NextResponse.next(passThrough);
}

const accessUrl = new URL('/access', request.url);
accessUrl.searchParams.set('from', pathname + search);
return NextResponse.redirect(accessUrl);
}

export const config = {
matcher: [
'/((?!api/uploadthing|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};
Loading