Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test:coverage": "vitest --coverage"
},
"dependencies": {
"@profullstack/form-guard": "^0.1.1",
"@supabase/supabase-js": "^2.47.0",
"bcrypt": "^6.0.0",
"fluent-ffmpeg": "^2.1.3",
Expand Down
71 changes: 63 additions & 8 deletions apps/web/src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { provenanceBlock, type Verdict } from '@profullstack/form-guard'
import { contactGuard } from '@/lib/contact-guard'

/**
* User input is interpolated into the notification email's HTML. Without
* escaping, a submitter can inject markup into the mail we read.
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}

const SUBJECT_LABELS: Record<string, string> = {
bug: 'Bug Report',
Expand All @@ -18,6 +33,33 @@ export async function POST(request: NextRequest) {

const { name, email, subject, message } = body

// Spam checks run before validation on purpose: a bot that gets
// "Name must be at least 2 characters" back has learned what to send
// next time, where one that gets a plain success has learned nothing.
let verdict: Verdict | null = null
if (contactGuard) {
verdict = await contactGuard.check({
fields: body as unknown as Record<string, unknown>,
headers: request.headers,
})
if (!verdict.allow) {
if (verdict.action === 'drop') {
console.warn(`contact: dropped submission (${verdict.reason}) ip=${verdict.ip ?? '?'}`)
return NextResponse.json({ success: true }, { status: 200 })
}
if (verdict.action === 'limited') {
return NextResponse.json(
{ error: 'Too many messages from this connection. Please try again later.' },
{ status: 429 }
)
}
return NextResponse.json(
{ error: 'That took too long, or came through too quickly. Please send it again.' },
{ status: 400 }
)
}
}

// Validate name
if (!name || typeof name !== 'string' || name.trim().length < 2) {
return NextResponse.json({ error: 'Name must be at least 2 characters' }, { status: 400 })
Expand Down Expand Up @@ -49,7 +91,9 @@ export async function POST(request: NextRequest) {
}

// Send email (async, don't wait)
sendContactEmail(name.trim(), email.trim(), subject, message.trim()).catch(console.error)
sendContactEmail(name.trim(), email.trim(), subject, message.trim(), verdict).catch(
console.error
)

return NextResponse.json({ success: true }, { status: 200 })
}
Expand All @@ -58,7 +102,8 @@ async function sendContactEmail(
name: string,
email: string,
subject: string,
message: string
message: string,
verdict: Verdict | null
) {
const adminEmail = process.env.ADMIN_EMAIL
const smtpHost = process.env.SMTP_HOST
Expand Down Expand Up @@ -87,12 +132,21 @@ async function sendContactEmail(

const subjectLabel = SUBJECT_LABELS[subject] || subject
const truncatedMessage = message.length > 50 ? message.substring(0, 50) + '...' : message
// A flagged message still arrives; the tag is only so an inbox rule
// can sort it. Scoring never drops anything.
const spamTag = verdict?.suspicious ? ` [spam? ${verdict.score}]` : ''
// Where it came from and why it scored as it did. None of this is in
// the headers: the notification is sent by us to us, so it
// authenticates identically whoever filled the form in.
const provenance = verdict
? `\n\n${provenanceBlock({ ip: verdict.ip, userAgent: verdict.userAgent, verdict })}`
: ''

await transporter.sendMail({
from: smtpFrom || smtpUser,
to: adminEmail,
replyTo: email,
subject: `[icemap Contact] ${subjectLabel}: ${truncatedMessage}`,
subject: `[icemap Contact] ${subjectLabel}: ${truncatedMessage}${spamTag}`,
text: `
New contact form submission from icemap.

Expand All @@ -101,28 +155,29 @@ Email: ${email}
Subject: ${subjectLabel}

Message:
${message}
${message}${provenance}
`.trim(),
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #6366f1;">New Contact Form Submission</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6b7280; width: 100px;"><strong>From:</strong></td>
<td style="padding: 8px 0; color: #1f2937;">${name}</td>
<td style="padding: 8px 0; color: #1f2937;">${escapeHtml(name)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280;"><strong>Email:</strong></td>
<td style="padding: 8px 0;"><a href="mailto:${email}" style="color: #6366f1;">${email}</a></td>
<td style="padding: 8px 0;"><a href="mailto:${encodeURIComponent(email)}" style="color: #6366f1;">${escapeHtml(email)}</a></td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280;"><strong>Subject:</strong></td>
<td style="padding: 8px 0; color: #1f2937;">${subjectLabel}</td>
<td style="padding: 8px 0; color: #1f2937;">${escapeHtml(subjectLabel)}</td>
</tr>
</table>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<h3 style="color: #374151; margin-bottom: 10px;">Message:</h3>
<div style="background: #f9fafb; padding: 16px; border-radius: 8px; white-space: pre-wrap; color: #1f2937;">${message}</div>
<div style="background: #f9fafb; padding: 16px; border-radius: 8px; white-space: pre-wrap; color: #1f2937;">${escapeHtml(message)}</div>
${provenance ? `<pre style="font: 12px/1.5 monospace; color: #9ca3af;">${escapeHtml(provenance.trim())}</pre>` : ''}
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #9ca3af; font-size: 12px;">
This message was sent from the icemap contact form. Reply directly to respond to the sender.
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/app/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import type { Metadata } from 'next'
import Footer from '@/components/Footer'
import ContactForm from '@/components/ContactForm'
import { contactGuard } from '@/lib/contact-guard'

export const metadata: Metadata = {
title: 'Contact - icemap',
description: 'Get in touch with the icemap team. Report bugs, ask questions, or share feedback.',
}

export default function ContactPage() {
// The form carries a token minted at render time, so this page must not
// be cached — a stale page would hand every visitor the same dead token.
export const dynamic = 'force-dynamic'

export default async function ContactPage() {
const token = contactGuard ? await contactGuard.issue() : null
const guardFields = token ? contactGuard!.fields(token) : null

return (
<div className="min-h-screen bg-gray-900 pt-14 flex flex-col">
<div className="flex-1">
Expand All @@ -24,7 +32,11 @@ export default function ContactPage() {
</p>
</div>

<ContactForm />
<ContactForm
token={token}
tokenName={guardFields?.token.name ?? null}
honeypotName={guardFields?.honeypot.name ?? null}
/>
</div>
</div>
<Footer />
Expand Down
37 changes: 35 additions & 2 deletions apps/web/src/components/ContactForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,23 @@ const SUBJECT_OPTIONS = [
{ value: 'other', label: 'Other' },
]

export default function ContactForm() {
interface ContactFormProps {
/** Minted by the page at render time; proves the form was loaded. */
token: string | null
tokenName: string | null
honeypotName: string | null
}

