Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

6.0 kB Ruw bestand
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<void> {
  app.get('/api/public/config', async (): Promise<PublicConfig> => {
    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<VoiceAssignment> => {
    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 [
      '<?xml version="1.0" encoding="UTF-8"?>',
      '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
      ...pages.map((page) => `  <url><loc>${config.publicUrl}${page === '/' ? '/' : page}</loc></url>`),
      '</urlset>',
      '',
    ].join('\n');
  });
}