Files
indiekit-endpoint-activitypub/lib/jf2-to-as2.js
Ricardo 2ca491f28b fix: wire content-warning property for CW text
Updated jf2-to-as2 and compose controller to use the renamed
"content-warning" property instead of overloading "summary" for
CW text. This pairs with the endpoint-posts fix that renamed the
CW form input to prevent collision with the summary field.

Confab-Link: http://localhost:8080/sessions/1dcdf030-8015-4d23-89da-b43fd69c7138
2026-03-18 00:24:35 +01:00

599 lines
18 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;
}
// ---------------------------------------------------------------------------
// 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"];
if (postType === "like") {
return {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Like",
actor: actorUrl,
object: properties["like-of"],
};
}
// Reposts are always public — Mastodon and other implementations expect this
if (postType === "repost") {
return {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Announce",
actor: actorUrl,
object: properties["repost-of"],
};
}
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") {
const bookmarkUrl = properties["bookmark-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>`;
}
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 {import("@fedify/fedify").Activity | null}
*/
export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) {
const postType = properties["post-type"];
const actorUri = new URL(actorUrl);
if (postType === "like") {
const likeOf = properties["like-of"];
if (!likeOf) return null;
return new Like({
actor: actorUri,
object: new URL(likeOf),
});
}
// Reposts are always public — Mastodon and other implementations expect this
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 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") {
const bookmarkUrl = properties["bookmark-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 {
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;
}
// 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") {
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";
}