import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { PublicConfig, VoiceAssignment } from '@wellformed/shared'; import { config } from '../config.js'; import { getWorkspaceBySlug } from '../control/workspaces.js'; import { emailEnabled } from '../mail/mailer.js'; import { LoginThrottle } from '../auth/throttle.js'; import { isDeclaredBot } from '../voice/bots.js'; import { cachedFit } from '../voice/fit.js'; import { drawPage } from '../voice/select.js'; import { markEngaged, recordImpression } from '../voice/store.js'; import { voiceIdParamSchema } from './schemas.js'; /** * A ceiling on impression rows one address can create, not a security control. * Deliberately generous: a person reloading a page is not an attack, and the * consequence of tripping it is a page that still renders but is not measured. */ export const voiceThrottle = new LoginThrottle(60, 5 * 60_000, 5 * 60_000); /** * What this install tells a visitor with no session. * * Shared between the /api/public/config route and the server-rendered * marketing page, which needs the same answers before any JavaScript runs. * The demos come back as a list: which one to link is the page's decision, * since only the page knows what language it is speaking. */ export function composePublicConfig(app: FastifyInstance): PublicConfig { // Resolved rather than echoed: a slug naming a workspace that was renamed // away or deleted would otherwise become a link to a 404 on the home page. const demos = config.demoSlugs.flatMap((slug) => { const workspace = getWorkspaceBySlug(app.control, slug); return workspace && workspace.status === 'active' ? [{ slug: workspace.slug, name: workspace.name, currency: workspace.currency, locale: workspace.locale }] : []; }); return { signupsOpen: config.signupsOpen, emailEnabled: emailEnabled(), metered: config.metered, demos, }; } /** * Draw the selling points one visit gets, and record the impression when the * visit deserves one. * * A declared bot gets a page but an empty id — composed, not counted, and * with no id there is nothing for it to beat against later. The same shape * answers the rate limit, so the client needs only one rule: no id, no beat. */ export function assignVoice(app: FastifyInstance, request: FastifyRequest): VoiceAssignment { const items = drawPage(cachedFit(app.control)); if (isDeclaredBot(request.headers['user-agent'])) return { id: '', items }; if (voiceThrottle.check(`ip:${request.ip}`) > 0) return { id: '', items }; voiceThrottle.fail(`ip:${request.ip}`); return { id: recordImpression(app.control, items), items }; } /** * What this install is, before anyone signs in. * * Every one of these is a decision the *operator* made, and the client has no * other way to learn it: whether to offer a signup link, whether "we sent you * a link" is a true sentence here, whether there is a demo to point at, and * what the plans cost. Nothing here names a workspace or an account, so it is * safe to answer unauthenticated — which it must be, since the marketing page * that reads it is the page you land on with no session at all. */ export async function publicRoutes(app: FastifyInstance): Promise { app.get('/api/public/config', async (): Promise => { return composePublicConfig(app); }); /** * Which selling points this visit gets. * * Sampled here rather than in the browser so the pool's weights never leave * the server and the recorded page is the one the server chose — an * assignment the client could author would be one the client could stuff. * * Over the rate limit — or from a declared crawler — the page is still * composed, just not recorded: the id comes back empty and the client knows * not to post the beat. Refusing to answer would mean a landing page that * fails because too many people asked for it, which is the wrong way round * for a measurement to fail. */ app.get('/api/public/voice', async (request): Promise => { return assignVoice(app, request); }); /** * A person, rather than a crawler, actually looked at it. * * Always 204, whether or not the id names anything. This says nothing back to * its caller by design — it is the one endpoint on the site an anonymous * stranger can address by id, and "that impression exists" is not a fact it * needs to be able to confirm. * * A declared bot's beat is dropped here too: Google's renderer runs timers, * so a crawler can pass the four-second dwell without a person existing. */ app.post('/api/public/voice/:id/seen', async (request, reply) => { const { id } = voiceIdParamSchema.parse(request.params); if (!isDeclaredBot(request.headers['user-agent'])) markEngaged(app.control, id); reply.code(204); return null; }); /** * The one crawler policy this install has, stated where crawlers look. * * Hosted, the landing pages are the product's shop window and welcome; a * self-hosted install is somebody's private books behind a login, and its * default is to ask not to be indexed. Served here rather than as a file so * the answer can depend on which of the two this is, and so the sitemap can * carry the operator's real origin. */ app.get('/robots.txt', async (request, reply) => { reply.type('text/plain'); if (!config.metered) return 'User-agent: *\nDisallow: /\n'; return `User-agent: *\nAllow: /\n\nSitemap: ${config.publicUrl}/sitemap.xml\n`; }); app.get('/sitemap.xml', async (request, reply) => { if (!config.metered) { reply.code(404).type('text/plain'); return 'This install asks not to be indexed.\n'; } const pages = ['/', '/en', '/nl']; reply.type('application/xml'); return [ '', '', ...pages.map((page) => ` ${config.publicUrl}${page === '/' ? '/' : page}`), '', '', ].join('\n'); }); }