From 53ef4b3b91a4928df9f0fe416367682598db07b2 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Wed, 12 Aug 2026 19:44:08 +0200 Subject: [PATCH] Serve the app on app.qrmaster.net, marketing on www Splits the two hostnames across one deployment. No files move: the Next app still serves every route on both hosts, and the middleware decides per host which paths it owns and 301s the rest. /login and /signup stay on www - all 82 marketing CTAs point at /signup, which carries a hard canonical to www plus ad traffic. src/lib/hosts.ts is the single source of truth for the boundary (APP_PATH_PREFIXES, isAppPath, wwwUrl, appUrl, urlForPath). The middleware and every absolute-URL builder read from it so they cannot drift apart. - Split the overloaded NEXT_PUBLIC_APP_URL into a www and an app origin. It previously fed both public URLs and in-app URLs, so any single value was wrong somewhere. Most important: QRCodeCard encodes this origin into the QR code the user downloads and prints, so it must stay on www. - Route Stripe return URLs, email links and OAuth redirects per path rather than against one origin, so /dashboard lands on app and /pricing on www. - Cross the host boundary once, after a successful login: the router cannot push across origins, so that jump needs a full load. The user arrives signed in because the session cookie is scoped to COOKIE_DOMAIN. - Keep the app host out of search indexes: X-Robots-Tag on every response plus a Disallow-all robots.txt via rewrite, and /sitemap.xml redirects to www. - Point the TikTok callback fallback at www explicitly. It used to read NEXT_PUBLIC_APP_URL, whose meaning changed here, and only the apex domain is verified with TikTok. Host splitting is inert while both origins are equal, so development is unaffected. Verified: tsc clean, production build succeeds including the Edge middleware bundle, and the path-to-host mapping is unit-checked (prefix traps like /created and /settings-guide stay on www, query strings do not break matching). Co-Authored-By: Claude Opus 5 --- Dockerfile | 5 +- PLAN_APP_SUBDOMAIN_2026-08-12.md | 29 +++- docker-compose.yml | 1 + env.example | 9 ++ public/robots-app.txt | 5 + src/app/(main)/(auth)/login/LoginClient.tsx | 10 ++ src/app/(main)/(auth)/signup/SignupClient.tsx | 31 +++-- src/app/(main)/api/auth/google/route.ts | 15 ++- src/app/(main)/api/auth/signup/route.ts | 7 +- src/app/(main)/api/auth/verify-email/route.ts | 8 +- src/app/(main)/api/stripe/checkout/route.ts | 8 +- .../stripe/create-checkout-session/route.ts | 20 ++- src/app/(main)/api/stripe/portal/route.ts | 3 +- src/app/(main)/api/tiktok/callback/route.ts | 5 +- src/app/(main)/api/tiktok/connect/route.ts | 5 +- src/app/(main)/r/[slug]/route.ts | 11 +- src/components/dashboard/QRCodeCard.tsx | 6 +- src/lib/email.ts | 28 ++-- src/lib/hosts.ts | 125 ++++++++++++++++++ src/lib/marketingEmail.ts | 5 +- src/lib/metaConversions.ts | 4 +- src/middleware.ts | 106 ++++++++++++++- 22 files changed, 383 insertions(+), 63 deletions(-) create mode 100644 public/robots-app.txt create mode 100644 src/lib/hosts.ts diff --git a/Dockerfile b/Dockerfile index 2c7b039..032ed9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,10 @@ ENV NEXTAUTH_SECRET="build-time-secret" ENV IP_SALT="build-time-salt" ENV STRIPE_SECRET_KEY="sk_test_placeholder_for_build" ENV RESEND_API_KEY="re_placeholder_for_build" -ENV NEXT_PUBLIC_APP_URL="https://www.qrmaster.net" +# Marketing host vs app host. NEXT_PUBLIC_WWW_URL must stay on www: it is the origin +# encoded into downloaded QR codes and used for public email links. +ENV NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net" +ENV NEXT_PUBLIC_APP_URL="https://app.qrmaster.net" # PostHog Analytics - REQUIRED at build time for client-side bundle ENV NEXT_PUBLIC_POSTHOG_KEY="phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ" ENV NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" diff --git a/PLAN_APP_SUBDOMAIN_2026-08-12.md b/PLAN_APP_SUBDOMAIN_2026-08-12.md index 7ef55f7..b4100ad 100644 --- a/PLAN_APP_SUBDOMAIN_2026-08-12.md +++ b/PLAN_APP_SUBDOMAIN_2026-08-12.md @@ -2,6 +2,22 @@ Stand: 2026-08-12 · Ziel: die eingeloggte App liegt auf `app.qrmaster.net`, Marketing/SEO bleibt auf `www.qrmaster.net`. +## Status + +| Schritt | Stand | +|---|---| +| B1 Cookie-Domain | committed + gepusht (`35ea8cc`) | +| B2–B7 | Code fertig, typecheck + Production-Build grün, **noch nicht deployt** | +| A4 Google Console | erledigt (beide Redirect-URIs eingetragen) | +| A1 DNS, A2 Caddy, A3 .env, A6 Deploy | offen bei Timo | + +Deploy-Reihenfolge unverändert: B1 zuerst allein live und einen Tag beobachten, dann B2–B7. + +Neu gegenüber dem ursprünglichen Plan: `src/lib/hosts.ts` ist die einzige Quelle der Wahrheit +für die Host-Grenze (`APP_PATH_PREFIXES`, `isAppPath`, `wwwUrl`, `appUrl`, `urlForPath`). +Middleware, Stripe-Rückkehr-URLs und E-Mail-Links lesen alle daraus, damit sie nicht +auseinanderdriften. + ## Zielarchitektur **Ein Docker-Image, ein Container, zwei Hostnames.** Caddy routet `www.qrmaster.net` und @@ -53,12 +69,23 @@ kein TLS-Fehler. ### A3. `.env` auf dem Server ergänzen Zwei Variablen statt einer. Die Trennung ist der Kern des ganzen Umbaus: +Für **Deploy 1** reicht: + +```dotenv +COOKIE_DOMAIN=.qrmaster.net +``` + +Für **Deploy 2** kommen dazu: + ```dotenv NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net NEXT_PUBLIC_APP_URL=https://app.qrmaster.net -COOKIE_DOMAIN=.qrmaster.net ``` +`NEXT_PUBLIC_APP_URL` erst zu Deploy 2 umstellen - vorher zeigt es auf www und muss dort +bleiben. Fehlen die Werte, greifen die Produktions-Fallbacks in `src/lib/hosts.ts`; ein +localhost-Wert kann damit nicht in gedruckte QR-Codes gelangen. + `NEXTAUTH_URL` bleibt `https://www.qrmaster.net` (wird nur noch von `api/social-assets/route.ts` gelesen, kein Auth-Bezug mehr). diff --git a/docker-compose.yml b/docker-compose.yml index be5fdd3..1a0ccb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,7 @@ services: NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050} + NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:-http://localhost:3050} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} diff --git a/env.example b/env.example index ccaf8d3..1fe2f2b 100644 --- a/env.example +++ b/env.example @@ -21,6 +21,15 @@ NEXTAUTH_SECRET=your-secret-key-here-change-in-production # between www.qrmaster.net and app.qrmaster.net. Only honoured when NODE_ENV=production. COOKIE_DOMAIN= +# Host split: marketing/SEO on WWW, the logged-in app on APP. Keep both pointing at the +# same origin locally so nothing redirects across hosts in development. +# In production: NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net +# NEXT_PUBLIC_APP_URL=https://app.qrmaster.net +# NEXT_PUBLIC_WWW_URL must stay on www - it is the origin encoded into downloaded QR +# codes and used for public links in emails. +NEXT_PUBLIC_WWW_URL=http://localhost:3050 +NEXT_PUBLIC_APP_URL=http://localhost:3050 + # OAuth Providers (Optional) GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/public/robots-app.txt b/public/robots-app.txt new file mode 100644 index 0000000..3f7e6b2 --- /dev/null +++ b/public/robots-app.txt @@ -0,0 +1,5 @@ +# Served at app.qrmaster.net/robots.txt via a middleware rewrite. +# The app host holds only the logged-in application - all indexable content lives on +# www.qrmaster.net, so nothing here should ever enter a search index. +User-agent: * +Disallow: / diff --git a/src/app/(main)/(auth)/login/LoginClient.tsx b/src/app/(main)/(auth)/login/LoginClient.tsx index b631c52..05778c4 100644 --- a/src/app/(main)/(auth)/login/LoginClient.tsx +++ b/src/app/(main)/(auth)/login/LoginClient.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button'; import { useTranslation } from '@/hooks/useTranslation'; import { useCsrf } from '@/hooks/useCsrf'; import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow'; +import { needsHostChange, urlForPath } from '@/lib/hosts'; type LoginClientProps = { showPageHeading?: boolean; @@ -63,6 +64,15 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps const redirectUrl = data.needsOnboarding ? appendRedirectParam('/onboarding', redirectTarget) : (redirectTarget || '/dashboard'); + + // Login lives on the marketing host, the app on app.*. The router cannot + // push across origins, so a host change needs a full load. The session + // cookie is shared via COOKIE_DOMAIN, so the user arrives signed in. + if (needsHostChange(redirectUrl)) { + window.location.assign(urlForPath(redirectUrl)); + return; + } + router.push(redirectUrl); router.refresh(); } else { diff --git a/src/app/(main)/(auth)/signup/SignupClient.tsx b/src/app/(main)/(auth)/signup/SignupClient.tsx index 8d4f861..a624755 100644 --- a/src/app/(main)/(auth)/signup/SignupClient.tsx +++ b/src/app/(main)/(auth)/signup/SignupClient.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button'; import { useTranslation } from '@/hooks/useTranslation'; import { useCsrf } from '@/hooks/useCsrf'; import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow'; +import { needsHostChange, urlForPath } from '@/lib/hosts'; export default function SignupClient() { const router = useRouter(); @@ -48,15 +49,15 @@ export default function SignupClient() { body: JSON.stringify({ name, email, password }), }); - const data = await response.json(); - - if (response.ok && data.success) { - if (data.requiresEmailVerification) { - router.push(`/verify-email?email=${encodeURIComponent(data.email)}`); - return; - } - - // Store user in localStorage for client-side + const data = await response.json(); + + if (response.ok && data.success) { + if (data.requiresEmailVerification) { + router.push(`/verify-email?email=${encodeURIComponent(data.email)}`); + return; + } + + // Store user in localStorage for client-side localStorage.setItem('user', JSON.stringify(data.user)); // Track successful signup with PostHog @@ -76,8 +77,16 @@ export default function SignupClient() { console.error('PostHog tracking error:', error); } - // Redirect to onboarding - router.push(appendRedirectParam('/onboarding', redirectTarget)); + // Redirect to onboarding - which lives on the app host, so this normally + // crosses the host boundary and cannot go through the router. + const onboardingUrl = appendRedirectParam('/onboarding', redirectTarget); + + if (needsHostChange(onboardingUrl)) { + window.location.assign(urlForPath(onboardingUrl)); + return; + } + + router.push(onboardingUrl); router.refresh(); } else { setError(data.error || 'Failed to create account'); diff --git a/src/app/(main)/api/auth/google/route.ts b/src/app/(main)/api/auth/google/route.ts index bbd8d92..a783802 100644 --- a/src/app/(main)/api/auth/google/route.ts +++ b/src/app/(main)/api/auth/google/route.ts @@ -6,6 +6,7 @@ import { getCookieDomain, getFlowCookieOptions, } from '@/lib/cookieConfig'; +import { appUrl, urlForPath, wwwUrl } from '@/lib/hosts'; import { signUserId } from '@/lib/session'; import { appendRedirectParam, @@ -40,7 +41,7 @@ export async function GET(request: NextRequest) { ); } - const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`; + const redirectUri = appUrl('/api/auth/google'); const scope = 'openid email profile'; const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect')); const oauthState = crypto.randomUUID(); @@ -72,10 +73,10 @@ export async function GET(request: NextRequest) { try { if (!state || !savedOauthState || state !== savedOauthState) { const invalidStateResponse = NextResponse.redirect( - `${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-state-invalid` + wwwUrl('/login?error=google-state-invalid') ); - invalidStateResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME); - invalidStateResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME); + invalidStateResponse.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() }); + invalidStateResponse.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() }); return invalidStateResponse; } @@ -89,7 +90,7 @@ export async function GET(request: NextRequest) { ); } - const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`; + const redirectUri = appUrl('/api/auth/google'); // Exchange code for tokens const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { @@ -220,7 +221,7 @@ export async function GET(request: NextRequest) { authMethod: 'google', isNewUser: isNewUser.toString(), })); - const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`); + const redirectUrl = new URL(urlForPath(onboardingTarget)); const response = NextResponse.redirect(redirectUrl.toString()); response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions()); @@ -234,7 +235,7 @@ export async function GET(request: NextRequest) { } catch (error) { console.error('Google OAuth error:', error); const errorResponse = NextResponse.redirect( - `${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed` + wwwUrl('/login?error=google-signin-failed') ); errorResponse.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() }); errorResponse.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() }); diff --git a/src/app/(main)/api/auth/signup/route.ts b/src/app/(main)/api/auth/signup/route.ts index 6762c80..034e090 100644 --- a/src/app/(main)/api/auth/signup/route.ts +++ b/src/app/(main)/api/auth/signup/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { wwwUrl } from '@/lib/hosts'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { db } from '@/lib/db'; @@ -17,7 +18,9 @@ import { triggerLifecycleScoring } from '@/lib/revops-server'; async function issueVerificationEmail(user: { email: string; name: string | null }) { const verificationToken = crypto.randomBytes(32).toString('base64url'); - const verificationUrl = new URL('/api/auth/verify-email', process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net'); + // Public link in an outgoing email, so it points at the marketing host. The endpoint + // itself is served on both hosts and redirects into the app afterwards. + const verificationUrl = new URL(wwwUrl('/api/auth/verify-email')); verificationUrl.searchParams.set('token', verificationToken); await db.verificationToken.deleteMany({ where: { identifier: user.email } }); @@ -147,7 +150,7 @@ export async function POST(request: NextRequest) { fbc: request.cookies.get('_fbc')?.value, fbp: request.cookies.get('_fbp')?.value, }, - eventSourceUrl: `${process.env.NEXT_PUBLIC_APP_URL}/signup`, + eventSourceUrl: wwwUrl('/signup'), }).catch(console.error); // Create response diff --git a/src/app/(main)/api/auth/verify-email/route.ts b/src/app/(main)/api/auth/verify-email/route.ts index b409093..55dd994 100644 --- a/src/app/(main)/api/auth/verify-email/route.ts +++ b/src/app/(main)/api/auth/verify-email/route.ts @@ -3,11 +3,13 @@ import { db } from '@/lib/db'; import { getAuthCookieOptions } from '@/lib/cookieConfig'; import { signUserId } from '@/lib/session'; import { sendWelcomeEmail } from '@/lib/email'; +import { appUrl, wwwUrl } from '@/lib/hosts'; export async function GET(request: NextRequest) { const token = new URL(request.url).searchParams.get('token'); - const publicAppUrl = process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin; - const expiredUrl = new URL('/verify-email?status=expired', publicAppUrl); + // /verify-email is a public page on the marketing host; /onboarding lives on the app + // host. The session cookie is shared across both, so the user stays signed in. + const expiredUrl = new URL(wwwUrl('/verify-email?status=expired')); if (!token) return NextResponse.redirect(expiredUrl); @@ -34,7 +36,7 @@ export async function GET(request: NextRequest) { console.error('Welcome email after verification failed:', error); } - const response = NextResponse.redirect(new URL('/onboarding?email_verified=1', publicAppUrl)); + const response = NextResponse.redirect(appUrl('/onboarding?email_verified=1')); response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions()); return response; } diff --git a/src/app/(main)/api/stripe/checkout/route.ts b/src/app/(main)/api/stripe/checkout/route.ts index b9fb3a7..fe1ef03 100644 --- a/src/app/(main)/api/stripe/checkout/route.ts +++ b/src/app/(main)/api/stripe/checkout/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { stripe } from '@/lib/stripe'; import { db } from '@/lib/db'; import { cookies } from 'next/headers'; +import { appUrl, wwwUrl } from '@/lib/hosts'; export async function POST(request: NextRequest) { try { @@ -53,15 +54,16 @@ export async function POST(request: NextRequest) { customer: customerId, mode: 'subscription', payment_method_types: ['card'], - allow_promotion_codes: true, + allow_promotion_codes: true, line_items: [ { price: priceId, quantity: 1, }, ], - success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`, - cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`, + // /dashboard is on the app host, /pricing on the marketing host. + success_url: appUrl('/dashboard?success=true'), + cancel_url: wwwUrl('/pricing?canceled=true'), metadata: { userId: user.id, plan, diff --git a/src/app/(main)/api/stripe/create-checkout-session/route.ts b/src/app/(main)/api/stripe/create-checkout-session/route.ts index 996019a..14dadb8 100644 --- a/src/app/(main)/api/stripe/create-checkout-session/route.ts +++ b/src/app/(main)/api/stripe/create-checkout-session/route.ts @@ -3,6 +3,7 @@ import { stripe, STRIPE_PLANS } from '@/lib/stripe'; import { db } from '@/lib/db'; import { getSessionUserId } from '@/lib/session'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { urlForPath } from '@/lib/hosts'; export async function POST(request: NextRequest) { try { @@ -109,7 +110,11 @@ export async function POST(request: NextRequest) { }); } - const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin; + // Resolved per path rather than against a single origin: safeReturnPath comes from an + // in-app page (/dashboard, /upgrade, ...) and belongs on the app host, while the + // default cancel target /pricing belongs on the marketing host. + const withParam = (path: string, param: string) => + `${path}${path.includes('?') ? '&' : '?'}${param}`; // Create Stripe Checkout Session const checkoutSession = await stripe.checkout.sessions.create({ @@ -123,12 +128,13 @@ export async function POST(request: NextRequest) { quantity: 1, }, ], - success_url: safeReturnPath - ? `${appUrl}${safeReturnPath}${safeReturnPath.includes('?') ? '&' : '?'}success=true&session_id={CHECKOUT_SESSION_ID}` - : `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`, - cancel_url: safeReturnPath - ? `${appUrl}${safeReturnPath}${safeReturnPath.includes('?') ? '&' : '?'}canceled=true` - : `${appUrl}/pricing?canceled=true`, + success_url: urlForPath( + withParam( + safeReturnPath || '/dashboard', + 'success=true&session_id={CHECKOUT_SESSION_ID}' + ) + ), + cancel_url: urlForPath(withParam(safeReturnPath || '/pricing', 'canceled=true')), metadata: { userId: user.id, plan, diff --git a/src/app/(main)/api/stripe/portal/route.ts b/src/app/(main)/api/stripe/portal/route.ts index bbe0d58..c33cc77 100644 --- a/src/app/(main)/api/stripe/portal/route.ts +++ b/src/app/(main)/api/stripe/portal/route.ts @@ -3,6 +3,7 @@ import { getSessionUserId } from '@/lib/session'; import { stripe } from '@/lib/stripe'; import { db } from '@/lib/db'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { appUrl } from '@/lib/hosts'; export async function POST(request: NextRequest) { try { @@ -56,7 +57,7 @@ export async function POST(request: NextRequest) { // Create Stripe Customer Portal session const portalSession = await stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, - return_url: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/settings`, + return_url: appUrl('/settings'), }); return NextResponse.json({ url: portalSession.url }); diff --git a/src/app/(main)/api/tiktok/callback/route.ts b/src/app/(main)/api/tiktok/callback/route.ts index 03efe68..2ef03c3 100644 --- a/src/app/(main)/api/tiktok/callback/route.ts +++ b/src/app/(main)/api/tiktok/callback/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { wwwUrl } from '@/lib/hosts'; import { assertExpectedTiktokAccount, TIKTOK_ACCOUNT_KEY, @@ -39,8 +40,10 @@ export async function GET(request: NextRequest) { return textResponse('TikTok client credentials are not configured.', 500); } + // Must match the URI used in /api/tiktok/connect - see the note there about the + // verified domain. const redirectUri = - process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`; + process.env.TIKTOK_REDIRECT_URI || wwwUrl('/api/tiktok/callback'); try { const tokenResponse = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { diff --git a/src/app/(main)/api/tiktok/connect/route.ts b/src/app/(main)/api/tiktok/connect/route.ts index 301ab82..ed1e7ae 100644 --- a/src/app/(main)/api/tiktok/connect/route.ts +++ b/src/app/(main)/api/tiktok/connect/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok'; +import { wwwUrl } from '@/lib/hosts'; const isProduction = process.env.NODE_ENV === 'production'; @@ -17,8 +18,10 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'TIKTOK_CLIENT_KEY not configured' }, { status: 500 }); } + // Falls back to the marketing host on purpose: TikTok only accepts callbacks on the + // verified domain, and app.qrmaster.net is not verified with them. const redirectUri = - process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`; + process.env.TIKTOK_REDIRECT_URI || wwwUrl('/api/tiktok/callback'); const oauthState = crypto.randomUUID(); diff --git a/src/app/(main)/r/[slug]/route.ts b/src/app/(main)/r/[slug]/route.ts index b975803..d6ddc54 100644 --- a/src/app/(main)/r/[slug]/route.ts +++ b/src/app/(main)/r/[slug]/route.ts @@ -1,4 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; +// These landing pages (/vcard, /display, /coupon, /feedback) are public marketing-host +// pages reached straight from a scanned QR code - never the app host. +import { getWwwOrigin } from '@/lib/hosts'; import { db } from '@/lib/db'; import { hashIP } from '@/lib/hash'; import { triggerLifecycleScoring } from '@/lib/revops-server'; @@ -47,7 +50,7 @@ export async function GET( break; case 'VCARD': // For vCard, redirect to display page - const baseUrlVcard = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; + const baseUrlVcard = getWwwOrigin(); destination = `${baseUrlVcard}/vcard?firstName=${encodeURIComponent(content.firstName || '')}&lastName=${encodeURIComponent(content.lastName || '')}&email=${encodeURIComponent(content.email || '')}&phone=${encodeURIComponent(content.phone || '')}&organization=${encodeURIComponent(content.organization || '')}&title=${encodeURIComponent(content.title || '')}`; break; case 'GEO': @@ -58,7 +61,7 @@ export async function GET( break; case 'TEXT': // For plain text, redirect to a display page - const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; + const baseUrlText = getWwwOrigin(); destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`; break; case 'PDF': @@ -81,12 +84,12 @@ export async function GET( break; case 'COUPON': // Redirect to coupon display page - const baseUrlCoupon = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; + const baseUrlCoupon = getWwwOrigin(); destination = `${baseUrlCoupon}/coupon/${slug}`; break; case 'FEEDBACK': // Redirect to feedback form page - const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; + const baseUrlFeedback = getWwwOrigin(); destination = `${baseUrlFeedback}/feedback/${slug}`; break; case 'BARCODE': diff --git a/src/components/dashboard/QRCodeCard.tsx b/src/components/dashboard/QRCodeCard.tsx index 492160a..92a31f5 100644 --- a/src/components/dashboard/QRCodeCard.tsx +++ b/src/components/dashboard/QRCodeCard.tsx @@ -7,6 +7,7 @@ import { Card, CardContent } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; import { formatDate } from '@/lib/utils'; +import { getWwwOrigin } from '@/lib/hosts'; import { ONBOARDING_DOWNLOAD_COMPLETE_EVENT, ONBOARDING_DOWNLOAD_COMPLETE_KEY, @@ -79,7 +80,10 @@ export const QRCodeCard: React.FC = ({ // For dynamic QR codes, use the redirect URL for tracking // For static QR codes, use the direct URL from content - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050'); + // + // Must be the WWW origin, never the app origin: this value gets encoded into the QR + // code the user downloads and prints. /r/ is served by the marketing host. + const baseUrl = getWwwOrigin(); // Get the QR URL based on type let qrUrl = ''; diff --git a/src/lib/email.ts b/src/lib/email.ts index ee06de8..2a254e9 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -25,6 +25,7 @@ */ import { Resend } from 'resend'; +import { appUrl, getWwwOrigin, wwwUrl } from '@/lib/hosts'; import nodemailer from 'nodemailer'; // Use a placeholder during build time, real key at runtime @@ -53,8 +54,7 @@ async function waitForRateLimit() { export async function sendPasswordResetEmail(email: string, resetToken: string) { await waitForRateLimit(); - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; - const resetUrl = `${appUrl}/reset-password?token=${resetToken}`; + const resetUrl = wwwUrl(`/reset-password?token=${resetToken}`); try { await resend.emails.send({ @@ -502,7 +502,7 @@ export async function sendAIFeatureLaunchEmail(email: string) {

