Files
indiekit-endpoint-activitypub/lib/controllers/profile.remote.js
Ricardo 9a61145d97 feat: FEP-8fcf/fe34 compliance, custom emoji, manual follow approval (v2.13.0)
- FEP-8fcf: add syncCollection to Undo(Announce) sendActivity
- FEP-fe34: centralized lookupWithSecurity() helper with crossOrigin: "ignore" on all 23 lookupObject call sites
- Custom emoji: replaceCustomEmoji() renders :shortcode: as inline <img> in content and actor display names
- Manual follow approval: profile toggle, ap_pending_follows collection, approve/reject controllers with federation, pending tab on followers page, follow_request notification type
- Coverage audit updated to v2.12.x (overall ~70% → ~82%)

Confab-Link: http://localhost:8080/sessions/1f1e729b-0087-499e-a991-f36f46211fe4
2026-03-17 08:21:36 +01:00

224 lines
5.8 KiB
JavaScript

/**
* Remote profile controllers — view remote actors and follow/unfollow.
*/
import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js";
import { lookupWithSecurity } from "../lookup-helpers.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 (signed request for Authorized Fetch)
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
let actor;
try {
actor = await lookupWithSecurity(ctx,new URL(actorUrl), { documentLoader });
} 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 bio = 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,
readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
actorUrl,
name,
actorHandle,
bio,
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.",
});
}
};
}