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
16 changes: 16 additions & 0 deletions src/build-invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ export function readOrder(form: Record<string, unknown>): 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(/\/+$/, '')
Expand Down
10 changes: 8 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions test/pit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) =>
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<string, string>, 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' })
Expand All @@ -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')
Expand Down
Loading