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:
parent
8ae013879b
commit
4431778c0a
7 changed files with 94 additions and 20 deletions
10
.env.example
10
.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://<your-canonical-domain>
|
||||
# Preview: https://<project>-git-<branch>-<team>.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=""
|
||||
|
|
|
|||
|
|
@ -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 `<!DOCTYPE html>
|
||||
<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"/>
|
||||
|
|
@ -49,7 +48,7 @@ function resetEmailHtml(resetUrl: string) {
|
|||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
|
||||
<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: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>
|
||||
</table>
|
||||
</td></tr></table>
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<tr>
|
||||
<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: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>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</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>
|
||||
<a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;" target="_blank">${SITE_HOSTNAME}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
62
src/lib/site-url.ts
Normal file
62
src/lib/site-url.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue