feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m55s

Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:08:33 +02:00
1 parent 2ff08e5127
commit ed7db6e048
76 files changed
+4834 -1376

No files matched your search

+60 -24
View File
@@ -1,20 +1,21 @@
"use server";
import { createHash, timingSafeEqual } from "node:crypto";
import { createHmac, timingSafeEqual } from "node:crypto";
import { getTranslations } from "next-intl/server";
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.
// Stateless email verification with a time-limited HMAC token.
//
// 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).
// Token format: `{issuedAtUnix}.{hmacHex}` where
// hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`)
// Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests
// (bare 64-char hex) are rejected.
/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
/** Secret mixed into the HMAC. Requires at least one of APP_KEY or AUTH_SECRET. */
function verifySecret(): string {
const secret = env.APP_KEY || env.AUTH_SECRET;
if (!secret)
@@ -24,26 +25,43 @@ function verifySecret(): string {
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()}`)
function sign(email: string, issuedAt: number): string {
return createHmac("sha256", verifySecret())
.update(`${email}|${issuedAt}`)
.digest("hex");
}
/** Compute a fresh verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase();
const issuedAt = Math.floor(Date.now() / 1000);
return `${issuedAt}.${sign(normalised, issuedAt)}`;
}
/**
* Constant-time check that `token` matches the expected digest for `email`.
* Returns false on any length/format mismatch rather than throwing.
* Constant-time check that `token` matches a non-expired HMAC for `email`.
* Returns false on format/expiry/signature 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);
if (!email || !token) return false;
const normalised = email.trim().toLowerCase();
const match = /^(\d+)\.([a-f0-9]{64})$/i.exec(token.trim());
if (!match) return false; // also rejects legacy forever-valid digests
const issuedAt = Number(match[1]);
const sig = match[2]?.toLowerCase() ?? "";
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return false;
const ageMs = Date.now() - issuedAt * 1000;
if (ageMs < 0 || ageMs > TOKEN_TTL_MS) return false;
const expected = sign(normalised, issuedAt);
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(token.toLowerCase(), "utf8");
const b = Buffer.from(sig, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
@@ -63,22 +81,40 @@ export async function sendVerification(email: string): Promise<boolean> {
const hotelName =
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
let subject = `Verify your email · ${hotelName}`;
let heading = "Verify your email";
let body = `Welcome to ${hotelName}! Confirm this email address to finish setting up your account.`;
let button = "Verify email";
let fallback =
"If the button doesn't work, paste this link into your browser:";
try {
const t = await getTranslations("emails.verify");
subject = t("subject", { hotel: hotelName });
heading = t("heading");
body = t("body", { hotel: hotelName });
button = t("button");
fallback = t("fallback");
} catch {
/* messages missing — keep English defaults */
}
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>
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
<p>${escapeHtml(body)}</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
${escapeHtml(button)}
</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">${escapeHtml(fallback)}</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);
return sendMail(normalised, subject, html);
}
function escapeHtml(s: string): string {