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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| siteOrigins | 38 to 44 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| answer | 46 to 53 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| action | 55 to 149 | 22 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| loader | 151 to 156 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No 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,
);
}