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
This commit is contained in:
Randall Stillwell 2026-04-19 10:12:44 -05:00
parent 8ae013879b
commit 4431778c0a
7 changed files with 94 additions and 20 deletions

View file

@ -18,7 +18,11 @@ AI_GATEWAY_API_KEY=""
# ─── Auth.js (required — generate with: npx auth secret) ───── # ─── Auth.js (required — generate with: npx auth secret) ─────
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://<your-canonical-domain>
# Preview: https://<project>-git-<branch>-<team>.vercel.app
AUTH_URL="http://localhost:3000"
# ─── OIDC SSO (optional) ────────────────────────────────────── # ─── OIDC SSO (optional) ──────────────────────────────────────
AUTHENTIK_ISSUER="" AUTHENTIK_ISSUER=""
@ -35,7 +39,9 @@ EMAIL_FROM_ADDRESS="mars@noreply.stillwell.cloud"
CRON_SECRET="" CRON_SECRET=""
# ─── Public site URL (used for OG metadata, canonical links) ─ # ─── 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 ──────────────────────────────────── # ─── Environment indicator ────────────────────────────────────
NEXT_PUBLIC_ENV="" NEXT_PUBLIC_ENV=""

View file

@ -2,9 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto"; import crypto from "crypto";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { sendEmail } from "@/lib/email-sender"; import { sendEmail } from "@/lib/email-sender";
import { getSiteHostname, getSiteUrlFromRequest } from "@/lib/site-url";
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
const BRAND = { const BRAND = {
dark: "#2d2b28", dark: "#2d2b28",
@ -19,7 +17,8 @@ const BRAND = {
font: "'Quicksand', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", 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 `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/> <html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet"/>
@ -49,7 +48,7 @@ function resetEmailHtml(resetUrl: string) {
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;"> <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
<tr><td align="center" style="padding:28px 0 8px;"> <tr><td align="center" style="padding:28px 0 8px;">
<p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};">AI-powered response card scanning for churches</p> <p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};">AI-powered response card scanning for churches</p>
<p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;"><a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;">echoocr.com</a></p> <p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;"><a href="${siteUrl}" style="color:${BRAND.link};text-decoration:underline;">${hostname}</a></p>
</td></tr> </td></tr>
</table> </table>
</td></tr></table> </td></tr></table>
@ -78,8 +77,13 @@ export async function POST(req: NextRequest) {
}, },
}); });
const resetUrl = `${SITE_URL}/reset-password?token=${token}`; const siteUrl = getSiteUrlFromRequest(req);
await sendEmail(user.email, "Reset your password — Echo", resetEmailHtml(resetUrl)); const resetUrl = `${siteUrl}/reset-password?token=${token}`;
await sendEmail(
user.email,
"Reset your password — Echo",
resetEmailHtml(resetUrl, siteUrl)
);
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSiteHostname, getSiteUrlFromRequest } from "@/lib/site-url";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
@ -10,7 +11,8 @@ export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl; const { searchParams } = req.nextUrl;
const template = searchParams.get("template") || "verification"; 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 = { const BRAND = {
dark: "#2d2b28", dark: "#2d2b28",
@ -105,7 +107,7 @@ export async function GET(req: NextRequest) {
<tr> <tr>
<td align="center" style="padding:28px 0 8px;"> <td align="center" style="padding:28px 0 8px;">
<p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};line-height:1.5;">AI-powered response card scanning for churches</p> <p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};line-height:1.5;">AI-powered response card scanning for churches</p>
<p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;line-height:1.5;"><a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;" target="_blank">echoocr.com</a></p> <p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;line-height:1.5;"><a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;" target="_blank">${SITE_HOSTNAME}</a></p>
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -1,11 +1,12 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth"; import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import { getSiteUrlFromRequest } from "@/lib/site-url";
import QRCode from "qrcode"; import QRCode from "qrcode";
type RouteContext = { params: Promise<{ id: string }> }; type RouteContext = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, ctx: RouteContext) { export async function GET(request: NextRequest, ctx: RouteContext) {
try { try {
const session = await requireApiAuthWithOrg(); const session = await requireApiAuthWithOrg();
const { id } = await ctx.params; const { id } = await ctx.params;
@ -21,10 +22,7 @@ export async function GET(_request: NextRequest, ctx: RouteContext) {
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
} }
const baseUrl = const baseUrl = getSiteUrlFromRequest(request);
process.env.NEXTAUTH_URL ||
process.env.AUTH_URL ||
"https://echoocr.app";
const surveyUrl = `${baseUrl}/s/${template.organization.slug}/${template.slug}`; const surveyUrl = `${baseUrl}/s/${template.organization.slug}/${template.slug}`;

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Quicksand } from "next/font/google"; import { Quicksand } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Providers } from "@/components/providers"; import { Providers } from "@/components/providers";
import { getSiteUrl } from "@/lib/site-url";
const quicksand = Quicksand({ const quicksand = Quicksand({
variable: "--font-sans", variable: "--font-sans",
@ -9,7 +10,7 @@ const quicksand = Quicksand({
weight: ["300", "400", "500", "600", "700"], weight: ["300", "400", "500", "600", "700"],
}); });
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com"; const siteUrl = getSiteUrl();
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {

View file

@ -1,6 +1,7 @@
import { BrevoClient } from "@getbrevo/brevo"; import { BrevoClient } from "@getbrevo/brevo";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import crypto from "crypto"; import crypto from "crypto";
import { getSiteHostname, getSiteUrl } from "@/lib/site-url";
const brevo = new BrevoClient({ apiKey: process.env.BREVO_API_KEY! }); 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", email: process.env.EMAIL_FROM_ADDRESS || "mars@noreply.stillwell.cloud",
}; };
const SITE_URL = const SITE_URL = getSiteUrl();
process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com"; const SITE_HOSTNAME = getSiteHostname(SITE_URL);
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Shared template shell */ /* Shared template shell */
@ -133,7 +134,7 @@ function emailShell({
AI-powered response card scanning for churches AI-powered response card scanning for churches
</p> </p>
<p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;line-height:1.5;"> <p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;line-height:1.5;">
<a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;" target="_blank">echoocr.com</a> <a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;" target="_blank">${SITE_HOSTNAME}</a>
</p> </p>
</td> </td>
</tr> </tr>

62
src/lib/site-url.ts Normal file
View file

@ -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;
}