Files

115 lines
3.8 KiB
TypeScript

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
// Dev fallback secret — must match lib/session.ts
const DEV_FALLBACK_SECRET = 'dev-secret-NOT-for-production-change-me-32chars!!'
function getSecretKey(): Uint8Array {
const secret = process.env.AUTH_SECRET ?? DEV_FALLBACK_SECRET
return new TextEncoder().encode(secret)
}
/** Paths (exact) that never require authentication. */
const PUBLIC_EXACT = new Set(['/', '/login'])
/** Path prefixes that never require authentication. */
const PUBLIC_PREFIXES = [
'/files/', // share link pages — public by design
'/api/files/', // individual file info + download — share links must work
'/api/auth/', // auth endpoints themselves
'/_next/',
]
/** Path prefixes that require admin role in addition to being authenticated. */
const ADMIN_PREFIXES = ['/admin', '/api/admin']
function isPublic(pathname: string): boolean {
if (PUBLIC_EXACT.has(pathname)) return true
return PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))
}
function isAdminRoute(pathname: string): boolean {
return ADMIN_PREFIXES.some((p) => pathname.startsWith(p))
}
const SECURITY_HEADERS: Record<string, string> = {
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
'X-DNS-Prefetch-Control': 'off',
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// Build the base response with security headers applied to every response
function withHeaders(res: NextResponse): NextResponse {
for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
res.headers.set(k, v)
}
return res
}
// Static assets — just add security headers
if (pathname.startsWith('/_next/') || pathname === '/favicon.ico') {
return withHeaders(NextResponse.next())
}
// Auth disabled (no secret configured) — pass all traffic through
if (!process.env.AUTH_SECRET && process.env.NODE_ENV !== 'production') {
// Still add security headers in dev mode
return withHeaders(NextResponse.next())
}
// Public routes — no auth needed
if (isPublic(pathname)) {
return withHeaders(NextResponse.next())
}
// Attempt to validate session
const token = request.cookies.get('backerup_session')?.value
let payload: { userId?: string; username?: string; role?: string } | null = null
if (token) {
try {
const { payload: p } = await jwtVerify(token, getSecretKey(), { algorithms: ['HS256'] })
payload = p as { userId?: string; username?: string; role?: string }
} catch {
// expired or tampered — treat as unauthenticated
}
}
// Not authenticated
if (!payload) {
if (pathname.startsWith('/api/')) {
return withHeaders(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }))
}
const loginUrl = request.nextUrl.clone()
loginUrl.pathname = '/login'
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
// Admin-only routes
if (isAdminRoute(pathname) && payload.role !== 'admin') {
if (pathname.startsWith('/api/')) {
return withHeaders(NextResponse.json({ error: 'Forbidden' }, { status: 403 }))
}
return withHeaders(NextResponse.redirect(new URL('/', request.url)))
}
// Authenticated — pass through
const response = NextResponse.next()
// Forward user info to server components/API routes via headers
response.headers.set('x-user-id', payload.userId ?? '')
response.headers.set('x-user-name', payload.username ?? '')
response.headers.set('x-user-role', payload.role ?? '')
return withHeaders(response)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|public/).*)'],
}