mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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
599 lines
18 KiB
JavaScript
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";
|
|
}
|