Skip to the content

Capsid menu

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

The functions of verify.server.ts
FunctionLinesComplexityCoveredCRAPMutation
readAuthor36 to 6910No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:40>40 to 462No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
inspectSource72 to 17322No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:147>147 to 1553No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
verifyWebmention176 to 2083No 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/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),
      }),
    );
  }
}