diff --git a/README.md b/README.md index da1186c5..d8fbb5c2 100644 --- a/README.md +++ b/README.md @@ -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= +VAULTZ_ACCESS_CODE= +VAULTZ_ACCESS_SECRET= ``` 3. **Start the local database:** diff --git a/app/access/page.tsx b/app/access/page.tsx new file mode 100644 index 00000000..d2835d21 --- /dev/null +++ b/app/access/page.tsx @@ -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 ( +
+
+
+
+ + + +
+

VaultZ

+
+ + + + + + +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 54e73ad4..78620553 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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'; @@ -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 ( + + + + {children} + + + + + ); + } + const [firstDesignation, activeYear, activePeriod] = await Promise.all([ getFirstDesignation(), getActiveYear(), diff --git a/components/access-form.tsx b/components/access-form.tsx new file mode 100644 index 00000000..5011ae35 --- /dev/null +++ b/components/access-form.tsx @@ -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; + +export function AccessForm({ from }: { from: string }) { + const [isSubmitting, setIsSubmitting] = useState(false); + const form = useForm({ + 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 ( + +
+ + name="passphrase" + label="Passphrase" + type="password" + autoFocus + placeholder="Enter passphrase" + /> + + + +
+ ); +} diff --git a/lib/access-gate.ts b/lib/access-gate.ts new file mode 100644 index 00000000..b7afb482 --- /dev/null +++ b/lib/access-gate.ts @@ -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); +} diff --git a/lib/actions/access.ts b/lib/actions/access.ts new file mode 100644 index 00000000..6e0483b5 --- /dev/null +++ b/lib/actions/access.ts @@ -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 : '/'); +} diff --git a/package-lock.json b/package-lock.json index cf1559bf..338633dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vaultz", - "version": "2.7.3", + "version": "2.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vaultz", - "version": "2.7.3", + "version": "2.7.5", "dependencies": { "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.7.0", diff --git a/package.json b/package.json index cf11b7b2..6e41c786 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vaultz", - "version": "2.7.4", + "version": "2.7.5", "private": true, "scripts": { "dev": "next dev --turbopack", diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 00000000..149240f9 --- /dev/null +++ b/proxy.ts @@ -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).*)', + ], +};