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:
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import express from "express";
|
|||||||
|
|
||||||
import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
|
import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
|
||||||
import { initRedisCache } from "./lib/redis-cache.js";
|
import { initRedisCache } from "./lib/redis-cache.js";
|
||||||
|
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
|
||||||
import {
|
import {
|
||||||
createFedifyMiddleware,
|
createFedifyMiddleware,
|
||||||
} from "./lib/federation-bridge.js";
|
} from "./lib/federation-bridge.js";
|
||||||
@@ -39,6 +40,10 @@ import {
|
|||||||
filterModeController,
|
filterModeController,
|
||||||
} from "./lib/controllers/moderation.js";
|
} from "./lib/controllers/moderation.js";
|
||||||
import { followersController } from "./lib/controllers/followers.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 { followingController } from "./lib/controllers/following.js";
|
||||||
import { activitiesController } from "./lib/controllers/activities.js";
|
import { activitiesController } from "./lib/controllers/activities.js";
|
||||||
import {
|
import {
|
||||||
@@ -304,6 +309,8 @@ export default class ActivityPubEndpoint {
|
|||||||
router.post("/admin/reader/block", blockController(mp, this));
|
router.post("/admin/reader/block", blockController(mp, this));
|
||||||
router.post("/admin/reader/unblock", unblockController(mp, this));
|
router.post("/admin/reader/unblock", unblockController(mp, this));
|
||||||
router.get("/admin/followers", followersController(mp));
|
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/following", followingController(mp));
|
||||||
router.get("/admin/activities", activitiesController(mp));
|
router.get("/admin/activities", activitiesController(mp));
|
||||||
router.get("/admin/featured", featuredGetController(mp));
|
router.get("/admin/featured", featuredGetController(mp));
|
||||||
@@ -493,7 +500,7 @@ export default class ActivityPubEndpoint {
|
|||||||
let replyToActor = null;
|
let replyToActor = null;
|
||||||
if (properties["in-reply-to"]) {
|
if (properties["in-reply-to"]) {
|
||||||
try {
|
try {
|
||||||
const remoteObject = await ctx.lookupObject(
|
const remoteObject = await lookupWithSecurity(ctx,
|
||||||
new URL(properties["in-reply-to"]),
|
new URL(properties["in-reply-to"]),
|
||||||
);
|
);
|
||||||
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
||||||
@@ -525,7 +532,7 @@ export default class ActivityPubEndpoint {
|
|||||||
|
|
||||||
for (const { handle } of mentionHandles) {
|
for (const { handle } of mentionHandles) {
|
||||||
try {
|
try {
|
||||||
const mentionedActor = await ctx.lookupObject(
|
const mentionedActor = await lookupWithSecurity(ctx,
|
||||||
new URL(`acct:${handle}`),
|
new URL(`acct:${handle}`),
|
||||||
);
|
);
|
||||||
if (mentionedActor?.id) {
|
if (mentionedActor?.id) {
|
||||||
@@ -701,7 +708,7 @@ export default class ActivityPubEndpoint {
|
|||||||
const documentLoader = await ctx.getDocumentLoader({
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
const remoteActor = await ctx.lookupObject(actorUrl, {
|
const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
if (!remoteActor) {
|
if (!remoteActor) {
|
||||||
@@ -802,7 +809,7 @@ export default class ActivityPubEndpoint {
|
|||||||
const documentLoader = await ctx.getDocumentLoader({
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
const remoteActor = await ctx.lookupObject(actorUrl, {
|
const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
if (!remoteActor) {
|
if (!remoteActor) {
|
||||||
@@ -1115,6 +1122,8 @@ export default class ActivityPubEndpoint {
|
|||||||
Indiekit.addCollection("ap_explore_tabs");
|
Indiekit.addCollection("ap_explore_tabs");
|
||||||
// Reports collection
|
// Reports collection
|
||||||
Indiekit.addCollection("ap_reports");
|
Indiekit.addCollection("ap_reports");
|
||||||
|
// Pending follow requests (manual approval)
|
||||||
|
Indiekit.addCollection("ap_pending_follows");
|
||||||
|
|
||||||
// Store collection references (posts resolved lazily)
|
// Store collection references (posts resolved lazily)
|
||||||
const indiekitCollections = Indiekit.collections;
|
const indiekitCollections = Indiekit.collections;
|
||||||
@@ -1140,6 +1149,8 @@ export default class ActivityPubEndpoint {
|
|||||||
ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
|
ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
|
||||||
// Reports collection
|
// Reports collection
|
||||||
ap_reports: indiekitCollections.get("ap_reports"),
|
ap_reports: indiekitCollections.get("ap_reports"),
|
||||||
|
// Pending follow requests (manual approval)
|
||||||
|
ap_pending_follows: indiekitCollections.get("ap_pending_follows"),
|
||||||
get posts() {
|
get posts() {
|
||||||
return indiekitCollections.get("posts");
|
return indiekitCollections.get("posts");
|
||||||
},
|
},
|
||||||
@@ -1331,6 +1342,15 @@ export default class ActivityPubEndpoint {
|
|||||||
{ reportedUrls: 1 },
|
{ reportedUrls: 1 },
|
||||||
{ background: true },
|
{ 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 {
|
} catch {
|
||||||
// Index creation failed — collections not yet available.
|
// Index creation failed — collections not yet available.
|
||||||
// Indexes already exist from previous startups; non-fatal.
|
// Indexes already exist from previous startups; non-fatal.
|
||||||
@@ -1375,7 +1395,7 @@ export default class ActivityPubEndpoint {
|
|||||||
const documentLoader = await ctx.getDocumentLoader({
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
const actor = await ctx.lookupObject(new URL(actorUrl), {
|
const actor = await lookupWithSecurity(ctx,new URL(actorUrl), {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
if (!actor) return "";
|
if (!actor) return "";
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { lookupWithSecurity } from "./lookup-helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch re-follow processor for imported accounts.
|
* Batch re-follow processor for imported accounts.
|
||||||
*
|
*
|
||||||
@@ -232,7 +234,7 @@ async function processOneFollow(options, entry) {
|
|||||||
const documentLoader = await ctx.getDocumentLoader({
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
const remoteActor = await ctx.lookupObject(entry.actorUrl, {
|
const remoteActor = await lookupWithSecurity(ctx,entry.actorUrl, {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
if (!remoteActor) {
|
if (!remoteActor) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { getToken, validateToken } from "../csrf.js";
|
import { getToken, validateToken } from "../csrf.js";
|
||||||
import { sanitizeContent } from "../timeline-store.js";
|
import { sanitizeContent } from "../timeline-store.js";
|
||||||
|
import { lookupWithSecurity } from "../lookup-helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch syndication targets from the Micropub config endpoint.
|
* Fetch syndication targets from the Micropub config endpoint.
|
||||||
@@ -79,7 +80,7 @@ export function composeController(mountPath, plugin) {
|
|||||||
const documentLoader = await ctx.getDocumentLoader({
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
const remoteObject = await ctx.lookupObject(new URL(replyTo), {
|
const remoteObject = await lookupWithSecurity(ctx,new URL(replyTo), {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
* the relationship between local content and the fediverse.
|
* the relationship between local content and the fediverse.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Redis from "ioredis";
|
||||||
import { getToken, validateToken } from "../csrf.js";
|
import { getToken, validateToken } from "../csrf.js";
|
||||||
import { jf2ToActivityStreams } from "../jf2-to-as2.js";
|
import { jf2ToActivityStreams } from "../jf2-to-as2.js";
|
||||||
|
import { lookupWithSecurity } from "../lookup-helpers.js";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -37,10 +39,12 @@ export function federationMgmtController(mountPath, plugin) {
|
|||||||
const { application } = request.app.locals;
|
const { application } = request.app.locals;
|
||||||
const collections = application?.collections;
|
const collections = application?.collections;
|
||||||
|
|
||||||
|
const redisUrl = plugin.options.redisUrl || "";
|
||||||
|
|
||||||
// Parallel: collection stats + posts + recent activities
|
// Parallel: collection stats + posts + recent activities
|
||||||
const [collectionStats, postsResult, recentActivities] =
|
const [collectionStats, postsResult, recentActivities] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getCollectionStats(collections),
|
getCollectionStats(collections, { redisUrl }),
|
||||||
getPaginatedPosts(collections, request.query.page),
|
getPaginatedPosts(collections, request.query.page),
|
||||||
getRecentActivities(collections),
|
getRecentActivities(collections),
|
||||||
]);
|
]);
|
||||||
@@ -219,7 +223,7 @@ export function lookupObjectController(mountPath, plugin) {
|
|||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
|
|
||||||
const object = await ctx.lookupObject(query, { documentLoader });
|
const object = await lookupWithSecurity(ctx,query, { documentLoader });
|
||||||
|
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return response
|
return response
|
||||||
@@ -239,11 +243,16 @@ export function lookupObjectController(mountPath, plugin) {
|
|||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
async function getCollectionStats(collections) {
|
async function getCollectionStats(collections, { redisUrl = "" } = {}) {
|
||||||
if (!collections) return [];
|
if (!collections) return [];
|
||||||
|
|
||||||
const stats = await Promise.all(
|
const stats = await Promise.all(
|
||||||
AP_COLLECTIONS.map(async (name) => {
|
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 col = collections.get(name);
|
||||||
const count = col ? await col.countDocuments() : 0;
|
const count = col ? await col.countDocuments() : 0;
|
||||||
return { name, count };
|
return { name, count };
|
||||||
@@ -253,6 +262,36 @@ async function getCollectionStats(collections) {
|
|||||||
return stats;
|
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) {
|
async function getPaginatedPosts(collections, pageParam) {
|
||||||
const postsCol = collections?.get("posts");
|
const postsCol = collections?.get("posts");
|
||||||
if (!postsCol) return { posts: [], cursor: null };
|
if (!postsCol) return { posts: [], cursor: null };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export function followersController(mountPath) {
|
export function followersController(mountPath) {
|
||||||
@@ -8,6 +11,9 @@ export function followersController(mountPath) {
|
|||||||
try {
|
try {
|
||||||
const { application } = request.app.locals;
|
const { application } = request.app.locals;
|
||||||
const collection = application?.collections?.get("ap_followers");
|
const collection = application?.collections?.get("ap_followers");
|
||||||
|
const pendingCol = application?.collections?.get("ap_pending_follows");
|
||||||
|
|
||||||
|
const tab = request.query.tab || "followers";
|
||||||
|
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
return response.render("activitypub-followers", {
|
return response.render("activitypub-followers", {
|
||||||
@@ -15,11 +21,50 @@ export function followersController(mountPath) {
|
|||||||
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
|
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
|
||||||
followers: [],
|
followers: [],
|
||||||
followerCount: 0,
|
followerCount: 0,
|
||||||
|
pendingFollows: [],
|
||||||
|
pendingCount: 0,
|
||||||
|
tab,
|
||||||
mountPath,
|
mountPath,
|
||||||
|
csrfToken: getToken(request),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
|
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 totalCount = await collection.countDocuments();
|
||||||
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||||
|
|
||||||
@@ -37,8 +82,12 @@ export function followersController(mountPath) {
|
|||||||
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
|
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
|
||||||
followers,
|
followers,
|
||||||
followerCount: totalCount,
|
followerCount: totalCount,
|
||||||
|
pendingFollows: [],
|
||||||
|
pendingCount,
|
||||||
|
tab,
|
||||||
mountPath,
|
mountPath,
|
||||||
cursor,
|
cursor,
|
||||||
|
csrfToken: getToken(request),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -49,12 +98,14 @@ export function followersController(mountPath) {
|
|||||||
function buildCursor(page, totalPages, basePath) {
|
function buildCursor(page, totalPages, basePath) {
|
||||||
if (totalPages <= 1) return null;
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const separator = basePath.includes("?") ? "&" : "?";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
previous: page > 1
|
previous: page > 1
|
||||||
? { href: `${basePath}?page=${page - 1}` }
|
? { href: `${basePath}${separator}page=${page - 1}` }
|
||||||
: undefined,
|
: undefined,
|
||||||
next: page < totalPages
|
next: page < totalPages
|
||||||
? { href: `${basePath}?page=${page + 1}` }
|
? { href: `${basePath}${separator}page=${page + 1}` }
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export function unboostController(mountPath, plugin) {
|
|||||||
// Send to followers
|
// Send to followers
|
||||||
await ctx.sendActivity({ identifier: handle }, "followers", undo, {
|
await ctx.sendActivity({ identifier: handle }, "followers", undo, {
|
||||||
preferSharedInbox: true,
|
preferSharedInbox: true,
|
||||||
|
syncCollection: true,
|
||||||
orderingKey: url,
|
orderingKey: url,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { getToken, validateToken } from "../csrf.js";
|
import { getToken, validateToken } from "../csrf.js";
|
||||||
import { sanitizeContent } from "../timeline-store.js";
|
import { sanitizeContent } from "../timeline-store.js";
|
||||||
|
import { lookupWithSecurity } from "../lookup-helpers.js";
|
||||||
import {
|
import {
|
||||||
getMessages,
|
getMessages,
|
||||||
getConversationPartners,
|
getConversationPartners,
|
||||||
@@ -180,11 +181,11 @@ export function submitMessageController(mountPath, plugin) {
|
|||||||
try {
|
try {
|
||||||
const recipientInput = to.trim();
|
const recipientInput = to.trim();
|
||||||
if (recipientInput.startsWith("http")) {
|
if (recipientInput.startsWith("http")) {
|
||||||
recipient = await ctx.lookupObject(recipientInput, { documentLoader });
|
recipient = await lookupWithSecurity(ctx,recipientInput, { documentLoader });
|
||||||
} else {
|
} else {
|
||||||
// Handle @user@domain format
|
// Handle @user@domain format
|
||||||
const handle = recipientInput.replace(/^@/, "");
|
const handle = recipientInput.replace(/^@/, "");
|
||||||
recipient = await ctx.lookupObject(handle, { documentLoader });
|
recipient = await lookupWithSecurity(ctx,handle, { documentLoader });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
recipient = null;
|
recipient = null;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { validateToken, getToken } from "../csrf.js";
|
import { validateToken, getToken } from "../csrf.js";
|
||||||
|
import { lookupWithSecurity } from "../lookup-helpers.js";
|
||||||
import {
|
import {
|
||||||
addMuted,
|
addMuted,
|
||||||
removeMuted,
|
removeMuted,
|
||||||
@@ -157,7 +158,7 @@ export function blockController(mountPath, plugin) {
|
|||||||
const documentLoader = await ctx.getDocumentLoader({
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
const remoteActor = await ctx.lookupObject(new URL(url), {
|
const remoteActor = await lookupWithSecurity(ctx,new URL(url), {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,7 +237,7 @@ export function unblockController(mountPath, plugin) {
|
|||||||
const documentLoader = await ctx.getDocumentLoader({
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
const remoteActor = await ctx.lookupObject(new URL(url), {
|
const remoteActor = await lookupWithSecurity(ctx,new URL(url), {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getToken } from "../csrf.js";
|
|||||||
import { extractObjectData, extractActorInfo } from "../timeline-store.js";
|
import { extractObjectData, extractActorInfo } from "../timeline-store.js";
|
||||||
import { getCached, setCache } from "../lookup-cache.js";
|
import { getCached, setCache } from "../lookup-cache.js";
|
||||||
import { fetchAndStoreQuote, stripQuoteReferenceHtml } from "../og-unfurl.js";
|
import { fetchAndStoreQuote, stripQuoteReferenceHtml } from "../og-unfurl.js";
|
||||||
|
import { lookupWithSecurity } from "../lookup-helpers.js";
|
||||||
|
|
||||||
// Load parent posts (inReplyTo chain) up to maxDepth levels
|
// Load parent posts (inReplyTo chain) up to maxDepth levels
|
||||||
async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
|
async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
|
||||||
@@ -28,7 +29,7 @@ async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxD
|
|||||||
|
|
||||||
if (!object) {
|
if (!object) {
|
||||||
try {
|
try {
|
||||||
object = await ctx.lookupObject(new URL(currentUrl), {
|
object = await lookupWithSecurity(ctx,new URL(currentUrl), {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
if (object) {
|
if (object) {
|
||||||
@@ -180,7 +181,7 @@ export function postDetailController(mountPath, plugin) {
|
|||||||
object = cached;
|
object = cached;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
object = await ctx.lookupObject(new URL(objectUrl), {
|
object = await lookupWithSecurity(ctx,new URL(objectUrl), {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
if (object) {
|
if (object) {
|
||||||
@@ -326,7 +327,7 @@ export function postDetailController(mountPath, plugin) {
|
|||||||
);
|
);
|
||||||
const qLoader = await qCtx.getDocumentLoader({ identifier: handle });
|
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,
|
documentLoader: qLoader,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,7 +337,7 @@ export function postDetailController(mountPath, plugin) {
|
|||||||
// If author photo is empty, try fetching the actor directly
|
// If author photo is empty, try fetching the actor directly
|
||||||
if (!quoteData.author.photo && quoteData.author.url) {
|
if (!quoteData.author.photo && quoteData.author.url) {
|
||||||
try {
|
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) {
|
if (actor) {
|
||||||
const actorInfo = await extractActorInfo(actor, { documentLoader: qLoader });
|
const actorInfo = await extractActorInfo(actor, { documentLoader: qLoader });
|
||||||
if (actorInfo.photo) quoteData.author.photo = actorInfo.photo;
|
if (actorInfo.photo) quoteData.author.photo = actorInfo.photo;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { getToken, validateToken } from "../csrf.js";
|
import { getToken, validateToken } from "../csrf.js";
|
||||||
import { sanitizeContent } from "../timeline-store.js";
|
import { sanitizeContent } from "../timeline-store.js";
|
||||||
|
import { lookupWithSecurity } from "../lookup-helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /admin/reader/profile — Show remote actor profile.
|
* GET /admin/reader/profile — Show remote actor profile.
|
||||||
@@ -43,7 +44,7 @@ export function remoteProfileController(mountPath, plugin) {
|
|||||||
let actor;
|
let actor;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
actor = await ctx.lookupObject(new URL(actorUrl), { documentLoader });
|
actor = await lookupWithSecurity(ctx,new URL(actorUrl), { documentLoader });
|
||||||
} catch {
|
} catch {
|
||||||
return response.status(404).render("error", {
|
return response.status(404).render("error", {
|
||||||
title: "Error",
|
title: "Error",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Resolve controller — accepts any fediverse URL or handle, resolves it
|
* Resolve controller — accepts any fediverse URL or handle, resolves it
|
||||||
* via lookupObject(), and redirects to the appropriate internal view.
|
* via lookupObject(), and redirects to the appropriate internal view.
|
||||||
*/
|
*/
|
||||||
|
import { lookupWithSecurity } from "../lookup-helpers.js";
|
||||||
import {
|
import {
|
||||||
Article,
|
Article,
|
||||||
Note,
|
Note,
|
||||||
@@ -59,7 +60,7 @@ export function resolveController(mountPath, plugin) {
|
|||||||
let object;
|
let object;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
object = await ctx.lookupObject(lookupInput, { documentLoader });
|
object = await lookupWithSecurity(ctx,lookupInput, { documentLoader });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[resolve] lookupObject failed for "${query}":`,
|
`[resolve] lookupObject failed for "${query}":`,
|
||||||
|
|||||||
+92
-47
@@ -99,55 +99,100 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
followerActor.preferredUsername?.toString() ||
|
followerActor.preferredUsername?.toString() ||
|
||||||
followerUrl;
|
followerUrl;
|
||||||
|
|
||||||
await collections.ap_followers.updateOne(
|
// Build common follower data
|
||||||
{ actorUrl: followerUrl },
|
const followerData = {
|
||||||
{
|
|
||||||
$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",
|
|
||||||
actorUrl: followerUrl,
|
actorUrl: followerUrl,
|
||||||
actorName: followerName,
|
handle: followerActor.preferredUsername?.toString() || "",
|
||||||
summary: `${followerName} followed you`,
|
name: followerName,
|
||||||
});
|
avatar: followerActor.icon
|
||||||
|
? (await followerActor.icon)?.url?.href || ""
|
||||||
|
: "",
|
||||||
|
inbox: followerActor.inbox?.id?.href || "",
|
||||||
|
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
|
||||||
|
};
|
||||||
|
|
||||||
// Store notification
|
// Check if manual approval is enabled
|
||||||
const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
|
const profile = await collections.ap_profile.findOne({});
|
||||||
await addNotification(collections, {
|
const manualApproval = profile?.manuallyApprovesFollowers || false;
|
||||||
uid: follow.id?.href || `follow:${followerUrl}`,
|
|
||||||
type: "follow",
|
if (manualApproval && collections.ap_pending_follows) {
|
||||||
actorUrl: followerInfo.url,
|
// Store as pending — do NOT send Accept yet
|
||||||
actorName: followerInfo.name,
|
await collections.ap_pending_follows.updateOne(
|
||||||
actorPhoto: followerInfo.photo,
|
{ actorUrl: followerUrl },
|
||||||
actorHandle: followerInfo.handle,
|
{
|
||||||
published: follow.published ? String(follow.published) : new Date().toISOString(),
|
$set: {
|
||||||
createdAt: new Date().toISOString(),
|
...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) => {
|
.on(Undo, async (ctx, undo) => {
|
||||||
const actorUrl = undo.actorId?.href || "";
|
const actorUrl = undo.actorId?.href || "";
|
||||||
|
|||||||
+2
-2
@@ -536,7 +536,7 @@ function buildPlainTags(properties, publicationUrl, existing) {
|
|||||||
for (const cat of asArray(properties.category)) {
|
for (const cat of asArray(properties.category)) {
|
||||||
tags.push({
|
tags.push({
|
||||||
type: "Hashtag",
|
type: "Hashtag",
|
||||||
name: `#${cat.replace(/\s+/g, "")}`,
|
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
|
||||||
href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
|
href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -558,7 +558,7 @@ function buildFedifyTags(properties, publicationUrl, postType) {
|
|||||||
for (const cat of asArray(properties.category)) {
|
for (const cat of asArray(properties.category)) {
|
||||||
tags.push(
|
tags.push(
|
||||||
new Hashtag({
|
new Hashtag({
|
||||||
name: `#${cat.replace(/\s+/g, "")}`,
|
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
|
||||||
href: new URL(
|
href: new URL(
|
||||||
`${publicationUrl}categories/${encodeURIComponent(cat)}`,
|
`${publicationUrl}categories/${encodeURIComponent(cat)}`,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+3
-2
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { unfurl } from "unfurl.js";
|
import { unfurl } from "unfurl.js";
|
||||||
import { extractObjectData } from "./timeline-store.js";
|
import { extractObjectData } from "./timeline-store.js";
|
||||||
|
import { lookupWithSecurity } from "./lookup-helpers.js";
|
||||||
|
|
||||||
const USER_AGENT =
|
const USER_AGENT =
|
||||||
"Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)";
|
"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) {
|
export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, documentLoader) {
|
||||||
try {
|
try {
|
||||||
const object = await ctx.lookupObject(new URL(quoteUrl), { documentLoader });
|
const object = await lookupWithSecurity(ctx,new URL(quoteUrl), { documentLoader });
|
||||||
if (!object) return;
|
if (!object) return;
|
||||||
|
|
||||||
const quoteData = await extractObjectData(object, { documentLoader });
|
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 author photo is empty, try fetching the actor directly
|
||||||
if (!quoteData.author.photo && quoteData.author.url) {
|
if (!quoteData.author.photo && quoteData.author.url) {
|
||||||
try {
|
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) {
|
if (actor) {
|
||||||
const { extractActorInfo } = await import("./timeline-store.js");
|
const { extractActorInfo } = await import("./timeline-store.js");
|
||||||
const actorInfo = await extractActorInfo(actor, { documentLoader });
|
const actorInfo = await extractActorInfo(actor, { documentLoader });
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
* 3. Extract author URL from post URL pattern → lookupObject
|
* 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.
|
* 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)
|
// Strategy 1: Look up remote post via Fedify (signed request)
|
||||||
try {
|
try {
|
||||||
const remoteObject = await ctx.lookupObject(new URL(postUrl), {
|
const remoteObject = await lookupWithSecurity(ctx,new URL(postUrl), {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
||||||
@@ -112,7 +114,7 @@ export async function resolveAuthor(
|
|||||||
|
|
||||||
if (authorUrl) {
|
if (authorUrl) {
|
||||||
try {
|
try {
|
||||||
const actor = await ctx.lookupObject(new URL(authorUrl), {
|
const actor = await lookupWithSecurity(ctx,new URL(authorUrl), {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
if (actor) {
|
if (actor) {
|
||||||
@@ -134,7 +136,7 @@ export async function resolveAuthor(
|
|||||||
const extractedUrl = extractAuthorUrl(postUrl);
|
const extractedUrl = extractAuthorUrl(postUrl);
|
||||||
if (extractedUrl) {
|
if (extractedUrl) {
|
||||||
try {
|
try {
|
||||||
const actor = await ctx.lookupObject(new URL(extractedUrl), {
|
const actor = await lookupWithSecurity(ctx,new URL(extractedUrl), {
|
||||||
documentLoader,
|
documentLoader,
|
||||||
});
|
});
|
||||||
if (actor) {
|
if (actor) {
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ export async function getNotifications(collections, options = {}) {
|
|||||||
// Type filter
|
// Type filter
|
||||||
if (options.type) {
|
if (options.type) {
|
||||||
// "reply" tab shows both replies and mentions
|
// "reply" tab shows both replies and mentions
|
||||||
|
// "follow" tab shows both follows and follow_requests
|
||||||
if (options.type === "reply") {
|
if (options.type === "reply") {
|
||||||
query.type = { $in: ["reply", "mention"] };
|
query.type = { $in: ["reply", "mention"] };
|
||||||
|
} else if (options.type === "follow") {
|
||||||
|
query.type = { $in: ["follow", "follow_request"] };
|
||||||
} else {
|
} else {
|
||||||
query.type = options.type;
|
query.type = options.type;
|
||||||
}
|
}
|
||||||
@@ -131,6 +134,8 @@ export async function getNotificationCountsByType(collections, unreadOnly = fals
|
|||||||
counts.all += count;
|
counts.all += count;
|
||||||
if (_id === "reply" || _id === "mention") {
|
if (_id === "reply" || _id === "mention") {
|
||||||
counts.reply += count;
|
counts.reply += count;
|
||||||
|
} else if (_id === "follow_request") {
|
||||||
|
counts.follow += count;
|
||||||
} else if (counts[_id] !== undefined) {
|
} else if (counts[_id] !== undefined) {
|
||||||
counts[_id] = count;
|
counts[_id] = count;
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-1
@@ -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
|
* Extract actor information from Fedify Person/Application/Service object
|
||||||
* @param {object} actor - Fedify actor 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
|
// Bot detection — Service and Application actors are automated accounts
|
||||||
const bot = actor instanceof Service || actor instanceof Application;
|
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;
|
if (shares?.totalItems != null) counts.boosts = shares.totalItems;
|
||||||
} catch { /* ignore */ }
|
} 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
|
// Build base timeline item
|
||||||
const item = {
|
const item = {
|
||||||
uid,
|
uid,
|
||||||
|
|||||||
@@ -10,6 +10,13 @@
|
|||||||
"noActivity": "No activity yet. Once your actor is federated, interactions will appear here.",
|
"noActivity": "No activity yet. Once your actor is federated, interactions will appear here.",
|
||||||
"noFollowers": "No followers yet.",
|
"noFollowers": "No followers yet.",
|
||||||
"noFollowing": "Not following anyone 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": "%d follower",
|
||||||
"followerCount_plural": "%d followers",
|
"followerCount_plural": "%d followers",
|
||||||
"followingCount": "%d following",
|
"followingCount": "%d following",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"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.",
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
@@ -6,19 +6,67 @@
|
|||||||
{% from "pagination/macro.njk" import pagination with context %}
|
{% from "pagination/macro.njk" import pagination with context %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if followers.length > 0 %}
|
{# Tab navigation — only show if there are pending requests #}
|
||||||
{% for follower in followers %}
|
{% if pendingCount > 0 %}
|
||||||
{{ card({
|
{% set followersBase = mountPath + "/admin/followers" %}
|
||||||
title: follower.name or follower.handle or follower.actorUrl,
|
<nav class="ap-tabs">
|
||||||
url: follower.actorUrl,
|
<a href="{{ followersBase }}" class="ap-tab{% if tab == 'followers' %} ap-tab--active{% endif %}">
|
||||||
photo: { url: follower.avatar, alt: follower.name } if follower.avatar,
|
{{ __("activitypub.followers") }}
|
||||||
description: { text: "@" + follower.handle if follower.handle },
|
{% if followerCount %}<span class="ap-tab__count">{{ followerCount }}</span>{% endif %}
|
||||||
published: follower.followedAt
|
</a>
|
||||||
}) }}
|
<a href="{{ followersBase }}?tab=pending" class="ap-tab{% if tab == 'pending' %} ap-tab--active{% endif %}">
|
||||||
{% endfor %}
|
{{ __("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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{% endif %}
|
{% 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__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
|
||||||
<span class="ap-notification__type-badge">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
{{ __("activitypub.notifications.boostedPost") }}
|
{{ __("activitypub.notifications.boostedPost") }}
|
||||||
{% elif item.type == "follow" %}
|
{% elif item.type == "follow" %}
|
||||||
{{ __("activitypub.notifications.followedYou") }}
|
{{ __("activitypub.notifications.followedYou") }}
|
||||||
|
{% elif item.type == "follow_request" %}
|
||||||
|
{{ __("activitypub.followRequest") }}
|
||||||
{% elif item.type == "reply" %}
|
{% elif item.type == "reply" %}
|
||||||
{{ __("activitypub.notifications.repliedTo") }}
|
{{ __("activitypub.notifications.repliedTo") }}
|
||||||
{% elif item.type == "mention" %}
|
{% elif item.type == "mention" %}
|
||||||
|
|||||||
Reference in New Issue
Block a user