From ce30dfea3b13aefbe3d5792b4d3272af55653931 Mon Sep 17 00:00:00 2001
From: svemagie <869694+svemagie@users.noreply.github.com>
Date: Sat, 21 Mar 2026 09:12:21 +0100
Subject: [PATCH] =?UTF-8?q?feat(activitypub):=20AP=20protocol=20compliance?=
=?UTF-8?q?=20=E2=80=94=20Like=20id,=20Like=20dispatcher,=20repost=20comme?=
=?UTF-8?q?ntary,=20ap-url=20API?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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}
๐ " 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
---
CLAUDE.md | 41 ++++++++++++++++++
index.js | 95 +++++++++++++++++++++++++++++++++++++++++
lib/federation-setup.js | 29 +++++++++++++
lib/jf2-to-as2.js | 40 ++++++++++++++---
4 files changed, 199 insertions(+), 6 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 886760f..45d9a18 100644
--- a/CLAUDE.md
+++ b/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: }` 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 `๐ `, 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}
๐ `. 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.
diff --git a/index.js b/index.js
index e8e34a0..238d693 100644
--- a/index.js
+++ b/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;
}
diff --git a/lib/federation-setup.js b/lib/federation-setup.js
index bc2ae0d..faaaf51 100644
--- a/lib/federation-setup.js
+++ b/lib/federation-setup.js
@@ -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 ---
diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js
index 7dddc60..4168644 100644
--- a/lib/jf2-to-as2.js
+++ b/lib/jf2-to-as2.js
@@ -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} ${repostOf}`,
+ content: commentary
+ ? `${commentary}
\u{1F501} ${repostOf}`
+ : `\u{1F501} ${repostOf}`,
};
}
@@ -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}
\u{1F516} ${bookmarkUrl}`
: `\u{1F516} ${bookmarkUrl}`;
+ } else if (postType === "repost") {
+ const repostUrl = properties["repost-of"];
+ const repostCommentary = linkifyUrls(properties.content?.html || properties.content || "");
+ noteOptions.content = repostCommentary
+ ? `${repostCommentary}
\u{1F501} ${repostUrl}`
+ : `\u{1F501} ${repostUrl}`;
} else {
noteOptions.content = linkifyUrls(properties.content?.html || properties.content || "");
}