Files
indiekit-endpoint-activitypub/lib/controllers/reader.js
Ricardo 5ff3197493 feat: add internal AP link resolution and OpenGraph card unfurling (v1.1.14)
Reader now resolves ActivityPub links internally instead of navigating
to external instances. Actor links open the profile view, post links
open a new post detail view with thread context (parent chain + replies).

External links in post content get rich preview cards (title, description,
image, favicon) fetched via unfurl.js at ingest time with fire-and-forget
async processing and concurrency limiting.

New files: post-detail controller, og-unfurl module, lookup-cache,
link preview template/CSS, client-side link interception JS.
Includes SSRF protection for OG fetching and GoToSocial URL support.
2026-02-21 18:32:12 +01:00

211 lines
6.2 KiB
JavaScript

/**
* Reader controller — shows timeline of posts from followed accounts.
*/
import { getTimelineItems } from "../storage/timeline.js";
import {
getNotifications,
getUnreadNotificationCount,
markAllNotificationsRead,
} from "../storage/notifications.js";
import { getToken } from "../csrf.js";
import {
getMutedUrls,
getMutedKeywords,
getBlockedUrls,
} from "../storage/moderation.js";
// Re-export controllers from split modules for backward compatibility
export {
composeController,
submitComposeController,
} from "./compose.js";
export {
remoteProfileController,
followController,
unfollowController,
} from "./profile.remote.js";
export { postDetailController } from "./post-detail.js";
export function readerController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = {
ap_timeline: application?.collections?.get("ap_timeline"),
ap_notifications: application?.collections?.get("ap_notifications"),
};
// Query parameters
const tab = request.query.tab || "all";
const before = request.query.before;
const after = request.query.after;
const limit = Number.parseInt(request.query.limit || "20", 10);
// Build query options
const options = { before, after, limit };
// Tab filtering
if (tab === "notes") {
options.type = "note";
options.excludeReplies = true;
} else if (tab === "articles") {
options.type = "article";
} else if (tab === "boosts") {
options.type = "boost";
}
// Get timeline items
const result = await getTimelineItems(collections, options);
// Apply client-side filtering for tabs not supported by storage layer
let items = result.items;
if (tab === "replies") {
items = items.filter((item) => item.inReplyTo);
} else if (tab === "media") {
items = items.filter(
(item) =>
(item.photo && item.photo.length > 0) ||
(item.video && item.video.length > 0) ||
(item.audio && item.audio.length > 0),
);
}
// Apply moderation filters (muted actors, keywords, blocked actors)
const modCollections = {
ap_muted: application?.collections?.get("ap_muted"),
ap_blocked: application?.collections?.get("ap_blocked"),
};
const [mutedUrls, mutedKeywords, blockedUrls] = await Promise.all([
getMutedUrls(modCollections),
getMutedKeywords(modCollections),
getBlockedUrls(modCollections),
]);
const hiddenUrls = new Set([...mutedUrls, ...blockedUrls]);
if (hiddenUrls.size > 0 || mutedKeywords.length > 0) {
items = items.filter((item) => {
// Filter by author URL
if (item.author?.url && hiddenUrls.has(item.author.url)) {
return false;
}
// Filter by muted keywords in content
if (mutedKeywords.length > 0 && item.content?.text) {
const lower = item.content.text.toLowerCase();
if (
mutedKeywords.some((kw) => lower.includes(kw.toLowerCase()))
) {
return false;
}
}
return true;
});
}
// Get unread notification count for badge
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 lookupUrls = new Set();
const objectUrlToUid = new Map();
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: [...lookupUrls] } })
.toArray();
for (const interaction of interactions) {
// Normalize to uid so template can look up by itemUid
const key =
objectUrlToUid.get(interaction.objectUrl) ||
interaction.objectUrl;
if (!interactionMap[key]) {
interactionMap[key] = {};
}
interactionMap[key][interaction.type] = true;
}
}
}
// CSRF token for interaction forms
const csrfToken = getToken(request.session);
response.render("activitypub-reader", {
title: response.locals.__("activitypub.reader.title"),
items,
tab,
before: result.before,
after: result.after,
unreadCount,
interactionMap,
csrfToken,
mountPath,
});
} catch (error) {
next(error);
}
};
}
export function notificationsController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = {
ap_notifications: application?.collections?.get("ap_notifications"),
};
const before = request.query.before;
const limit = Number.parseInt(request.query.limit || "20", 10);
// Get notifications
const result = await getNotifications(collections, { before, limit });
// Get unread count before marking as read
const unreadCount = await getUnreadNotificationCount(collections);
// Mark all as read when page loads
if (result.items.length > 0) {
await markAllNotificationsRead(collections);
}
response.render("activitypub-notifications", {
title: response.locals.__("activitypub.notifications.title"),
items: result.items,
before: result.before,
unreadCount,
mountPath,
});
} catch (error) {
next(error);
}
};
}