mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
- Add actor profile page with outbox fetcher for viewing AP actor posts - Add follow/unfollow buttons on actor profile (delegates to AP plugin) - Add AP actor link on item cards for posts from ActivityPub sources - Add ensureActivityPubChannel() for auto-creating Fediverse channel - Add AP-aware item storage with dedup, attachments, and categories - Add CSS styles for actor profile cards and AP-specific UI elements - Bump version to 1.0.31
256 lines
7.1 KiB
JavaScript
256 lines
7.1 KiB
JavaScript
/**
|
|
* Fetch a remote ActivityPub actor's outbox for on-demand reading.
|
|
* Returns ephemeral jf2 items — nothing is stored in MongoDB.
|
|
*
|
|
* @module activitypub/outbox-fetcher
|
|
*/
|
|
|
|
const AP_ACCEPT =
|
|
'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
const FETCH_TIMEOUT = 10_000;
|
|
const USER_AGENT = "Indiekit/1.0 (Microsub reader)";
|
|
|
|
/**
|
|
* Fetch a remote actor's profile and recent posts from their outbox.
|
|
*
|
|
* @param {string} actorUrl - Full URL of the AP actor
|
|
* @param {object} [options]
|
|
* @param {number} [options.limit=20] - Max items to return
|
|
* @returns {Promise<{ actor: object, items: Array }>}
|
|
*/
|
|
export async function fetchActorOutbox(actorUrl, options = {}) {
|
|
const limit = options.limit || 20;
|
|
|
|
// 1. Fetch actor profile
|
|
const actor = await fetchJson(actorUrl);
|
|
if (!actor || !actor.outbox) {
|
|
throw new Error("Could not resolve actor or outbox URL");
|
|
}
|
|
|
|
const actorInfo = {
|
|
name:
|
|
actor.name ||
|
|
actor.preferredUsername ||
|
|
new URL(actorUrl).pathname.split("/").pop(),
|
|
url: actor.url || actor.id || actorUrl,
|
|
photo: actor.icon?.url || actor.icon || "",
|
|
summary: stripHtml(actor.summary || ""),
|
|
handle: actor.preferredUsername || "",
|
|
followersCount: 0,
|
|
followingCount: 0,
|
|
};
|
|
|
|
// Resolve follower/following counts if available
|
|
if (typeof actor.followers === "string") {
|
|
try {
|
|
const followersCollection = await fetchJson(actor.followers);
|
|
actorInfo.followersCount = followersCollection?.totalItems || 0;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
if (typeof actor.following === "string") {
|
|
try {
|
|
const followingCollection = await fetchJson(actor.following);
|
|
actorInfo.followingCount = followingCollection?.totalItems || 0;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
// 2. Fetch outbox (OrderedCollection)
|
|
const outboxUrl =
|
|
typeof actor.outbox === "string" ? actor.outbox : actor.outbox?.id;
|
|
const outbox = await fetchJson(outboxUrl);
|
|
if (!outbox) {
|
|
return { actor: actorInfo, items: [] };
|
|
}
|
|
|
|
// 3. Get items — may be inline or on a first page
|
|
let activities = [];
|
|
|
|
if (outbox.orderedItems?.length > 0) {
|
|
activities = outbox.orderedItems;
|
|
} else if (outbox.first) {
|
|
const firstPageUrl =
|
|
typeof outbox.first === "string" ? outbox.first : outbox.first?.id;
|
|
if (firstPageUrl) {
|
|
const firstPage = await fetchJson(firstPageUrl);
|
|
activities = firstPage?.orderedItems || firstPage?.items || [];
|
|
}
|
|
}
|
|
|
|
// 4. Convert Create activities to jf2 items
|
|
const items = [];
|
|
for (const activity of activities) {
|
|
if (items.length >= limit) break;
|
|
|
|
const item = activityToJf2(activity, actorInfo);
|
|
if (item) items.push(item);
|
|
}
|
|
|
|
return { actor: actorInfo, items };
|
|
}
|
|
|
|
/**
|
|
* Convert a single AP activity (or bare object) to jf2 format.
|
|
* @param {object} activity - AP activity or object
|
|
* @param {object} actorInfo - Actor profile info
|
|
* @returns {object|null} jf2 item or null if not displayable
|
|
*/
|
|
function activityToJf2(activity, actorInfo) {
|
|
// Unwrap Create/Announce — the displayable content is the inner object
|
|
let object = activity;
|
|
const activityType = activity.type;
|
|
|
|
if (activityType === "Create" || activityType === "Announce") {
|
|
object = activity.object;
|
|
if (!object || typeof object === "string") return null; // Unresolved reference
|
|
}
|
|
|
|
// Skip non-content types (Follow, Like, etc.)
|
|
const contentTypes = new Set([
|
|
"Note",
|
|
"Article",
|
|
"Page",
|
|
"Video",
|
|
"Audio",
|
|
"Image",
|
|
"Event",
|
|
"Question",
|
|
]);
|
|
if (!contentTypes.has(object.type)) return null;
|
|
|
|
const contentHtml = object.content || "";
|
|
const contentText = stripHtml(contentHtml);
|
|
|
|
const jf2 = {
|
|
type: "entry",
|
|
url: object.url || object.id || "",
|
|
uid: object.id || object.url || "",
|
|
name: object.name || undefined,
|
|
content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
|
|
summary: object.summary ? stripHtml(object.summary) : undefined,
|
|
published: object.published || activity.published || undefined,
|
|
author: {
|
|
name: actorInfo.name,
|
|
url: actorInfo.url,
|
|
photo: actorInfo.photo,
|
|
},
|
|
category: extractTags(object.tag),
|
|
photo: extractMedia(object.attachment, "image"),
|
|
video: extractMedia(object.attachment, "video"),
|
|
audio: extractMedia(object.attachment, "audio"),
|
|
_source: { type: "activitypub", actorUrl: actorInfo.url },
|
|
};
|
|
|
|
// Boost attribution
|
|
if (activityType === "Announce" && activity.actor) {
|
|
jf2._boostedBy = actorInfo;
|
|
// The inner object may have its own author
|
|
if (object.attributedTo) {
|
|
const attributedUrl =
|
|
typeof object.attributedTo === "string"
|
|
? object.attributedTo
|
|
: object.attributedTo?.id || object.attributedTo?.url;
|
|
if (attributedUrl) {
|
|
jf2.author = {
|
|
name:
|
|
object.attributedTo?.name ||
|
|
object.attributedTo?.preferredUsername ||
|
|
attributedUrl,
|
|
url: attributedUrl,
|
|
photo: object.attributedTo?.icon?.url || "",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (object.inReplyTo) {
|
|
const replyUrl =
|
|
typeof object.inReplyTo === "string"
|
|
? object.inReplyTo
|
|
: object.inReplyTo?.id;
|
|
if (replyUrl) jf2["in-reply-to"] = [replyUrl];
|
|
}
|
|
|
|
return jf2;
|
|
}
|
|
|
|
/**
|
|
* Extract hashtags from AP tag array.
|
|
* @param {Array} tags - AP tag objects
|
|
* @returns {Array<string>}
|
|
*/
|
|
function extractTags(tags) {
|
|
if (!Array.isArray(tags)) return [];
|
|
return tags
|
|
.filter((t) => t.type === "Hashtag" || t.type === "Tag")
|
|
.map((t) => (t.name || "").replace(/^#/, ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Extract media URLs from AP attachment array.
|
|
* @param {Array} attachments - AP attachment objects
|
|
* @param {string} mediaPrefix - "image", "video", or "audio"
|
|
* @returns {Array<string>}
|
|
*/
|
|
function extractMedia(attachments, mediaPrefix) {
|
|
if (!Array.isArray(attachments)) return [];
|
|
return attachments
|
|
.filter((a) => (a.mediaType || "").startsWith(`${mediaPrefix}/`))
|
|
.map((a) => a.url || a.href || "")
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Fetch a URL as ActivityPub JSON.
|
|
* @param {string} url
|
|
* @returns {Promise<object|null>}
|
|
*/
|
|
async function fetchJson(url) {
|
|
if (!url) return null;
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
Accept: AP_ACCEPT,
|
|
"User-Agent": USER_AGENT,
|
|
},
|
|
signal: controller.signal,
|
|
redirect: "follow",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.warn(
|
|
`[Microsub] AP fetch failed: ${response.status} for ${url}`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
if (error.name === "AbortError") {
|
|
console.warn(`[Microsub] AP fetch timeout for ${url}`);
|
|
} else {
|
|
console.warn(`[Microsub] AP fetch error for ${url}: ${error.message}`);
|
|
}
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strip HTML tags for plain text.
|
|
* @param {string} html
|
|
* @returns {string}
|
|
*/
|
|
function stripHtml(html) {
|
|
return (html || "").replace(/<[^>]*>/g, "").trim();
|
|
}
|