diff --git a/index.js b/index.js index 9d2d559..e8e34a0 100644 --- a/index.js +++ b/index.js @@ -583,7 +583,7 @@ export default class ActivityPubEndpoint { } } - const activity = jf2ToAS2Activity( + const activity = await jf2ToAS2Activity( properties, actorUrl, self._publicationUrl, diff --git a/lib/federation-setup.js b/lib/federation-setup.js index e019d9f..bc2ae0d 100644 --- a/lib/federation-setup.js +++ b/lib/federation-setup.js @@ -560,7 +560,7 @@ function setupFeatured(federation, mountPath, handle, collections, publicationUr }); if (!post) continue; const actorUrl = ctx.getActorUri(identifier).href; - const activity = jf2ToAS2Activity( + const activity = await jf2ToAS2Activity( post.properties, actorUrl, publicationUrl, @@ -637,19 +637,21 @@ function setupOutbox(federation, mountPath, handle, collections) { .toArray(); const { jf2ToAS2Activity } = await import("./jf2-to-as2.js"); - const items = posts - .map((post) => { - try { - return jf2ToAS2Activity( - post.properties, - ctx.getActorUri(identifier).href, - collections._publicationUrl, - ); - } catch { - return null; - } - }) - .filter(Boolean); + const items = ( + await Promise.all( + posts.map(async (post) => { + try { + return await jf2ToAS2Activity( + post.properties, + ctx.getActorUri(identifier).href, + collections._publicationUrl, + ); + } catch { + return null; + } + }), + ) + ).filter(Boolean); return { items, @@ -687,7 +689,7 @@ function setupObjectDispatchers(federation, mountPath, handle, collections, publ // Soft-deleted posts should not be dereferenceable if (post.properties?.deleted) return null; const actorUrl = ctx.getActorUri(handle).href; - const activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl); + const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl); // Only Create activities wrap Note/Article objects if (!(activity instanceof Create)) return null; return await activity.getObject(); diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index d1ebc65..7dddc60 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -14,6 +14,7 @@ import { Create, Hashtag, Image, + Like, Mention, Note, Video, @@ -79,6 +80,30 @@ function linkifyMentions(html, resolvedMentions) { 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) // --------------------------------------------------------------------------- @@ -239,13 +264,24 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, optio * @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} + * @returns {Promise} */ -export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) { +export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) { const postType = properties["post-type"]; const actorUri = new URL(actorUrl); - // Likes are delivered as bookmarks — fall through to bookmark handling below + // 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))) { + return new Like({ + 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") {