import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { ATTRIBUTION_COOKIE_NAME, buildAttributionSnapshot, serializeAttributionCookie, } from '@/lib/revops'; import { verifySignedUserIdEdge } from '@/lib/session-edge'; import { getAuthCookieName, getCookieDomain } from '@/lib/cookieConfig'; import { getAppOrigin, getWwwOrigin, isAppPath, isHostSplitEnabled, wwwUrl, } from '@/lib/hosts'; const isProduction = process.env.NODE_ENV === 'production'; function attachAttributionCookie(req: NextRequest, response: NextResponse) { if (req.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value) { return response; } const path = req.nextUrl.pathname; if (path.startsWith('/api/') || path.startsWith('/_next') || path.startsWith('/r/') || path.includes('.')) { return response; } const snapshot = buildAttributionSnapshot({ utmSource: req.nextUrl.searchParams.get('utm_source'), utmMedium: req.nextUrl.searchParams.get('utm_medium'), utmCampaign: req.nextUrl.searchParams.get('utm_campaign'), utmContent: req.nextUrl.searchParams.get('utm_content'), utmTerm: req.nextUrl.searchParams.get('utm_term'), referrer: req.headers.get('referer'), landingPath: path, firstSeenAt: new Date(), }); response.cookies.set(ATTRIBUTION_COOKIE_NAME, serializeAttributionCookie(snapshot), { httpOnly: false, secure: isProduction, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 90, domain: getCookieDomain(), }); return response; } /** Hostname of the app host, or null when marketing and app share one origin (dev). */ function getAppHostname(): string | null { if (!isHostSplitEnabled()) { return null; } try { return new URL(getAppOrigin()).hostname; } catch { return null; } } /** Absolute target on the other host, preserving path and query. */ function crossHostUrl(origin: string, req: NextRequest): string { const url = new URL(req.nextUrl.pathname + req.nextUrl.search, origin); return url.toString(); } /** * Route a request that arrived on the app host (app.qrmaster.net). * * The app host serves only the logged-in app; everything else belongs to the marketing * host and gets redirected so a stray link or an old bookmark still lands somewhere * sensible. Returns null when the request is an app path and should continue through the * normal auth handling below. */ function routeAppHost(req: NextRequest): NextResponse | null { const path = req.nextUrl.pathname; // Keep the app host out of search indexes entirely - the marketing host owns all SEO. if (path === '/robots.txt') { return NextResponse.rewrite(new URL('/robots-app.txt', req.url)); } if (path === '/sitemap.xml') { return NextResponse.redirect(wwwUrl('/sitemap.xml'), 301); } // API and framework internals must be served on both hosts: the app calls its own // /api routes, and the Stripe webhook still points at the marketing host. if (path.startsWith('/api/') || path.startsWith('/_next')) { return NextResponse.next(); } // QR redirects belong to the marketing host. Redirecting instead of 404ing keeps any // code that was generated with the wrong origin working. if (path.startsWith('/r/')) { return NextResponse.redirect(crossHostUrl(getWwwOrigin(), req), 301); } if (path.includes('.')) { return NextResponse.next(); } if (isAppPath(path)) { return null; } return NextResponse.redirect(crossHostUrl(getWwwOrigin(), req), 301); } async function routeRequest(req: NextRequest): Promise { const path = req.nextUrl.pathname; const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname; if (hostname === 'qrmaster.net') { const url = req.nextUrl.clone(); url.protocol = 'https'; url.port = ''; url.hostname = 'www.qrmaster.net'; return NextResponse.redirect(url, 301); } const appHostname = getAppHostname(); if (appHostname) { if (hostname === appHostname) { const appHostResponse = routeAppHost(req); if (appHostResponse) { return appHostResponse; } // Falls through: app path on the app host, continue to the auth check below. } else if (isAppPath(path)) { // App path requested on the marketing host - move it to the app host. Keeps old // bookmarks and the dashboard link in email footers working. return NextResponse.redirect(crossHostUrl(getAppOrigin(), req), 301); } } // 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority if (path === '/guide/tracking-analytics') { return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/tracking', req.url), 301)); } if (path === '/guide/bulk-qr-code-generation') { return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/developer', req.url), 301)); } if (path === '/guide/qr-code-best-practices') { return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/basics', req.url), 301)); } if (path === '/create-qr') { return attachAttributionCookie(req, NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301)); } if (path === '/bar-code-generator' || path === '/barcode-generator') { return attachAttributionCookie(req, NextResponse.redirect(new URL('/tools/barcode-generator', req.url), 301)); } // Public routes that don't require authentication const publicPaths = [ '/', '/pricing', '/faq', '/blog', '/login', '/signup', '/privacy', '/newsletter', '/restaurants', '/tools', '/features', // '/guide', // Redirected to /learn/* '/de', '/qr-code-erstellen', '/dynamic-qr-code-generator', '/dynamic-barcode-generator', '/bulk-qr-code-generator', '/qr-code-tracking', '/qr-code-analytics', '/reprint-calculator', '/custom-qr-code-generator', '/manage-qr-codes', '/coupon', '/feedback', '/vcard', '/display', '/contact', '/about', '/learn', '/use-cases', '/authors', '/press', '/testimonials', '/qr-code-for-marketing-campaigns', '/qr-code-for', '/qr-code-print-size-guide', '/use-cases/flyer-qr-codes', '/use-cases/packaging-qr-codes', '/use-cases/real-estate-sign-qr-codes', '/use-cases/feedback-qr-codes', '/use-cases/payment-qr-codes', '/use-cases/coupon-qr-codes', '/alternatives', '/vs', ]; // Check if path is public const isPublicPath = publicPaths.some(p => path === p || path.startsWith(p + '/')); // Allow API routes if (path.startsWith('/api/')) { return attachAttributionCookie(req, NextResponse.next()); } // Allow redirect routes (QR code redirects) if (path.startsWith('/r/')) { return attachAttributionCookie(req, NextResponse.next()); } // Allow static files if (path.includes('.') || path.startsWith('/_next')) { return attachAttributionCookie(req, NextResponse.next()); } // Allow public paths if (isPublicPath) { return attachAttributionCookie(req, NextResponse.next()); } const protectedPaths = [ '/analytics', '/bulk-creation', '/create', '/dashboard', '/integrations', '/onboarding', '/qr', '/settings', ]; const isProtectedPath = protectedPaths.some(p => path === p || path.startsWith(p + '/')); if (!isProtectedPath) { return attachAttributionCookie(req, NextResponse.next()); } // For protected routes, require a validly signed userId cookie const userId = await verifySignedUserIdEdge(req.cookies.get(getAuthCookieName())?.value); if (!userId) { // Not authenticated - redirect to signup, which lives on the marketing host. const signupUrl = new URL(wwwUrl('/signup')); const redirectTarget = `${path}${req.nextUrl.search}`; signupUrl.searchParams.set('redirect', redirectTarget); return attachAttributionCookie(req, NextResponse.redirect(signupUrl)); } // Authenticated - allow access return attachAttributionCookie(req, NextResponse.next()); } export async function middleware(req: NextRequest) { const response = await routeRequest(req); const appHostname = getAppHostname(); const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname; // Belt and braces alongside robots-app.txt: the app host must never be indexed, and // setting the header here covers every response the routing above can produce. if (appHostname && hostname === appHostname) { response.headers.set('X-Robots-Tag', 'noindex, nofollow'); } 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) * - public folder */ '/((?!_next/static|_next/image|favicon.ico|logo.svg|og-image.png).*)', ], };