mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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.
211 lines
6.2 KiB
JavaScript
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);
|
|
}
|
|
};
|
|
}
|