From 4431778c0a05f24e8e41f8408d2d4cae8871b3d9 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Sun, 19 Apr 2026 10:12:44 -0500 Subject: [PATCH] Replace hardcoded echoocr.com fallbacks with resolved site URL Introduces src/lib/site-url.ts with getSiteUrl(), getSiteUrlFromRequest(), and getSiteHostname() helpers that cascade through NEXT_PUBLIC_SITE_URL, AUTH_URL, VERCEL_URL, and localhost so no code path ever falls back to a third-party domain we may not own. - forgot-password route now derives the base URL from the incoming request origin so reset links always match the host the user hit - email-sender, layout metadata, email preview, and QR code routes use the new helper; email footers display the derived hostname instead of a hardcoded brand string - .env.example clarifies the real expected values for AUTH_URL and NEXT_PUBLIC_SITE_URL per environment Made-with: Cursor --- .env.example | 10 +++- src/app/api/auth/forgot-password/route.ts | 18 +++--- src/app/api/dev/email-preview/route.ts | 6 +- src/app/api/form-templates/[id]/qr/route.ts | 8 +-- src/app/layout.tsx | 3 +- src/lib/email-sender.ts | 7 ++- src/lib/site-url.ts | 62 +++++++++++++++++++++ 7 files changed, 94 insertions(+), 20 deletions(-) create mode 100644 src/lib/site-url.ts diff --git a/.env.example b/.env.example index b04656e..1ec8745 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,11 @@ AI_GATEWAY_API_KEY="" # ─── Auth.js (required — generate with: npx auth secret) ───── AUTH_SECRET="" -AUTH_URL="https://echoocr.yourdomain.com" +# AUTH_URL must match the canonical origin this deployment serves. +# Local dev: http://localhost:3000 +# Production: https:// +# Preview: https://-git--.vercel.app +AUTH_URL="http://localhost:3000" # ─── OIDC SSO (optional) ────────────────────────────────────── AUTHENTIK_ISSUER="" @@ -35,7 +39,9 @@ EMAIL_FROM_ADDRESS="mars@noreply.stillwell.cloud" CRON_SECRET="" # ─── Public site URL (used for OG metadata, canonical links) ─ -NEXT_PUBLIC_SITE_URL="https://echoocr.com" +# Used by transactional emails, OpenGraph tags, and QR codes. +# If unset, the app falls back to AUTH_URL, then VERCEL_URL, then localhost. +NEXT_PUBLIC_SITE_URL="" # ─── Environment indicator ──────────────────────────────────── NEXT_PUBLIC_ENV="" diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts index 6cf737a..ec15a52 100644 --- a/src/app/api/auth/forgot-password/route.ts +++ b/src/app/api/auth/forgot-password/route.ts @@ -2,9 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; import { prisma } from "@/lib/db"; import { sendEmail } from "@/lib/email-sender"; - -const SITE_URL = - process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com"; +import { getSiteHostname, getSiteUrlFromRequest } from "@/lib/site-url"; const BRAND = { dark: "#2d2b28", @@ -19,7 +17,8 @@ const BRAND = { font: "'Quicksand', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", }; -function resetEmailHtml(resetUrl: string) { +function resetEmailHtml(resetUrl: string, siteUrl: string) { + const hostname = getSiteHostname(siteUrl); return ` @@ -49,7 +48,7 @@ function resetEmailHtml(resetUrl: string) {

AI-powered response card scanning for churches

-

echoocr.com

+

${hostname}

