From 8b9bff4d2ee8cf0454f529131b7c33b828a8eb36 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:13:47 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20AP=20inbox=20reliability=20=E2=80=94=20P?= =?UTF-8?q?eerTube=20View=20skip,=20raw=20body=20digest,=20signature=20win?= =?UTF-8?q?dow,=20trailing=20slash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit federation-bridge.js: - Buffer application/activity+json and ld+json bodies that Express doesn't parse (inbox POSTs from Mastodon, PeerTube, etc.) - Store original bytes in req._rawBody and pass them verbatim to Fedify so HTTP Signature Digest verification passes; JSON.stringify reorders keys which caused every Mastodon Like/Announce/Create to be silently rejected - Short-circuit PeerTube View (WatchAction) activities with 200 before Fedify's JSON-LD parser throws on Schema.org extensions federation-setup.js: - Accept signatures up to 12 hours old (Mastodon retries with the original signature hours after a failed delivery) - Look up AP object URLs with $in [url, url+"/"] to tolerate trailing slash differences between stored posts and AP object URLs inbox-listeners.js: - Register a no-op .on(View) handler so Fedify doesn't log noisy "Unsupported activity type" errors for PeerTube watch events Co-Authored-By: Claude Sonnet 4.6 --- lib/federation-bridge.js | 41 ++++++++++++++++++++++++++++++++++++++-- lib/federation-setup.js | 10 +++++++++- lib/inbox-listeners.js | 9 ++++++++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/lib/federation-bridge.js b/lib/federation-bridge.js index 1295745..0f109df 100644 --- a/lib/federation-bridge.js +++ b/lib/federation-bridge.js @@ -35,8 +35,11 @@ export function fromExpressRequest(req) { // Express body parser already consumed the stream — reconstruct // so downstream handlers (e.g. @fedify/debugger login) can read it. const ct = req.headers["content-type"] || ""; - if (ct.includes("application/json")) { - body = JSON.stringify(req.body); + // Handle activity+json and ld+json bodies (PeerTube, Mastodon, etc.). + // Use original raw bytes when available (set by the buffer guard below) + // so Fedify's HTTP Signature Digest check passes. + if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) { + body = req._rawBody || JSON.stringify(req.body); } else if (ct.includes("application/x-www-form-urlencoded")) { body = new URLSearchParams(req.body).toString(); } else { @@ -129,6 +132,40 @@ async function sendFedifyResponse(res, response, request) { export function createFedifyMiddleware(federation, contextDataFactory) { return async (req, res, next) => { try { + // Buffer application/activity+json and ld+json request bodies ourselves — + // Express's JSON body parser only handles application/json, so req.body + // is otherwise undefined for AP inbox POSTs. We need the body to: + // 1. Detect and short-circuit PeerTube View (WatchAction) activities + // before Fedify's JSON-LD parser chokes on Schema.org extensions. + // 2. Preserve original bytes in req._rawBody so fromExpressRequest() + // can pass them to Fedify verbatim, keeping HTTP Signature Digest + // verification intact (JSON.stringify reorders keys, breaking it). + const _apct = req.headers["content-type"] || ""; + if ( + req.method === "POST" && + !req.body && + req.readable && + (_apct.includes("activity+json") || _apct.includes("ld+json")) + ) { + const _chunks = []; + for await (const _chunk of req) { + _chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk)); + } + const _raw = Buffer.concat(_chunks); + req._rawBody = _raw; // preserve for Fedify Digest check + try { + req.body = JSON.parse(_raw.toString("utf8")); + } catch { + req.body = {}; + } + } + // Silently accept PeerTube View (WatchAction) — return 200 to prevent + // retries. Fedify's vocab parser throws on PeerTube's Schema.org + // extensions before any inbox handler is reached. + if (req.method === "POST" && req.body?.type === "View") { + return res.status(200).end(); + } + const request = fromExpressRequest(req); const contextData = await Promise.resolve(contextDataFactory(req)); diff --git a/lib/federation-setup.js b/lib/federation-setup.js index 4ab7e89..4334920 100644 --- a/lib/federation-setup.js +++ b/lib/federation-setup.js @@ -135,6 +135,10 @@ export function setupFederation(options) { const federation = createFederation({ kv, queue, + // Accept signatures up to 12 h old. + // Mastodon retries failed deliveries with the original signature, which + // can be hours old by the time the delivery succeeds. + signatureTimeWindow: { hours: 12 }, }); // --- Actor dispatcher --- @@ -659,7 +663,11 @@ function setupObjectDispatchers(federation, mountPath, handle, collections, publ async function resolvePost(ctx, id) { if (!collections.posts || !publicationUrl) return null; const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`; - const post = await collections.posts.findOne({ "properties.url": postUrl }); + // Match with or without trailing slash — AP object URLs omit the slash + // but posts are stored with one, so an exact match would fail. + const post = await collections.posts.findOne({ + "properties.url": { $in: [postUrl, postUrl + "/"] }, + }); if (!post) return null; if (post?.properties?.["post-status"] === "draft") return null; if (post?.properties?.visibility === "unlisted") return null; diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index 63e048a..4376f3a 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -24,6 +24,7 @@ import { Remove, Undo, Update, + View, } from "@fedify/fedify/vocab"; import { logActivity as logActivityShared } from "./activity-log.js"; @@ -822,7 +823,13 @@ export function registerInboxListeners(inboxChain, options) { } catch (error) { console.warn("[ActivityPub] Flag handler error:", error.message); } - }); + }) + // ── View (PeerTube watch) ───────────────────────────────────────────── + // PeerTube broadcasts View (WatchAction) activities to all followers + // whenever someone watches a video. Fedify has no built-in handler for + // this type, producing noisy "Unsupported activity type" log errors. + // Silently accept and discard. + .on(View, async () => {}); } /**