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