fix: buffer ActivityPub body before checking for PeerTube View activities

The previous patch checked req.body?.type === "View" but Express's JSON
body parser ignores application/activity+json, so req.body was always
undefined and the guard never fired.

Fix in two parts:
1. In createFedifyMiddleware: manually buffer and JSON-parse the raw
   request stream for activity+json/ld+json POSTs, storing the result on
   req.body before the type check.
2. In fromExpressRequest: extend the content-type check to include
   activity+json/ld+json so non-View activities are correctly forwarded
   to Fedify with the buffered body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-03-14 22:40:54 +01:00
parent 3708dd92c3
commit 314a08542b
@@ -7,10 +7,23 @@
* listener is reached, so the .on(View, ...) no-op handler added earlier * listener is reached, so the .on(View, ...) no-op handler added earlier
* never fires. * never fires.
* *
* Fix: in createFedifyMiddleware (federation-bridge.js), add an early-return * Root cause of the previous (broken) patch: Express's JSON body parser only
* guard that checks req.body.type === "View" and responds 200 immediately, * handles `application/json`, not `application/activity+json`. So `req.body`
* so Fedify never attempts to parse the activity. Returning 200 (rather than * is always undefined for ActivityPub inbox POSTs, meaning the check
* 4xx) prevents the sending server from retrying. * `req.body?.type === "View"` never matched and Fedify still received the raw
* stream.
*
* Fix (two changes to federation-bridge.js):
*
* 1. In createFedifyMiddleware: for ActivityPub POST requests where the body
* hasn't been parsed yet, buffer the raw stream, JSON-parse it, and store
* the result on req.body before the guard runs. Then check type === "View"
* and return 200 if so (preventing retries from the sender).
*
* 2. In fromExpressRequest: extend the content-type check to also handle
* `application/activity+json` and `application/ld+json` bodies (i.e. use
* JSON.stringify(req.body) to reconstruct the stream), so that non-View
* ActivityPub activities are forwarded correctly to Fedify.
*/ */
import { access, readFile, writeFile } from "node:fs/promises"; import { access, readFile, writeFile } from "node:fs/promises";
@@ -21,9 +34,67 @@ const candidates = [
]; ];
const patchSpecs = [ const patchSpecs = [
// --- Patch 1: extend fromExpressRequest to handle activity+json bodies ---
{ {
name: "inbox-skip-view-activity-parse", name: "from-express-request-activity-json-fix",
marker: "// PeerTube View parse skip", marker: "// PeerTube activity+json body fix",
oldSnippet: ` if (ct.includes("application/json")) {
body = JSON.stringify(req.body);
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
newSnippet: ` // PeerTube activity+json body fix
if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) {
body = JSON.stringify(req.body);
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
},
// --- Patch 2a: replace the old (broken) v1 guard with the buffering v2 guard ---
// Handles the case where the previous version of this script was already run.
{
name: "inbox-skip-view-activity-parse-v2",
marker: "// PeerTube View parse skip v2",
oldSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify
// attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
// Schema.org extensions (e.g. InteractionCounter), causing a
// "Failed to parse activity" error. Return 200 to prevent retries.
// PeerTube View parse skip
if (req.method === "POST" && req.body?.type === "View") {
return res.status(200).end();
}
const request = fromExpressRequest(req);`,
newSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify
// attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
// Schema.org extensions (e.g. InteractionCounter), causing a
// "Failed to parse activity" error. Return 200 to prevent retries.
// PeerTube View parse skip v2
const _apct = req.headers["content-type"] || "";
if (
req.method === "POST" &&
!req.body &&
req.readable &&
(_apct.includes("activity+json") || _apct.includes("ld+json"))
) {
// Express doesn't parse application/activity+json, so buffer it ourselves.
const _chunks = [];
for await (const _chunk of req) {
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
}
try {
req.body = JSON.parse(Buffer.concat(_chunks).toString("utf8"));
} catch {
req.body = {};
}
}
if (req.method === "POST" && req.body?.type === "View") {
return res.status(200).end();
}
const request = fromExpressRequest(req);`,
},
// --- Patch 2b: apply the buffering v2 guard on a fresh (unpatched) file ---
// Handles the case where neither v1 nor v2 patch has been applied yet.
{
name: "inbox-skip-view-activity-parse-v2-fresh",
marker: "// PeerTube View parse skip v2",
oldSnippet: ` return async (req, res, next) => { oldSnippet: ` return async (req, res, next) => {
try { try {
const request = fromExpressRequest(req);`, const request = fromExpressRequest(req);`,
@@ -33,7 +104,25 @@ const patchSpecs = [
// attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's // attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
// Schema.org extensions (e.g. InteractionCounter), causing a // Schema.org extensions (e.g. InteractionCounter), causing a
// "Failed to parse activity" error. Return 200 to prevent retries. // "Failed to parse activity" error. Return 200 to prevent retries.
// PeerTube View parse skip // PeerTube View parse skip v2
const _apct = req.headers["content-type"] || "";
if (
req.method === "POST" &&
!req.body &&
req.readable &&
(_apct.includes("activity+json") || _apct.includes("ld+json"))
) {
// Express doesn't parse application/activity+json, so buffer it ourselves.
const _chunks = [];
for await (const _chunk of req) {
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
}
try {
req.body = JSON.parse(Buffer.concat(_chunks).toString("utf8"));
} catch {
req.body = {};
}
}
if (req.method === "POST" && req.body?.type === "View") { if (req.method === "POST" && req.body?.type === "View") {
return res.status(200).end(); return res.status(200).end();
} }
@@ -82,6 +171,7 @@ for (const spec of patchSpecs) {
await writeFile(filePath, updated, "utf8"); await writeFile(filePath, updated, "utf8");
patchedFiles.add(filePath); patchedFiles.add(filePath);
console.log(`[postinstall] Applied ${spec.name} to ${filePath}`);
} }
if (!foundAnyTarget) { if (!foundAnyTarget) {
@@ -92,7 +182,7 @@ for (const spec of patchSpecs) {
if (checkedFiles.size === 0) { if (checkedFiles.size === 0) {
console.log("[postinstall] No federation-bridge files found for View activity parse-skip patch"); console.log("[postinstall] No federation-bridge files found for View activity parse-skip patch");
} else if (patchedFiles.size === 0) { } else if (patchedFiles.size === 0) {
console.log("[postinstall] inbox-skip-view-activity-parse patch already applied"); console.log("[postinstall] inbox-skip-view-activity-parse patch already up to date");
} else { } else {
console.log( console.log(
`[postinstall] Patched inbox-skip-view-activity-parse in ${patchedFiles.size}/${checkedFiles.size} file(s)`, `[postinstall] Patched inbox-skip-view-activity-parse in ${patchedFiles.size}/${checkedFiles.size} file(s)`,