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