mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(contact): add Turnstile CAPTCHA, honeypot, and robustness fixes (#4248)
* improvement(contact): add Turnstile CAPTCHA, honeypot, and robustness fixes - Add Cloudflare Turnstile with graceful degradation: when the widget fails to load (ad blockers, iOS privacy, corporate DNS), submissions fall through to a tighter rate-limit bucket rather than hard-blocking - Add honeypot field to filter automated submissions without user impact - Add separate CAPTCHA_UNAVAILABLE_RATE_LIMIT bucket (3/min) for the no-captcha path so spam via ad-blocker bypass remains expensive - Pass expectedHostname to verifyTurnstileToken to close cross-site token reuse gap - Add SITE_HOSTNAME as module-level constant (avoid URL parsing per req) - Wire onExpire/onError/onUnsupported callbacks so token expiry during slow form-filling falls back gracefully instead of showing a captcha error - Add getResponsePromise(30_000) timeout to prevent indefinite hang on network blips - Add size: 'invisible' to Turnstile options (required for execute mode) - Move turnstile.ts to lib/core/security/ alongside csp/encryption/input-validation - Switch all CSS to --landing-* variables throughout contact form - Move error display inline next to label with truncation in LandingField - Add labelClassName prop to LandingField for context-specific overrides - Simplify contact page to single-column max-w-[640px] layout * fix(contact): fall through to no-captcha rate limit on Cloudflare transport errors * chore(contact): remove extraneous comments from route * fix(contact): remove forced min-height on success state, let content flow naturally * fix(contact): cast CONTACT_TOPIC_OPTIONS to satisfy Combobox mutable type * fix(contact): disable submit during CAPTCHA resolution window, add relative to form
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Combobox, Input, Textarea } from '@/components/emcn'
|
||||
import Link from 'next/link'
|
||||
import { Combobox, type ComboboxOption, Input, Textarea } from '@/components/emcn'
|
||||
import { Check } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
import {
|
||||
CONTACT_TOPIC_OPTIONS,
|
||||
@@ -34,12 +37,28 @@ const INITIAL_FORM_STATE: ContactFormState = {
|
||||
message: '',
|
||||
}
|
||||
|
||||
const COMBOBOX_TOPICS = [...CONTACT_TOPIC_OPTIONS]
|
||||
|
||||
const LANDING_INPUT =
|
||||
'h-[36px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-3 font-[430] font-season text-[14px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]'
|
||||
'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]'
|
||||
|
||||
async function submitContactRequest(payload: ContactRequestPayload) {
|
||||
const LANDING_TEXTAREA =
|
||||
'min-h-[140px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 py-2.5 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]'
|
||||
|
||||
const LANDING_COMBOBOX =
|
||||
'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] hover:bg-[var(--landing-bg-surface)] focus-within:border-[var(--landing-border-strong)]'
|
||||
|
||||
const LANDING_SUBMIT =
|
||||
'flex h-[40px] w-full items-center justify-center rounded-[5px] border border-[var(--landing-text-subtle)] bg-[var(--landing-text-subtle)] font-[430] font-season text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[var(--landing-bg-hover)] hover:bg-[var(--landing-bg-hover)] disabled:cursor-not-allowed disabled:opacity-60'
|
||||
|
||||
const LANDING_LABEL =
|
||||
'font-[500] font-season text-[13px] text-[var(--landing-text)] tracking-[0.02em]'
|
||||
|
||||
interface SubmitContactRequestInput extends ContactRequestPayload {
|
||||
website: string
|
||||
captchaToken?: string
|
||||
captchaUnavailable?: boolean
|
||||
}
|
||||
|
||||
async function submitContactRequest(payload: SubmitContactRequestInput) {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -59,9 +78,7 @@ async function submitContactRequest(payload: ContactRequestPayload) {
|
||||
}
|
||||
|
||||
export function ContactForm() {
|
||||
const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
|
||||
const [errors, setErrors] = useState<ContactErrors>({})
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
|
||||
const contactMutation = useMutation({
|
||||
mutationFn: submitContactRequest,
|
||||
@@ -71,8 +88,23 @@ export function ContactForm() {
|
||||
setErrors({})
|
||||
setSubmitSuccess(true)
|
||||
},
|
||||
onError: () => {
|
||||
turnstileRef.current?.reset()
|
||||
},
|
||||
})
|
||||
|
||||
const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
|
||||
const [errors, setErrors] = useState<ContactErrors>({})
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [website, setWebsite] = useState('')
|
||||
const [widgetReady, setWidgetReady] = useState(false)
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
|
||||
}, [])
|
||||
|
||||
function updateField<TField extends keyof ContactFormState>(
|
||||
field: TField,
|
||||
value: ContactFormState[TField]
|
||||
@@ -91,9 +123,10 @@ export function ContactForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (contactMutation.isPending) return
|
||||
if (contactMutation.isPending || isSubmitting) return
|
||||
setIsSubmitting(true)
|
||||
|
||||
const parsed = contactRequestSchema.safeParse({
|
||||
...form,
|
||||
@@ -110,35 +143,55 @@ export function ContactForm() {
|
||||
subject: fieldErrors.subject?.[0],
|
||||
message: fieldErrors.message?.[0],
|
||||
})
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
contactMutation.mutate(parsed.data)
|
||||
let captchaToken: string | undefined
|
||||
let captchaUnavailable: boolean | undefined
|
||||
const widget = turnstileRef.current
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
if (widgetReady && widget) {
|
||||
try {
|
||||
widget.reset()
|
||||
widget.execute()
|
||||
captchaToken = await widget.getResponsePromise(30_000)
|
||||
} catch {
|
||||
captchaUnavailable = true
|
||||
}
|
||||
} else {
|
||||
captchaUnavailable = true
|
||||
}
|
||||
}
|
||||
|
||||
contactMutation.mutate({ ...parsed.data, website, captchaToken, captchaUnavailable })
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const isBusy = contactMutation.isPending || isSubmitting
|
||||
|
||||
const submitError = contactMutation.isError
|
||||
? contactMutation.error instanceof Error
|
||||
? contactMutation.error.message
|
||||
: 'Failed to send message. Please try again.'
|
||||
? toError(contactMutation.error).message || 'Failed to send message. Please try again.'
|
||||
: null
|
||||
|
||||
if (submitSuccess) {
|
||||
return (
|
||||
<div className='flex min-h-[460px] flex-col items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] px-8 py-16 text-center'>
|
||||
<div className='flex h-16 w-16 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--bg-subtle)] text-[var(--text-primary)]'>
|
||||
<div className='flex flex-col items-center px-8 py-16 text-center'>
|
||||
<div className='flex h-16 w-16 items-center justify-center rounded-full border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] text-[var(--landing-text)]'>
|
||||
<Check className='h-8 w-8' />
|
||||
</div>
|
||||
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--text-primary)] leading-[1.2] tracking-[-0.02em]'>
|
||||
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--landing-text)] leading-[1.2] tracking-[-0.02em]'>
|
||||
Message received
|
||||
</h2>
|
||||
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--text-secondary)] leading-[1.6]'>
|
||||
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--landing-text-body)] leading-[1.6]'>
|
||||
Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
|
||||
shortly.
|
||||
</p>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setSubmitSuccess(false)}
|
||||
className='mt-6 font-season text-[13px] text-[var(--text-primary)] underline underline-offset-2 transition-opacity hover:opacity-80'
|
||||
className='mt-6 font-season text-[13px] text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
@@ -147,12 +200,33 @@ export function ContactForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className='flex flex-col gap-4 rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] p-6 sm:p-8'
|
||||
>
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<LandingField htmlFor='contact-name' label='Name' error={errors.name}>
|
||||
<form onSubmit={handleSubmit} className='relative flex flex-col gap-5'>
|
||||
{/* Honeypot */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
|
||||
>
|
||||
<label htmlFor='contact-website'>Website</label>
|
||||
<input
|
||||
id='contact-website'
|
||||
name='website'
|
||||
type='text'
|
||||
tabIndex={-1}
|
||||
autoComplete='off'
|
||||
value={website}
|
||||
onChange={(event) => setWebsite(event.target.value)}
|
||||
data-lpignore='true'
|
||||
data-1p-ignore='true'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-5 sm:grid-cols-2'>
|
||||
<LandingField
|
||||
htmlFor='contact-name'
|
||||
label='Name'
|
||||
error={errors.name}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Input
|
||||
id='contact-name'
|
||||
value={form.name}
|
||||
@@ -161,7 +235,12 @@ export function ContactForm() {
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
<LandingField htmlFor='contact-email' label='Email' error={errors.email}>
|
||||
<LandingField
|
||||
htmlFor='contact-email'
|
||||
label='Email'
|
||||
error={errors.email}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Input
|
||||
id='contact-email'
|
||||
type='email'
|
||||
@@ -173,8 +252,14 @@ export function ContactForm() {
|
||||
</LandingField>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<LandingField htmlFor='contact-company' label='Company' optional error={errors.company}>
|
||||
<div className='grid gap-5 sm:grid-cols-2'>
|
||||
<LandingField
|
||||
htmlFor='contact-company'
|
||||
label='Company'
|
||||
optional
|
||||
error={errors.company}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Input
|
||||
id='contact-company'
|
||||
value={form.company}
|
||||
@@ -183,21 +268,31 @@ export function ContactForm() {
|
||||
className={LANDING_INPUT}
|
||||
/>
|
||||
</LandingField>
|
||||
<LandingField htmlFor='contact-topic' label='Topic' error={errors.topic}>
|
||||
<LandingField
|
||||
htmlFor='contact-topic'
|
||||
label='Topic'
|
||||
error={errors.topic}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Combobox
|
||||
options={COMBOBOX_TOPICS}
|
||||
options={CONTACT_TOPIC_OPTIONS as unknown as ComboboxOption[]}
|
||||
value={form.topic}
|
||||
selectedValue={form.topic}
|
||||
onChange={(value) => updateField('topic', value as ContactRequestPayload['topic'])}
|
||||
placeholder='Select a topic'
|
||||
editable={false}
|
||||
filterOptions={false}
|
||||
className='h-[36px] rounded-[5px] px-3 font-[430] font-season text-[14px]'
|
||||
className={LANDING_COMBOBOX}
|
||||
/>
|
||||
</LandingField>
|
||||
</div>
|
||||
|
||||
<LandingField htmlFor='contact-subject' label='Subject' error={errors.subject}>
|
||||
<LandingField
|
||||
htmlFor='contact-subject'
|
||||
label='Subject'
|
||||
error={errors.subject}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Input
|
||||
id='contact-subject'
|
||||
value={form.subject}
|
||||
@@ -207,33 +302,53 @@ export function ContactForm() {
|
||||
/>
|
||||
</LandingField>
|
||||
|
||||
<LandingField htmlFor='contact-message' label='Message' error={errors.message}>
|
||||
<LandingField
|
||||
htmlFor='contact-message'
|
||||
label='Message'
|
||||
error={errors.message}
|
||||
labelClassName={LANDING_LABEL}
|
||||
>
|
||||
<Textarea
|
||||
id='contact-message'
|
||||
value={form.message}
|
||||
onChange={(event) => updateField('message', event.target.value)}
|
||||
placeholder='Share details so we can help as quickly as possible'
|
||||
className='min-h-[140px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-3 py-2.5 font-[430] font-season text-[14px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]'
|
||||
className={LANDING_TEXTAREA}
|
||||
/>
|
||||
</LandingField>
|
||||
|
||||
{turnstileSiteKey ? (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ execution: 'execute', appearance: 'execute', size: 'invisible' }}
|
||||
onWidgetLoad={() => setWidgetReady(true)}
|
||||
onExpire={() => setWidgetReady(false)}
|
||||
onError={() => setWidgetReady(false)}
|
||||
onUnsupported={() => setWidgetReady(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{submitError ? (
|
||||
<p role='alert' className='font-season text-[13px] text-[var(--text-error)]'>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={contactMutation.isPending}
|
||||
className={cn(
|
||||
'flex h-[40px] w-full items-center justify-center rounded-[5px] bg-[var(--text-primary)]',
|
||||
'font-[430] font-season text-[14px] text-[var(--bg)] transition-opacity',
|
||||
'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
)}
|
||||
>
|
||||
{contactMutation.isPending ? 'Sending...' : 'Send message'}
|
||||
<button type='submit' disabled={isBusy} className={LANDING_SUBMIT}>
|
||||
{isBusy ? 'Sending...' : 'Send message'}
|
||||
</button>
|
||||
|
||||
<p className='text-center font-season text-[12px] text-[var(--landing-text-muted)] leading-[1.6]'>
|
||||
By submitting, you agree to our{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
className='text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,21 @@ interface LandingFieldProps {
|
||||
optional?: boolean
|
||||
error?: string
|
||||
children: React.ReactNode
|
||||
/** Replaces the default label className. */
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
export function LandingField({ label, htmlFor, optional, error, children }: LandingFieldProps) {
|
||||
const DEFAULT_LABEL_CLASSNAME =
|
||||
'font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
|
||||
|
||||
export function LandingField({
|
||||
label,
|
||||
htmlFor,
|
||||
optional,
|
||||
error,
|
||||
children,
|
||||
labelClassName,
|
||||
}: LandingFieldProps) {
|
||||
const errorId = error ? `${htmlFor}-error` : undefined
|
||||
const describedChild =
|
||||
errorId && isValidElement<{ 'aria-describedby'?: string; 'aria-invalid'?: boolean }>(children)
|
||||
@@ -16,19 +28,22 @@ export function LandingField({ label, htmlFor, optional, error, children }: Land
|
||||
: children
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className='font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
|
||||
>
|
||||
{label}
|
||||
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
|
||||
</label>
|
||||
<div className='flex min-h-[18px] items-baseline justify-between gap-3'>
|
||||
<label htmlFor={htmlFor} className={labelClassName ?? DEFAULT_LABEL_CLASSNAME}>
|
||||
{label}
|
||||
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
|
||||
</label>
|
||||
{error ? (
|
||||
<span
|
||||
id={errorId}
|
||||
role='alert'
|
||||
className='truncate font-season text-[12px] text-[var(--text-error)]'
|
||||
>
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{describedChild}
|
||||
{error ? (
|
||||
<p id={errorId} role='alert' className='text-[12px] text-[var(--text-error)]'>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact Us',
|
||||
description:
|
||||
"Get in touch with Sim. Ask a general question, request an integration, or get help. We'll respond quickly.",
|
||||
'Get in touch with Sim. Ask a general question, request an integration, or get help.',
|
||||
metadataBase: new URL(SITE_URL),
|
||||
alternates: { canonical: '/contact' },
|
||||
openGraph: {
|
||||
@@ -19,35 +19,6 @@ export const metadata: Metadata = {
|
||||
},
|
||||
}
|
||||
|
||||
interface DirectContact {
|
||||
label: string
|
||||
description: string
|
||||
email: string
|
||||
}
|
||||
|
||||
const DIRECT_CONTACTS: DirectContact[] = [
|
||||
{
|
||||
label: 'Support',
|
||||
description: 'Bugs, account issues, and product help.',
|
||||
email: 'help@sim.ai',
|
||||
},
|
||||
{
|
||||
label: 'Sales',
|
||||
description: 'Enterprise plans, demos, and procurement.',
|
||||
email: 'enterprise@sim.ai',
|
||||
},
|
||||
{
|
||||
label: 'Privacy',
|
||||
description: 'Data requests and privacy questions.',
|
||||
email: 'privacy@sim.ai',
|
||||
},
|
||||
{
|
||||
label: 'Security',
|
||||
description: 'Vulnerability reports and security disclosures.',
|
||||
email: 'security@sim.ai',
|
||||
},
|
||||
]
|
||||
|
||||
export default async function ContactPage() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
@@ -57,54 +28,20 @@ export default async function ContactPage() {
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
|
||||
<div className='mx-auto max-w-[1100px] px-6 pt-[72px] pb-24 sm:px-12'>
|
||||
<div className='max-w-2xl'>
|
||||
<span className='mb-4 block font-martian-mono text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
|
||||
Contact us
|
||||
</span>
|
||||
<h1 className='mb-5 text-balance font-[500] text-4xl text-[var(--landing-text)] leading-[1.05] tracking-[-0.02em] md:text-5xl'>
|
||||
We're here to help
|
||||
</h1>
|
||||
<p className='text-[var(--landing-text-muted)] text-base leading-[1.7]'>
|
||||
Got a general question, integration request, or need help? Send us a message and our
|
||||
team will get back to you. For urgent issues, email the right team directly.
|
||||
</p>
|
||||
</div>
|
||||
<div className='mx-auto max-w-[640px] px-6 pt-[72px] pb-24 sm:px-12'>
|
||||
<span className='mb-4 block font-martian-mono text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
|
||||
Contact us
|
||||
</span>
|
||||
<h1 className='mb-5 text-balance font-[500] text-4xl text-[var(--landing-text)] leading-[1.05] tracking-[-0.02em] md:text-5xl'>
|
||||
We're here to help
|
||||
</h1>
|
||||
<p className='text-pretty text-[var(--landing-text-muted)] text-base leading-[1.7]'>
|
||||
Got a general question, integration request, or need help? Send us a message and our team
|
||||
will get back to you.
|
||||
</p>
|
||||
|
||||
<div className='mt-14 grid gap-10 lg:grid-cols-[1fr_1.4fr] lg:gap-16'>
|
||||
<aside className='flex flex-col gap-6'>
|
||||
<div>
|
||||
<h2 className='mb-1 font-[500] text-[var(--landing-text)] text-lg tracking-[-0.01em]'>
|
||||
Other ways to reach us
|
||||
</h2>
|
||||
<p className='text-[var(--landing-text-muted)] text-sm leading-[1.6]'>
|
||||
Prefer email? Reach the right team directly.
|
||||
</p>
|
||||
</div>
|
||||
<ul className='flex flex-col gap-5'>
|
||||
{DIRECT_CONTACTS.map(({ label, description, email }) => (
|
||||
<li
|
||||
key={email}
|
||||
className='border-[var(--landing-bg-elevated)] border-t pt-5 first:border-t-0 first:pt-0'
|
||||
>
|
||||
<p className='font-[500] text-[var(--landing-text)] text-sm'>{label}</p>
|
||||
<p className='mt-1 text-[13px] text-[var(--landing-text-muted)] leading-[1.6]'>
|
||||
{description}
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className='mt-1.5 inline-block text-[13px] text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div className='dark'>
|
||||
<ContactForm />
|
||||
</div>
|
||||
<div className='dark mt-14'>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import { renderHelpConfirmationEmail } from '@/components/emails'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { getEmailDomain } from '@/lib/core/utils/urls'
|
||||
import { getEmailDomain, SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
|
||||
const logger = createLogger('ContactAPI')
|
||||
const rateLimiter = new RateLimiter()
|
||||
const SITE_HOSTNAME = new URL(SITE_URL).hostname
|
||||
|
||||
const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 10,
|
||||
@@ -24,6 +26,14 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
|
||||
refillIntervalMs: 60_000,
|
||||
}
|
||||
|
||||
const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 3,
|
||||
refillRate: 1,
|
||||
refillIntervalMs: 60_000,
|
||||
}
|
||||
|
||||
const SUCCESS_RESPONSE = { success: true, message: "Thanks — we'll be in touch soon." }
|
||||
|
||||
export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
@@ -47,7 +57,67 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const body = (await req.json()) as Record<string, unknown>
|
||||
|
||||
const honeypot = body?.website
|
||||
if (typeof honeypot === 'string' && honeypot.trim().length > 0) {
|
||||
logger.warn(`[${requestId}] Honeypot triggered, discarding`, { ip })
|
||||
return NextResponse.json(SUCCESS_RESPONSE, { status: 201 })
|
||||
}
|
||||
|
||||
const captchaUnavailable = body?.captchaUnavailable === true
|
||||
|
||||
if (captchaUnavailable) {
|
||||
const nocaptchaKey = `public:contact:nocaptcha:${ip}`
|
||||
const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect(
|
||||
nocaptchaKey,
|
||||
CAPTCHA_UNAVAILABLE_RATE_LIMIT
|
||||
)
|
||||
if (!nocaptchaAllowed) {
|
||||
logger.warn(`[${requestId}] Rate limit exceeded (no-captcha) for IP ${ip}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isTurnstileConfigured() && !captchaUnavailable) {
|
||||
const token = typeof body?.captchaToken === 'string' ? body.captchaToken : null
|
||||
const verification = await verifyTurnstileToken({
|
||||
token,
|
||||
remoteIp: ip,
|
||||
expectedHostname: SITE_HOSTNAME,
|
||||
})
|
||||
if (!verification.success && verification.transportError) {
|
||||
logger.warn(
|
||||
`[${requestId}] Captcha transport error, falling back to no-captcha rate limit`,
|
||||
{ ip }
|
||||
)
|
||||
const nocaptchaKey = `public:contact:nocaptcha:${ip}`
|
||||
const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect(
|
||||
nocaptchaKey,
|
||||
CAPTCHA_UNAVAILABLE_RATE_LIMIT
|
||||
)
|
||||
if (!nocaptchaAllowed) {
|
||||
logger.warn(`[${requestId}] Rate limit exceeded (transport-error fallback) for IP ${ip}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
} else if (!verification.success) {
|
||||
logger.warn(`[${requestId}] Captcha verification failed`, {
|
||||
ip,
|
||||
errorCodes: verification.errorCodes,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Captcha verification failed. Please try again.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const validationResult = contactRequestSchema.safeParse(body)
|
||||
|
||||
if (!validationResult.success) {
|
||||
@@ -113,10 +183,7 @@ ${message}
|
||||
logger.warn(`[${requestId}] Failed to send contact confirmation email`, err)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: "Thanks — we'll be in touch soon." },
|
||||
{ status: 201 }
|
||||
)
|
||||
return NextResponse.json(SUCCESS_RESPONSE, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not configured')) {
|
||||
logger.error(`[${requestId}] Email service configuration error`, error)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const TURNSTILE_SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
const TURNSTILE_TIMEOUT_MS = 5_000
|
||||
|
||||
const logger = createLogger('TurnstileVerify')
|
||||
|
||||
interface TurnstileSiteverifyResponse {
|
||||
success: boolean
|
||||
'error-codes'?: string[]
|
||||
challenge_ts?: string
|
||||
hostname?: string
|
||||
action?: string
|
||||
cdata?: string
|
||||
metadata?: { interactive?: boolean }
|
||||
}
|
||||
|
||||
export interface VerifyTurnstileResult {
|
||||
success: boolean
|
||||
errorCodes?: string[]
|
||||
/** True when the siteverify request itself failed (network/timeout). */
|
||||
transportError?: boolean
|
||||
}
|
||||
|
||||
export interface VerifyTurnstileOptions {
|
||||
token: string | null | undefined
|
||||
remoteIp?: string
|
||||
/** Rejects the token when Cloudflare's reported hostname differs. */
|
||||
expectedHostname?: string
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a Turnstile token against Cloudflare's siteverify endpoint. Tokens
|
||||
* are single-use and expire after 300 seconds; never cache the result.
|
||||
*/
|
||||
export async function verifyTurnstileToken({
|
||||
token,
|
||||
remoteIp,
|
||||
expectedHostname,
|
||||
idempotencyKey,
|
||||
}: VerifyTurnstileOptions): Promise<VerifyTurnstileResult> {
|
||||
const secret = env.TURNSTILE_SECRET_KEY
|
||||
if (!secret) {
|
||||
logger.warn('Turnstile verification called without TURNSTILE_SECRET_KEY configured')
|
||||
return { success: false, errorCodes: ['missing-input-secret'] }
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return { success: false, errorCodes: ['missing-input-response'] }
|
||||
}
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('secret', secret)
|
||||
body.set('response', token)
|
||||
if (remoteIp && remoteIp !== 'unknown') body.set('remoteip', remoteIp)
|
||||
if (idempotencyKey) body.set('idempotency_key', idempotencyKey)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TURNSTILE_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const response = await fetch(TURNSTILE_SITEVERIFY_URL, {
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Turnstile siteverify returned non-2xx', { status: response.status })
|
||||
return { success: false, transportError: true }
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TurnstileSiteverifyResponse
|
||||
|
||||
if (!data.success) {
|
||||
return { success: false, errorCodes: data['error-codes'] }
|
||||
}
|
||||
|
||||
if (expectedHostname && data.hostname && data.hostname !== expectedHostname) {
|
||||
logger.warn('Turnstile hostname mismatch', {
|
||||
expected: expectedHostname,
|
||||
actual: data.hostname,
|
||||
})
|
||||
return { success: false, errorCodes: ['hostname-mismatch'] }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
const error = toError(err)
|
||||
logger.warn('Turnstile siteverify request failed', {
|
||||
aborted: error.name === 'AbortError',
|
||||
error: error.message,
|
||||
})
|
||||
return { success: false, transportError: true }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function isTurnstileConfigured(): boolean {
|
||||
return Boolean(env.TURNSTILE_SECRET_KEY)
|
||||
}
|
||||
Reference in New Issue
Block a user