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

@@ -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.

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;
}

View File

@@ -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 ---

View File

@@ -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 || "");
}