fix: reader UI fixes and correct Fedify API usage (v1.1.8→1.1.12)

- Fix Unknown authors by adding multi-strategy fallback chain in
  extractObjectData (getAttributedTo → actorFallback → attributionIds)
- Fix empty boosts from Lemmy/PieFed by checking content before storing
- Fix @mention/hashtag styling to stay inline instead of breaking layout
- Fix compose reply to show sanitized HTML blockquote instead of raw text
- Add default-checked syndication targets for AP and Bluesky
- Use authenticated document loader for all lookupObject calls
  (fixes 401 errors on servers requiring Authorized Fetch)
- Fix like handler 404 by using canonical AP uid for interactions
  instead of display URLs; add data-item-uid to card template
- Fix profile bio showing Nunjucks macro source code by renaming
  summary→bio to avoid collision with Indiekit's summary macro
- Fix Fedify API misuse in timeline-store.js: use instanceof Article
  (not string comparison), replyTargetId (not inReplyTo), getTags()
  and getAttachments() async methods (not sync property access)
- Fix inbox-listeners.js: use replyTargetId instead of non-existent
  getInReplyTo(), use instanceof Article for Update handler
- Add error logging to interaction catch blocks
This commit is contained in:
Ricardo
2026-02-21 17:08:28 +01:00
parent b81ecbcaa4
commit 313d5d414c
15 changed files with 280 additions and 107 deletions

View File

@@ -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 {

View File

@@ -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 });

View File

@@ -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");
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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,

View File

@@ -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;
}
}
}

View File

@@ -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 {

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -18,9 +18,9 @@
<a href="{{ replyContext.author.url }}">{{ replyContext.author.name }}</a>
</div>
{% endif %}
{% if replyContext.content and replyContext.content.text %}
{% if replyContext.content and (replyContext.content.html or replyContext.content.text) %}
<blockquote class="ap-compose__context-text">
{{ replyContext.content.text | truncate(300) }}
{{ replyContext.content.html | safe if replyContext.content.html else replyContext.content.text | truncate(300) }}
</blockquote>
{% endif %}
<a href="{{ replyTo }}" class="ap-compose__context-link" target="_blank" rel="noopener">{{ replyTo }}</a>
@@ -74,7 +74,7 @@
<legend>{{ __("activitypub.compose.syndicateLabel") }}</legend>
{% for target in syndicationTargets %}
<label class="ap-compose__syndication-target">
<input type="checkbox" name="mp-syndicate-to" value="{{ target.uid }}" checked>
<input type="checkbox" name="mp-syndicate-to" value="{{ target.uid }}" {{ "checked" if target.defaultChecked }}>
{{ target.name }}
</label>
{% endfor %}

View File

@@ -57,8 +57,8 @@
{% if actorHandle %}
<div class="ap-profile__handle">@{{ actorHandle }}@{{ instanceHost }}</div>
{% endif %}
{% if summary %}
<div class="ap-profile__bio">{{ summary | safe }}</div>
{% if bio %}
<div class="ap-profile__bio">{{ bio | safe }}</div>
{% endif %}
</div>

View File

@@ -97,11 +97,13 @@
{% endif %}
{# Interaction buttons — Alpine.js for optimistic updates #}
{# Dynamic data moved to data-* attributes to prevent XSS from inline interpolation #}
{# Use canonical AP uid for interactions (Fedify lookupObject), display url for links #}
{% set itemUrl = item.url or item.originalUrl %}
{% set isLiked = interactionMap[itemUrl].like if interactionMap[itemUrl] else false %}
{% set isBoosted = interactionMap[itemUrl].boost if interactionMap[itemUrl] else false %}
{% set itemUid = item.uid or item.url or item.originalUrl %}
{% set isLiked = interactionMap[itemUid].like if interactionMap[itemUid] else false %}
{% set isBoosted = interactionMap[itemUid].boost if interactionMap[itemUid] else false %}
<footer class="ap-card__actions"
data-item-uid="{{ itemUid }}"
data-item-url="{{ itemUrl }}"
data-csrf-token="{{ csrfToken }}"
data-mount-path="{{ mountPath }}"
@@ -115,7 +117,7 @@
this.loading = true;
this.error = '';
const el = this.$root;
const itemUrl = el.dataset.itemUrl;
const itemUid = el.dataset.itemUid;
const csrfToken = el.dataset.csrfToken;
const basePath = el.dataset.mountPath;
const prev = { liked: this.liked, boosted: this.boosted };
@@ -130,7 +132,7 @@
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ url: itemUrl })
body: JSON.stringify({ url: itemUid })
});
const data = await res.json();
if (!data.success) {
@@ -147,7 +149,7 @@
if (this.error) setTimeout(() => this.error = '', 3000);
}
}">
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUrl | urlencode }}"
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUid | urlencode }}"
class="ap-card__action ap-card__action--reply"
title="{{ __('activitypub.reader.actions.reply') }}">
↩ {{ __("activitypub.reader.actions.reply") }}