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 <noreply@anthropic.com>
72 lines
1.9 KiB
TypeScript
72 lines
1.9 KiB
TypeScript
import 'server-only';
|
|
|
|
import crypto from 'crypto';
|
|
import { wwwUrl } from '@/lib/hosts';
|
|
|
|
const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 365;
|
|
|
|
function getSigningSecret(): string {
|
|
const secret = process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.NEXTAUTH_SECRET;
|
|
|
|
if (!secret) {
|
|
throw new Error('Set EMAIL_UNSUBSCRIBE_SECRET before sending marketing email.');
|
|
}
|
|
|
|
return secret;
|
|
}
|
|
|
|
function sign(payload: string): string {
|
|
return crypto.createHmac('sha256', getSigningSecret()).update(payload).digest('base64url');
|
|
}
|
|
|
|
function normalizeEmail(email: string): string {
|
|
return email.trim().toLowerCase();
|
|
}
|
|
|
|
export function createMarketingUnsubscribeUrl(email: string): string {
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ email: normalizeEmail(email), expiresAt: Date.now() + TOKEN_TTL_MS })
|
|
).toString('base64url');
|
|
const token = `${payload}.${sign(payload)}`;
|
|
return wwwUrl(`/unsubscribe?token=${encodeURIComponent(token)}`);
|
|
}
|
|
|
|
export function getUnsubscribeEmail(token: string | null | undefined): string | null {
|
|
if (!token) return null;
|
|
|
|
const separator = token.lastIndexOf('.');
|
|
if (separator <= 0 || separator === token.length - 1) return null;
|
|
|
|
const payload = token.slice(0, separator);
|
|
const providedSignature = token.slice(separator + 1);
|
|
const expectedSignature = sign(payload);
|
|
const providedBuffer = Buffer.from(providedSignature);
|
|
const expectedBuffer = Buffer.from(expectedSignature);
|
|
|
|
if (
|
|
providedBuffer.length !== expectedBuffer.length ||
|
|
!crypto.timingSafeEqual(providedBuffer, expectedBuffer)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
|
|
email?: unknown;
|
|
expiresAt?: unknown;
|
|
};
|
|
|
|
if (
|
|
typeof decoded.email !== 'string' ||
|
|
typeof decoded.expiresAt !== 'number' ||
|
|
decoded.expiresAt < Date.now()
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return normalizeEmail(decoded.email);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|