@@ -78,8 +77,13 @@ export async function POST(req: NextRequest) { }, }); - const resetUrl = `${SITE_URL}/reset-password?token=${token}`; - await sendEmail(user.email, "Reset your password — Echo", resetEmailHtml(resetUrl)); + const siteUrl = getSiteUrlFromRequest(req); + const resetUrl = `${siteUrl}/reset-password?token=${token}`; + await sendEmail( + user.email, + "Reset your password — Echo", + resetEmailHtml(resetUrl, siteUrl) + ); } return NextResponse.json({ ok: true }); diff --git a/src/app/api/dev/email-preview/route.ts b/src/app/api/dev/email-preview/route.ts index d7bd95d..2607451 100644 --- a/src/app/api/dev/email-preview/route.ts +++ b/src/app/api/dev/email-preview/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { getSiteHostname, getSiteUrlFromRequest } from "@/lib/site-url"; export async function GET(req: NextRequest) { if (process.env.NODE_ENV === "production") { @@ -10,7 +11,8 @@ export async function GET(req: NextRequest) { const { searchParams } = req.nextUrl; const template = searchParams.get("template") || "verification"; - const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com"; + const SITE_URL = getSiteUrlFromRequest(req); + const SITE_HOSTNAME = getSiteHostname(SITE_URL); const BRAND = { dark: "#2d2b28", @@ -105,7 +107,7 @@ export async function GET(req: NextRequest) {

AI-powered response card scanning for churches

-

echoocr.com

+

${SITE_HOSTNAME}

diff --git a/src/app/api/form-templates/[id]/qr/route.ts b/src/app/api/form-templates/[id]/qr/route.ts index 2c2be0e..2257dec 100644 --- a/src/app/api/form-templates/[id]/qr/route.ts +++ b/src/app/api/form-templates/[id]/qr/route.ts @@ -1,11 +1,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth"; +import { getSiteUrlFromRequest } from "@/lib/site-url"; import QRCode from "qrcode"; type RouteContext = { params: Promise<{ id: string }> }; -export async function GET(_request: NextRequest, ctx: RouteContext) { +export async function GET(request: NextRequest, ctx: RouteContext) { try { const session = await requireApiAuthWithOrg(); const { id } = await ctx.params; @@ -21,10 +22,7 @@ export async function GET(_request: NextRequest, ctx: RouteContext) { return new Response("Not found", { status: 404 }); } - const baseUrl = - process.env.NEXTAUTH_URL || - process.env.AUTH_URL || - "https://echoocr.app"; + const baseUrl = getSiteUrlFromRequest(request); const surveyUrl = `${baseUrl}/s/${template.organization.slug}/${template.slug}`; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b78d054..cc8b8fe 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Quicksand } from "next/font/google"; import "./globals.css"; import { Providers } from "@/components/providers"; +import { getSiteUrl } from "@/lib/site-url"; const quicksand = Quicksand({ variable: "--font-sans", @@ -9,7 +10,7 @@ const quicksand = Quicksand({ weight: ["300", "400", "500", "600", "700"], }); -const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com"; +const siteUrl = getSiteUrl(); export const metadata: Metadata = { title: { diff --git a/src/lib/email-sender.ts b/src/lib/email-sender.ts index b3069ab..0c1b7e3 100644 --- a/src/lib/email-sender.ts +++ b/src/lib/email-sender.ts @@ -1,6 +1,7 @@ import { BrevoClient } from "@getbrevo/brevo"; import { prisma } from "@/lib/db"; import crypto from "crypto"; +import { getSiteHostname, getSiteUrl } from "@/lib/site-url"; const brevo = new BrevoClient({ apiKey: process.env.BREVO_API_KEY! }); @@ -9,8 +10,8 @@ const DEFAULT_SENDER = { email: process.env.EMAIL_FROM_ADDRESS || "mars@noreply.stillwell.cloud", }; -const SITE_URL = - process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com"; +const SITE_URL = getSiteUrl(); +const SITE_HOSTNAME = getSiteHostname(SITE_URL); /* ------------------------------------------------------------------ */ /* Shared template shell */ @@ -133,7 +134,7 @@ function emailShell({ AI-powered response card scanning for churches

- echoocr.com + ${SITE_HOSTNAME}

diff --git a/src/lib/site-url.ts b/src/lib/site-url.ts new file mode 100644 index 0000000..198c5e3 --- /dev/null +++ b/src/lib/site-url.ts @@ -0,0 +1,62 @@ +/** + * Resolve the canonical site URL using a cascade that works across every + * environment (local dev, Vercel previews, production). + * + * Order of preference: + * 1. NEXT_PUBLIC_SITE_URL — explicit brand/marketing URL (e.g. https://echo.stillwell.cloud) + * 2. AUTH_URL — set in production for Auth.js; identical to canonical host + * 3. VERCEL_URL — auto-injected on every Vercel deploy (e.g. *.vercel.app) + * 4. http://localhost:3000 — final fallback for local dev + * + * Never returns a hardcoded third-party domain the team might not own. + */ +export function getSiteUrl(): string { + const explicit = process.env.NEXT_PUBLIC_SITE_URL || process.env.AUTH_URL; + if (explicit) return stripTrailingSlash(explicit); + + if (process.env.VERCEL_URL) { + return `https://${process.env.VERCEL_URL}`; + } + + return "http://localhost:3000"; +} + +/** + * Resolve the site URL from an incoming request first (most reliable — whatever + * host the user actually hit), falling back to env vars. + * + * Prefer this in API routes where you're generating links the user will follow + * (password reset emails, invite links, QR codes, etc.), so links always point + * back to the same domain the request came from. + */ +export function getSiteUrlFromRequest(req: Request): string { + try { + const url = new URL(req.url); + const forwardedHost = req.headers.get("x-forwarded-host"); + const forwardedProto = req.headers.get("x-forwarded-proto"); + if (forwardedHost) { + const proto = forwardedProto || url.protocol.replace(":", "") || "https"; + return `${proto}://${forwardedHost}`; + } + return url.origin; + } catch { + return getSiteUrl(); + } +} + +/** + * Extract a human-readable hostname (e.g. "echo.stillwell.cloud") from any + * URL string. Useful for email footers and marketing copy so the displayed + * brand domain always matches the link target. + */ +export function getSiteHostname(siteUrl: string = getSiteUrl()): string { + try { + return new URL(siteUrl).hostname; + } catch { + return siteUrl; + } +} + +function stripTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +}