Skip to the content

Capsid menu

app/routes/webmention.ts

131 lines and 4 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of webmention.ts
FunctionLinesComplexityCoveredCRAPMutation
siteOrigins38 to 442No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
answer46 to 532No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
action55 to 14922No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
loader151 to 1561No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/db/index.ts, app/lib/client-ip.ts, app/lib/context.ts, app/lib/error-message.mjs, app/lib/rate-limit.mjs, app/lib/seo.ts, app/lib/webmention/queue.ts, app/lib/webmention/urls.mjs. No module of this repo imports it.

Source

import { countOpenWebmentions, receiveWebmention, webmentionTarget } from "~/db";
import { clientIp } from "~/lib/client-ip";
import { getEnv } from "~/lib/context";
import { readCapped } from "@dustinedwards/site-runtime/body";
import { limitHit } from "~/lib/rate-limit.mjs";
import { SITE_ORIGIN } from "~/lib/seo";
import type { WebmentionVerifyMessage } from "~/lib/webmention/queue";
import { sourceVerdict, targetSlug } from "~/lib/webmention/urls.mjs";

import type { Route } from "./+types/webmention";
import { errorMessage } from "~/lib/error-message.mjs";

/**
 * Public by necessity, and it writes rows, so four bounds: a per-IP rate, a publicly visible target,
 * one row per (source, target), and a global cap on open rows. It stores no IP address.
 */

/** Bytes. A real webmention is well under 500; 4 KB allows heavy percent-encoding. */
const MAX_BODY_BYTES = 4 * 1024;

const RATE_LIMIT = 20;
const RATE_PERIOD_SECONDS = 60;

/**
 * Small: a higher cap buys a real sender nothing and raises what an attacker can store. 503, not
 * 429: the condition is this site's state, not the caller's rate.
 */
const OPEN_QUEUE_CAP = 500;

const NO_STORE = "private, no-store";

/** One string for every target problem: a distinct message for a draft would be an oracle for unpublished slugs. */
const BAD_TARGET = "target must be a published post on this site";

const BAD_FORM = "send application/x-www-form-urlencoded with source and target";

/** Two origins, not one: pre-cutover, pinning a constant would refuse the other host at the moment of the move. */
function siteOrigins(request: Request): string[] {
  try {
    return [...new Set([SITE_ORIGIN, new URL(request.url).origin])];
  } catch {
    return [SITE_ORIGIN];
  }
}

function answer(body: string, status: number, extra: HeadersInit = {}): Response {
  const headers = new Headers({
    "content-type": "text/plain; charset=utf-8",
    "cache-control": NO_STORE,
  });
  for (const [name, value] of new Headers(extra)) headers.set(name, value);
  return new Response(body, { status, headers });
}

export async function action({ request, context }: Route.ActionArgs) {
  if (request.method !== "POST") {
    return answer("Method Not Allowed", 405, { allow: "POST" });
  }

  /* Content-Length is a client hint: used to refuse early, never to permit. */
  const declared = Number(request.headers.get("content-length") ?? "");
  if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
    return answer("Payload Too Large", 413);
  }

  const env = getEnv(context);
  /* Absent on a Worker Preview, which cannot consume a queue: refused before anything is written. */
  if (!env.ASK_BUDGET || !env.WEBMENTION_VERIFY) {
    return answer("Webmentions unavailable", 503);
  }

  /* The rate limit precedes the body read: every check below it costs something. */
  const ip = clientIp(request);
  const rate = await limitHit(env, `wm:${ip}`, RATE_LIMIT, RATE_PERIOD_SECONDS);
  if (rate.status === "unavailable") {
    return answer("Webmentions unavailable", 503, { "retry-after": String(rate.retryAfterSeconds) });
  }
  if (rate.status === "limited") {
    return answer("Too Many Requests", 429, { "retry-after": String(rate.retryAfterSeconds) });
  }

  /* Checked on the header, where it is free. The protocol specifies only this encoding. */
  const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
  if (!contentType.includes("application/x-www-form-urlencoded")) {
    return answer(BAD_FORM, 400);
  }

  let body: string | null;
  try {
    body = await readCapped(request, MAX_BODY_BYTES);
  } catch (error) {
    // The sender's body broke mid-read: its request is malformed, not this site down, so 400, logged.
    console.error(JSON.stringify({ alert: "webmention-body-unreadable", detail: errorMessage(error) }));
    return answer(BAD_FORM, 400);
  }
  if (body === null) {
    return answer("Payload Too Large", 413);
  }

  const fields = new URLSearchParams(body);
  const source = fields.get("source") ?? "";
  const target = fields.get("target") ?? "";
  if (!source || !target) {
    return answer(BAD_FORM, 400);
  }

  const origins = siteOrigins(request);
  const slug = targetSlug(target, origins);
  if (slug === null) {
    return answer(BAD_TARGET, 400);
  }

  // Refused before anything is written, so a URL this Worker will not fetch never becomes a row.
  const verdict = sourceVerdict(source, target, origins);
  if (!verdict.ok) {
    return answer(verdict.reason, 400);
  }

  /* After the source check: only this costs a query, so garbage sources cannot make this site read D1. */
  if (!(await webmentionTarget(env, slug))) {
    return answer(BAD_TARGET, 400);
  }

  /* A count, not a reservation: two concurrent requests can both pass at the boundary, one row past the cap. */
  const open = await countOpenWebmentions(env);
  if (open >= OPEN_QUEUE_CAP) {
    // Moderating /admin/mentions reopens the endpoint.
    console.log(JSON.stringify({ event: "webmention-open-cap-refused", open, cap: OPEN_QUEUE_CAP }));
    return answer("Webmention queue is full, please retry later", 503);
  }

  const id = await receiveWebmention(env, { sourceUrl: source, targetSlug: slug });

  /*
   * 202 first, verification on the queue: retried there and dead-lettered rather than lost with the
   * isolate. A failed send answers 503 so the sender retries; its re-send resets the same row.
   */
  try {
    const message: WebmentionVerifyMessage = { id, source, target };
    await env.WEBMENTION_VERIFY.send(message);
  } catch (error) {
    console.error(
      JSON.stringify({ alert: "webmention-enqueue-failed", id, detail: errorMessage(error) }),
    );
    return answer("Webmentions unavailable", 503, { "retry-after": "60" });
  }

  return answer("Accepted. It will be verified and reviewed before it appears.", 202);
}

export function loader() {
  return answer(
    "Webmention endpoint. POST source and target as application/x-www-form-urlencoded.",
    200,
  );
}