"use server"; import { createHash, timingSafeEqual } from "node:crypto"; import { env } from "@/env"; import { sendMail } from "@/lib/services/email"; import { siteSettings } from "@/lib/services/site-settings"; // Stateless email verification, AtomCMS-faithful but DB-table-free. // // Instead of persisting a row (password_resets style), the token is a keyed // digest of the email address: sha256(email + APP_KEY). Because APP_KEY is a // server-only secret, an attacker who only knows the email cannot forge a // matching token, and /verify can recompute + compare it without any storage. // The token is therefore deterministic per (email, secret) pair and stays valid // until the account's mail_verified flips to '1' (after which /verify no-ops). /** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */ function verifySecret(): string { const secret = env.APP_KEY || env.AUTH_SECRET; if (!secret) throw new Error( "APP_KEY or AUTH_SECRET must be set for email verification", ); return secret; } /** Compute the verification token for an email (lowercased + trimmed). */ export async function verificationToken(email: string): Promise { const normalised = email.trim().toLowerCase(); return createHash("sha256") .update(`${normalised}|${verifySecret()}`) .digest("hex"); } /** * Constant-time check that `token` matches the expected digest for `email`. * Returns false on any length/format mismatch rather than throwing. */ export async function isValidVerificationToken( email: string, token: string, ): Promise { if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false; const expected = await verificationToken(email); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(token.toLowerCase(), "utf8"); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } /** * Build the verification link + email and send it. No-ops gracefully when SMTP * is unconfigured (sendMail returns false). */ export async function sendVerification(email: string): Promise { const normalised = email.trim().toLowerCase(); if (!normalised) return false; const token = await verificationToken(normalised); const base = env.APP_URL.replace(/\/+$/, ""); const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`; const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME; const html = `

Verify your email

Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.

Verify email

If the button doesn't work, paste this link into your browser:

${link}

`.trim(); return sendMail(normalised, `Verify your email ยท ${hotelName}`, html); } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }