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