/** * The built-in contact form (#702): validation, storage and notification. * * Messages stay on this server, one JSON file each in `content/messages/`. * No SMTP and no form service: the owner reads them in the admin panel and * learns about new ones through an optional plain-text POST to * `contact.notifyUrl` (an ntfy topic, typically). The notification carries * nothing about the sender: no name, no address, no text. A push service is a * third party, and a fixed "new message" needs no line in the privacy policy. * * Retention is enforced on every read and write rather than by a timer, so * nothing older than `retentionDays` survives the next visit to the inbox or * the next submission. The privacy policy can then state a fixed period. */ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; export interface ContactMessage { id: string; /** ISO timestamp. */ receivedAt: string; name: string; email: string; message: string; read: boolean; } export interface ContactInput { name?: unknown; email?: unknown; message?: unknown; /** Honeypot: a field hidden from people. Anything in it means a bot. */ website?: unknown; /** When the form was rendered (epoch ms), for the minimum fill time. */ startedAt?: unknown; } export const LIMITS = { name: 100, email: 200, message: 5000 } as const; /** Faster than a person can type a message; slower than a bot posting on load. */ export const MIN_FILL_MS = 3000; /** A form older than this was not filled in just now. */ const MAX_FILL_MS = 24 * 60 * 60 * 1000; /** * Hard cap on stored messages. A flood that gets past the rate limit fills the * inbox and then stops, rather than filling the disk. New messages are refused * at the cap instead of evicting old ones: a flood must not delete real mail. */ export const MAX_MESSAGES = 500; const ID_RE = /^\d{13}-[a-f0-9]{8}$/; // One `@`, a local part, and a domain of dot-separated labels. The labels // exclude the dot, so each character can match only one way: the previous // pattern let `[^…]+\.[^…]+` split a dotted domain in many places and ran in // polynomial time on crafted input (CodeQL js/polynomial-redos). const EMAIL_RE = /^[^\s@<>()[\]\\,;:"]+@[^\s@<>()[\]\\,;:".]+(?:\.[^\s@<>()[\]\\,;:".]+)+$/; export function messagesDir(): string { return path.join(process.cwd(), 'content', 'messages'); } export type ValidationResult = | { ok: true; name: string; email: string; message: string } | { ok: false; reason: 'invalid' } /** Looks like a bot. The route answers as if it had worked, so the bot learns nothing. */ | { ok: false; reason: 'spam' }; export function validateContact(input: ContactInput, now = Date.now()): ValidationResult { if (typeof input.website === 'string' && input.website.trim()) { return { ok: false, reason: 'spam' }; } const started = Number(input.startedAt); if (!Number.isFinite(started) || now - started < MIN_FILL_MS || now - started > MAX_FILL_MS) { return { ok: false, reason: 'spam' }; } const text = (value: unknown) => (typeof value === 'string' ? value.trim() : ''); const name = text(input.name); const email = text(input.email); const message = text(input.message); if ( !name || name.length > LIMITS.name || // Length first: the pattern never sees more than the limit. email.length > LIMITS.email || !EMAIL_RE.test(email) || !message || message.length > LIMITS.message ) { return { ok: false, reason: 'invalid' }; } // A name is one line. Keeping newlines out also keeps them out of the // notification text and the reply subject. return { ok: true, name: name.replace(/\s+/g, ' '), email, message }; } function isMessage(value: unknown): value is ContactMessage { const m = value as ContactMessage; return ( !!m && typeof m.id === 'string' && ID_RE.test(m.id) && typeof m.receivedAt === 'string' && typeof m.name === 'string' && typeof m.email === 'string' && typeof m.message === 'string' ); } async function writeMessage(msg: ContactMessage): Promise { await fs.mkdir(messagesDir(), { recursive: true }); const target = path.join(messagesDir(), `${msg.id}.json`); const tmp = `${target}.${process.pid}.tmp`; await fs.writeFile(tmp, JSON.stringify(msg, null, 2), { mode: 0o600 }); await fs.rename(tmp, target); } /** * Every stored message, newest first, after deleting the ones past their * retention. Unreadable files are skipped rather than failing the inbox. */ export async function listMessages( retentionDays: number, now = Date.now(), ): Promise { let names: string[]; try { names = await fs.readdir(messagesDir()); } catch { return []; } const cutoff = now - retentionDays * 24 * 60 * 60 * 1000; const messages: ContactMessage[] = []; for (const name of names) { const id = name.replace(/\.json$/, ''); if (!ID_RE.test(id) || !name.endsWith('.json')) continue; const file = path.join(messagesDir(), name); if (Number(id.slice(0, 13)) < cutoff) { await fs.rm(file, { force: true }); continue; } try { const parsed = JSON.parse(await fs.readFile(file, 'utf8')); if (isMessage(parsed)) messages.push({ ...parsed, read: parsed.read === true }); } catch { // A half-written or hand-edited file is not worth failing the inbox for. } } return messages.sort((a, b) => b.id.localeCompare(a.id)); } export async function saveMessage( fields: { name: string; email: string; message: string }, retentionDays: number, now = Date.now(), ): Promise { const existing = await listMessages(retentionDays, now); if (existing.length >= MAX_MESSAGES) return 'full'; const msg: ContactMessage = { id: `${now}-${crypto.randomBytes(4).toString('hex')}`, receivedAt: new Date(now).toISOString(), ...fields, read: false, }; await writeMessage(msg); return msg; } /** Ids come from the admin client; the pattern keeps them inside the directory. */ export function isMessageId(id: unknown): id is string { return typeof id === 'string' && ID_RE.test(id); } export async function setRead(id: string, read: boolean): Promise { if (!isMessageId(id)) return false; const file = path.join(messagesDir(), `${id}.json`); try { const parsed = JSON.parse(await fs.readFile(file, 'utf8')); if (!isMessage(parsed)) return false; await writeMessage({ ...parsed, read }); return true; } catch { return false; } } export async function deleteMessage(id: string): Promise { if (!isMessageId(id)) return false; try { await fs.rm(path.join(messagesDir(), `${id}.json`)); return true; } catch { return false; } } /** * Tell the owner a message arrived. Best effort: the message is already * stored, so a failing push service must not fail the visitor's submission. * * The body is plain text, which is what ntfy expects; its `Title` and `Click` * headers are ignored by endpoints that do not know them. */ export async function notifyNewMessage( notifyUrl: string | undefined, inboxUrl: string | null, ): Promise { if (!notifyUrl) return; try { const headers: Record = { 'Content-Type': 'text/plain; charset=utf-8', Title: 'New contact form message', Tags: 'envelope', }; if (inboxUrl) headers.Click = inboxUrl; const res = await fetch(notifyUrl, { method: 'POST', headers, body: 'A new message arrived through the contact form.', signal: AbortSignal.timeout(5000), }); if (!res.ok) console.warn(`[Folio] contact: notification answered ${res.status}`); } catch (err) { console.warn(`[Folio] contact: notification failed: ${(err as Error).message}`); } }