www.qrmaster.net • - Dashboard • + DashboardHelp

@@ -559,7 +559,9 @@ function createSmtpTransport() { }); } -const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net'; +// Public marketing origin, used for the email chrome: logo, hero image, footer links. +// Per-page links below resolve their own host through appUrl() / wwwUrl(). +const wwwOrigin = getWwwOrigin(); export async function sendEmailVerificationEmail(email: string, name: string, verificationUrl: string) { const transport = createSmtpTransport(); @@ -579,7 +581,7 @@ export async function sendEmailVerificationEmail(email: string, name: string, ve export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUrl: string) { await waitForRateLimit(); - const createUrl = `${appUrl}/create`; + const createUrl = appUrl('/create'); const transport = createSmtpTransport(); @@ -714,7 +716,7 @@ function emailShell(headExtra: string, bodyContent: string): string {

- www.qrmaster.net + www.qrmaster.net  ·  support@qrmaster.net

@@ -740,7 +742,7 @@ const dotGridPattern = `url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2F */ export async function sendWelcomeEmail(email: string, name: string) { const transport = createSmtpTransport(); - const createUrl = `${appUrl}/create`; + const createUrl = appUrl('/create'); const firstName = name.split(' ')[0]; const html = emailShell('', ` @@ -792,7 +794,7 @@ export async function sendWelcomeEmail(email: string, name: string) { - Beautiful QR Code Experience + Beautiful QR Code Experience @@ -904,7 +906,7 @@ export async function sendWelcomeEmail(email: string, name: string) {
- Timo + Timo
@@ -940,7 +942,7 @@ export async function sendWelcomeEmail(email: string, name: string) { */ export async function sendActivationNudgeEmail(email: string, name: string) { const transport = createSmtpTransport(); - const createUrl = `${appUrl}/create`; + const createUrl = appUrl('/create'); const firstName = name.split(' ')[0]; const steps = [ @@ -1077,7 +1079,7 @@ export async function sendActivationNudgeEmail(email: string, name: string) { */ export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount: number) { const transport = createSmtpTransport(); - const pricingUrl = `${appUrl}/pricing`; + const pricingUrl = wwwUrl('/pricing'); const firstName = name.split(' ')[0]; const features = [ @@ -1253,7 +1255,7 @@ export async function sendThirtyDayNudgeEmail( scanCount: number = 0 ) { const transport = createSmtpTransport(); - const pricingUrl = `${appUrl}/pricing`; + const pricingUrl = wwwUrl('/pricing'); const firstName = name.split(' ')[0]; const html = emailShell('', ` @@ -1435,7 +1437,7 @@ export async function sendFirstScanEmail( ) { const transport = createSmtpTransport(); const firstName = name.split(' ')[0]; - const analyticsUrl = `${appUrl}/analytics`; + const analyticsUrl = appUrl('/analytics'); const time = scan.ts.toLocaleTimeString('en-GB', { hour: '2-digit', diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts new file mode 100644 index 0000000..e9baf12 --- /dev/null +++ b/src/lib/hosts.ts @@ -0,0 +1,125 @@ +/** + * Host boundary between the marketing site and the app. + * + * Marketing/SEO content and the auth entry points (/login, /signup) live on + * www.qrmaster.net; everything behind the login lives on app.qrmaster.net. + * + * Both hostnames are served by the SAME Next deployment - nothing moves in the file + * tree. This module is the single source of truth for which host owns which path, shared + * by the middleware (which redirects the mismatches) and by every place that builds an + * absolute URL: Stripe return URLs, emails, OAuth redirects. + * + * Safe to import from middleware, route handlers and client components alike: no node + * APIs, and the NEXT_PUBLIC_* reads stay literal so the compiler can inline them. + */ + +const isProduction = process.env.NODE_ENV === 'production'; + +/** + * Production fallbacks are hardcoded on purpose. If NEXT_PUBLIC_WWW_URL were missing in + * production a localhost fallback would end up encoded into downloaded - and printed - + * QR codes. A wrong-but-real domain is recoverable, `http://localhost:3050` on a flyer + * is not. + */ +const WWW_FALLBACK = isProduction ? 'https://www.qrmaster.net' : 'http://localhost:3050'; +const APP_FALLBACK = isProduction ? 'https://app.qrmaster.net' : 'http://localhost:3050'; + +/** + * Path prefixes owned by the app host. + * + * Keep in sync with the `(app)` route group. `/upgrade` is included even though it is not + * in the middleware's protectedPaths list - it is an in-app page, only ever linked from + * inside the app. + */ +export const APP_PATH_PREFIXES = [ + '/analytics', + '/bulk-creation', + '/create', + '/dashboard', + '/integrations', + '/onboarding', + '/qr', + '/settings', + '/upgrade', +] as const; + +function stripTrailingSlash(url: string): string { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +/** + * True when `path` is served by the app host. + * + * Accepts a bare pathname or a path with query/hash - callers routinely pass things like + * `/dashboard?success=true`, and matching those against the prefixes directly would miss. + */ +export function isAppPath(path: string): boolean { + const pathname = path.split(/[?#]/)[0]; + + return APP_PATH_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) + ); +} + +/** Origin of the marketing host, e.g. `https://www.qrmaster.net`. */ +export function getWwwOrigin(): string { + return stripTrailingSlash(process.env.NEXT_PUBLIC_WWW_URL || WWW_FALLBACK); +} + +/** Origin of the app host, e.g. `https://app.qrmaster.net`. */ +export function getAppOrigin(): string { + return stripTrailingSlash(process.env.NEXT_PUBLIC_APP_URL || APP_FALLBACK); +} + +/** + * Whether marketing and app actually live on different hostnames. + * + * False in development, where both point at localhost:3050 - the middleware must not try + * to split hosts there or every request would redirect to itself. + */ +export function isHostSplitEnabled(): boolean { + try { + return new URL(getWwwOrigin()).host !== new URL(getAppOrigin()).host; + } catch { + return false; + } +} + +/** Absolute URL for `path` on the marketing host. */ +export function wwwUrl(path: string): string { + return new URL(path, getWwwOrigin()).toString(); +} + +/** Absolute URL for `path` on the app host. */ +export function appUrl(path: string): string { + return new URL(path, getAppOrigin()).toString(); +} + +/** + * Absolute URL for `path` on whichever host owns it. + * + * Use this whenever the path is not known statically - Stripe return paths, post-auth + * redirect targets - so a caller can never send a user to the wrong host. + */ +export function urlForPath(path: string): string { + return isAppPath(path) ? appUrl(path) : wwwUrl(path); +} + +/** + * Whether navigating to `path` from the current page crosses the host boundary. + * + * next/navigation's router can only push same-origin URLs, so a crossing needs a full + * `window.location` load. Always false on the server and in development, where both + * hosts are the same origin - callers then keep their normal client-side navigation. + */ +export function needsHostChange(path: string): boolean { + if (typeof window === 'undefined') { + return false; + } + + try { + return new URL(urlForPath(path)).origin !== window.location.origin; + } catch { + return false; + } +} diff --git a/src/lib/marketingEmail.ts b/src/lib/marketingEmail.ts index f33b4d5..1a2318b 100644 --- a/src/lib/marketingEmail.ts +++ b/src/lib/marketingEmail.ts @@ -1,6 +1,7 @@ import 'server-only'; import crypto from 'crypto'; +import { wwwUrl } from '@/lib/hosts'; const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 365; @@ -27,9 +28,7 @@ export function createMarketingUnsubscribeUrl(email: string): string { JSON.stringify({ email: normalizeEmail(email), expiresAt: Date.now() + TOKEN_TTL_MS }) ).toString('base64url'); const token = `${payload}.${sign(payload)}`; - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net'; - - return `${appUrl}/unsubscribe?token=${encodeURIComponent(token)}`; + return wwwUrl(`/unsubscribe?token=${encodeURIComponent(token)}`); } export function getUnsubscribeEmail(token: string | null | undefined): string | null { diff --git a/src/lib/metaConversions.ts b/src/lib/metaConversions.ts index c29b7d6..c7f9a04 100644 --- a/src/lib/metaConversions.ts +++ b/src/lib/metaConversions.ts @@ -1,4 +1,5 @@ import * as crypto from 'crypto'; +import { getWwwOrigin } from '@/lib/hosts'; const BASE_URL = 'https://graph.facebook.com/v21.0'; const PIXEL_ID = process.env.META_PIXEL_ID; @@ -41,7 +42,8 @@ export async function sendConversionEvent(event: ConversionEvent): Promise { event_name: event.eventName, event_time: event.eventTime ?? Math.floor(Date.now() / 1000), - event_source_url: event.eventSourceUrl ?? process.env.NEXT_PUBLIC_APP_URL, + // Ad attribution happens on the public site, so the fallback is the marketing host. + event_source_url: event.eventSourceUrl ?? getWwwOrigin(), action_source: 'website', user_data: hashedUserData, custom_data: event.customData ?? {}, diff --git a/src/middleware.ts b/src/middleware.ts index 76001eb..7070490 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -7,6 +7,13 @@ import { } from '@/lib/revops'; import { verifySignedUserIdEdge } from '@/lib/session-edge'; import { getCookieDomain } from '@/lib/cookieConfig'; +import { + getAppOrigin, + getWwwOrigin, + isAppPath, + isHostSplitEnabled, + wwwUrl, +} from '@/lib/hosts'; const isProduction = process.env.NODE_ENV === 'production'; @@ -44,7 +51,69 @@ function attachAttributionCookie(req: NextRequest, response: NextResponse) { return response; } -export async function middleware(req: NextRequest) { +/** 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; @@ -56,6 +125,23 @@ export async function middleware(req: NextRequest) { 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)); @@ -164,8 +250,8 @@ export async function middleware(req: NextRequest) { const userId = await verifySignedUserIdEdge(req.cookies.get('userId')?.value); if (!userId) { - // Not authenticated - redirect to signup - const signupUrl = new URL('/signup', req.url); + // 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)); @@ -175,6 +261,20 @@ export async function middleware(req: NextRequest) { 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: [ /*