Files
indiekit-endpoint-activitypub/lib/jf2-to-as2.js
svemagie ce30dfea3b 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>
2026-03-21 09:12:21 +01:00

675 lines
22 KiB
JavaScript

/**
* Convert Indiekit JF2 post properties to ActivityStreams 2.0 objects.
*
* Two export flavors:
* - jf2ToActivityStreams() — returns plain JSON-LD objects (for content negotiation)
* - jf2ToAS2Activity() — returns Fedify vocab instances (for outbox + syndicator)
*/
import { Temporal } from "@js-temporal/polyfill";
import {
Announce,
Article,
Audio,
Create,
Hashtag,
Image,
Like,
Mention,
Note,
Video,
} from "@fedify/fedify/vocab";
// ---------------------------------------------------------------------------
// Content helpers
// ---------------------------------------------------------------------------
/**
* Convert bare URLs in HTML content to clickable links.
* Skips URLs already inside href attributes or anchor tag text.
*/
function linkifyUrls(html) {
if (!html) return html;
return html.replace(
/(?<![=">])(https?:\/\/[^\s<"]+)/g,
'<a href="$1">$1</a>',
);
}
/**
* Parse @user@domain mention patterns from text content.
* Returns array of { handle: "user@domain", username: "user", domain: "domain.tld" }.
*/
export function parseMentions(text) {
if (!text) return [];
// Strip HTML tags for parsing
const plain = text.replace(/<[^>]*>/g, " ");
const mentionRegex = /(?<![\/\w])@([\w.-]+)@([\w.-]+\.\w{2,})/g;
const mentions = [];
const seen = new Set();
let match;
while ((match = mentionRegex.exec(plain)) !== null) {
const handle = `${match[1]}@${match[2]}`;
if (!seen.has(handle.toLowerCase())) {
seen.add(handle.toLowerCase());
mentions.push({ handle, username: match[1], domain: match[2] });
}
}
return mentions;
}
/**
* Replace @user@domain patterns in HTML with linked mentions.
* resolvedMentions: [{ handle, actorUrl, profileUrl? }]
* Uses profileUrl (human-readable) for href, falls back to Mastodon-style URL.
*/
function linkifyMentions(html, resolvedMentions) {
if (!html || !resolvedMentions?.length) return html;
for (const { handle, profileUrl } of resolvedMentions) {
// Escape handle for regex (dots, hyphens)
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match @handle not already inside an HTML tag attribute or anchor text
const pattern = new RegExp(`(?<!["\\/\\w])@${escaped}(?![\\w])`, "gi");
const parts = handle.split("@");
const url = profileUrl || `https://${parts[1]}/@${parts[0]}`;
html = html.replace(
pattern,
`<a href="${url}" class="mention" rel="nofollow noopener" target="_blank">@${handle}</a>`,
);
}
return html;
}
// ---------------------------------------------------------------------------
// ActivityPub URL detection
// ---------------------------------------------------------------------------
/**
* Check whether a URL serves ActivityPub content by doing a quick content
* negotiation request. Returns true if the server responds with an AP
* media type (application/activity+json or application/ld+json).
* Fails silently — any network/timeout error returns false.
*/
async function isApUrl(url) {
try {
const res = await fetch(url, {
headers: { Accept: "application/activity+json, application/ld+json" },
redirect: "follow",
signal: AbortSignal.timeout(3000),
});
const ct = res.headers.get("content-type") || "";
return ct.includes("activity+json") || ct.includes("ld+json");
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Plain JSON-LD (content negotiation on individual post URLs)
// ---------------------------------------------------------------------------
/**
* Convert JF2 properties to a plain ActivityStreams JSON-LD object.
*
* @param {object} properties - JF2 post properties
* @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
* @param {string} publicationUrl - Publication base URL with trailing slash
* @returns {object} ActivityStreams activity (Create, Like, or Announce)
*/
export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, options = {}) {
const postType = properties["post-type"];
// Likes are delivered as bookmarks — fall through to bookmark handling below
// Reposts are always public — Mastodon and other implementations expect this
if (postType === "repost") {
// 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",
id: postUrl,
attributedTo: actorUrl,
published: properties.published,
url: postUrl,
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [`${actorUrl.replace(/\/$/, "")}/followers`],
content: commentary
? `${commentary}<br><br>\u{1F501} <a href="${repostOf}">${repostOf}</a>`
: `\u{1F501} <a href="${repostOf}">${repostOf}</a>`,
};
}
const isArticle = postType === "article" && properties.name;
const postUrl = resolvePostUrl(properties.url, publicationUrl);
const visibility = properties.visibility || options.visibility || "public";
const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`;
const object = {
type: isArticle ? "Article" : "Note",
id: postUrl,
attributedTo: actorUrl,
published: properties.published,
url: postUrl,
to: visibility === "unlisted"
? [followersUrl]
: visibility === "followers"
? [followersUrl]
: ["https://www.w3.org/ns/activitystreams#Public"],
cc: visibility === "unlisted"
? ["https://www.w3.org/ns/activitystreams#Public"]
: visibility === "followers"
? []
: [followersUrl],
};
if (postType === "bookmark" || postType === "like") {
const bookmarkUrl = properties["bookmark-of"] || properties["like-of"];
const commentary = linkifyUrls(properties.content?.html || properties.content || "");
object.content = commentary
? `${commentary}<br><br>\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`
: `\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`;
object.tag = [
{
type: "Hashtag",
name: "#bookmark",
href: `${publicationUrl}categories/bookmark`,
},
];
} else {
object.content = linkifyUrls(properties.content?.html || properties.content || "");
}
// Append permalink to content so fediverse clients show a clickable link
if (postUrl && object.content) {
object.content += `<p>\u{1F517} <a href="${postUrl}">${postUrl}</a></p>`;
}
// OG image for fediverse preview cards
const ogMatch = postUrl && postUrl.match(/\/([\w-]+)\/(\d{4})\/(\d{2})\/(\d{2})\/([\w-]+)\/?$/);
if (ogMatch) {
object.image = {
type: "Image",
url: `${publicationUrl.replace(/\/$/, "")}/og/${ogMatch[5]}.png`,
mediaType: "image/png",
};
}
if (isArticle) {
object.name = properties.name;
if (properties.summary) {
object.summary = properties.summary;
}
}
if (properties.sensitive || properties["post-status"] === "sensitive") {
object.sensitive = true;
}
// Content warning text for Mastodon CW display
if (properties["content-warning"]) {
object.summary = properties["content-warning"];
object.sensitive = true;
}
if (properties["in-reply-to"]) {
object.inReplyTo = properties["in-reply-to"];
}
const attachments = buildPlainAttachments(properties, publicationUrl);
if (attachments.length > 0) {
object.attachment = attachments;
}
const tags = buildPlainTags(properties, publicationUrl, object.tag);
// Add Mention tags + cc addressing + content linkification for @mentions
const resolvedMentions = options.mentions || [];
for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
if (mentionUrl) {
tags.push({ type: "Mention", href: mentionUrl, name: `@${handle}` });
if (!object.cc.includes(mentionUrl)) {
object.cc.push(mentionUrl);
}
}
}
if (tags.length > 0) {
object.tag = tags;
}
// Linkify @mentions in content (resolved get actor links, unresolved get profile links)
if (resolvedMentions.length > 0 && object.content) {
object.content = linkifyMentions(object.content, resolvedMentions);
}
return {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
actor: actorUrl,
object,
};
}
// ---------------------------------------------------------------------------
// Fedify vocab objects (outbox dispatcher + syndicator delivery)
// ---------------------------------------------------------------------------
/**
* Convert JF2 properties to a Fedify Activity object.
*
* @param {object} properties - JF2 post properties
* @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
* @param {string} publicationUrl - Publication base URL with trailing slash
* @param {object} [options] - Optional settings
* @param {string} [options.replyToActorUrl] - Original post author's actor URL (for reply addressing)
* @param {string} [options.replyToActorHandle] - Original post author's handle (for Mention tag)
* @returns {Promise<import("@fedify/fedify").Activity | null>}
*/
export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) {
const postType = properties["post-type"];
const actorUri = new URL(actorUrl);
// Likes of ActivityPub objects are sent as a proper Like activity.
// Likes of regular URLs fall through to bookmark-style Create(Note) below.
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"),
});
}
}
// Reposts are always public — upstream @rmdes addressing
if (postType === "repost") {
const repostOf = properties["repost-of"];
if (!repostOf) return null;
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;
const postUrl = resolvePostUrl(properties.url, publicationUrl);
const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`;
const { replyToActorUrl, replyToActorHandle } = options;
const noteOptions = {
attributedTo: actorUri,
};
// Determine visibility: per-post override > option default > "public"
const visibility = properties.visibility || options.visibility || "public";
// Addressing based on visibility:
// - "public": to: PUBLIC, cc: followers (+ reply author)
// - "unlisted": to: followers, cc: PUBLIC (+ reply author)
// - "followers": to: followers (+ reply author), no PUBLIC
const PUBLIC = new URL("https://www.w3.org/ns/activitystreams#Public");
const followersUri = new URL(followersUrl);
if (replyToActorUrl && properties["in-reply-to"]) {
const replyAuthor = new URL(replyToActorUrl);
if (visibility === "unlisted") {
noteOptions.to = followersUri;
noteOptions.ccs = [PUBLIC, replyAuthor];
} else if (visibility === "followers") {
noteOptions.tos = [followersUri, replyAuthor];
} else {
// public (default)
noteOptions.to = PUBLIC;
noteOptions.ccs = [followersUri, replyAuthor];
}
} else {
if (visibility === "unlisted") {
noteOptions.to = followersUri;
noteOptions.cc = PUBLIC;
} else if (visibility === "followers") {
noteOptions.to = followersUri;
} else {
// public (default)
noteOptions.to = PUBLIC;
noteOptions.cc = followersUri;
}
}
if (postUrl) {
noteOptions.id = new URL(postUrl);
noteOptions.url = new URL(postUrl);
}
if (properties.published) {
try {
noteOptions.published = Temporal.Instant.from(properties.published);
} catch {
// Invalid date format — skip
}
}
// Content
if (postType === "bookmark" || postType === "like") {
const bookmarkUrl = properties["bookmark-of"] || properties["like-of"];
const commentary = linkifyUrls(properties.content?.html || properties.content || "");
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 || "");
}
// Append permalink to content so fediverse clients show a clickable link
// back to the canonical post on the author's site
if (postUrl && noteOptions.content) {
noteOptions.content += `<p>\u{1F517} <a href="${postUrl}">${postUrl}</a></p>`;
}
if (isArticle) {
noteOptions.name = properties.name;
if (properties.summary) {
noteOptions.summary = properties.summary;
}
}
// Content warning / sensitive flag
if (properties.sensitive) {
noteOptions.sensitive = true;
}
if (properties["post-status"] === "sensitive") {
noteOptions.sensitive = true;
}
// Content warning text for Mastodon CW display
if (properties["content-warning"]) {
noteOptions.summary = properties["content-warning"];
noteOptions.sensitive = true;
}
if (properties["in-reply-to"]) {
noteOptions.replyTarget = new URL(properties["in-reply-to"]);
}
// Attachments
const fedifyAttachments = buildFedifyAttachments(properties, publicationUrl);
if (fedifyAttachments.length > 0) {
noteOptions.attachments = fedifyAttachments;
}
// OG image for fediverse preview cards
const ogMatchF = postUrl && postUrl.match(/\/([\w-]+)\/(\d{4})\/(\d{2})\/(\d{2})\/([\w-]+)\/?$/);
if (ogMatchF) {
noteOptions.image = new Image({
url: new URL(`${publicationUrl.replace(/\/$/, "")}/og/${ogMatchF[5]}.png`),
mediaType: "image/png",
});
}
// Tags: hashtags + Mention for reply addressing + @mentions
const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
if (replyToActorUrl) {
fedifyTags.push(
new Mention({
href: new URL(replyToActorUrl),
name: replyToActorHandle ? `@${replyToActorHandle}` : undefined,
}),
);
}
// Add Mention tags + cc addressing for resolved @mentions
const resolvedMentions = options.mentions || [];
const ccUrls = [];
for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
if (mentionUrl) {
// Skip if same as replyToActorUrl (already added above)
const alreadyTagged = replyToActorUrl && mentionUrl === replyToActorUrl;
if (!alreadyTagged) {
fedifyTags.push(
new Mention({
href: new URL(mentionUrl),
name: `@${handle}`,
}),
);
}
ccUrls.push(new URL(mentionUrl));
}
}
// Merge mention actors into cc/ccs
if (ccUrls.length > 0) {
if (noteOptions.ccs) {
noteOptions.ccs = [...noteOptions.ccs, ...ccUrls];
} else if (noteOptions.cc) {
noteOptions.ccs = [noteOptions.cc, ...ccUrls];
delete noteOptions.cc;
} else {
noteOptions.ccs = ccUrls;
}
}
if (fedifyTags.length > 0) {
noteOptions.tags = fedifyTags;
}
// Linkify @mentions in content
if (resolvedMentions.length > 0 && noteOptions.content) {
noteOptions.content = linkifyMentions(noteOptions.content, resolvedMentions);
}
const object = isArticle
? new Article(noteOptions)
: new Note(noteOptions);
return new Create({
actor: actorUri,
object,
});
}
// ---------------------------------------------------------------------------
// URL resolution helpers
// ---------------------------------------------------------------------------
/**
* Resolve a post URL, ensuring it's absolute.
* @param {string} url - Post URL (may be relative or absolute)
* @param {string} publicationUrl - Base publication URL
* @returns {string} Absolute URL
*/
export function resolvePostUrl(url, publicationUrl) {
if (!url) return "";
if (url.startsWith("http")) return url;
const base = publicationUrl.replace(/\/$/, "");
return `${base}/${url.replace(/^\//, "")}`;
}
function resolveMediaUrl(url, publicationUrl) {
if (!url) return "";
if (url.startsWith("http")) return url;
const base = publicationUrl.replace(/\/$/, "");
return `${base}/${url.replace(/^\//, "")}`;
}
// ---------------------------------------------------------------------------
// Attachment builders
// ---------------------------------------------------------------------------
function buildPlainAttachments(properties, publicationUrl) {
const attachments = [];
if (properties.photo) {
for (const photo of asArray(properties.photo)) {
const url = typeof photo === "string" ? photo : photo.url;
const alt = typeof photo === "string" ? "" : photo.alt || "";
attachments.push({
type: "Image",
mediaType: guessMediaType(url),
url: resolveMediaUrl(url, publicationUrl),
name: alt,
});
}
}
if (properties.video) {
for (const video of asArray(properties.video)) {
const url = typeof video === "string" ? video : video.url;
attachments.push({
type: "Video",
url: resolveMediaUrl(url, publicationUrl),
name: "",
});
}
}
if (properties.audio) {
for (const audio of asArray(properties.audio)) {
const url = typeof audio === "string" ? audio : audio.url;
attachments.push({
type: "Audio",
url: resolveMediaUrl(url, publicationUrl),
name: "",
});
}
}
return attachments;
}
function buildFedifyAttachments(properties, publicationUrl) {
const attachments = [];
if (properties.photo) {
for (const photo of asArray(properties.photo)) {
const url = typeof photo === "string" ? photo : photo.url;
const alt = typeof photo === "string" ? "" : photo.alt || "";
attachments.push(
new Image({
url: new URL(resolveMediaUrl(url, publicationUrl)),
mediaType: guessMediaType(url),
name: alt,
}),
);
}
}
if (properties.video) {
for (const video of asArray(properties.video)) {
const url = typeof video === "string" ? video : video.url;
attachments.push(
new Video({
url: new URL(resolveMediaUrl(url, publicationUrl)),
}),
);
}
}
if (properties.audio) {
for (const audio of asArray(properties.audio)) {
const url = typeof audio === "string" ? audio : audio.url;
attachments.push(
new Audio({
url: new URL(resolveMediaUrl(url, publicationUrl)),
}),
);
}
}
return attachments;
}
// ---------------------------------------------------------------------------
// Tag builders
// ---------------------------------------------------------------------------
function buildPlainTags(properties, publicationUrl, existing) {
const tags = [...(existing || [])];
if (properties.category) {
for (const cat of asArray(properties.category)) {
tags.push({
type: "Hashtag",
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
});
}
}
return tags;
}
function buildFedifyTags(properties, publicationUrl, postType) {
const tags = [];
if (postType === "bookmark" || postType === "like") {
tags.push(
new Hashtag({
name: "#bookmark",
href: new URL(`${publicationUrl}categories/bookmark`),
}),
);
}
if (properties.category) {
for (const cat of asArray(properties.category)) {
tags.push(
new Hashtag({
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
href: new URL(
`${publicationUrl}categories/${encodeURIComponent(cat)}`,
),
}),
);
}
}
return tags;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function asArray(value) {
return Array.isArray(value) ? value : [value];
}
function guessMediaType(url) {
const ext = url.split(".").pop()?.toLowerCase();
const types = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
avif: "image/avif",
};
return types[ext] || "image/jpeg";
}