mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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);
|
||||
}
|
||||
|
||||
|
||||
30
index.js
30
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 "";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
253
lib/controllers/follow-requests.js
Normal file
253
lib/controllers/follow-requests.js
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Follow request controllers — approve and reject pending follow requests
|
||||
* when manual follow approval is enabled.
|
||||
*/
|
||||
|
||||
import { validateToken } from "../csrf.js";
|
||||
import { lookupWithSecurity } from "../lookup-helpers.js";
|
||||
import { logActivity } from "../activity-log.js";
|
||||
import { addNotification } from "../storage/notifications.js";
|
||||
import { extractActorInfo } from "../timeline-store.js";
|
||||
|
||||
/**
|
||||
* POST /admin/followers/approve — Accept a pending follow request.
|
||||
*/
|
||||
export function approveFollowController(mountPath, plugin) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
if (!validateToken(request)) {
|
||||
return response.status(403).json({
|
||||
success: false,
|
||||
error: "Invalid CSRF token",
|
||||
});
|
||||
}
|
||||
|
||||
const { actorUrl } = request.body;
|
||||
|
||||
if (!actorUrl) {
|
||||
return response.status(400).json({
|
||||
success: false,
|
||||
error: "Missing actor URL",
|
||||
});
|
||||
}
|
||||
|
||||
const { application } = request.app.locals;
|
||||
const pendingCol = application?.collections?.get("ap_pending_follows");
|
||||
const followersCol = application?.collections?.get("ap_followers");
|
||||
|
||||
if (!pendingCol || !followersCol) {
|
||||
return response.status(503).json({
|
||||
success: false,
|
||||
error: "Collections not available",
|
||||
});
|
||||
}
|
||||
|
||||
// Find the pending request
|
||||
const pending = await pendingCol.findOne({ actorUrl });
|
||||
if (!pending) {
|
||||
return response.status(404).json({
|
||||
success: false,
|
||||
error: "No pending follow request from this actor",
|
||||
});
|
||||
}
|
||||
|
||||
// Move to ap_followers
|
||||
await followersCol.updateOne(
|
||||
{ actorUrl },
|
||||
{
|
||||
$set: {
|
||||
actorUrl: pending.actorUrl,
|
||||
handle: pending.handle || "",
|
||||
name: pending.name || "",
|
||||
avatar: pending.avatar || "",
|
||||
inbox: pending.inbox || "",
|
||||
sharedInbox: pending.sharedInbox || "",
|
||||
followedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
// Remove from pending
|
||||
await pendingCol.deleteOne({ actorUrl });
|
||||
|
||||
// Send Accept(Follow) via federation
|
||||
if (plugin._federation) {
|
||||
try {
|
||||
const { Accept, Follow } = await import("@fedify/fedify/vocab");
|
||||
const handle = plugin.options.actor.handle;
|
||||
const ctx = plugin._federation.createContext(
|
||||
new URL(plugin._publicationUrl),
|
||||
{ handle, publicationUrl: plugin._publicationUrl },
|
||||
);
|
||||
|
||||
const documentLoader = await ctx.getDocumentLoader({
|
||||
identifier: handle,
|
||||
});
|
||||
|
||||
// Resolve the remote actor for delivery
|
||||
const remoteActor = await lookupWithSecurity(ctx, new URL(actorUrl), {
|
||||
documentLoader,
|
||||
});
|
||||
|
||||
if (remoteActor) {
|
||||
// Reconstruct the Follow using stored activity ID
|
||||
const followObj = new Follow({
|
||||
id: pending.followActivityId
|
||||
? new URL(pending.followActivityId)
|
||||
: undefined,
|
||||
actor: new URL(actorUrl),
|
||||
object: ctx.getActorUri(handle),
|
||||
});
|
||||
|
||||
await ctx.sendActivity(
|
||||
{ identifier: handle },
|
||||
remoteActor,
|
||||
new Accept({
|
||||
actor: ctx.getActorUri(handle),
|
||||
object: followObj,
|
||||
}),
|
||||
{ orderingKey: actorUrl },
|
||||
);
|
||||
}
|
||||
|
||||
const activitiesCol = application?.collections?.get("ap_activities");
|
||||
if (activitiesCol) {
|
||||
await logActivity(activitiesCol, {
|
||||
direction: "outbound",
|
||||
type: "Accept(Follow)",
|
||||
actorUrl: plugin._publicationUrl,
|
||||
objectUrl: actorUrl,
|
||||
actorName: pending.name || actorUrl,
|
||||
summary: `Approved follow request from ${pending.name || actorUrl}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ActivityPub] Could not send Accept to ${actorUrl}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[ActivityPub] Approved follow request from ${pending.name || actorUrl}`,
|
||||
);
|
||||
|
||||
// Redirect back to followers page
|
||||
return response.redirect(`${mountPath}/admin/followers`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/followers/reject — Reject a pending follow request.
|
||||
*/
|
||||
export function rejectFollowController(mountPath, plugin) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
if (!validateToken(request)) {
|
||||
return response.status(403).json({
|
||||
success: false,
|
||||
error: "Invalid CSRF token",
|
||||
});
|
||||
}
|
||||
|
||||
const { actorUrl } = request.body;
|
||||
|
||||
if (!actorUrl) {
|
||||
return response.status(400).json({
|
||||
success: false,
|
||||
error: "Missing actor URL",
|
||||
});
|
||||
}
|
||||
|
||||
const { application } = request.app.locals;
|
||||
const pendingCol = application?.collections?.get("ap_pending_follows");
|
||||
|
||||
if (!pendingCol) {
|
||||
return response.status(503).json({
|
||||
success: false,
|
||||
error: "Collections not available",
|
||||
});
|
||||
}
|
||||
|
||||
// Find the pending request
|
||||
const pending = await pendingCol.findOne({ actorUrl });
|
||||
if (!pending) {
|
||||
return response.status(404).json({
|
||||
success: false,
|
||||
error: "No pending follow request from this actor",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from pending
|
||||
await pendingCol.deleteOne({ actorUrl });
|
||||
|
||||
// Send Reject(Follow) via federation
|
||||
if (plugin._federation) {
|
||||
try {
|
||||
const { Reject, Follow } = await import("@fedify/fedify/vocab");
|
||||
const handle = plugin.options.actor.handle;
|
||||
const ctx = plugin._federation.createContext(
|
||||
new URL(plugin._publicationUrl),
|
||||
{ handle, publicationUrl: plugin._publicationUrl },
|
||||
);
|
||||
|
||||
const documentLoader = await ctx.getDocumentLoader({
|
||||
identifier: handle,
|
||||
});
|
||||
|
||||
const remoteActor = await lookupWithSecurity(ctx, new URL(actorUrl), {
|
||||
documentLoader,
|
||||
});
|
||||
|
||||
if (remoteActor) {
|
||||
const followObj = new Follow({
|
||||
id: pending.followActivityId
|
||||
? new URL(pending.followActivityId)
|
||||
: undefined,
|
||||
actor: new URL(actorUrl),
|
||||
object: ctx.getActorUri(handle),
|
||||
});
|
||||
|
||||
await ctx.sendActivity(
|
||||
{ identifier: handle },
|
||||
remoteActor,
|
||||
new Reject({
|
||||
actor: ctx.getActorUri(handle),
|
||||
object: followObj,
|
||||
}),
|
||||
{ orderingKey: actorUrl },
|
||||
);
|
||||
}
|
||||
|
||||
const activitiesCol = application?.collections?.get("ap_activities");
|
||||
if (activitiesCol) {
|
||||
await logActivity(activitiesCol, {
|
||||
direction: "outbound",
|
||||
type: "Reject(Follow)",
|
||||
actorUrl: plugin._publicationUrl,
|
||||
objectUrl: actorUrl,
|
||||
actorName: pending.name || actorUrl,
|
||||
summary: `Rejected follow request from ${pending.name || actorUrl}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ActivityPub] Could not send Reject to ${actorUrl}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[ActivityPub] Rejected follow request from ${pending.name || actorUrl}`,
|
||||
);
|
||||
|
||||
return response.redirect(`${mountPath}/admin/followers`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}":`,
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -536,7 +536,7 @@ function buildPlainTags(properties, publicationUrl, existing) {
|
||||
for (const cat of asArray(properties.category)) {
|
||||
tags.push({
|
||||
type: "Hashtag",
|
||||
name: `#${cat.replace(/\s+/g, "")}`,
|
||||
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
|
||||
href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
|
||||
});
|
||||
}
|
||||
@@ -558,7 +558,7 @@ function buildFedifyTags(properties, publicationUrl, postType) {
|
||||
for (const cat of asArray(properties.category)) {
|
||||
tags.push(
|
||||
new Hashtag({
|
||||
name: `#${cat.replace(/\s+/g, "")}`,
|
||||
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
|
||||
href: new URL(
|
||||
`${publicationUrl}categories/${encodeURIComponent(cat)}`,
|
||||
),
|
||||
|
||||
27
lib/lookup-helpers.js
Normal file
27
lib/lookup-helpers.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Centralized wrapper for ctx.lookupObject() with FEP-fe34 origin-based
|
||||
* security. All lookupObject calls MUST go through this helper so the
|
||||
* crossOrigin policy is applied consistently.
|
||||
*
|
||||
* @module lookup-helpers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Look up a remote ActivityPub object with cross-origin security.
|
||||
*
|
||||
* FEP-fe34 prevents spoofed attribution attacks by verifying that a
|
||||
* fetched object's `id` matches the origin of the URL used to fetch it.
|
||||
* Using `crossOrigin: "ignore"` tells Fedify to silently discard objects
|
||||
* whose id doesn't match the fetch origin, rather than throwing.
|
||||
*
|
||||
* @param {object} ctx - Fedify Context
|
||||
* @param {string|URL} input - URL or handle to look up
|
||||
* @param {object} [options] - Additional options passed to lookupObject
|
||||
* @returns {Promise<object|null>} Resolved object or null
|
||||
*/
|
||||
export function lookupWithSecurity(ctx, input, options = {}) {
|
||||
return ctx.lookupObject(input, {
|
||||
crossOrigin: "ignore",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,29 @@ export function sanitizeContent(html) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace custom emoji :shortcode: placeholders with inline <img> tags.
|
||||
* Applied AFTER sanitization — the <img> tags are controlled output from
|
||||
* trusted emoji data, not user-supplied HTML.
|
||||
*
|
||||
* @param {string} html - Content HTML (already sanitized)
|
||||
* @param {Array<{shortcode: string, url: string}>} emojis - Custom emoji data
|
||||
* @returns {string} HTML with shortcodes replaced by <img> tags
|
||||
*/
|
||||
export function replaceCustomEmoji(html, emojis) {
|
||||
if (!emojis?.length || !html) return html;
|
||||
let result = html;
|
||||
for (const { shortcode, url } of emojis) {
|
||||
const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`:${escaped}:`, "g");
|
||||
result = result.replace(
|
||||
pattern,
|
||||
`<img class="ap-custom-emoji" src="${url}" alt=":${shortcode}:" title=":${shortcode}:" draggable="false">`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract actor information from Fedify Person/Application/Service object
|
||||
* @param {object} actor - Fedify actor object
|
||||
@@ -104,7 +127,10 @@ export async function extractActorInfo(actor, options = {}) {
|
||||
// Bot detection — Service and Application actors are automated accounts
|
||||
const bot = actor instanceof Service || actor instanceof Application;
|
||||
|
||||
return { name, url, photo, handle, emojis, bot };
|
||||
// Replace custom emoji shortcodes in display name with <img> tags
|
||||
const nameHtml = replaceCustomEmoji(name, emojis);
|
||||
|
||||
return { name, nameHtml, url, photo, handle, emojis, bot };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,6 +362,10 @@ export async function extractObjectData(object, options = {}) {
|
||||
if (shares?.totalItems != null) counts.boosts = shares.totalItems;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Replace custom emoji :shortcode: in content with inline <img> tags.
|
||||
// Applied after sanitization — these are trusted emoji from the post's tags.
|
||||
content.html = replaceCustomEmoji(content.html, emojis);
|
||||
|
||||
// Build base timeline item
|
||||
const item = {
|
||||
uid,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,19 +6,67 @@
|
||||
{% from "pagination/macro.njk" import pagination with context %}
|
||||
|
||||
{% block content %}
|
||||
{% if followers.length > 0 %}
|
||||
{% for follower in followers %}
|
||||
{{ card({
|
||||
title: follower.name or follower.handle or follower.actorUrl,
|
||||
url: follower.actorUrl,
|
||||
photo: { url: follower.avatar, alt: follower.name } if follower.avatar,
|
||||
description: { text: "@" + follower.handle if follower.handle },
|
||||
published: follower.followedAt
|
||||
}) }}
|
||||
{% endfor %}
|
||||
{# Tab navigation — only show if there are pending requests #}
|
||||
{% if pendingCount > 0 %}
|
||||
{% set followersBase = mountPath + "/admin/followers" %}
|
||||
<nav class="ap-tabs">
|
||||
<a href="{{ followersBase }}" class="ap-tab{% if tab == 'followers' %} ap-tab--active{% endif %}">
|
||||
{{ __("activitypub.followers") }}
|
||||
{% if followerCount %}<span class="ap-tab__count">{{ followerCount }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ followersBase }}?tab=pending" class="ap-tab{% if tab == 'pending' %} ap-tab--active{% endif %}">
|
||||
{{ __("activitypub.pendingFollows") }}
|
||||
<span class="ap-tab__count">{{ pendingCount }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{{ pagination(cursor) if cursor }}
|
||||
{% if tab == "pending" %}
|
||||
{# Pending follow requests #}
|
||||
{% if pendingFollows.length > 0 %}
|
||||
{% for pending in pendingFollows %}
|
||||
<div class="ap-follow-request">
|
||||
{{ card({
|
||||
title: pending.name or pending.handle or pending.actorUrl,
|
||||
url: pending.actorUrl,
|
||||
photo: { url: pending.avatar, alt: pending.name } if pending.avatar,
|
||||
description: { text: "@" + pending.handle if pending.handle }
|
||||
}) }}
|
||||
<div class="ap-follow-request__actions">
|
||||
<form method="post" action="{{ mountPath }}/admin/followers/approve" class="ap-follow-request__form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="actorUrl" value="{{ pending.actorUrl }}">
|
||||
<button type="submit" class="button">{{ __("activitypub.approve") }}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ mountPath }}/admin/followers/reject" class="ap-follow-request__form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="actorUrl" value="{{ pending.actorUrl }}">
|
||||
<button type="submit" class="button button--danger">{{ __("activitypub.reject") }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{{ pagination(cursor) if cursor }}
|
||||
{% else %}
|
||||
{{ prose({ text: __("activitypub.noPendingFollows") }) }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ prose({ text: __("activitypub.noFollowers") }) }}
|
||||
{# Accepted followers #}
|
||||
{% if followers.length > 0 %}
|
||||
{% for follower in followers %}
|
||||
{{ card({
|
||||
title: follower.name or follower.handle or follower.actorUrl,
|
||||
url: follower.actorUrl,
|
||||
photo: { url: follower.avatar, alt: follower.name } if follower.avatar,
|
||||
description: { text: "@" + follower.handle if follower.handle },
|
||||
published: follower.followedAt
|
||||
}) }}
|
||||
{% endfor %}
|
||||
|
||||
{{ pagination(cursor) if cursor }}
|
||||
{% else %}
|
||||
{{ prose({ text: __("activitypub.noFollowers") }) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{% endif %}
|
||||
<span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
|
||||
<span class="ap-notification__type-badge">
|
||||
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
|
||||
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" or item.type == "follow_request" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
{{ __("activitypub.notifications.boostedPost") }}
|
||||
{% elif item.type == "follow" %}
|
||||
{{ __("activitypub.notifications.followedYou") }}
|
||||
{% elif item.type == "follow_request" %}
|
||||
{{ __("activitypub.followRequest") }}
|
||||
{% elif item.type == "reply" %}
|
||||
{{ __("activitypub.notifications.repliedTo") }}
|
||||
{% elif item.type == "mention" %}
|
||||
|
||||
Reference in New Issue
Block a user