91 lines
3.4 KiB
TypeScript
91 lines
3.4 KiB
TypeScript
"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<string> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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 = `
|
|
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
|
|
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
|
|
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
|
|
<p style="margin:1.25rem 0">
|
|
<a href="${link}"
|
|
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
|
|
Verify email
|
|
</a>
|
|
</p>
|
|
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
|
|
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
|
|
</div>
|
|
`.trim();
|
|
|
|
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|