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 () => {}); } /**