import { PREVIEW_ID_COOKIE, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview'; import { verifyPreviewToken } from '@akinon/next/utils/preview-token'; import { cookies, draftMode } from 'next/headers'; import { redirect } from 'next/navigation'; import { NextRequest, NextResponse } from 'next/server'; /** * Enables/disables Next.js Draft Mode for widget previews, and records WHICH * named preview is being viewed. * * Visitors never call this directly — any page url carrying `?preview` is * redirected here by the middleware (see src/proxy.ts), and redirected back * to the same page once the cookies are set. With draft mode on, the widget * fetch layer serves `-preview-` copies (falling back to live). * * The preview id rides in its own cookie rather than the url: pages keep * their clean addresses and links followed afterwards stay inside the same * preview. The cookie is folded into the pz route segment by the middleware * (see settings.js `pzSegments`) because the widget pages are force-static, * where Next.js blanks `cookies()` inside the render. * * Deny-by-default: entering preview ALWAYS requires `?preview=` — a * signed, expiring token minted by /api/preview/link for an editor user (see * utils/preview-token.ts); PREVIEW_SECRET is the signing key and never * travels in a url. Environments without the env have preview disabled * entirely. Exiting never requires a token. */ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; // Only ever redirect within the site — the target comes from the query. const target = searchParams.get('redirect') ?? '/'; let safeTarget = target.startsWith('/') && !target.startsWith('//') ? target : '/'; // Never bounce back to a url that still carries ?preview — that would // re-enter the middleware redirect and loop. const targetUrl = new URL(safeTarget, request.nextUrl.origin); targetUrl.searchParams.delete('preview'); targetUrl.searchParams.delete(PREVIEW_ID_QUERY_PARAM); safeTarget = `${targetUrl.pathname}${targetUrl.search}`; // URL normalization can reintroduce a protocol-relative target the first // guard already rejected (e.g. '/..//evil.com' normalizes to pathname // '//evil.com', which browsers resolve to https://evil.com) — re-check the // FINAL string, not just the raw input. if (!safeTarget.startsWith('/') || safeTarget.startsWith('//')) { safeTarget = '/'; } const draft = await draftMode(); const cookieStore = await cookies(); if (searchParams.has('exit')) { draft.disable(); cookieStore.delete(PREVIEW_ID_COOKIE); redirect(safeTarget); } const secret = process.env.PREVIEW_SECRET; if (!secret) { return new NextResponse( 'Preview is disabled: PREVIEW_SECRET is not configured.', { status: 401 } ); } const verdict = await verifyPreviewToken( searchParams.get('token') ?? '', secret ); if (verdict.ok !== true) { const { reason } = verdict; return new NextResponse( reason === 'expired' ? 'This preview link has expired — open the page from the editor again to get a fresh one.' : 'Preview link is invalid or missing. Preview pages are opened from the theme editor.', { status: 401 } ); } const { previewId } = verdict.claims; draft.enable(); // Same flags Next gives its own draft cookie (SameSite=None; Secure outside // development): the storefront may be embedded cross-site (editor, // Omnitron), and a Lax id cookie would be dropped there while the draft // cookie survives — draft mode on, no id, live content under a "Preview" // banner. `nextUrl.protocol` is not consulted: behind a TLS-terminating // proxy it reads http even in production. const isDevelopment = process.env.NODE_ENV === 'development'; cookieStore.set(PREVIEW_ID_COOKIE, previewId, { path: '/', httpOnly: true, sameSite: isDevelopment ? 'lax' : 'none', secure: !isDevelopment }); redirect(safeTarget); }