fix: add AP inbox digest patch and AP URL lookup API for fediverse interaction

- patch-ap-inbox-raw-body-digest: preserve raw request bytes through the
  AP inbox buffer guard so Fedify's HTTP Signature Digest verification
  passes (JSON.stringify re-encoding broke SHA-256 digest check, causing
  Mastodon likes/replies/boosts to be silently rejected)
- patch-ap-url-lookup-api: add GET /activitypub/api/ap-url endpoint that
  maps a blog post URL to its Fedify-served AP object URL, enabling
  reliable content negotiation for authorize_interaction redirects
- wire both patches into postinstall and serve scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-03-15 15:00:13 +01:00
parent d2573146a7
commit a42a6b6d45
3 changed files with 313 additions and 2 deletions

View File

@@ -0,0 +1,123 @@
/**
* Patch: preserve raw body bytes through the AP inbox buffer guard so that
* Fedify's HTTP Signature Digest verification passes.
*
* Root cause:
* patch-inbox-skip-view-activity-parse.mjs buffers the request body for
* application/activity+json requests (needed to detect PeerTube View
* activities before Fedify parses them). It stores the parsed JSON in
* req.body. fromExpressRequest() then reconstructs the body for Fedify via
* JSON.stringify(req.body).
*
* Fedify 2.x verifies the HTTP Signature "Digest: SHA-256=..." header that
* Mastodon (and most other AP servers) include with every inbox POST.
* The digest is computed over the EXACT original request bytes. Re-encoding
* the body via JSON.stringify() produces different bytes (different key
* ordering, whitespace, Unicode escaping), so the digest check fails and
* Fedify silently rejects every inbound Like, Announce, and Create activity
* from Mastodon. The activity never reaches the inbox handlers and is never
* stored in ap_activities — so conversations/AP shows zero interactions.
*
* Fix (two changes to federation-bridge.js):
*
* 1. In createFedifyMiddleware buffer guard: after the for-await loop, store
* the original Buffer in req._rawBody before JSON-parsing it into req.body.
*
* 2. In fromExpressRequest: when req._rawBody is available, pass it directly
* to new Request() instead of JSON.stringify(req.body). This gives Fedify
* the original bytes so its SHA-256 digest check matches the Digest header.
*/
import { access, readFile, writeFile } from "node:fs/promises";
const candidates = [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
];
const MARKER = "// raw body digest fix";
const patchSpecs = [
// Patch A: store raw bytes in req._rawBody alongside req.body
{
name: "raw-body-store",
oldSnippet: ` try {
req.body = JSON.parse(Buffer.concat(_chunks).toString("utf8"));
} catch {
req.body = {};
}`,
newSnippet: ` const _raw = Buffer.concat(_chunks); // raw body digest fix
req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification
try {
req.body = JSON.parse(_raw.toString("utf8"));
} catch {
req.body = {};
}`,
},
// Patch B: use req._rawBody in fromExpressRequest when available
{
name: "from-express-request-use-raw-body",
oldSnippet: ` // PeerTube activity+json body fix
if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) {
body = JSON.stringify(req.body);
}`,
newSnippet: ` // PeerTube activity+json body fix
if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) {
// Use original raw bytes when available (set by createFedifyMiddleware buffer guard).
// JSON.stringify() changes byte layout, breaking Fedify's HTTP Signature Digest check.
body = req._rawBody || JSON.stringify(req.body); // raw body digest fix
}`,
},
];
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
let checked = 0;
let patched = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) {
continue;
}
checked += 1;
let source = await readFile(filePath, "utf8");
if (source.includes(MARKER)) {
continue;
}
let filePatched = false;
for (const spec of patchSpecs) {
if (!source.includes(spec.oldSnippet)) {
console.log(`[postinstall] patch-ap-inbox-raw-body-digest: ${spec.name} snippet not found in ${filePath}`);
continue;
}
source = source.replace(spec.oldSnippet, spec.newSnippet);
filePatched = true;
console.log(`[postinstall] Applied ${spec.name} to ${filePath}`);
}
if (filePatched) {
await writeFile(filePath, source, "utf8");
patched += 1;
}
}
if (checked === 0) {
console.log("[postinstall] patch-ap-inbox-raw-body-digest: no target files found");
} else if (patched === 0) {
console.log("[postinstall] patch-ap-inbox-raw-body-digest: already up to date");
} else {
console.log(`[postinstall] patch-ap-inbox-raw-body-digest: patched ${patched}/${checked} file(s)`);
}

View File

