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
254 lines
7.5 KiB
JavaScript
254 lines
7.5 KiB
JavaScript
/**
|
|
* Follow request controllers — approve and reject pending follow requests
|
|
* when manual follow approval is enabled.
|
|
*/
|
|
|
|
import { validateToken } from "../csrf.js";
|
|
import { lookupWithSecurity } from "../lookup-helpers.js";
|
|
import { logActivity } from "../activity-log.js";
|
|
import { addNotification } from "../storage/notifications.js";
|
|
import { extractActorInfo } from "../timeline-store.js";
|
|
|
|
/**
|
|
* POST /admin/followers/approve — Accept a pending follow request.
|
|
*/
|
|
export function approveFollowController(mountPath, plugin) {
|
|
return async (request, response, next) => {
|
|
try {
|
|
if (!validateToken(request)) {
|
|
return response.status(403).json({
|
|
success: false,
|
|
error: "Invalid CSRF token",
|
|
});
|
|
}
|
|
|
|
const { actorUrl } = request.body;
|
|
|
|
if (!actorUrl) {
|
|
return response.status(400).json({
|
|
success: false,
|
|
error: "Missing actor URL",
|
|
});
|
|
}
|
|
|
|
const { application } = request.app.locals;
|
|
const pendingCol = application?.collections?.get("ap_pending_follows");
|
|
const followersCol = application?.collections?.get("ap_followers");
|
|
|
|
if (!pendingCol || !followersCol) {
|
|
return response.status(503).json({
|
|
success: false,
|
|
error: "Collections not available",
|
|
});
|
|
}
|
|
|
|
// Find the pending request
|
|
const pending = await pendingCol.findOne({ actorUrl });
|
|
if (!pending) {
|
|
return response.status(404).json({
|
|
success: false,
|
|
error: "No pending follow request from this actor",
|
|
});
|
|
}
|
|
|
|
// Move to ap_followers
|
|
await followersCol.updateOne(
|
|
{ actorUrl },
|
|
{
|
|
$set: {
|
|
actorUrl: pending.actorUrl,
|
|
handle: pending.handle || "",
|
|
name: pending.name || "",
|
|
avatar: pending.avatar || "",
|
|
inbox: pending.inbox || "",
|
|
sharedInbox: pending.sharedInbox || "",
|
|
followedAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
{ upsert: true },
|
|
);
|
|
|
|
// Remove from pending
|
|
await pendingCol.deleteOne({ actorUrl });
|
|
|
|
// Send Accept(Follow) via federation
|
|
if (plugin._federation) {
|
|
try {
|
|
const { Accept, Follow } = await import("@fedify/fedify/vocab");
|
|
const handle = plugin.options.actor.handle;
|
|
const ctx = plugin._federation.createContext(
|
|
new URL(plugin._publicationUrl),
|
|
{ handle, publicationUrl: plugin._publicationUrl },
|
|
);
|
|
|
|
const documentLoader = await ctx.getDocumentLoader({
|
|
identifier: handle,
|
|
});
|
|
|
|
// Resolve the remote actor for delivery
|
|
const remoteActor = await lookupWithSecurity(ctx, new URL(actorUrl), {
|
|
documentLoader,
|
|
});
|
|
|
|
if (remoteActor) {
|
|
// Reconstruct the Follow using stored activity ID
|
|
const followObj = new Follow({
|
|
id: pending.followActivityId
|
|
? new URL(pending.followActivityId)
|
|
: undefined,
|
|
actor: new URL(actorUrl),
|
|
object: ctx.getActorUri(handle),
|
|
});
|
|
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
remoteActor,
|
|
new Accept({
|
|
actor: ctx.getActorUri(handle),
|
|
object: followObj,
|
|
}),
|
|
{ orderingKey: actorUrl },
|
|
);
|
|
}
|
|
|
|
const activitiesCol = application?.collections?.get("ap_activities");
|
|
if (activitiesCol) {
|
|
await logActivity(activitiesCol, {
|
|
direction: "outbound",
|
|
type: "Accept(Follow)",
|
|
actorUrl: plugin._publicationUrl,
|
|
objectUrl: actorUrl,
|
|
actorName: pending.name || actorUrl,
|
|
summary: `Approved follow request from ${pending.name || actorUrl}`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Could not send Accept to ${actorUrl}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
console.info(
|
|
`[ActivityPub] Approved follow request from ${pending.name || actorUrl}`,
|
|
);
|
|
|
|
// Redirect back to followers page
|
|
return response.redirect(`${mountPath}/admin/followers`);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* POST /admin/followers/reject — Reject a pending follow request.
|
|
*/
|
|
export function rejectFollowController(mountPath, plugin) {
|
|
return async (request, response, next) => {
|
|
try {
|
|
if (!validateToken(request)) {
|
|
return response.status(403).json({
|
|
success: false,
|
|
error: "Invalid CSRF token",
|
|
});
|
|
}
|
|
|
|
const { actorUrl } = request.body;
|
|
|
|
if (!actorUrl) {
|
|
return response.status(400).json({
|
|
success: false,
|
|
error: "Missing actor URL",
|
|
});
|
|
}
|
|
|
|
const { application } = request.app.locals;
|
|
const pendingCol = application?.collections?.get("ap_pending_follows");
|
|
|
|
if (!pendingCol) {
|
|
return response.status(503).json({
|
|
success: false,
|
|
error: "Collections not available",
|
|
});
|
|
}
|
|
|
|
// Find the pending request
|
|
const pending = await pendingCol.findOne({ actorUrl });
|
|
if (!pending) {
|
|
return response.status(404).json({
|
|
success: false,
|
|
error: "No pending follow request from this actor",
|
|
});
|
|
}
|
|
|
|
// Remove from pending
|
|
await pendingCol.deleteOne({ actorUrl });
|
|
|
|
// Send Reject(Follow) via federation
|
|
if (plugin._federation) {
|
|
try {
|
|
const { Reject, Follow } = await import("@fedify/fedify/vocab");
|
|
const handle = plugin.options.actor.handle;
|
|
const ctx = plugin._federation.createContext(
|
|
new URL(plugin._publicationUrl),
|
|
{ handle, publicationUrl: plugin._publicationUrl },
|
|
);
|
|
|
|
const documentLoader = await ctx.getDocumentLoader({
|
|
identifier: handle,
|
|
});
|
|
|
|
const remoteActor = await lookupWithSecurity(ctx, new URL(actorUrl), {
|
|
documentLoader,
|
|
});
|
|
|
|
if (remoteActor) {
|
|
const followObj = new Follow({
|
|
id: pending.followActivityId
|
|
? new URL(pending.followActivityId)
|
|
: undefined,
|
|
actor: new URL(actorUrl),
|
|
object: ctx.getActorUri(handle),
|
|
});
|
|
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
remoteActor,
|
|
new Reject({
|
|
actor: ctx.getActorUri(handle),
|
|
object: followObj,
|
|
}),
|
|
{ orderingKey: actorUrl },
|
|
);
|
|
}
|
|
|
|
const activitiesCol = application?.collections?.get("ap_activities");
|
|
if (activitiesCol) {
|
|
await logActivity(activitiesCol, {
|
|
direction: "outbound",
|
|
type: "Reject(Follow)",
|
|
actorUrl: plugin._publicationUrl,
|
|
objectUrl: actorUrl,
|
|
actorName: pending.name || actorUrl,
|
|
summary: `Rejected follow request from ${pending.name || actorUrl}`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Could not send Reject to ${actorUrl}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
console.info(
|
|
`[ActivityPub] Rejected follow request from ${pending.name || actorUrl}`,
|
|
);
|
|
|
|
return response.redirect(`${mountPath}/admin/followers`);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
}
|