fix: AP inbox reliability — PeerTube View skip, raw body digest, signature window, trailing slash

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 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-16 12:13:47 +01:00
parent d0cb9a76aa
commit 8b9bff4d2e
3 changed files with 56 additions and 4 deletions

View File

@@ -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));

View File

@@ -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;

View File

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