export default function ContactForm({ token, tokenName, honeypotName }: ContactFormProps) {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [subject, setSubject] = useState('')
const [message, setMessage] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
// Honeypot. Nothing visible sets this, so anything in it came from a bot.
const [honeypot, setHoneypot] = useState('')

async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
Expand All @@ -31,7 +40,14 @@ export default function ContactForm() {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, subject, message }),
body: JSON.stringify({
name,
email,
subject,
message,
...(tokenName && token ? { [tokenName]: token } : {}),
...(honeypotName ? { [honeypotName]: honeypot } : {}),
}),
})

if (!res.ok) {
Expand Down Expand Up @@ -78,6 +94,23 @@ export default function ContactForm() {

return (
<form onSubmit={handleSubmit} className="glass rounded-2xl p-8 border border-white/10">
{/* Honeypot. Positioned off-canvas rather than display:none — some
bots skip fields they can tell are not rendered. */}
{honeypotName && (
<div aria-hidden="true" className="absolute -left-[9999px] h-px w-px overflow-hidden">
<label>
Website
<input
type="text"
name={honeypotName}
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</label>
</div>
)}
{error && (
<div className="mb-6 px-4 py-3 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-sm">
{error}
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/lib/contact-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'server-only'
import { createFormGuard } from '@profullstack/form-guard'

/**
* Shared guard for the public contact form.
*
* The page that renders the form mints a token; the route that receives
* it verifies one. Both import this instance, because a `binding` or
* field-name mismatch between them would reject every real submission
* without saying so.
*
* A honeypot alone would not help here: most contact-form spam POSTs
* straight at /api/contact and never renders the page, so a hidden field
* is simply absent from the body rather than filled. The token is the
* part a request that skipped the page cannot produce.
*
* The secret never reaches the browser, only the signature does. It must
* be identical across every instance serving the form, so it falls back
* to SMTP_PASSWORD, which sending already cannot work without.
*/
const secret =
process.env.FORM_GUARD_SECRET ?? process.env.SMTP_PASSWORD ?? ''

if (!secret) {
// Without a secret there is nothing to sign with, so the form falls back
// to being unprotected. That is survivable only because SMTP is equally
// unconfigured in that case and nothing is being delivered anyway — but
// it should never be true silently.
console.warn(
'contact-guard: no FORM_GUARD_SECRET or SMTP_PASSWORD set — the contact form is UNPROTECTED'
)
}

export const contactGuard = secret
? createFormGuard({
secret,
binding: 'icemap:contact',
brandTerms: ['icemap'],
rateLimit: { max: 5, windowMs: 60 * 60 * 1000 },
// Set FORM_GUARD_ENFORCE=0 to score without blocking, if a real
// sender ever reports being turned away.
requireToken: process.env.FORM_GUARD_ENFORCE !== '0',
})
: null
Loading
Loading