/** * POST /api/submit — receive one message from the website. * * Same contract as server.py, so the page code is unchanged: * { ref, type, data, createdAt, page } * * Storage: Cloudflare D1 (binding DB). * Privacy: the sender's IP is hashed with RATE_SALT and kept only in the * separate rate_limits table for 10 minutes. It is never linked to a message, * and anonymous messages store only the text. */ const TYPES = new Set(["appointment", "comment", "anonymous"]); const LIMITS = { name: 120, phone: 40, email: 160, date: 20, time: 10, topic: 80, notes: 1500, message: 2500 }; const MAX_BODY = 20000; const RATE_LIMIT = 20; const RATE_WINDOW = 10 * 60; // seconds function json(status, payload, extraHeaders) { return new Response(JSON.stringify(payload), { status, headers: Object.assign( { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }, extraHeaders || {} ) }); } function clean(value, limit) { if (typeof value !== "string") return ""; let out = ""; for (const ch of value) { const code = ch.codePointAt(0); if (ch === "\n" || ch === "\t" || code >= 32) out += ch; } return out.trim().slice(0, limit); } function makeRef() { const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; const bytes = crypto.getRandomValues(new Uint8Array(8)); let text = ""; for (const b of bytes) text += alphabet[b % alphabet.length]; return "MW-" + text.slice(0, 5) + "-" + text.slice(5, 8); } async function hashIp(ip, salt) { const bytes = new TextEncoder().encode(String(salt || "") + "|" + ip); const digest = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, "0")) .join("") .slice(0, 32); } function buildData(type, input) { const source = input && typeof input === "object" ? input : {}; if (type === "appointment") { const data = { name: clean(source.name, LIMITS.name), phone: clean(source.phone, LIMITS.phone), email: clean(source.email, LIMITS.email), date: clean(source.date, LIMITS.date), time: clean(source.time, LIMITS.time), topic: clean(source.topic, LIMITS.topic), notes: clean(source.notes, LIMITS.notes) }; if (!data.name || !data.phone || !data.date || !data.time) return null; if (!/^\d{4}-\d{2}-\d{2}$/.test(data.date)) return null; if (!/^\d{1,2}:\d{2}$/.test(data.time)) return null; if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) return null; return data; } if (type === "comment") { const data = { name: clean(source.name, LIMITS.name), email: clean(source.email, LIMITS.email), message: clean(source.message, LIMITS.message) }; if (!data.message) return null; if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) return null; return data; } const data = { message: clean(source.message, LIMITS.message) }; if (!data.message) return null; return data; } export async function onRequestPost({ request, env }) { if (!env || !env.DB) { return json(500, { ok: false, error: "Message storage is not configured." }); } // Rate limiting needs a secret salt; without one a plain hash of an IPv4 // address is reversible. Use the admin key as a fallback rather than // silently storing a weak hash. const salt = env.RATE_SALT || env.MW_ADMIN_KEY; if (!salt) { return json(500, { ok: false, error: "The site is not fully configured." }); } const declared = Number(request.headers.get("content-length") || 0); if (declared > MAX_BODY) { return json(413, { ok: false, error: "Message is too long." }); } let raw; try { raw = await request.text(); } catch (err) { return json(400, { ok: false, error: "Could not read the message." }); } if (raw.length > MAX_BODY) { return json(413, { ok: false, error: "Message is too long." }); } let payload; try { payload = JSON.parse(raw); } catch (err) { return json(400, { ok: false, error: "Invalid request." }); } if (!payload || typeof payload !== "object") { return json(400, { ok: false, error: "Invalid request." }); } const type = payload.type; if (!TYPES.has(type)) { return json(400, { ok: false, error: "Unknown message type." }); } const data = buildData(type, payload.data); if (!data) { return json(400, { ok: false, error: "Please check the required fields." }); } const nowSeconds = Math.floor(Date.now() / 1000); const cutoff = nowSeconds - RATE_WINDOW; try { const ip = request.headers.get("cf-connecting-ip") || "unknown"; const ipHash = await hashIp(ip, salt); const seen = await env.DB.prepare( "SELECT COUNT(*) AS n FROM rate_limits WHERE ip_hash = ? AND ts > ?" ) .bind(ipHash, cutoff) .first(); if (seen && Number(seen.n) >= RATE_LIMIT) { return json(429, { ok: false, error: "Too many messages from this device. Please try again later." }); } await env.DB.prepare("INSERT INTO rate_limits (ip_hash, ts) VALUES (?, ?)") .bind(ipHash, nowSeconds) .run(); // keep the rate table small: only the current 10-minute window is needed await env.DB.prepare("DELETE FROM rate_limits WHERE ts <= ?") .bind(cutoff) .run(); const receivedAt = new Date().toISOString(); const createdAt = typeof payload.createdAt === "string" && payload.createdAt.length <= 40 ? payload.createdAt : receivedAt; const page = clean(payload.page, 300); let ref = typeof payload.ref === "string" && /^MW-[A-Z0-9]{5}-[A-Z0-9]{3}$/.test(payload.ref) ? payload.ref : makeRef(); for (let attempt = 0; attempt < 2; attempt++) { try { await env.DB.prepare( "INSERT INTO submissions (ref, type, data, page, created_at, received_at) VALUES (?, ?, ?, ?, ?, ?)" ) .bind(ref, type, JSON.stringify(data), page, createdAt, receivedAt) .run(); return json(201, { ok: true, ref, receivedAt }); } catch (err) { if (attempt === 0) { ref = makeRef(); continue; } return json(500, { ok: false, error: "Could not save the message." }); } } } catch (err) { return json(500, { ok: false, error: "Could not save the message." }); } return json(500, { ok: false, error: "Could not save the message." }); } export async function onRequestGet() { return json(405, { ok: false, error: "Use POST." }, { allow: "POST" }); }