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
This commit is contained in:
Ricardo
2026-03-17 08:21:36 +01:00
parent 0c84913ac7
commit 9a61145d97
24 changed files with 656 additions and 92 deletions

View File

@@ -3408,3 +3408,28 @@
}
}
/* Follow request approve/reject actions */
.ap-follow-request {
margin-block-end: var(--space-m);
}
.ap-follow-request__actions {
display: flex;
gap: var(--space-s);
margin-block-start: var(--space-xs);
padding-inline-start: var(--space-l);
}
.ap-follow-request__form {
display: inline;
}
.button--danger {
background-color: var(--color-red45);
color: white;
}
.button--danger:hover {
background-color: var(--color-red35, #c0392b);
}

View File

@@ -2,6 +2,7 @@ import express from "express";
import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
import { initRedisCache } from "./lib/redis-cache.js";
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
import {
createFedifyMiddleware,
} from "./lib/federation-bridge.js";
@@ -39,6 +40,10 @@ import {
filterModeController,
} from "./lib/controllers/moderation.js";
import { followersController } from "./lib/controllers/followers.js";
import {
approveFollowController,
rejectFollowController,
} from "./lib/controllers/follow-requests.js";
import { followingController } from "./lib/controllers/following.js";
import { activitiesController } from "./lib/controllers/activities.js";
import {
@@ -304,6 +309,8 @@ export default class ActivityPubEndpoint {
router.post("/admin/reader/block", blockController(mp, this));
router.post("/admin/reader/unblock", unblockController(mp, this));
router.get("/admin/followers", followersController(mp));
router.post("/admin/followers/approve", approveFollowController(mp, this));
router.post("/admin/followers/reject", rejectFollowController(mp, this));
router.get("/admin/following", followingController(mp));
router.get("/admin/activities", activitiesController(mp));
router.get("/admin/featured", featuredGetController(mp));
@@ -493,7 +500,7 @@ export default class ActivityPubEndpoint {
let replyToActor = null;
if (properties["in-reply-to"]) {
try {
const remoteObject = await ctx.lookupObject(
const remoteObject = await lookupWithSecurity(ctx,
new URL(properties["in-reply-to"]),
);
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
@@ -525,7 +532,7 @@ export default class ActivityPubEndpoint {
for (const { handle } of mentionHandles) {
try {
const mentionedActor = await ctx.lookupObject(
const mentionedActor = await lookupWithSecurity(ctx,
new URL(`acct:${handle}`),
);
if (mentionedActor?.id) {
@@ -701,7 +708,7 @@ export default class ActivityPubEndpoint {
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteActor = await ctx.lookupObject(actorUrl, {
const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
documentLoader,
});
if (!remoteActor) {
@@ -802,7 +809,7 @@ export default class ActivityPubEndpoint {
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteActor = await ctx.lookupObject(actorUrl, {
const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
documentLoader,
});
if (!remoteActor) {
@@ -1115,6 +1122,8 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_explore_tabs");
// Reports collection
Indiekit.addCollection("ap_reports");
// Pending follow requests (manual approval)
Indiekit.addCollection("ap_pending_follows");
// Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections;
@@ -1140,6 +1149,8 @@ export default class ActivityPubEndpoint {
ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
// Reports collection
ap_reports: indiekitCollections.get("ap_reports"),
// Pending follow requests (manual approval)
ap_pending_follows: indiekitCollections.get("ap_pending_follows"),
get posts() {
return indiekitCollections.get("posts");
},
@@ -1331,6 +1342,15 @@ export default class ActivityPubEndpoint {
{ reportedUrls: 1 },
{ background: true },
);
// Pending follow requests — unique on actorUrl
this._collections.ap_pending_follows.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_pending_follows.createIndex(
{ requestedAt: -1 },
{ background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.
@@ -1375,7 +1395,7 @@ export default class ActivityPubEndpoint {
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const actor = await ctx.lookupObject(new URL(actorUrl), {
const actor = await lookupWithSecurity(ctx,new URL(actorUrl), {
documentLoader,
});
if (!actor) return "";

View File

@@ -1,3 +1,5 @@
import { lookupWithSecurity } from "./lookup-helpers.js";
/**
* Batch re-follow processor for imported accounts.
*
@@ -232,7 +234,7 @@ async function processOneFollow(options, entry) {
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteActor = await ctx.lookupObject(entry.actorUrl, {
const remoteActor = await lookupWithSecurity(ctx,entry.actorUrl, {
documentLoader,
});
if (!remoteActor) {

View File

@@ -4,6 +4,7 @@
import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js";
import { lookupWithSecurity } from "../lookup-helpers.js";
/**
* Fetch syndication targets from the Micropub config endpoint.
@@ -79,7 +80,7 @@ export function composeController(mountPath, plugin) {
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteObject = await ctx.lookupObject(new URL(replyTo), {
const remoteObject = await lookupWithSecurity(ctx,new URL(replyTo), {
documentLoader,
});

View File

@@ -3,8 +3,10 @@
* the relationship between local content and the fediverse.
*/
import Redis from "ioredis";
import { getToken, validateToken } from "../csrf.js";
import { jf2ToActivityStreams } from "../jf2-to-as2.js";
import { lookupWithSecurity } from "../lookup-helpers.js";
const PAGE_SIZE = 20;
@@ -37,10 +39,12 @@ export function federationMgmtController(mountPath, plugin) {
const { application } = request.app.locals;
const collections = application?.collections;
const redisUrl = plugin.options.redisUrl || "";
// Parallel: collection stats + posts + recent activities
const [collectionStats, postsResult, recentActivities] =
await Promise.all([
getCollectionStats(collections),
getCollectionStats(collections, { redisUrl }),
getPaginatedPosts(collections, request.query.page),
getRecentActivities(collections),
]);
@@ -219,7 +223,7 @@ export function lookupObjectController(mountPath, plugin) {
identifier: handle,
});
const object = await ctx.lookupObject(query, { documentLoader });
const object = await lookupWithSecurity(ctx,query, { documentLoader });
if (!object) {
return response
@@ -239,11 +243,16 @@ export function lookupObjectController(mountPath, plugin) {
// --- Helpers ---
async function getCollectionStats(collections) {
async function getCollectionStats(collections, { redisUrl = "" } = {}) {
if (!collections) return [];
const stats = await Promise.all(
AP_COLLECTIONS.map(async (name) => {
// When Redis handles KV, count fedify::* keys from Redis instead
if (name === "ap_kv" && redisUrl) {
const count = await countRedisKvKeys(redisUrl);
return { name: "ap_kv (redis)", count };
}
const col = collections.get(name);
const count = col ? await col.countDocuments() : 0;
return { name, count };
@@ -253,6 +262,36 @@ async function getCollectionStats(collections) {
return stats;
}
/**
* Count Fedify KV keys in Redis (prefix: "fedify::").
* Uses SCAN to avoid blocking on large key spaces.
*/
async function countRedisKvKeys(redisUrl) {
let client;
try {
client = new Redis(redisUrl, { lazyConnect: true, connectTimeout: 3000 });
await client.connect();
let count = 0;
let cursor = "0";
do {
const [nextCursor, keys] = await client.scan(
cursor,
"MATCH",
"fedify::*",
"COUNT",
500,
);
cursor = nextCursor;
count += keys.length;
} while (cursor !== "0");
return count;
} catch {
return 0;
} finally {
client?.disconnect();
}
}
async function getPaginatedPosts(collections, pageParam) {
const postsCol = collections?.get("posts");
if (!postsCol) return { posts: [], cursor: null };

View File

@@ -0,0 +1,253 @@
/**
* 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);
}
};
}

View File

@@ -1,6 +1,9 @@
/**
* Followers list controller — paginated list of accounts following this actor.
* Followers list controller — paginated list of accounts following this actor,
* with pending follow requests tab when manual approval is enabled.
*/
import { getToken } from "../csrf.js";
const PAGE_SIZE = 20;
export function followersController(mountPath) {
@@ -8,6 +11,9 @@ export function followersController(mountPath) {
try {
const { application } = request.app.locals;
const collection = application?.collections?.get("ap_followers");
const pendingCol = application?.collections?.get("ap_pending_follows");
const tab = request.query.tab || "followers";
if (!collection) {
return response.render("activitypub-followers", {
@@ -15,11 +21,50 @@ export function followersController(mountPath) {
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
followers: [],
followerCount: 0,
pendingFollows: [],
pendingCount: 0,
tab,
mountPath,
csrfToken: getToken(request),
});
}
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
// Count pending follow requests
const pendingCount = pendingCol
? await pendingCol.countDocuments()
: 0;
if (tab === "pending") {
// Show pending follow requests
const totalPages = Math.ceil(pendingCount / PAGE_SIZE);
const pendingFollows = pendingCol
? await pendingCol
.find()
.sort({ requestedAt: -1 })
.skip((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
.toArray()
: [];
const cursor = buildCursor(page, totalPages, mountPath + "/admin/followers?tab=pending");
return response.render("activitypub-followers", {
title: response.locals.__("activitypub.followers"),
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
followers: [],
followerCount: await collection.countDocuments(),
pendingFollows,
pendingCount,
tab,
mountPath,
cursor,
csrfToken: getToken(request),
});
}
// Show accepted followers (default)
const totalCount = await collection.countDocuments();
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
@@ -37,8 +82,12 @@ export function followersController(mountPath) {
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
followers,
followerCount: totalCount,
pendingFollows: [],
pendingCount,
tab,
mountPath,
cursor,
csrfToken: getToken(request),
});
} catch (error) {
next(error);
@@ -49,12 +98,14 @@ export function followersController(mountPath) {
function buildCursor(page, totalPages, basePath) {
if (totalPages <= 1) return null;
const separator = basePath.includes("?") ? "&" : "?";
return {
previous: page > 1
? { href: `${basePath}?page=${page - 1}` }
? { href: `${basePath}${separator}page=${page - 1}` }
: undefined,
next: page < totalPages
? { href: `${basePath}?page=${page + 1}` }
? { href: `${basePath}${separator}page=${page + 1}` }
: undefined,
};
}

View File

@@ -198,6 +198,7 @@ export function unboostController(mountPath, plugin) {
// Send to followers
await ctx.sendActivity({ identifier: handle }, "followers", undo, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: url,
});

View File

@@ -5,6 +5,7 @@
import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js";
import { lookupWithSecurity } from "../lookup-helpers.js";
import {
getMessages,
getConversationPartners,
@@ -180,11 +181,11 @@ export function submitMessageController(mountPath, plugin) {
try {
const recipientInput = to.trim();
if (recipientInput.startsWith("http")) {
recipient = await ctx.lookupObject(recipientInput, { documentLoader });
recipient = await lookupWithSecurity(ctx,recipientInput, { documentLoader });
} else {
// Handle @user@domain format
const handle = recipientInput.replace(/^@/, "");
recipient = await ctx.lookupObject(handle, { documentLoader });
recipient = await lookupWithSecurity(ctx,handle, { documentLoader });
}
} catch {
recipient = null;

View File

@@ -3,6 +3,7 @@
*/
import { validateToken, getToken } from "../csrf.js";
import { lookupWithSecurity } from "../lookup-helpers.js";
import {
addMuted,
removeMuted,
@@ -157,7 +158,7 @@ export function blockController(mountPath, plugin) {
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteActor = await ctx.lookupObject(new URL(url), {
const remoteActor = await lookupWithSecurity(ctx,new URL(url), {
documentLoader,
});
@@ -236,7 +237,7 @@ export function unblockController(mountPath, plugin) {
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteActor = await ctx.lookupObject(new URL(url), {
const remoteActor = await lookupWithSecurity(ctx,new URL(url), {
documentLoader,
});

View File

@@ -4,6 +4,7 @@ import { getToken } from "../csrf.js";
import { extractObjectData, extractActorInfo } from "../timeline-store.js";
import { getCached, setCache } from "../lookup-cache.js";
import { fetchAndStoreQuote, stripQuoteReferenceHtml } from "../og-unfurl.js";
import { lookupWithSecurity } from "../lookup-helpers.js";
// Load parent posts (inReplyTo chain) up to maxDepth levels
async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
@@ -28,7 +29,7 @@ async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxD
if (!object) {
try {
object = await ctx.lookupObject(new URL(currentUrl), {
object = await lookupWithSecurity(ctx,new URL(currentUrl), {
documentLoader,
});
if (object) {
@@ -180,7 +181,7 @@ export function postDetailController(mountPath, plugin) {
object = cached;
} else {
try {
object = await ctx.lookupObject(new URL(objectUrl), {
object = await lookupWithSecurity(ctx,new URL(objectUrl), {
documentLoader,
});
if (object) {
@@ -326,7 +327,7 @@ export function postDetailController(mountPath, plugin) {
);
const qLoader = await qCtx.getDocumentLoader({ identifier: handle });
const quoteObject = await qCtx.lookupObject(new URL(timelineItem.quoteUrl), {
const quoteObject = await lookupWithSecurity(qCtx,new URL(timelineItem.quoteUrl), {
documentLoader: qLoader,
});
@@ -336,7 +337,7 @@ export function postDetailController(mountPath, plugin) {
// If author photo is empty, try fetching the actor directly
if (!quoteData.author.photo && quoteData.author.url) {
try {
const actor = await qCtx.lookupObject(new URL(quoteData.author.url), { documentLoader: qLoader });
const actor = await lookupWithSecurity(qCtx,new URL(quoteData.author.url), { documentLoader: qLoader });
if (actor) {
const actorInfo = await extractActorInfo(actor, { documentLoader: qLoader });
if (actorInfo.photo) quoteData.author.photo = actorInfo.photo;

View File

@@ -4,6 +4,7 @@
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.
@@ -43,7 +44,7 @@ export function remoteProfileController(mountPath, plugin) {
let actor;
try {
actor = await ctx.lookupObject(new URL(actorUrl), { documentLoader });
actor = await lookupWithSecurity(ctx,new URL(actorUrl), { documentLoader });
} catch {
return response.status(404).render("error", {
title: "Error",

View File

@@ -2,6 +2,7 @@
* Resolve controller — accepts any fediverse URL or handle, resolves it
* via lookupObject(), and redirects to the appropriate internal view.
*/
import { lookupWithSecurity } from "../lookup-helpers.js";
import {
Article,
Note,
@@ -59,7 +60,7 @@ export function resolveController(mountPath, plugin) {
let object;
try {
object = await ctx.lookupObject(lookupInput, { documentLoader });
object = await lookupWithSecurity(ctx,lookupInput, { documentLoader });
} catch (error) {
console.warn(
`[resolve] lookupObject failed for "${query}":`,

View File

@@ -99,55 +99,100 @@ export function registerInboxListeners(inboxChain, options) {
followerActor.preferredUsername?.toString() ||
followerUrl;
await collections.ap_followers.updateOne(
{ actorUrl: followerUrl },
{
$set: {
actorUrl: followerUrl,
handle: followerActor.preferredUsername?.toString() || "",
name: followerName,
avatar: followerActor.icon
? (await followerActor.icon)?.url?.href || ""
: "",
inbox: followerActor.inbox?.id?.href || "",
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
followedAt: new Date().toISOString(),
},
},
{ upsert: true },
);
// Auto-accept: send Accept back
await ctx.sendActivity(
{ identifier: handle },
followerActor,
new Accept({
actor: ctx.getActorUri(handle),
object: follow,
}),
{ orderingKey: followerUrl },
);
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Follow",
// Build common follower data
const followerData = {
actorUrl: followerUrl,
actorName: followerName,
summary: `${followerName} followed you`,
});
handle: followerActor.preferredUsername?.toString() || "",
name: followerName,
avatar: followerActor.icon
? (await followerActor.icon)?.url?.href || ""
: "",
inbox: followerActor.inbox?.id?.href || "",
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
};
// Store notification
const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
await addNotification(collections, {
uid: follow.id?.href || `follow:${followerUrl}`,
type: "follow",
actorUrl: followerInfo.url,
actorName: followerInfo.name,
actorPhoto: followerInfo.photo,
actorHandle: followerInfo.handle,
published: follow.published ? String(follow.published) : new Date().toISOString(),
createdAt: new Date().toISOString(),
});
// Check if manual approval is enabled
const profile = await collections.ap_profile.findOne({});
const manualApproval = profile?.manuallyApprovesFollowers || false;
if (manualApproval && collections.ap_pending_follows) {
// Store as pending — do NOT send Accept yet
await collections.ap_pending_follows.updateOne(
{ actorUrl: followerUrl },
{
$set: {
...followerData,
followActivityId: follow.id?.href || "",
requestedAt: new Date().toISOString(),
},
},
{ upsert: true },
);
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Follow",
actorUrl: followerUrl,
actorName: followerName,
summary: `${followerName} requested to follow you`,
});
// Notification with type "follow_request"
const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
await addNotification(collections, {
uid: follow.id?.href || `follow_request:${followerUrl}`,
type: "follow_request",
actorUrl: followerInfo.url,
actorName: followerInfo.name,
actorPhoto: followerInfo.photo,
actorHandle: followerInfo.handle,
published: follow.published ? String(follow.published) : new Date().toISOString(),
createdAt: new Date().toISOString(),
});
} else {
// Auto-accept: store follower + send Accept back
await collections.ap_followers.updateOne(
{ actorUrl: followerUrl },
{
$set: {
...followerData,
followedAt: new Date().toISOString(),
},
},
{ upsert: true },
);
await ctx.sendActivity(
{ identifier: handle },
followerActor,
new Accept({
actor: ctx.getActorUri(handle),
object: follow,
}),
{ orderingKey: followerUrl },
);
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Follow",
actorUrl: followerUrl,
actorName: followerName,
summary: `${followerName} followed you`,
});
// Store notification
const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
await addNotification(collections, {
uid: follow.id?.href || `follow:${followerUrl}`,
type: "follow",
actorUrl: followerInfo.url,
actorName: followerInfo.name,
actorPhoto: followerInfo.photo,
actorHandle: followerInfo.handle,
published: follow.published ? String(follow.published) : new Date().toISOString(),
createdAt: new Date().toISOString(),
});
}
})
.on(Undo, async (ctx, undo) => {
const actorUrl = undo.actorId?.href || "";

View File

@@ -536,7 +536,7 @@ function buildPlainTags(properties, publicationUrl, existing) {
for (const cat of asArray(properties.category)) {
tags.push({
type: "Hashtag",
name: `#${cat.replace(/\s+/g, "")}`,
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
});
}
@@ -558,7 +558,7 @@ function buildFedifyTags(properties, publicationUrl, postType) {
for (const cat of asArray(properties.category)) {
tags.push(
new Hashtag({
name: `#${cat.replace(/\s+/g, "")}`,
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
href: new URL(
`${publicationUrl}categories/${encodeURIComponent(cat)}`,
),

27
lib/lookup-helpers.js Normal file
View File

@@ -0,0 +1,27 @@
/**
* Centralized wrapper for ctx.lookupObject() with FEP-fe34 origin-based
* security. All lookupObject calls MUST go through this helper so the
* crossOrigin policy is applied consistently.
*
* @module lookup-helpers
*/
/**
* Look up a remote ActivityPub object with cross-origin security.
*
* FEP-fe34 prevents spoofed attribution attacks by verifying that a
* fetched object's `id` matches the origin of the URL used to fetch it.
* Using `crossOrigin: "ignore"` tells Fedify to silently discard objects
* whose id doesn't match the fetch origin, rather than throwing.
*
* @param {object} ctx - Fedify Context
* @param {string|URL} input - URL or handle to look up
* @param {object} [options] - Additional options passed to lookupObject
* @returns {Promise<object|null>} Resolved object or null
*/
export function lookupWithSecurity(ctx, input, options = {}) {
return ctx.lookupObject(input, {
crossOrigin: "ignore",
...options,
});
}

View File

@@ -5,6 +5,7 @@
import { unfurl } from "unfurl.js";
import { extractObjectData } from "./timeline-store.js";
import { lookupWithSecurity } from "./lookup-helpers.js";
const USER_AGENT =
"Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)";
@@ -262,7 +263,7 @@ export async function fetchAndStorePreviews(collections, uid, html) {
*/
export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, documentLoader) {
try {
const object = await ctx.lookupObject(new URL(quoteUrl), { documentLoader });
const object = await lookupWithSecurity(ctx,new URL(quoteUrl), { documentLoader });
if (!object) return;
const quoteData = await extractObjectData(object, { documentLoader });
@@ -270,7 +271,7 @@ export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, docume
// If author photo is empty, try fetching the actor directly
if (!quoteData.author.photo && quoteData.author.url) {
try {
const actor = await ctx.lookupObject(new URL(quoteData.author.url), { documentLoader });
const actor = await lookupWithSecurity(ctx,new URL(quoteData.author.url), { documentLoader });
if (actor) {
const { extractActorInfo } = await import("./timeline-store.js");
const actorInfo = await extractActorInfo(actor, { documentLoader });

View File

@@ -10,6 +10,8 @@
* 3. Extract author URL from post URL pattern → lookupObject
*/
import { lookupWithSecurity } from "./lookup-helpers.js";
/**
* Extract a probable author URL from a post URL using common fediverse patterns.
*
@@ -68,7 +70,7 @@ export async function resolveAuthor(
) {
// Strategy 1: Look up remote post via Fedify (signed request)
try {
const remoteObject = await ctx.lookupObject(new URL(postUrl), {
const remoteObject = await lookupWithSecurity(ctx,new URL(postUrl), {
documentLoader,
});
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
@@ -112,7 +114,7 @@ export async function resolveAuthor(
if (authorUrl) {
try {
const actor = await ctx.lookupObject(new URL(authorUrl), {
const actor = await lookupWithSecurity(ctx,new URL(authorUrl), {
documentLoader,
});
if (actor) {
@@ -134,7 +136,7 @@ export async function resolveAuthor(
const extractedUrl = extractAuthorUrl(postUrl);
if (extractedUrl) {
try {
const actor = await ctx.lookupObject(new URL(extractedUrl), {
const actor = await lookupWithSecurity(ctx,new URL(extractedUrl), {
documentLoader,
});
if (actor) {

View File

@@ -65,8 +65,11 @@ export async function getNotifications(collections, options = {}) {
// Type filter
if (options.type) {
// "reply" tab shows both replies and mentions
// "follow" tab shows both follows and follow_requests
if (options.type === "reply") {
query.type = { $in: ["reply", "mention"] };
} else if (options.type === "follow") {
query.type = { $in: ["follow", "follow_request"] };
} else {
query.type = options.type;
}
@@ -131,6 +134,8 @@ export async function getNotificationCountsByType(collections, unreadOnly = fals
counts.all += count;
if (_id === "reply" || _id === "mention") {
counts.reply += count;
} else if (_id === "follow_request") {
counts.follow += count;
} else if (counts[_id] !== undefined) {
counts[_id] = count;
}

View File

@@ -33,6 +33,29 @@ export function sanitizeContent(html) {
});
}
/**
* Replace custom emoji :shortcode: placeholders with inline <img> tags.
* Applied AFTER sanitization — the <img> tags are controlled output from
* trusted emoji data, not user-supplied HTML.
*
* @param {string} html - Content HTML (already sanitized)
* @param {Array<{shortcode: string, url: string}>} emojis - Custom emoji data
* @returns {string} HTML with shortcodes replaced by <img> tags
*/
export function replaceCustomEmoji(html, emojis) {
if (!emojis?.length || !html) return html;
let result = html;
for (const { shortcode, url } of emojis) {
const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`:${escaped}:`, "g");
result = result.replace(
pattern,
`<img class="ap-custom-emoji" src="${url}" alt=":${shortcode}:" title=":${shortcode}:" draggable="false">`,
);
}
return result;
}
/**
* Extract actor information from Fedify Person/Application/Service object
* @param {object} actor - Fedify actor object
@@ -104,7 +127,10 @@ export async function extractActorInfo(actor, options = {}) {
// Bot detection — Service and Application actors are automated accounts
const bot = actor instanceof Service || actor instanceof Application;
return { name, url, photo, handle, emojis, bot };
// Replace custom emoji shortcodes in display name with <img> tags
const nameHtml = replaceCustomEmoji(name, emojis);
return { name, nameHtml, url, photo, handle, emojis, bot };
}
/**
@@ -336,6 +362,10 @@ export async function extractObjectData(object, options = {}) {
if (shares?.totalItems != null) counts.boosts = shares.totalItems;
} catch { /* ignore */ }
// Replace custom emoji :shortcode: in content with inline <img> tags.
// Applied after sanitization — these are trusted emoji from the post's tags.
content.html = replaceCustomEmoji(content.html, emojis);
// Build base timeline item
const item = {
uid,

View File

@@ -10,6 +10,13 @@
"noActivity": "No activity yet. Once your actor is federated, interactions will appear here.",
"noFollowers": "No followers yet.",
"noFollowing": "Not following anyone yet.",
"pendingFollows": "Pending",
"noPendingFollows": "No pending follow requests.",
"approve": "Approve",
"reject": "Reject",
"followApproved": "Follow request approved.",
"followRejected": "Follow request rejected.",
"followRequest": "requested to follow you",
"followerCount": "%d follower",
"followerCount_plural": "%d followers",
"followingCount": "%d following",

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.12.1",
"version": "2.13.0",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",

View File

@@ -6,19 +6,67 @@
{% from "pagination/macro.njk" import pagination with context %}
{% block content %}
{% if followers.length > 0 %}
{% for follower in followers %}
{{ card({
title: follower.name or follower.handle or follower.actorUrl,
url: follower.actorUrl,
photo: { url: follower.avatar, alt: follower.name } if follower.avatar,
description: { text: "@" + follower.handle if follower.handle },
published: follower.followedAt
}) }}
{% endfor %}
{# Tab navigation — only show if there are pending requests #}
{% if pendingCount > 0 %}
{% set followersBase = mountPath + "/admin/followers" %}
<nav class="ap-tabs">
<a href="{{ followersBase }}" class="ap-tab{% if tab == 'followers' %} ap-tab--active{% endif %}">
{{ __("activitypub.followers") }}
{% if followerCount %}<span class="ap-tab__count">{{ followerCount }}</span>{% endif %}
</a>
<a href="{{ followersBase }}?tab=pending" class="ap-tab{% if tab == 'pending' %} ap-tab--active{% endif %}">
{{ __("activitypub.pendingFollows") }}
<span class="ap-tab__count">{{ pendingCount }}</span>
</a>
</nav>
{% endif %}
{{ pagination(cursor) if cursor }}
{% if tab == "pending" %}
{# Pending follow requests #}
{% if pendingFollows.length > 0 %}
{% for pending in pendingFollows %}
<div class="ap-follow-request">
{{ card({
title: pending.name or pending.handle or pending.actorUrl,
url: pending.actorUrl,
photo: { url: pending.avatar, alt: pending.name } if pending.avatar,
description: { text: "@" + pending.handle if pending.handle }
}) }}
<div class="ap-follow-request__actions">
<form method="post" action="{{ mountPath }}/admin/followers/approve" class="ap-follow-request__form">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="hidden" name="actorUrl" value="{{ pending.actorUrl }}">
<button type="submit" class="button">{{ __("activitypub.approve") }}</button>
</form>
<form method="post" action="{{ mountPath }}/admin/followers/reject" class="ap-follow-request__form">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="hidden" name="actorUrl" value="{{ pending.actorUrl }}">
<button type="submit" class="button button--danger">{{ __("activitypub.reject") }}</button>
</form>
</div>
</div>
{% endfor %}
{{ pagination(cursor) if cursor }}
{% else %}
{{ prose({ text: __("activitypub.noPendingFollows") }) }}
{% endif %}
{% else %}
{{ prose({ text: __("activitypub.noFollowers") }) }}
{# Accepted followers #}
{% if followers.length > 0 %}
{% for follower in followers %}
{{ card({
title: follower.name or follower.handle or follower.actorUrl,
url: follower.actorUrl,
photo: { url: follower.avatar, alt: follower.name } if follower.avatar,
description: { text: "@" + follower.handle if follower.handle },
published: follower.followedAt
}) }}
{% endfor %}
{{ pagination(cursor) if cursor }}
{% else %}
{{ prose({ text: __("activitypub.noFollowers") }) }}
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -15,7 +15,7 @@
{% endif %}
<span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
<span class="ap-notification__type-badge">
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" or item.type == "follow_request" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
</span>
</div>
@@ -32,6 +32,8 @@
{{ __("activitypub.notifications.boostedPost") }}
{% elif item.type == "follow" %}
{{ __("activitypub.notifications.followedYou") }}
{% elif item.type == "follow_request" %}
{{ __("activitypub.followRequest") }}
{% elif item.type == "reply" %}
{{ __("activitypub.notifications.repliedTo") }}
{% elif item.type == "mention" %}