diff --git a/src/build-invoice.ts b/src/build-invoice.ts index e1a5c36..e7da7d2 100644 --- a/src/build-invoice.ts +++ b/src/build-invoice.ts @@ -40,6 +40,22 @@ export function readOrder(form: Record): BuildOrder { return { email, name: str('name', 80), idea } } +/** + * Contact-form spam: the stock "questions about pricing" notes that are sent to + * every form on the web. They describe no app, so they get no invoice. + */ +const SPAM_IDEAS = [ + /questions? about (your )?(pricing|prices|availability)/i, + /general process of working with you/i, + /let me know what (details|information) you need/i, + /interested in (scheduling|booking) a (brief|quick|short) (consultation|call)/i, + /\b(guest post|backlinks?|seo services|rank (your|on) (website|google))\b/i, +] + +export function looksLikeSpam(order: BuildOrder): boolean { + return SPAM_IDEAS.some((re) => re.test(order.idea)) +} + export async function orderByInvoice(cfg: BuildInvoicing, order: BuildOrder, now = new Date()): Promise<{ build: Invoice; hosting: Invoice }> { const doFetch = cfg.fetch ?? fetch const base = cfg.payAgentUrl.replace(/\/+$/, '') diff --git a/src/server.ts b/src/server.ts index 5f33992..0a2ec4c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,7 +9,7 @@ import { Hono, type Context } from 'hono' import { parseBang, BANGS } from './bangs.js' import { aboutPage, buildPage, startPage, connectPage, errorPage, homePage, mediaPage, mobileConfig, newsletterPage, offlinePage, subscribersAdminPage, openSearchXml, privacyPage, resultsPage, ringOpml, ringPage } from './html.js' import { adminAuthorized, brandFor, Newsletter, normalizeSource, resendMailer } from './newsletter.js' -import { orderByInvoice, OrderError, readOrder, type BuildInvoicing } from './build-invoice.js' +import { looksLikeSpam, orderByInvoice, OrderError, readOrder, type BuildInvoicing } from './build-invoice.js' import { looksLikeName, Registry } from './moshpit.js' import { RateLimiter } from './ratelimit.js' import { snippetFor } from './footer.js' @@ -225,7 +225,13 @@ export function createApp(deps: Deps) { // The honeypot (as on /subscribe): a bot that fills it is sent back to /build and no invoice is made. if (typeof form.website === 'string' && form.website !== '') return c.redirect('/build') try { - const { build } = await orderByInvoice(cfg, readOrder(form)) + const order = readOrder(form) + // Stock contact-form spam is answered like the honeypot: back to /build, no invoice, no email. + if (looksLikeSpam(order)) { + console.log('build order: spam ignored from', order.email) + return c.redirect('/build') + } + const { build } = await orderByInvoice(cfg, order) return c.redirect(build.url) } catch (e) { return again(e instanceof Error ? e.message : String(e), e instanceof OrderError && !e.message.startsWith('pay.agent') ? 400 : 502) diff --git a/test/pit.test.ts b/test/pit.test.ts index 4042f93..dae656f 100644 --- a/test/pit.test.ts +++ b/test/pit.test.ts @@ -591,8 +591,8 @@ describe('/build', () => { keys: {}, buildInvoicing: { payAgentUrl: 'https://pa.test/', key: 'pa_test', fetch: payAgent, notifyTo: 'builder@example.com', mailer: async (m) => void mails.push(m) }, }) - const post = (form: Record) => - instance.request('/build/invoice', { method: 'POST', body: new URLSearchParams(form).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } }, { ip: '203.0.113.9' }) + const post = (form: Record, ip = '203.0.113.9') => + instance.request('/build/invoice', { method: 'POST', body: new URLSearchParams(form).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } }, { ip }) expect(await (await instance.request('/build', {}, { ip: '203.0.113.9' })).text()).toContain('action="/build/invoice"') const bad = await post({ idea: 'A booking page for my shop', email: 'nope' }) @@ -606,6 +606,13 @@ describe('/build', () => { expect(sent).toHaveLength(0) expect(mails).toHaveLength(0) + // Stock contact-form spam (2026-10-28): no honeypot hit, but no app either, so no invoice and no email. + const spam = await post({ idea: 'Hello, I have a few questions about pricing, availability, and the general process of working with you. Please let me know what details you need from me to proceed. Thanks!', email: 'someone+abc@gmail.com', name: 'Samuel' }, '203.0.113.10') + expect(spam.status).toBe(302) + expect(spam.headers.get('location')).toBe('/build') + expect(sent).toHaveLength(0) + expect(mails).toHaveLength(0) + const ok = await post({ idea: 'A booking page for my barbershop\nwith reminders', email: 'Pat@Example.com', name: 'Pat' }) expect(ok.status).toBe(302) expect(ok.headers.get('location')).toBe('https://pa.test/i/inv-1')