feat(activitypub): AP protocol compliance — Like id, Like dispatcher, repost commentary, ap-url API

Five improvements to strict ActivityPub protocol compliance and
real-world Mastodon interoperability:

1. allowPrivateAddress: true in createFederation (federation-setup.js)
   Fixes Fedify's SSRF guard rejecting own-site URLs that resolve to
   private IPs on the local LAN (e.g. home-network deployments where
   the blog hostname maps to 10.x.x.x internally).

2. Canonical id on Like activities (jf2-to-as2.js)
   Per AP §6.2.1, activities SHOULD have an id URI so remote servers
   can dereference them. Derives mount path from actor URL and constructs
   {publicationUrl}{mount}/activities/like/{post-path}.

3. Like activity object dispatcher (federation-setup.js)
   Per AP §3.1, objects with an id MUST be dereferenceable at that URI.
   Registers federation.setObjectDispatcher(Like, .../activities/like/{+id})
   so fetching the canonical Like URL returns the activity as AP JSON.
   Adds Like to @fedify/fedify/vocab imports.

4. Repost commentary in AP output (jf2-to-as2.js)
   - jf2ToAS2Activity: only sends Announce for pure reposts (no content);
     reposts with commentary fall through to Create(Note) with content
     formatted as "{commentary}<br><br>🔁 <url>" so followers see the text.
   - jf2ToActivityStreams: prepends commentary to the repost Note content
     for correct display in content-negotiation / search responses.

5. GET /api/ap-url public endpoint (index.js)
   Resolves a blog post URL → its Fedify-served AP object URL for use by
   "Also on Fediverse" widgets. Prevents nginx from intercepting
   authorize_interaction requests that need AP JSON.
   Special case: AP-likes return { apUrl: likeOf } so authorize_interaction
   opens the original remote post rather than the blog's like post.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-21 09:12:21 +01:00
parent 842fc5af2a
commit ce30dfea3b
4 changed files with 199 additions and 6 deletions

View File

@@ -259,6 +259,101 @@ export default class ActivityPubEndpoint {
});
});
// Public API: resolve a blog post URL → its Fedify-served AP object URL.
//
// GET /api/ap-url?post=https://blog.example.com/notes/foo/
// → { apUrl: "https://blog.example.com/activitypub/objects/note/notes/foo/" }
//
// Used by "Also on Fediverse" widgets so that the Mastodon authorize_interaction
// flow receives a URL that is always routed to Node.js (never intercepted by a
// static file server), ensuring reliable AP content negotiation.
//
// Special case — AP-likes: when like-of points to an ActivityPub object the
// widget should open the *original* post on the remote instance so the user
// can interact with it there. We return { apUrl: likeOf } in that case.
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" });
}
const postType = post.properties?.["post-type"];
// For AP-likes: the widget should open the liked post on the remote instance
// so the user can interact with it there. We detect AP URLs the same way as
// jf2-to-as2.js: HEAD request with Accept: application/activity+json.
if (postType === "like") {
const likeOf = post.properties?.["like-of"] || "";
if (likeOf) {
let isAp = false;
try {
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), 3000);
const r = await fetch(likeOf, {
method: "HEAD",
headers: { Accept: "application/activity+json, application/ld+json" },
signal: ctrl.signal,
});
clearTimeout(tid);
const ct = r.headers.get("content-type") || "";
isAp = ct.includes("activity+json") || ct.includes("ld+json");
} catch { /* network error — treat as non-AP */ }
if (isAp) {
res.set("Cache-Control", "public, max-age=60");
return res.json({ apUrl: likeOf });
}
}
}
// Determine the AP object type (mirrors jf2-to-as2.js logic)
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 apUrl = `${publicationUrl}${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;
}