mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
95
index.js
95
index.js
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user