All files / web/src middleware.ts

0% Statements 0/169
0% Branches 0/1
0% Functions 0/1
0% Lines 0/169

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170                                                                                                                                                                                                                                                                                                                                                   
import { type NextRequest, NextResponse } from 'next/server'
import { createGuestToken, GUEST_COOKIE_NAME } from './lib/guest-token'
import { defaultLocale, LOCALE_COOKIE_NAME, locales, type Locale } from './i18n/routing'

/**
 * NextAuth session cookie names
 * v5 uses 'authjs.session-token' in dev and '__Secure-authjs.session-token' in prod
 */
const NEXTAUTH_SESSION_COOKIE =
  process.env.NODE_ENV === 'production' ? '__Secure-authjs.session-token' : 'authjs.session-token'

/**
 * Middleware to:
 * 1. Detect and set locale based on Accept-Language header or cookie
 * 2. Ensure every visitor gets a guest token (unless authenticated)
 * 3. Add pathname and locale to headers for Server Components
 */
export async function middleware(request: NextRequest) {
  const start = performance.now()

  // Create mutable headers to pass to server components
  const requestHeaders = new Headers(request.headers)

  // Detect locale from cookie or Accept-Language header
  let locale = request.cookies.get(LOCALE_COOKIE_NAME)?.value as Locale | undefined

  // Track if we need to set a new locale cookie
  const needsLocaleCookie = !locale || !locales.includes(locale)

  if (needsLocaleCookie) {
    // Parse Accept-Language header
    const acceptLanguage = request.headers.get('accept-language')
    if (acceptLanguage) {
      const preferred = acceptLanguage
        .split(',')
        .map((lang) => lang.split(';')[0].trim().slice(0, 2))
        .find((lang) => locales.includes(lang as Locale))
      locale = (preferred as Locale) || defaultLocale
    } else {
      locale = defaultLocale
    }
  }

  // Add locale to request headers for Server Components
  requestHeaders.set('x-locale', locale ?? defaultLocale)

  // Add pathname to request headers so Server Components can access it
  requestHeaders.set('x-pathname', request.nextUrl.pathname)

  // Check if user has a NextAuth session (authenticated user)
  const hasAuthSession = !!request.cookies.get(NEXTAUTH_SESSION_COOKIE)?.value

  // Protect /admin pages — redirect unauthenticated users to sign-in
  if (request.nextUrl.pathname.startsWith('/admin') && !hasAuthSession) {
    const signInUrl = new URL('/auth/signin', request.url)
    signInUrl.searchParams.set('callbackUrl', request.nextUrl.pathname)
    return NextResponse.redirect(signInUrl)
  }

  // Redirect authenticated users from home page to practice
  if (hasAuthSession && request.nextUrl.pathname === '/') {
    return NextResponse.redirect(new URL('/practice', request.url))
  }

  // Guest cookie handling: skip for authenticated users
  let existing = request.cookies.get(GUEST_COOKIE_NAME)?.value
  let guestId: string | null = null
  let clearGuestCookie = false

  let verifyTime = 0
  if (!hasAuthSession) {
    // No auth session — handle guest cookie as before
    if (existing) {
      try {
        const t = performance.now()
        const { verifyGuestToken } = await import('./lib/guest-token')
        const verified = await verifyGuestToken(existing)
        verifyTime = performance.now() - t
        guestId = verified.sid
      } catch {
        existing = undefined
      }
    }

    if (!existing) {
      const sid = crypto.randomUUID()
      guestId = sid
    }
  } else {
    // Authenticated user — clear stale guest cookie if present
    if (existing) {
      clearGuestCookie = true
    }
  }

  // Pass guest ID to route handlers via request header
  if (guestId) {
    requestHeaders.set('x-guest-id', guestId)
  }

  const total = performance.now() - start
  if (total > 50) {
    console.log(
      `[PERF] middleware: ${total.toFixed(1)}ms | ` +
        `verify=${verifyTime.toFixed(1)}ms | ` +
        `auth=${hasAuthSession} | ` +
        `path=${request.nextUrl.pathname}`
    )
  }

  // Create response with modified request headers
  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })

  // Set locale cookie if it was new
  if (needsLocaleCookie && locale) {
    response.cookies.set({
      name: LOCALE_COOKIE_NAME,
      value: locale,
      path: '/',
      maxAge: 60 * 60 * 24 * 365, // 1 year
      sameSite: 'lax',
    })
  }

  // Set guest cookie if it was new (unauthenticated visitors only)
  if (!hasAuthSession && !existing && guestId) {
    const token = await createGuestToken(guestId)
    response.cookies.set({
      name: GUEST_COOKIE_NAME,
      value: token,
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      path: '/',
      maxAge: 60 * 60 * 24 * 30, // 30 days
    })
  }

  // Clear stale guest cookie for authenticated users
  if (clearGuestCookie) {
    response.cookies.set({
      name: GUEST_COOKIE_NAME,
      value: '',
      path: '/',
      maxAge: 0,
    })
  }

  return response
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - api routes (next-intl doesn't need to handle these)
     *
     * Note: This matcher handles both i18n routing and guest tokens
     */
    '/((?!api|_next|_vercel|.*\\..*).*)',
  ],
}