diff --git a/assets/reader.css b/assets/reader.css index 1476b11..72b0a8c 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -296,24 +296,40 @@ max-width: 100%; } -/* @mentions — styled as subtle pills to distinguish from prose */ -.ap-card__content .h-card, -.ap-card__content a.u-url.mention { - color: var(--color-on-offset); - font-size: var(--font-size-s); - text-decoration: none; +/* @mentions — keep inline, style as subtle links */ +.ap-card__content .h-card { + display: inline; } +.ap-card__content .h-card a, +.ap-card__content a.u-url.mention { + display: inline; + color: var(--color-on-offset); + text-decoration: none; + white-space: nowrap; +} + +.ap-card__content .h-card a span, +.ap-card__content a.u-url.mention span { + display: inline; +} + +.ap-card__content .h-card a:hover, .ap-card__content a.u-url.mention:hover { color: var(--color-primary); text-decoration: underline; } -/* Hashtag mentions — subtle tag styling */ +/* Hashtag mentions — keep inline, subtle styling */ .ap-card__content a.mention.hashtag { + display: inline; color: var(--color-on-offset); - font-size: var(--font-size-s); text-decoration: none; + white-space: nowrap; +} + +.ap-card__content a.mention.hashtag span { + display: inline; } .ap-card__content a.mention.hashtag:hover { diff --git a/index.js b/index.js index e138d40..77bd09f 100644 --- a/index.js +++ b/index.js @@ -496,7 +496,13 @@ export default class ActivityPubEndpoint { ); // Resolve the remote actor to get their inbox - const remoteActor = await ctx.lookupObject(actorUrl); + // Use authenticated document loader for servers requiring Authorized Fetch + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + const remoteActor = await ctx.lookupObject(actorUrl, { + documentLoader, + }); if (!remoteActor) { return { ok: false, error: "Could not resolve remote actor" }; } @@ -591,7 +597,13 @@ export default class ActivityPubEndpoint { { handle, publicationUrl: this._publicationUrl }, ); - const remoteActor = await ctx.lookupObject(actorUrl); + // Use authenticated document loader for servers requiring Authorized Fetch + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + const remoteActor = await ctx.lookupObject(actorUrl, { + documentLoader, + }); if (!remoteActor) { // Even if we can't resolve, remove locally await this._collections.ap_following.deleteOne({ actorUrl }); diff --git a/lib/batch-refollow.js b/lib/batch-refollow.js index e273240..149bd26 100644 --- a/lib/batch-refollow.js +++ b/lib/batch-refollow.js @@ -227,8 +227,13 @@ async function processOneFollow(options, entry) { try { const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl }); - // Resolve the remote actor - const remoteActor = await ctx.lookupObject(entry.actorUrl); + // Resolve the remote actor (signed request for Authorized Fetch) + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + const remoteActor = await ctx.lookupObject(entry.actorUrl, { + documentLoader, + }); if (!remoteActor) { throw new Error("Could not resolve remote actor"); } diff --git a/lib/controllers/compose.js b/lib/controllers/compose.js index 008b68f..17b69af 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -3,8 +3,8 @@ */ import { Temporal } from "@js-temporal/polyfill"; -import { getTimelineItem } from "../storage/timeline.js"; import { getToken, validateToken } from "../csrf.js"; +import { sanitizeContent } from "../timeline-store.js"; /** * Fetch syndication targets from the Micropub config endpoint. @@ -61,7 +61,12 @@ export function composeController(mountPath, plugin) { }; // Try to find the post in our timeline first - replyContext = await getTimelineItem(collections, replyTo); + // Note: Timeline stores uid (canonical AP URL) and url (display URL). + // The card link passes the display URL, so search both fields. + const ap_timeline = collections.ap_timeline; + replyContext = ap_timeline + ? await ap_timeline.findOne({ $or: [{ uid: replyTo }, { url: replyTo }] }) + : null; // If not in timeline, try to look up remotely if (!replyContext && plugin._federation) { @@ -71,14 +76,22 @@ export function composeController(mountPath, plugin) { new URL(plugin._publicationUrl), { handle, publicationUrl: plugin._publicationUrl }, ); - const remoteObject = await ctx.lookupObject(new URL(replyTo)); + // Use authenticated document loader for Authorized Fetch + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + const remoteObject = await ctx.lookupObject(new URL(replyTo), { + documentLoader, + }); if (remoteObject) { let authorName = ""; let authorUrl = ""; if (typeof remoteObject.getAttributedTo === "function") { - const author = await remoteObject.getAttributedTo(); + const author = await remoteObject.getAttributedTo({ + documentLoader, + }); const actor = Array.isArray(author) ? author[0] : author; if (actor) { @@ -90,18 +103,22 @@ export function composeController(mountPath, plugin) { } } + const rawHtml = remoteObject.content?.toString() || ""; replyContext = { url: replyTo, name: remoteObject.name?.toString() || "", content: { - text: - remoteObject.content?.toString()?.slice(0, 300) || "", + html: sanitizeContent(rawHtml), + text: rawHtml.replace(/<[^>]*>/g, "").slice(0, 300), }, author: { name: authorName, url: authorUrl }, }; } - } catch { - // Could not resolve — form still works without context + } catch (error) { + console.warn( + `[ActivityPub] lookupObject failed for ${replyTo} (compose):`, + error.message, + ); } } } @@ -112,6 +129,13 @@ export function composeController(mountPath, plugin) { ? await getSyndicationTargets(application, token) : []; + // Default-check only AP (Fedify) and Bluesky targets + // "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky + for (const target of syndicationTargets) { + const name = target.name || ""; + target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net"; + } + const csrfToken = getToken(request.session); response.render("activitypub-compose", { @@ -198,13 +222,20 @@ export function submitComposeController(mountPath, plugin) { // If replying, also send to the original author if (inReplyTo) { try { - const remoteObject = await ctx.lookupObject(new URL(inReplyTo)); + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + const remoteObject = await ctx.lookupObject(new URL(inReplyTo), { + documentLoader, + }); if ( remoteObject && typeof remoteObject.getAttributedTo === "function" ) { - const author = await remoteObject.getAttributedTo(); + const author = await remoteObject.getAttributedTo({ + documentLoader, + }); const recipient = Array.isArray(author) ? author[0] : author; diff --git a/lib/controllers/interactions-boost.js b/lib/controllers/interactions-boost.js index eb7cbe8..e612bb3 100644 --- a/lib/controllers/interactions-boost.js +++ b/lib/controllers/interactions-boost.js @@ -57,15 +57,20 @@ export function boostController(mountPath, plugin) { orderingKey: url, }); - // Also send to the original post author + // Also send to the original post author (signed request for Authorized Fetch) try { - const remoteObject = await ctx.lookupObject(new URL(url)); + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + const remoteObject = await ctx.lookupObject(new URL(url), { + documentLoader, + }); if ( remoteObject && typeof remoteObject.getAttributedTo === "function" ) { - const author = await remoteObject.getAttributedTo(); + const author = await remoteObject.getAttributedTo({ documentLoader }); const recipient = Array.isArray(author) ? author[0] : author; if (recipient) { @@ -77,8 +82,11 @@ export function boostController(mountPath, plugin) { ); } } - } catch { - // Non-critical — followers still received the boost + } catch (error) { + console.warn( + `[ActivityPub] lookupObject failed for ${url} (boost):`, + error.message, + ); } // Track the interaction diff --git a/lib/controllers/interactions-like.js b/lib/controllers/interactions-like.js index 67969b6..11fe507 100644 --- a/lib/controllers/interactions-like.js +++ b/lib/controllers/interactions-like.js @@ -43,29 +43,57 @@ export function likeController(mountPath, plugin) { { handle, publicationUrl: plugin._publicationUrl }, ); - // Look up the remote post to find its author - const remoteObject = await ctx.lookupObject(new URL(url)); + // Use authenticated document loader for servers requiring Authorized Fetch + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); - if (!remoteObject) { - return response.status(404).json({ - success: false, - error: "Could not resolve remote post", - }); - } - - // Get the post author for delivery + // Resolve author for delivery — try multiple strategies let recipient = null; - if (typeof remoteObject.getAttributedTo === "function") { - const author = await remoteObject.getAttributedTo(); - recipient = Array.isArray(author) ? author[0] : author; + // Strategy 1: Look up remote post via Fedify (signed request) + try { + const remoteObject = await ctx.lookupObject(new URL(url), { + documentLoader, + }); + if (remoteObject && typeof remoteObject.getAttributedTo === "function") { + const author = await remoteObject.getAttributedTo({ documentLoader }); + recipient = Array.isArray(author) ? author[0] : author; + } + } catch (error) { + console.warn( + `[ActivityPub] lookupObject failed for ${url}:`, + error.message, + ); } + // Strategy 2: Use author URL from our timeline (already stored) + // Note: Timeline items store both uid (canonical AP URL) and url (display URL). + // The card passes the display URL, so we search by both fields. if (!recipient) { - return response.status(404).json({ - success: false, - error: "Could not resolve post author", - }); + const { application } = request.app.locals; + const ap_timeline = application?.collections?.get("ap_timeline"); + const timelineItem = ap_timeline + ? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] }) + : null; + const authorUrl = timelineItem?.author?.url; + + if (authorUrl) { + try { + recipient = await ctx.lookupObject(new URL(authorUrl), { + documentLoader, + }); + } catch { + // Could not resolve author actor either + } + } + + if (!recipient) { + return response.status(404).json({ + success: false, + error: "Could not resolve post author", + }); + } } // Generate a unique activity ID @@ -170,13 +198,45 @@ export function unlikeController(mountPath, plugin) { { handle, publicationUrl: plugin._publicationUrl }, ); - // Resolve the recipient - const remoteObject = await ctx.lookupObject(new URL(url)); + // Use authenticated document loader for servers requiring Authorized Fetch + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + + // Resolve the recipient — try remote first, then timeline fallback let recipient = null; - if (remoteObject && typeof remoteObject.getAttributedTo === "function") { - const author = await remoteObject.getAttributedTo(); - recipient = Array.isArray(author) ? author[0] : author; + try { + const remoteObject = await ctx.lookupObject(new URL(url), { + documentLoader, + }); + if (remoteObject && typeof remoteObject.getAttributedTo === "function") { + const author = await remoteObject.getAttributedTo({ documentLoader }); + recipient = Array.isArray(author) ? author[0] : author; + } + } catch (error) { + console.warn( + `[ActivityPub] lookupObject failed for ${url} (unlike):`, + error.message, + ); + } + + if (!recipient) { + const ap_timeline = application?.collections?.get("ap_timeline"); + const timelineItem = ap_timeline + ? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] }) + : null; + const authorUrl = timelineItem?.author?.url; + + if (authorUrl) { + try { + recipient = await ctx.lookupObject(new URL(authorUrl), { + documentLoader, + }); + } catch { + // Could not resolve — will proceed to cleanup + } + } } if (!recipient) { diff --git a/lib/controllers/moderation.js b/lib/controllers/moderation.js index 4a424cd..c36cc8a 100644 --- a/lib/controllers/moderation.js +++ b/lib/controllers/moderation.js @@ -151,7 +151,12 @@ export function blockController(mountPath, plugin) { { handle, publicationUrl: plugin._publicationUrl }, ); - const remoteActor = await ctx.lookupObject(new URL(url)); + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + const remoteActor = await ctx.lookupObject(new URL(url), { + documentLoader, + }); if (remoteActor) { const block = new Block({ @@ -225,7 +230,12 @@ export function unblockController(mountPath, plugin) { { handle, publicationUrl: plugin._publicationUrl }, ); - const remoteActor = await ctx.lookupObject(new URL(url)); + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + const remoteActor = await ctx.lookupObject(new URL(url), { + documentLoader, + }); if (remoteActor) { const block = new Block({ diff --git a/lib/controllers/profile.remote.js b/lib/controllers/profile.remote.js index b20a558..6a05ff1 100644 --- a/lib/controllers/profile.remote.js +++ b/lib/controllers/profile.remote.js @@ -36,11 +36,14 @@ export function remoteProfileController(mountPath, plugin) { { handle, publicationUrl: plugin._publicationUrl }, ); - // Look up the remote actor + // Look up the remote actor (signed request for Authorized Fetch) + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); let actor; try { - actor = await ctx.lookupObject(new URL(actorUrl)); + actor = await ctx.lookupObject(new URL(actorUrl), { documentLoader }); } catch { return response.status(404).render("error", { title: "Error", @@ -61,7 +64,7 @@ export function remoteProfileController(mountPath, plugin) { actor.preferredUsername?.toString() || actorUrl; const actorHandle = actor.preferredUsername?.toString() || ""; - const summary = sanitizeContent(actor.summary?.toString() || ""); + const bio = sanitizeContent(actor.summary?.toString() || ""); let icon = ""; let image = ""; @@ -126,7 +129,7 @@ export function remoteProfileController(mountPath, plugin) { actorUrl, name, actorHandle, - summary, + bio, icon, image, instanceHost, diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index f9d5b06..0100728 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -107,26 +107,47 @@ export function readerController(mountPath) { const unreadCount = await getUnreadNotificationCount(collections); // Get interaction state for liked/boosted indicators + // Interactions are keyed by canonical AP uid (new) or display url (legacy). + // Query by both, normalize map keys to uid for template lookup. const interactionsCol = application?.collections?.get("ap_interactions"); const interactionMap = {}; if (interactionsCol) { - const itemUrls = items - .map((item) => item.url || item.originalUrl) - .filter(Boolean); + const lookupUrls = new Set(); + const objectUrlToUid = new Map(); - if (itemUrls.length > 0) { + for (const item of items) { + const uid = item.uid; + const displayUrl = item.url || item.originalUrl; + + if (uid) { + lookupUrls.add(uid); + objectUrlToUid.set(uid, uid); + } + + if (displayUrl) { + lookupUrls.add(displayUrl); + objectUrlToUid.set(displayUrl, uid || displayUrl); + } + } + + if (lookupUrls.size > 0) { const interactions = await interactionsCol - .find({ objectUrl: { $in: itemUrls } }) + .find({ objectUrl: { $in: [...lookupUrls] } }) .toArray(); for (const interaction of interactions) { - if (!interactionMap[interaction.objectUrl]) { - interactionMap[interaction.objectUrl] = {}; + // Normalize to uid so template can look up by itemUid + const key = + objectUrlToUid.get(interaction.objectUrl) || + interaction.objectUrl; + + if (!interactionMap[key]) { + interactionMap[key] = {}; } - interactionMap[interaction.objectUrl][interaction.type] = true; + interactionMap[key][interaction.type] = true; } } } diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index e3400a0..cdbe288 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -9,6 +9,7 @@ import { Accept, Add, Announce, + Article, Block, Create, Delete, @@ -365,14 +366,8 @@ export function registerInboxListeners(inboxChain, options) { actorObj?.preferredUsername?.toString() || actorUrl; - let inReplyTo = null; - if (object instanceof Note && typeof object.getInReplyTo === "function") { - try { - inReplyTo = (await object.getInReplyTo())?.id?.href ?? null; - } catch { - /* remote fetch may fail */ - } - } + // Use replyTargetId (non-fetching) for the inReplyTo URL + const inReplyTo = object.replyTargetId?.href || null; // Log replies to our posts (existing behavior for conversations) const pubUrl = collections._publicationUrl; @@ -505,7 +500,7 @@ export function registerInboxListeners(inboxChain, options) { } // PATH 1: If object is a Note/Article → Update timeline item content - if (object && (object instanceof Note || object.type === "Article")) { + if (object && (object instanceof Note || object instanceof Article)) { const objectUrl = object.id?.href || ""; if (objectUrl) { try { diff --git a/lib/timeline-store.js b/lib/timeline-store.js index 4501c2b..3921ff0 100644 --- a/lib/timeline-store.js +++ b/lib/timeline-store.js @@ -3,6 +3,7 @@ * @module timeline-store */ +import { Article } from "@fedify/fedify"; import sanitizeHtml from "sanitize-html"; /** @@ -98,9 +99,9 @@ export async function extractObjectData(object, options = {}) { const uid = object.id?.href || ""; const url = object.url?.href || uid; - // Determine type + // Determine type — use instanceof for Fedify vocab objects let type = "note"; - if (object.type?.toLowerCase() === "article") { + if (object instanceof Article) { type = "article"; } if (options.boostedBy) { @@ -179,42 +180,51 @@ export async function extractObjectData(object, options = {}) { } } - // Extract tags/categories + // Extract tags/categories — Fedify uses async getTags() const category = []; - if (object.tag) { - const tags = Array.isArray(object.tag) ? object.tag : [object.tag]; - for (const tag of tags) { - if (tag.type === "Hashtag" && tag.name) { - category.push(tag.name.toString().replace(/^#/, "")); + try { + if (typeof object.getTags === "function") { + const tags = await object.getTags(); + for (const tag of tags) { + if (tag.name) { + const tagName = tag.name.toString().replace(/^#/, ""); + if (tagName) category.push(tagName); + } } } + } catch { + // Tags extraction failed — non-critical } - // Extract media attachments + // Extract media attachments — Fedify uses async getAttachments() const photo = []; const video = []; const audio = []; - if (object.attachment) { - const attachments = Array.isArray(object.attachment) ? object.attachment : [object.attachment]; - for (const att of attachments) { - const mediaUrl = att.url?.href || ""; - if (!mediaUrl) continue; + try { + if (typeof object.getAttachments === "function") { + const attachments = await object.getAttachments(); + for (const att of attachments) { + const mediaUrl = att.url?.href || ""; + if (!mediaUrl) continue; - const mediaType = att.mediaType?.toLowerCase() || ""; + const mediaType = att.mediaType?.toLowerCase() || ""; - if (mediaType.startsWith("image/")) { - photo.push(mediaUrl); - } else if (mediaType.startsWith("video/")) { - video.push(mediaUrl); - } else if (mediaType.startsWith("audio/")) { - audio.push(mediaUrl); + if (mediaType.startsWith("image/")) { + photo.push(mediaUrl); + } else if (mediaType.startsWith("video/")) { + video.push(mediaUrl); + } else if (mediaType.startsWith("audio/")) { + audio.push(mediaUrl); + } } } + } catch { + // Attachment extraction failed — non-critical } - // In-reply-to - const inReplyTo = object.inReplyTo?.href || ""; + // In-reply-to — Fedify uses replyTargetId (non-fetching) + const inReplyTo = object.replyTargetId?.href || ""; // Build base timeline item const item = { diff --git a/package.json b/package.json index a0a3995..c7a9744 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.1.8", + "version": "1.1.12", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-compose.njk b/views/activitypub-compose.njk index 523f197..786db1d 100644 --- a/views/activitypub-compose.njk +++ b/views/activitypub-compose.njk @@ -18,9 +18,9 @@ {{ replyContext.author.name }} {% endif %} - {% if replyContext.content and replyContext.content.text %} + {% if replyContext.content and (replyContext.content.html or replyContext.content.text) %}
- {{ replyContext.content.text | truncate(300) }} + {{ replyContext.content.html | safe if replyContext.content.html else replyContext.content.text | truncate(300) }}{% endif %} {{ replyTo }} @@ -74,7 +74,7 @@ {% for target in syndicationTargets %} {% endfor %} diff --git a/views/activitypub-remote-profile.njk b/views/activitypub-remote-profile.njk index 9b4019b..7bf7a15 100644 --- a/views/activitypub-remote-profile.njk +++ b/views/activitypub-remote-profile.njk @@ -57,8 +57,8 @@ {% if actorHandle %}