Files
indiekit-endpoint-activitypub/lib/controllers/profile.remote.js
Ricardo 4e514235c2 feat: ActivityPub reader — timeline, notifications, compose, moderation
Add a dedicated fediverse reader view with:
- Timeline view showing posts from followed accounts with threading,
  content warnings, boosts, and media display
- Compose form with dual-path posting (quick AP reply + Micropub blog post)
- Native AP interactions (like, boost, reply, follow/unfollow)
- Notifications view for likes, boosts, follows, mentions, replies
- Moderation tools (mute/block actors, keyword filters)
- Remote actor profile pages with follow state
- Automatic timeline cleanup with configurable retention
- CSRF protection, XSS prevention, input validation throughout

Removes Microsub bridge dependency — AP content now lives in its own
MongoDB collections (ap_timeline, ap_notifications, ap_interactions,
ap_muted, ap_blocked).

Bumps version to 1.1.0.
2026-02-21 12:13:10 +01:00

219 lines
5.5 KiB
JavaScript

/**
* Remote profile controllers — view remote actors and follow/unfollow.
*/
import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js";
/**
* GET /admin/reader/profile — Show remote actor profile.
* @param {string} mountPath - Plugin mount path
* @param {object} plugin - ActivityPub plugin instance
*/
export function remoteProfileController(mountPath, plugin) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const actorUrl = request.query.url || request.query.handle;
if (!actorUrl) {
return response.status(400).render("error", {
title: "Error",
content: "Missing actor URL or handle",
});
}
if (!plugin._federation) {
return response.status(503).render("error", {
title: "Error",
content: "Federation not initialized",
});
}
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
// Look up the remote actor
let actor;
try {
actor = await ctx.lookupObject(new URL(actorUrl));
} catch {
return response.status(404).render("error", {
title: "Error",
content: response.locals.__("activitypub.profile.remote.notFound"),
});
}
if (!actor) {
return response.status(404).render("error", {
title: "Error",
content: response.locals.__("activitypub.profile.remote.notFound"),
});
}
// Extract actor info
const name =
actor.name?.toString() ||
actor.preferredUsername?.toString() ||
actorUrl;
const actorHandle = actor.preferredUsername?.toString() || "";
const summary = sanitizeContent(actor.summary?.toString() || "");
let icon = "";
let image = "";
try {
const iconObj = await actor.getIcon();
icon = iconObj?.url?.href || "";
} catch {
// No icon
}
try {
const imageObj = await actor.getImage();
image = imageObj?.url?.href || "";
} catch {
// No header image
}
// Extract host for "View on {instance}"
let instanceHost = "";
try {
instanceHost = new URL(actorUrl).hostname;
} catch {
// Invalid URL
}
// Check if we're following this actor
const followingCol = application?.collections?.get("ap_following");
const isFollowing = followingCol
? !!(await followingCol.findOne({ actorUrl }))
: false;
// Get their posts from our timeline (only if following)
let posts = [];
if (isFollowing) {
const timelineCol = application?.collections?.get("ap_timeline");
if (timelineCol) {
posts = await timelineCol
.find({ "author.url": actorUrl })
.sort({ published: -1 })
.limit(20)
.toArray();
}
}
// Check mute/block state
const mutedCol = application?.collections?.get("ap_muted");
const blockedCol = application?.collections?.get("ap_blocked");
const isMuted = mutedCol
? !!(await mutedCol.findOne({ url: actorUrl }))
: false;
const isBlocked = blockedCol
? !!(await blockedCol.findOne({ url: actorUrl }))
: false;
const csrfToken = getToken(request.session);
response.render("activitypub-remote-profile", {
title: name,
actorUrl,
name,
actorHandle,
summary,
icon,
image,
instanceHost,
isFollowing,
isMuted,
isBlocked,
posts,
csrfToken,
mountPath,
});
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/follow — Follow a remote actor.
*/
export function followController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing actor URL",
});
}
const result = await plugin.followActor(url);
return response.json({
success: result.ok,
error: result.error || undefined,
});
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/unfollow — Unfollow a remote actor.
*/
export function unfollowController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing actor URL",
});
}
const result = await plugin.unfollowActor(url);
return response.json({
success: result.ok,
error: result.error || undefined,
});
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}