From 7f1f6814800c53487cf47adcd18adad8e9f4bf82 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Prefer email? hello@rssamplifier.com. For @@ -280,6 +238,8 @@ export default async function SalesPage({ searchParams }) { * @returns {string} */ function errorMessage(code) { + // A message from the form's own checks, sent back by a plain form post. + if (formErrors().has(code)) return code; switch (code) { case 'bad-email': return 'That does not look like an email address — we would have no way to reply.'; diff --git a/apps/web/src/lib/salesContact.js b/apps/web/src/lib/salesContact.js new file mode 100644 index 0000000..6ef22ae --- /dev/null +++ b/apps/web/src/lib/salesContact.js @@ -0,0 +1,182 @@ +import { createContactForm } from '@profullstack/contact-form'; +import { clientIp } from '@profullstack/x402-gateway/edge'; +import { dataset } from '@rssamplifier/db'; +import { hashIp } from '@rssamplifier/ingest'; +import { sendEmail } from '@rssamplifier/mail'; + +import { db } from './db.js'; + +/** + * The corpus enquiry form on /sales, and its handler at /api/sales/contact. + * + * ## Why a form here when /contact deliberately has none + * + * That page's comment says it plainly: "no form, because a form on a site with + * no accounts is one more thing to spam", and it routes to published addresses + * instead. The reasoning holds and this is not a reversal of it — it is a + * different trade. /contact's job is to get a stranger to the right mailbox, + * which a list of links does perfectly well. This page's job is to start a + * negotiation, and the fields below (what they want it for, at what scale) are + * exactly the ones that decide whether there is a deal and what it looks like. + * So the spam is paid for rather than avoided: @profullstack/contact-form's + * proof-of-render token and honeypot, and a per-address limit kept in the + * database. + * + * ## Why it is written down before it is sent + * + * Mail here is best-effort. That is right for a sign-in link, which the reader + * can simply request again — it is wrong for a sales enquiry, which the sender + * believes is delivered and will never send twice. So the row lands first + * (`persist`), the notification goes on top of it, and a Resend outage costs a + * notification rather than a customer (`onSendError: 'ignore'`). + * + * Built by a factory so the tests can hand it a store and a transport; the + * site uses `salesContact` below. + */ + +/** How long the flood window is. */ +export const WINDOW_MS = 60 * 60 * 1000; + +/** + * Enquiries one address may send in that window. + * + * Three, not one. A genuine sender who mistypes their own email address and + * resends is the most likely repeat here, and refusing them is worse than + * accepting a third message from a spammer we are also storing and rate + * limiting. + */ +export const MAX_PER_WINDOW = 3; + +export const TOO_MANY = + 'That is several enquiries in an hour. Email hello@rssamplifier.com directly and we will pick it up there.'; +export const NO_USE_CASE = + 'Please say what you want the corpus for — it is the field that decides what we can offer.'; + +/** The address a request comes from, the way every other limit on this site reads it. */ +const ipOf = (req) => clientIp({ headers: req.headers }) || null; +const ipHashOf = (ip) => hashIp(ip, process.env['IP_HASH_SALT']); + +/** + * @param {{ + * store?: { enquiryCountFrom: Function, insertEnquiry: Function }, + * client?: () => unknown, + * send?: typeof sendEmail, + * guard?: object | false, + * }} [deps] + */ +export function makeSalesContact(deps = {}) { + const store = deps.store ?? dataset; + const client = deps.client ?? db; + const send = deps.send ?? sendEmail; + + return createContactForm({ + binding: 'rssamplifier:sales', + brandTerms: ['rss amplifier', 'rssamplifier'], + // Read through a non-literal property access: Next inlines `process.env.FOO` + // at build time. + to: process.env['SALES_EMAIL'] || 'hello@rssamplifier.com', + action: '/api/sales/contact', + // A plain form post (JavaScript off) comes back to the form's anchor. + redirect: '/sales#enquire', + guard: deps.guard ?? { rateLimit: { max: MAX_PER_WINDOW, windowMs: WINDOW_MS } }, + ip: ipOf, + style: 'ok', + questions: [ + { name: 'intro', type: 'section', label: 'Only the email and the last box are required' }, + { name: 'name', label: 'Your name', placeholder: 'Your name', maxLength: 120, autocomplete: 'name' }, + { + name: 'email', + type: 'email', + label: 'Your email address', + placeholder: 'you@example.com', + maxLength: 200, + required: true, + autocomplete: 'email', + }, + { + name: 'org', + label: 'Company or project', + placeholder: 'Company or project', + maxLength: 160, + autocomplete: 'organization', + }, + { + // `use_case` is the name that reads correctly in HTML; a JSON caller may + // send `useCase` (see mapBody). + name: 'use_case', + type: 'textarea', + label: 'What do you want the corpus for?', + placeholder: + 'What do you want the corpus for?\n\nWhich streams you need, roughly what scale, how often you would pull, and what it is being used to build.', + rows: 5, + maxLength: 4000, + required: true, + pattern: '^[\\s\\S]{10,}$', + patternMessage: NO_USE_CASE, + help: 'The last box is the one that decides what we can offer, so it is the one worth writing. Rough is fine; vague costs us both a round trip.', + }, + ], + messageField: 'use_case', + mapBody: (body) => ({ ...body, use_case: body.use_case ?? body.useCase }), + // The database keeps the limit across restarts and instances. + verify: async (_body, req) => { + const since = new Date(Date.now() - WINDOW_MS).toISOString(); + const recent = await store.enquiryCountFrom(client(), ipHashOf(ipOf(req)), since); + return recent >= MAX_PER_WINDOW ? TOO_MANY : true; + }, + persist: async (s, verdict) => { + const v = s.values ?? {}; + await store.insertEnquiry(client(), { + name: v.name || null, + email: v.email.toLowerCase(), + org: v.org || null, + useCase: v.use_case, + ipHash: ipHashOf(verdict?.ip ?? null), + userAgent: verdict?.userAgent?.slice(0, 300) ?? null, + }); + return undefined; + }, + subject: (s) => `Corpus enquiry: ${s.values?.org || s.email}`, + send: async (mail) => { + const r = await send({ + to: mail.to, + subject: mail.subject, + text: `${mail.text ?? ''}\n\nStored in dataset_enquiries. Grant access with an insert into dataset_grants.`, + html: mail.html, + replyTo: mail.replyTo?.toLowerCase(), + }); + return { sent: r.ok, error: r.error }; + }, + onSendError: 'ignore', + submitLabel: 'Send enquiry', + successMessage: + 'Thank you — that is recorded and someone will reply to the address you gave.', + responses: { + limited: { body: { ok: false, error: TOO_MANY }, status: 429 }, + }, + }); +} + +export const salesContact = makeSalesContact(); + +/** + * Every message a plain form post (JavaScript off) can be sent back to /sales + * with in `?error=`. The page shows one of these or a generic line, never + * whatever text a link happens to carry. + */ +export function formErrors(form = salesContact) { + const out = new Set([ + TOO_MANY, + 'That took too long. Please send it again.', + 'Too many messages. Please try again later.', + 'Invalid submission', + ]); + for (const q of form.questions) { + if (q.type === 'section') continue; + out.add(`${q.label} is required`); + out.add(`${q.label} is too long (${q.maxLength} characters at most)`); + if (q.type === 'email') out.add(`${q.label} must be a valid email address`); + if (q.patternMessage) out.add(q.patternMessage); + } + return out; +} diff --git a/apps/web/test/sales-contact.test.js b/apps/web/test/sales-contact.test.js new file mode 100644 index 0000000..b10c758 --- /dev/null +++ b/apps/web/test/sales-contact.test.js @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createFormGuard } from '@profullstack/contact-form'; + +import { MAX_PER_WINDOW, NO_USE_CASE, TOO_MANY, formErrors, makeSalesContact } from '../src/lib/salesContact.js'; + +const guard = createFormGuard({ secret: 'test-secret-not-used-anywhere-else', binding: 'rssamplifier:sales' }); + +/** A sales form over an in-memory store and transport. */ +function harness({ recent = 0 } = {}) { + const rows = []; + const mails = []; + const form = makeSalesContact({ + guard, + client: () => null, + store: { + enquiryCountFrom: async () => recent, + insertEnquiry: async (_client, row) => void rows.push(row), + }, + send: async (mail) => { + mails.push(mail); + return { ok: true }; + }, + }); + return { form, rows, mails }; +} + +let ip = 0; +const json = (body) => + new Request('http://localhost/api/sales/contact', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-for': `198.51.100.${++ip}` }, + body: JSON.stringify(body), + }); + +const answers = { + name: 'Test Person', + email: 'Test@Example.invalid', + org: 'Example Lab', + use_case: 'Pretraining a small model on independent writing, weekly pulls.', +}; +const token = () => guard.issue(Date.now() - 10_000); + +test('the page gets a token, the honeypot and the corpus questions', async () => { + const { form } = harness(); + const props = await form.props(); + assert.equal(props.tokenField, 'fg_token'); + assert.ok(props.token); + assert.equal(props.honeypotField, 'website'); + assert.equal(props.action, '/api/sales/contact'); + assert.deepEqual( + props.questions.map((q) => q.name), + ['intro', 'name', 'email', 'org', 'use_case'], + ); +}); + +test('a POST without a token is answered like success and neither stored nor sent', async () => { + const { form, rows, mails } = harness(); + const warn = console.warn; + console.warn = () => {}; + try { + const res = await form.POST(json(answers)); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true }); + } finally { + console.warn = warn; + } + assert.equal(rows.length, 0); + assert.equal(mails.length, 0); +}); + +test('a valid enquiry is stored first, then announced', async () => { + const { form, rows, mails } = harness(); + const res = await form.POST(json({ ...answers, fg_token: await token() })); + assert.deepEqual(await res.json(), { ok: true }); + assert.equal(rows.length, 1); + assert.equal(rows[0].email, 'test@example.invalid'); + assert.equal(rows[0].org, 'Example Lab'); + assert.equal(rows[0].useCase, answers.use_case); + assert.equal(mails.length, 1); + assert.equal(mails[0].subject, 'Corpus enquiry: Example Lab'); + assert.equal(mails[0].replyTo, 'test@example.invalid'); + assert.match(mails[0].text, /Stored in dataset_enquiries/); +}); + +test('a JSON caller may send useCase', async () => { + const { form, rows } = harness(); + const { use_case, ...rest } = answers; + const res = await form.POST(json({ ...rest, useCase: use_case, fg_token: await token() })); + assert.deepEqual(await res.json(), { ok: true }); + assert.equal(rows[0].useCase, use_case); +}); + +test('a use case under ten characters is refused with the old wording', async () => { + const { form, rows } = harness(); + const res = await form.POST(json({ ...answers, use_case: 'data', fg_token: await token() })); + assert.equal(res.status, 400); + assert.deepEqual(await res.json(), { ok: false, error: NO_USE_CASE }); + assert.equal(rows.length, 0); +}); + +test('the per-address limit kept in the database still applies', async () => { + const { form, rows } = harness({ recent: MAX_PER_WINDOW }); + const res = await form.POST(json({ ...answers, fg_token: await token() })); + assert.equal((await res.json()).error, TOO_MANY); + assert.equal(rows.length, 0); +}); + +test('a plain form post is sent back to the form, with a message the page will show', async () => { + const { form } = harness(); + const body = new URLSearchParams({ ...answers, use_case: 'x', fg_token: await token() }); + const res = await form.POST( + new Request('http://localhost/api/sales/contact', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': '203.0.113.9' }, + body, + }), + ); + assert.equal(res.status, 303); + const location = new URL(res.headers.get('location'), 'http://localhost'); + assert.equal(location.pathname, '/sales'); + assert.equal(location.hash, '#enquire'); + assert.ok(formErrors(form).has(location.searchParams.get('error'))); +}); diff --git a/bun.lock b/bun.lock index a0de009..42b8501 100644 --- a/bun.lock +++ b/bun.lock @@ -35,9 +35,10 @@ }, "apps/web": { "name": "@rssamplifier/web", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@logicsrc/openaccess": "^0.3.1", + "@profullstack/contact-form": "0.2.0", "@profullstack/footer": "latest", "@profullstack/leaderboard": "^0.3.1", "@profullstack/notifications": "^0.1.4", @@ -354,8 +355,14 @@ "@peculiar/x509": ["@peculiar/x509@2.2.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-csr": "^2.11.0", "@peculiar/asn1-ecc": "^2.11.0", "@peculiar/asn1-pkcs9": "^2.11.0", "@peculiar/asn1-rsa": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-post-quantum": "^2.11.0", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q=="], + "@profullstack/contact-form": ["@profullstack/contact-form@0.2.0", "", { "dependencies": { "@profullstack/emailer": "^1.0.3", "@profullstack/form-guard": "^0.3.0" }, "peerDependencies": { "react": ">=18.0.0" }, "optionalPeers": ["react"] }, "sha512-J2SSQo0qSYOAESe1jbOtIkAqimW+6/8EehOANVaXAq/DbMFCPfhKk0iH+g5lmrKHTG1yqs5a7o4nmrvt/WtiwA=="], + + "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], + "@profullstack/footer": ["@profullstack/footer@0.2.6", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-BKwbDszAc7e0mYIBiPjgr/5WS8WyDt8IW718s/zW/8pCWOIB1BGwSNs6fmImRSo5kqIjQKUVbXp22jZQ5GEipA=="], + "@profullstack/form-guard": ["@profullstack/form-guard@0.3.0", "", {}, "sha512-/rn5xd4mMG6eXGe85BmPfhz7zz3mmsmNV7U6a1bNhATIpVtKKEUEGF4QPrFx8sg6YDtpFkzmvjNEaFJm3DbFxg=="], + "@profullstack/keys": ["@profullstack/keys@0.1.0", "", {}, "sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w=="], "@profullstack/leaderboard": ["@profullstack/leaderboard@0.3.1", "", {}, "sha512-LbxLwy+RvT/Qez0mfWCk4o9hvcq0kzwBZ0vF+CYp+oGvyqJWUaSTLhCHWGtKKAI3c0ZUXj6ll0iuRjbuZUE2Sg=="], diff --git a/package.json b/package.json index deb9f30..61bdd42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rssamplifier.com", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "description": "Agent-first public RSS directory. Submit a URL, a list or an OPML file; every blog gets its own page.",