app/lib/webmention/verify.server.ts
183 lines and 5 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| readAuthor | 36 to 69 | 10 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:40> | 40 to 46 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| inspectSource | 72 to 173 | 22 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:147> | 147 to 155 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| verifyWebmention | 176 to 208 | 3 | 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/error-message.mjs, app/lib/seo.ts, app/lib/webmention/urls.mjs. Imported by workers/webmention-verify.ts.
Source
import { parseHTML } from "linkedom";
import { recordWebmentionVerdict, type WebmentionVerdict } from "~/db";
import { readCapped } from "@dustinedwards/site-runtime/body";
import { SITE_ORIGIN } from "~/lib/seo";
import { collapseExcerpt, sameDocument, sourceVerdict } from "~/lib/webmention/urls.mjs";
import { errorMessage } from "~/lib/error-message.mjs";
// linkedom's document, not the DOM's.
type ParsedDocument = ReturnType<typeof parseHTML>["document"];
// The row is written `unverified` and moves to `pending` only if an anchor on the source resolves to the
// target. Runs in the queue consumer, workers/webmention-verify.ts, which retries a run whose verdict did
// not land. Nothing from the source is stored as markup.
const MAX_SOURCE_BYTES = 1024 * 1024;
const SOURCE_TIMEOUT_MS = 5000;
// Redirects are followed by hand, each hop re-checked: an automatic follow would let a public source
// bounce the fetch to a loopback name or an IP literal the route refused on the first URL.
const MAX_REDIRECTS = 5;
// A fixed set, not a message: free text would put a remote server's prose into the admin plane.
export const FAILURE_REASONS = {
fetchError: "fetch-error",
timeout: "timeout",
tooLarge: "too-large",
notHtml: "not-html",
noLink: "no-link",
redirectRefused: "redirect-refused",
} as const;
// No h-card: the name is the source's hostname, a fact rather than a guess. `u-url` is kept only if it
// resolves to absolute http(s).
function readAuthor(
document: ParsedDocument,
sourceUrl: string,
): { authorName: string | null; authorUrl: string | null } {
const hostname = (() => {
try {
return new URL(sourceUrl).hostname;
} catch {
return null;
}
})();
const card = document.querySelector(".h-card");
if (!card) return { authorName: hostname, authorUrl: null };
const nameNode = card.querySelector(".p-name");
const name = collapseExcerpt(nameNode?.textContent ?? card.textContent ?? "");
const urlNode = card.querySelector(".u-url");
const rawUrl = urlNode?.getAttribute("href") ?? null;
let authorUrl: string | null = null;
if (rawUrl) {
try {
const resolved = new URL(rawUrl, sourceUrl);
if (resolved.protocol === "https:" || resolved.protocol === "http:") {
authorUrl = resolved.href;
}
} catch {
authorUrl = null;
}
}
return { authorName: name.length > 0 ? name : hostname, authorUrl };
}
// Pure of the database, so a test can drive the decision over a stubbed fetch.
export async function inspectSource(
sourceUrl: string,
targetUrl: string,
): Promise<WebmentionVerdict> {
// One signal, read at both sites: a timeout during the body read makes the read throw, which would
// otherwise be misreported as a fetch error.
const signal = AbortSignal.timeout(SOURCE_TIMEOUT_MS);
// The same two origins the route refused on the first URL: the canonical one and the target's host,
// which differ before the cutover. A hop to either is a redirect back onto this site.
const siteOrigins = [...new Set([SITE_ORIGIN, new URL(targetUrl).origin])];
let response: Response;
let at = sourceUrl;
for (let hop = 0; ; hop += 1) {
try {
response = await fetch(at, {
method: "GET",
headers: { accept: "text/html, application/xhtml+xml" },
redirect: "manual",
signal,
});
} catch {
return {
status: "failed",
failureReason: signal.aborted ? FAILURE_REASONS.timeout : FAILURE_REASONS.fetchError,
};
}
const location = response.headers.get("location");
if (response.status < 300 || response.status > 399 || !location) break;
await response.body?.cancel();
if (hop >= MAX_REDIRECTS) {
return { status: "failed", failureReason: FAILURE_REASONS.redirectRefused };
}
let next: string;
try {
next = new URL(location, at).href;
} catch {
return { status: "failed", failureReason: FAILURE_REASONS.redirectRefused };
}
if (!sourceVerdict(next, targetUrl, siteOrigins).ok) {
return { status: "failed", failureReason: FAILURE_REASONS.redirectRefused };
}
at = next;
}
if (!response.ok) {
return { status: "failed", failureReason: FAILURE_REASONS.fetchError };
}
// Checked before the body is read, so a video costs nothing to refuse. An absent content-type is not HTML.
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
if (!contentType.includes("text/html") && !contentType.includes("application/xhtml+xml")) {
return { status: "failed", failureReason: FAILURE_REASONS.notHtml };
}
let body: string | null;
try {
body = await readCapped(response, MAX_SOURCE_BYTES);
} catch {
return {
status: "failed",
failureReason: signal.aborted ? FAILURE_REASONS.timeout : FAILURE_REASONS.fetchError,
};
}
if (body === null) {
return { status: "failed", failureReason: FAILURE_REASONS.tooLarge };
}
if (signal.aborted) {
return { status: "failed", failureReason: FAILURE_REASONS.timeout };
}
const { document } = parseHTML(body);
// Resolved against the source before comparing. The first match wins: a page linking here twice is one mention.
const anchors = [...document.querySelectorAll("a[href]")];
const match = anchors.find((a) => {
const href = a.getAttribute("href");
if (!href) return false;
try {
return sameDocument(new URL(href, sourceUrl).href, targetUrl);
} catch {
return false;
}
});
if (!match) {
return { status: "failed", failureReason: FAILURE_REASONS.noLink };
}
const { authorName, authorUrl } = readAuthor(document, sourceUrl);
// The containing element's text: the anchor alone is usually the title; the parent is the sentence.
const container = match.parentElement ?? match;
const excerpt = collapseExcerpt(container.textContent ?? "");
return {
status: "pending",
authorName,
authorUrl,
excerpt: excerpt.length > 0 ? excerpt : null,
};
}
// NEVER THROWS: every source failure is a recorded verdict, so the consumer retries only a failed write.
export async function verifyWebmention(
env: Env,
id: number,
sourceUrl: string,
targetUrl: string,
): Promise<void> {
let verdict: WebmentionVerdict;
try {
verdict = await inspectSource(sourceUrl, targetUrl);
} catch (error) {
console.error(
JSON.stringify({
alert: "webmention-verify-threw",
id,
detail: errorMessage(error),
}),
);
verdict = { status: "failed", failureReason: FAILURE_REASONS.fetchError };
}
try {
await recordWebmentionVerdict(env, id, verdict);
} catch (error) {
// The row stays `unverified`, a visible state rather than a fabricated verdict.
console.error(
JSON.stringify({
alert: "webmention-verdict-write-failed",
id,
detail: errorMessage(error),
}),
);
}
}