@@ -0,0 +1,188 @@
/**
* Patch: add a public GET /api/ap-url endpoint to the ActivityPub endpoint.
*
* Problem:
* The "Also on fediverse" widget on blog post pages passes the blog post URL
* (e.g. https://blog.giersig.eu/replies/bd78a/) to the Mastodon
* authorize_interaction flow:
* https://{instance}/authorize_interaction?uri={blog-post-url}
*
* When the remote instance fetches that URI with Accept: application/activity+json,
* it may hit a static file server (nginx/Caddy) that returns HTML instead of
* AP JSON, causing the interaction to fail with "Could not connect to the given
* address" or a similar error.
*
* Fix:
* Add a public API route to the AP endpoint:
* GET /activitypub/api/ap-url?post={blog-post-url}
*
* This resolves the post in MongoDB, determines its object type (Note or Article),
* and returns the canonical Fedify-served AP object URL:
* { apUrl: "https://blog.giersig.eu/activitypub/objects/note/replies/bd78a/" }
*
* The "Also on fediverse" JS widget can then call this API and use the returned
* apUrl in the authorize_interaction redirect instead of the blog post URL.
* Fedify-served URLs (/activitypub/objects/…) are always proxied to Node.js and
* will reliably return AP JSON with correct content negotiation.
*
* The patch inserts the new route in the `routesPublic` getter of index.js,
* just before the closing `return router` statement.
*/
import { access, readFile, writeFile } from "node:fs/promises";
const candidates = [
"node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
];
const MARKER = "// AP URL lookup endpoint";
const OLD_SNIPPET = ` router.all("/inbox", (req, res) => {
res
.status(405)
.set("Allow", "POST")
.type("application/activity+json")
.json({
error: "Method Not Allowed",
message: "The shared inbox only accepts POST requests",
});
});
return router;
}
/**
* Authenticated admin routes — mounted at mountPath, behind IndieAuth.
*/`;
const NEW_SNIPPET = ` router.all("/inbox", (req, res) => {
res
.status(405)
.set("Allow", "POST")
.type("application/activity+json")
.json({
error: "Method Not Allowed",
message: "The shared inbox only accepts POST requests",
});
});
// AP URL lookup endpoint
// Public API: resolve a blog post URL → its Fedify-served AP object URL.
// GET /api/ap-url?post=https://blog.example.com/notes/foo/
// Returns { apUrl: "https://blog.example.com/activitypub/objects/note/notes/foo/" }
//
// Use this in "Also on fediverse" widgets so that authorize_interaction
// uses a URL that is always routed to Node.js (never intercepted by a
// static file server), ensuring reliable AP content negotiation.
router.get("/api/ap-url", async (req, res) => {
try {
const postParam = req.query.post;
if (!postParam) {
return res.status(400).json({ error: "post parameter required" });
}
const { application } = req.app.locals;
const postsCollection = application.collections?.get("posts");
if (!postsCollection) {
return res.status(503).json({ error: "Database unavailable" });
}
const publicationUrl = (self._publicationUrl || application.url || "").replace(/\\/$/, "");
// Match with or without trailing slash
const postUrl = postParam.replace(/\\/$/, "");
const post = await postsCollection.findOne({
"properties.url": { $in: [postUrl, postUrl + "/"] },
});
if (!post) {
return res.status(404).json({ error: "Post not found" });
}
// Draft and unlisted posts are not federated
if (post?.properties?.["post-status"] === "draft") {
return res.status(404).json({ error: "Post not found" });
}
if (post?.properties?.visibility === "unlisted") {
return res.status(404).json({ error: "Post not found" });
}
// Determine the AP object type (mirrors jf2-to-as2.js logic)
const postType = post.properties?.["post-type"];
const isArticle = postType === "article" && !!post.properties?.name;
const objectType = isArticle ? "article" : "note";
// Extract the path portion after the publication base URL
const resolvedUrl = (post.properties?.url || "").replace(/\\/$/, "");
if (!resolvedUrl.startsWith(publicationUrl)) {
return res.status(500).json({ error: "Post URL does not match publication base" });
}
const postPath = resolvedUrl.slice(publicationUrl.length).replace(/^\\//, "");
const mp = (self.options.mountPath || "").replace(/\\/$/, "");
const apBase = publicationUrl;
const apUrl = \`\${apBase}\${mp}/objects/\${objectType}/\${postPath}\`;
res.set("Cache-Control", "public, max-age=300");
res.json({ apUrl });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
return router;
}
/**
* Authenticated admin routes — mounted at mountPath, behind IndieAuth.
*/`;
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
let checked = 0;
let patched = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) {
continue;
}
checked += 1;
const source = await readFile(filePath, "utf8");
if (source.includes(MARKER)) {
continue;
}
if (!source.includes(OLD_SNIPPET)) {
console.log(`[postinstall] patch-ap-url-lookup-api: old snippet not found in ${filePath}`);
continue;
}
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
if (updated === source) {
continue;
}
await writeFile(filePath, updated, "utf8");
patched += 1;
console.log(`[postinstall] Applied patch-ap-url-lookup-api to ${filePath}`);
}
if (checked === 0) {
console.log("[postinstall] patch-ap-url-lookup-api: no target files found");
} else if (patched === 0) {
console.log("[postinstall] patch-ap-url-lookup-api: already up to date");
} else {
console.log(`[postinstall] patch-ap-url-lookup-api: patched ${patched}/${checked} file(s)`);
}