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:
41
CLAUDE.md
41
CLAUDE.md
@@ -439,6 +439,7 @@ On restart, `refollow:pending` entries are reset to `import` to prevent stale cl
|
||||
| `GET/POST` | `{mount}/admin/migrate` | Mastodon migration | Yes |
|
||||
| `*` | `{mount}/admin/refollow/*` | Batch refollow control | Yes |
|
||||
| `*` | `{mount}/__debug__/*` | Fedify debug dashboard (if enabled) | Password |
|
||||
| `GET` | `{mount}/api/ap-url?post={url}` | Resolve blog post URL → AP object URL (for "Also on Fediverse" widget) | No |
|
||||
| `GET` | `{mount}/users/:identifier` | Public profile page (HTML fallback) | No |
|
||||
| `GET` | `/*` (root) | Content negotiation (AP clients only) | No |
|
||||
|
||||
@@ -516,3 +517,43 @@ The reader CSS (`assets/reader.css`) uses Indiekit's theme custom properties for
|
||||
- `--color-red45`, `--color-green50`, etc. (not hardcoded hex)
|
||||
|
||||
Post types are differentiated by left border color: purple (notes), green (articles), yellow (boosts), primary (replies).
|
||||
|
||||
## svemagie Fork — Changes vs Upstream
|
||||
|
||||
This fork (`svemagie/indiekit-endpoint-activitypub`) extends the upstream `rmdes/indiekit-endpoint-activitypub` with the following changes. All additions are motivated by strict ActivityPub protocol compliance and real-world interoperability with Mastodon.
|
||||
|
||||
### 1. `allowPrivateAddress: true` in `createFederation` (`lib/federation-setup.js`)
|
||||
|
||||
**Problem:** When the blog hostname resolves to a private RFC-1918 address (e.g. `10.x.x.x`) from within the local network where the server runs, Fedify's built-in SSRF guard throws `"Disallowed private URL"` for own-site lookups. This breaks `lookupObject()` and WebFinger for posts on the same site.
|
||||
|
||||
**Fix:** `allowPrivateAddress: true` is passed to `createFederation`. This disables the SSRF IP check so Fedify can dereference own-site URLs that happen to resolve to private IPs on the LAN.
|
||||
|
||||
### 2. Canonical `id` on Like activities (`lib/jf2-to-as2.js`)
|
||||
|
||||
**Problem:** Per ActivityPub §6.2.1, activities sent from a server SHOULD carry an `id` URI so that remote servers can dereference them. The `Like` activity produced by `jf2ToAS2Activity` had no `id`, which meant remote servers couldn't look it up by URL.
|
||||
|
||||
**Fix:** `jf2ToAS2Activity` derives the mount path from the actor URL (`/activitypub/users/sven` → `/activitypub`) and constructs a canonical id at `{publicationUrl}{mountPath}/activities/like/{post-relative-path}`.
|
||||
|
||||
### 3. Like activity dispatcher (`lib/federation-setup.js`)
|
||||
|
||||
**Problem:** Per ActivityPub §3.1, objects with an `id` MUST be dereferenceable at that URI. With canonical ids added (change 2), requests to `/activitypub/activities/like/{id}` would 404 because no Fedify dispatcher was registered for that path pattern.
|
||||
|
||||
**Fix:** `Like` is added to the `@fedify/fedify/vocab` imports and a `federation.setObjectDispatcher(Like, ...)` is registered after the Article dispatcher in `setupObjectDispatchers`. The handler looks up the post in MongoDB, filters drafts/unlisted/deleted, calls `jf2ToAS2Activity`, and returns the `Like` if that's what was produced.
|
||||
|
||||
### 4. Repost commentary in ActivityPub output (`lib/jf2-to-as2.js`)
|
||||
|
||||
**Problem (two bugs):**
|
||||
1. `jf2ToAS2Activity` always returned a bare `Announce { object: <external-url> }` for reposts, even when the post had author commentary. External URLs (e.g. fromjason.xyz) don't serve AP JSON, so Mastodon received the `Announce` but couldn't fetch the object — the activity was silently dropped from followers' timelines.
|
||||
2. `jf2ToActivityStreams` (used for content negotiation/search) returned a `Note` whose `content` was hardcoded to `🔁 <url>`, ignoring any commentary text.
|
||||
|
||||
**Fix:**
|
||||
- `jf2ToAS2Activity`: if the repost has commentary (`properties.content`), skip the early `Announce` return and fall through to the `Create(Note)` path — the note content block now has a `repost` branch that formats the content as `{commentary}<br><br>🔁 <url>`. Pure reposts (no commentary) keep the `Announce` behaviour.
|
||||
- `jf2ToActivityStreams`: extracts `commentary` from `properties.content` and prepends it to the note content when present.
|
||||
|
||||
### 5. `/api/ap-url` public endpoint (`index.js`)
|
||||
|
||||
**Problem:** The "Also on Fediverse" widget on blog post pages passes the blog post URL to Mastodon's `authorize_interaction` flow. When the remote instance fetches that URL with `Accept: application/activity+json`, it may hit nginx (which serves static HTML), causing "Could not connect to the given address" errors.
|
||||
|
||||
**Fix:** A public `GET /api/ap-url?post={blog-post-url}` route is added to `routesPublic`. It resolves the post in MongoDB, determines its AP object type, and returns the canonical Fedify-served URL (`/activitypub/objects/note/{path}` or `/activitypub/objects/article/{path}`). These paths are always proxied to Node.js and reliably return AP JSON.
|
||||
|
||||
**Special case — AP-likes:** When `like-of` points to an ActivityPub URL (e.g. a Mastodon status), the endpoint detects it via a HEAD request with `Accept: application/activity+json` and returns `{ apUrl: likeOf }` instead. This causes the `authorize_interaction` flow to open the *original remote post* (where the user can like/boost/reply natively) rather than the blog's own representation of the like.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Group,
|
||||
Hashtag,
|
||||
Image,
|
||||
Like,
|
||||
Note,
|
||||
Organization,
|
||||
Person,
|
||||
@@ -143,6 +144,11 @@ export function setupFederation(options) {
|
||||
// Mastodon retries failed deliveries with the original signature, which
|
||||
// can be hours old by the time the delivery succeeds.
|
||||
signatureTimeWindow: { hours: 12 },
|
||||
// Allow fetching own-site URLs that resolve to private IPs (e.g. when
|
||||
// the blog hostname resolves to a RFC-1918 address on the local LAN).
|
||||
// Without this, Fedify's SSRF guard blocks lookupObject() and WebFinger
|
||||
// calls for own-site posts, producing errors in the activity log.
|
||||
allowPrivateAddress: true,
|
||||
});
|
||||
|
||||
// --- Actor dispatcher ---
|
||||
@@ -714,6 +720,29 @@ function setupObjectDispatchers(federation, mountPath, handle, collections, publ
|
||||
return obj instanceof Article ? obj : null;
|
||||
},
|
||||
);
|
||||
|
||||
// Like activity dispatcher — makes AP-like activities dereferenceable.
|
||||
// Per ActivityPub §3.1, objects with an `id` MUST be fetchable at that URI.
|
||||
// Like activities produced by jf2ToAS2Activity carry a canonical id at
|
||||
// /activitypub/activities/like/{post-path}; this dispatcher serves them.
|
||||
federation.setObjectDispatcher(
|
||||
Like,
|
||||
`${mountPath}/activities/like/{+id}`,
|
||||
async (ctx, { id }) => {
|
||||
if (!collections.posts || !publicationUrl) return null;
|
||||
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
|
||||
const post = await collections.posts.findOne({
|
||||
"properties.url": { $in: [postUrl, postUrl + "/"] },
|
||||
});
|
||||
if (!post) return null;
|
||||
if (post?.properties?.["post-status"] === "draft") return null;
|
||||
if (post?.properties?.visibility === "unlisted") return null;
|
||||
if (post.properties?.deleted) return null;
|
||||
const actorUrl = ctx.getActorUri(handle).href;
|
||||
const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
|
||||
return activity instanceof Like ? activity : null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -126,6 +126,7 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, optio
|
||||
// Same rationale as like — serve as Note for content negotiation.
|
||||
const repostOf = properties["repost-of"];
|
||||
const postUrl = resolvePostUrl(properties.url, publicationUrl);
|
||||
const commentary = linkifyUrls(properties.content?.html || properties.content || "");
|
||||
return {
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
type: "Note",
|
||||
@@ -135,7 +136,9 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, optio
|
||||
url: postUrl,
|
||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
cc: [`${actorUrl.replace(/\/$/, "")}/followers`],
|
||||
content: `\u{1F501} <a href="${repostOf}">${repostOf}</a>`,
|
||||
content: commentary
|
||||
? `${commentary}<br><br>\u{1F501} <a href="${repostOf}">${repostOf}</a>`
|
||||
: `\u{1F501} <a href="${repostOf}">${repostOf}</a>`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +278,19 @@ export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, opt
|
||||
if (postType === "like") {
|
||||
const likeOfUrl = properties["like-of"];
|
||||
if (likeOfUrl && (await isApUrl(likeOfUrl))) {
|
||||
// Build a canonical id so remote servers can dereference this activity
|
||||
// (ActivityPub §6.2.1 — activities SHOULD have an id URI).
|
||||
// Derive the mount path from the actor URL (e.g. "/activitypub") so
|
||||
// we don't need mountPath threaded through as an option here.
|
||||
const actorPath = new URL(actorUrl).pathname; // e.g. "/activitypub/users/sven"
|
||||
const mp = actorPath.replace(/\/users\/[^/]+$/, ""); // → "/activitypub"
|
||||
const postRelPath = (properties.url || "")
|
||||
.replace(publicationUrl.replace(/\/$/, ""), "")
|
||||
.replace(/^\//, "")
|
||||
.replace(/\/$/, ""); // e.g. "likes/9acc3"
|
||||
const likeActivityId = `${publicationUrl.replace(/\/$/, "")}${mp}/activities/like/${postRelPath}`;
|
||||
return new Like({
|
||||
id: new URL(likeActivityId),
|
||||
actor: actorUri,
|
||||
object: new URL(likeOfUrl),
|
||||
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
|
||||
@@ -287,11 +302,18 @@ export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, opt
|
||||
if (postType === "repost") {
|
||||
const repostOf = properties["repost-of"];
|
||||
if (!repostOf) return null;
|
||||
return new Announce({
|
||||
actor: actorUri,
|
||||
object: new URL(repostOf),
|
||||
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
|
||||
});
|
||||
const repostContent = properties.content?.html || properties.content || "";
|
||||
if (!repostContent) {
|
||||
// Pure repost — send as a native Announce (boost) so remote servers
|
||||
// can display it as a boost of the original post.
|
||||
return new Announce({
|
||||
actor: actorUri,
|
||||
object: new URL(repostOf),
|
||||
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
|
||||
});
|
||||
}
|
||||
// Has commentary — fall through to Create(Note) so the text is federated.
|
||||
// The note content block below handles the "repost" post-type.
|
||||
}
|
||||
|
||||
const isArticle = postType === "article" && properties.name;
|
||||
@@ -358,6 +380,12 @@ export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, opt
|
||||
noteOptions.content = commentary
|
||||
? `${commentary}<br><br>\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`
|
||||
: `\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`;
|
||||
} else if (postType === "repost") {
|
||||
const repostUrl = properties["repost-of"];
|
||||
const repostCommentary = linkifyUrls(properties.content?.html || properties.content || "");
|
||||
noteOptions.content = repostCommentary
|
||||
? `${repostCommentary}<br><br>\u{1F501} <a href="${repostUrl}">${repostUrl}</a>`
|
||||
: `\u{1F501} <a href="${repostUrl}">${repostUrl}</a>`;
|
||||
} else {
|
||||
noteOptions.content = linkifyUrls(properties.content?.html || properties.content || "");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user