diff --git a/assets/reader.css b/assets/reader.css index 36155a4..1988295 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -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); +} + diff --git a/index.js b/index.js index 5298da8..5d837b5 100644 --- a/index.js +++ b/index.js @@ -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 ""; diff --git a/lib/batch-refollow.js b/lib/batch-refollow.js index 99ac690..583fdc8 100644 --- a/lib/batch-refollow.js +++ b/lib/batch-refollow.js @@ -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) { diff --git a/lib/controllers/compose.js b/lib/controllers/compose.js index 3d76f5a..d3e4c5e 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -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, }); diff --git a/lib/controllers/federation-mgmt.js b/lib/controllers/federation-mgmt.js index e0030fa..bda6a73 100644 --- a/lib/controllers/federation-mgmt.js +++ b/lib/controllers/federation-mgmt.js @@ -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 }; diff --git a/lib/controllers/follow-requests.js b/lib/controllers/follow-requests.js new file mode 100644 index 0000000..ea3088e --- /dev/null +++ b/lib/controllers/follow-requests.js @@ -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); + } + }; +} diff --git a/lib/controllers/followers.js b/lib/controllers/followers.js index 9060bc0..a5ec95b 100644 --- a/lib/controllers/followers.js +++ b/lib/controllers/followers.js @@ -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, }; } diff --git a/lib/controllers/interactions-boost.js b/lib/controllers/interactions-boost.js index 17edb46..b8ea98e 100644 --- a/lib/controllers/interactions-boost.js +++ b/lib/controllers/interactions-boost.js @@ -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, }); diff --git a/lib/controllers/messages.js b/lib/controllers/messages.js index 5740896..a454bf3 100644 --- a/lib/controllers/messages.js +++ b/lib/controllers/messages.js @@ -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; diff --git a/lib/controllers/moderation.js b/lib/controllers/moderation.js index 83304ae..5e61335 100644 --- a/lib/controllers/moderation.js +++ b/lib/controllers/moderation.js @@ -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, }); diff --git a/lib/controllers/post-detail.js b/lib/controllers/post-detail.js index 90acafd..90776d0 100644 --- a/lib/controllers/post-detail.js +++ b/lib/controllers/post-detail.js @@ -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; diff --git a/lib/controllers/profile.remote.js b/lib/controllers/profile.remote.js index 2e0c629..e2cbd91 100644 --- a/lib/controllers/profile.remote.js +++ b/lib/controllers/profile.remote.js @@ -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", diff --git a/lib/controllers/resolve.js b/lib/controllers/resolve.js index 23e3fbc..466acde 100644 --- a/lib/controllers/resolve.js +++ b/lib/controllers/resolve.js @@ -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}":`, diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index 85b01e3..da169c7 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -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 || ""; diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index c991dbe..c87726e 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -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)}`, ), diff --git a/lib/lookup-helpers.js b/lib/lookup-helpers.js new file mode 100644 index 0000000..149c932 --- /dev/null +++ b/lib/lookup-helpers.js @@ -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} Resolved object or null + */ +export function lookupWithSecurity(ctx, input, options = {}) { + return ctx.lookupObject(input, { + crossOrigin: "ignore", + ...options, + }); +} diff --git a/lib/og-unfurl.js b/lib/og-unfurl.js index 0d219eb..a3505c0 100644 --- a/lib/og-unfurl.js +++ b/lib/og-unfurl.js @@ -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 }); diff --git a/lib/resolve-author.js b/lib/resolve-author.js index 9d45d4c..051a5ef 100644 --- a/lib/resolve-author.js +++ b/lib/resolve-author.js @@ -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) { diff --git a/lib/storage/notifications.js b/lib/storage/notifications.js index 5d80479..a24bcdf 100644 --- a/lib/storage/notifications.js +++ b/lib/storage/notifications.js @@ -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; } diff --git a/lib/timeline-store.js b/lib/timeline-store.js index ce206e1..28b05bf 100644 --- a/lib/timeline-store.js +++ b/lib/timeline-store.js @@ -33,6 +33,29 @@ export function sanitizeContent(html) { }); } +/** + * Replace custom emoji :shortcode: placeholders with inline tags. + * Applied AFTER sanitization — the 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 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, + `:${shortcode}:`, + ); + } + 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 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 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, diff --git a/locales/en.json b/locales/en.json index 7034ee0..98ce7da 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/package.json b/package.json index 18e2775..755677f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/views/activitypub-followers.njk b/views/activitypub-followers.njk index 9f6964a..534058e 100644 --- a/views/activitypub-followers.njk +++ b/views/activitypub-followers.njk @@ -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" %} + + {% endif %} - {{ pagination(cursor) if cursor }} + {% if tab == "pending" %} + {# Pending follow requests #} + {% if pendingFollows.length > 0 %} + {% for pending in pendingFollows %} +
+ {{ 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 } + }) }} +
+
+ + + +
+
+ + + +
+
+
+ {% 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 %} diff --git a/views/partials/ap-notification-card.njk b/views/partials/ap-notification-card.njk index 69f32e0..d8bce3d 100644 --- a/views/partials/ap-notification-card.njk +++ b/views/partials/ap-notification-card.njk @@ -15,7 +15,7 @@ {% endif %} - {% 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 %} @@ -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" %}