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:
co-authored by
Claude Sonnet 4.6
parent
d0cb9a76aa
commit
8b9bff4d2e
@@ -35,8 +35,11 @@ export function fromExpressRequest(req) {
|
|||||||
// Express body parser already consumed the stream — reconstruct
|
// Express body parser already consumed the stream — reconstruct
|
||||||
// so downstream handlers (e.g. @fedify/debugger login) can read it.
|
// so downstream handlers (e.g. @fedify/debugger login) can read it.
|
||||||
const ct = req.headers["content-type"] || "";
|
const ct = req.headers["content-type"] || "";
|
||||||
if (ct.includes("application/json")) {
|
// Handle activity+json and ld+json bodies (PeerTube, Mastodon, etc.).
|
||||||
body = JSON.stringify(req.body);
|
// 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")) {
|
} else if (ct.includes("application/x-www-form-urlencoded")) {
|
||||||
body = new URLSearchParams(req.body).toString();
|
body = new URLSearchParams(req.body).toString();
|
||||||
} else {
|
} else {
|
||||||
@@ -129,6 +132,40 @@ async function sendFedifyResponse(res, response, request) {
|
|||||||
export function createFedifyMiddleware(federation, contextDataFactory) {
|
export function createFedifyMiddleware(federation, contextDataFactory) {
|
||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
try {
|
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 request = fromExpressRequest(req);
|
||||||
const contextData = await Promise.resolve(contextDataFactory(req));
|
const contextData = await Promise.resolve(contextDataFactory(req));
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ export function setupFederation(options) {
|
|||||||
const federation = createFederation({
|
const federation = createFederation({
|
||||||
kv,
|
kv,
|
||||||
queue,
|
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 ---
|
// --- Actor dispatcher ---
|
||||||
@@ -659,7 +663,11 @@ function setupObjectDispatchers(federation, mountPath, handle, collections, publ
|
|||||||
async function resolvePost(ctx, id) {
|
async function resolvePost(ctx, id) {
|
||||||
if (!collections.posts || !publicationUrl) return null;
|
if (!collections.posts || !publicationUrl) return null;
|
||||||
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
|
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) return null;
|
||||||
if (post?.properties?.["post-status"] === "draft") return null;
|
if (post?.properties?.["post-status"] === "draft") return null;
|
||||||
if (post?.properties?.visibility === "unlisted") return null;
|
if (post?.properties?.visibility === "unlisted") return null;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Remove,
|
Remove,
|
||||||
Undo,
|
Undo,
|
||||||
Update,
|
Update,
|
||||||
|
View,
|
||||||
} from "@fedify/fedify/vocab";
|
} from "@fedify/fedify/vocab";
|
||||||
|
|
||||||
import { logActivity as logActivityShared } from "./activity-log.js";
|
import { logActivity as logActivityShared } from "./activity-log.js";
|
||||||
@@ -822,7 +823,13 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[ActivityPub] Flag handler error:", error.message);
|
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 () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user