feat: federation management page with collection stats, post actions, object lookup (v2.12.0)
Confab-Link: http://localhost:8080/sessions/c2335791-4b8c-44a6-b1b7-8d0fa8d7f647
This commit is contained in:
@@ -3190,3 +3190,221 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Federation Management
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-federation__section {
|
||||||
|
margin-block-end: var(--space-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__section h2 {
|
||||||
|
margin-block-end: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
||||||
|
gap: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-s);
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__stat-count {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-on-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__stat-label {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__actions-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-s);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__result {
|
||||||
|
margin-block-start: var(--space-xs);
|
||||||
|
color: var(--color-green50);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__error {
|
||||||
|
margin-block-start: var(--space-xs);
|
||||||
|
color: var(--color-red45);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__lookup-form {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__lookup-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: var(--border-width-thin) solid var(--color-outline);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
font: inherit;
|
||||||
|
color: var(--color-on-background);
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__json-view {
|
||||||
|
margin-block-start: var(--space-s);
|
||||||
|
padding: var(--space-m);
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
color: var(--color-on-background);
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__posts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__post-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
padding: var(--space-s);
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__post-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__post-title {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__post-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__post-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__post-btn {
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
border: var(--border-width-thin) solid var(--color-outline);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-on-background);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__post-btn:hover {
|
||||||
|
background: var(--color-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__post-btn--danger {
|
||||||
|
color: var(--color-red45);
|
||||||
|
border-color: var(--color-red45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__post-btn--danger:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-red45) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: hsl(var(--tint-neutral) 10% / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__modal {
|
||||||
|
width: min(90vw, 48rem);
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
box-shadow: 0 4px 24px hsl(var(--tint-neutral) 10% / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-s) var(--space-m);
|
||||||
|
border-block-end: var(--border-width-thin) solid var(--color-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__modal-close {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 var(--space-xs);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__modal .ap-federation__json-view {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.ap-federation__post-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-federation__lookup-form {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ import { logActivity } from "./lib/activity-log.js";
|
|||||||
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
|
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
|
||||||
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
|
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
|
||||||
import { deleteFederationController } from "./lib/controllers/federation-delete.js";
|
import { deleteFederationController } from "./lib/controllers/federation-delete.js";
|
||||||
|
import {
|
||||||
|
federationMgmtController,
|
||||||
|
rebroadcastController,
|
||||||
|
viewApJsonController,
|
||||||
|
broadcastActorUpdateController,
|
||||||
|
lookupObjectController,
|
||||||
|
} from "./lib/controllers/federation-mgmt.js";
|
||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
mountPath: "/activitypub",
|
mountPath: "/activitypub",
|
||||||
@@ -169,6 +176,11 @@ export default class ActivityPubEndpoint {
|
|||||||
text: "activitypub.myProfile.title",
|
text: "activitypub.myProfile.title",
|
||||||
requiresDatabase: true,
|
requiresDatabase: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: `${this.options.mountPath}/admin/federation`,
|
||||||
|
text: "activitypub.federationMgmt.title",
|
||||||
|
requiresDatabase: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +325,11 @@ export default class ActivityPubEndpoint {
|
|||||||
router.post("/admin/refollow/resume", refollowResumeController(mp, this));
|
router.post("/admin/refollow/resume", refollowResumeController(mp, this));
|
||||||
router.get("/admin/refollow/status", refollowStatusController(mp));
|
router.get("/admin/refollow/status", refollowStatusController(mp));
|
||||||
router.post("/admin/federation/delete", deleteFederationController(mp, this));
|
router.post("/admin/federation/delete", deleteFederationController(mp, this));
|
||||||
|
router.get("/admin/federation", federationMgmtController(mp, this));
|
||||||
|
router.post("/admin/federation/rebroadcast", rebroadcastController(mp, this));
|
||||||
|
router.get("/admin/federation/ap-json", viewApJsonController(mp, this));
|
||||||
|
router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, this));
|
||||||
|
router.get("/admin/federation/lookup", lookupObjectController(mp, this));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
@@ -326,6 +343,38 @@ export default class ActivityPubEndpoint {
|
|||||||
const router = express.Router(); // eslint-disable-line new-cap
|
const router = express.Router(); // eslint-disable-line new-cap
|
||||||
const self = this;
|
const self = this;
|
||||||
|
|
||||||
|
// Intercept Micropub delete actions to broadcast Delete to fediverse.
|
||||||
|
// Wraps res.json to detect successful delete responses, then fires
|
||||||
|
// broadcastDelete asynchronously so remote servers remove the post.
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (req.method !== "POST") return next();
|
||||||
|
if (!req.path.endsWith("/micropub")) return next();
|
||||||
|
|
||||||
|
const action = req.query?.action || req.body?.action;
|
||||||
|
if (action !== "delete") return next();
|
||||||
|
|
||||||
|
const postUrl = req.query?.url || req.body?.url;
|
||||||
|
if (!postUrl) return next();
|
||||||
|
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
res.json = function (body) {
|
||||||
|
// Fire broadcastDelete after successful delete (status 200)
|
||||||
|
if (res.statusCode === 200 && body?.success === "delete") {
|
||||||
|
console.info(
|
||||||
|
`[ActivityPub] Micropub delete detected for ${postUrl}, broadcasting Delete to followers`,
|
||||||
|
);
|
||||||
|
self.broadcastDelete(postUrl).catch((error) => {
|
||||||
|
console.warn(
|
||||||
|
`[ActivityPub] broadcastDelete after Micropub delete failed: ${error.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return originalJson(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
// Let Fedify handle NodeInfo data (/nodeinfo/2.1)
|
// Let Fedify handle NodeInfo data (/nodeinfo/2.1)
|
||||||
// Only pass GET/HEAD requests — POST/PUT/DELETE must not go through
|
// Only pass GET/HEAD requests — POST/PUT/DELETE must not go through
|
||||||
// Fedify here, because fromExpressRequest() consumes the body stream,
|
// Fedify here, because fromExpressRequest() consumes the body stream,
|
||||||
@@ -483,6 +532,7 @@ export default class ActivityPubEndpoint {
|
|||||||
resolvedMentions.push({
|
resolvedMentions.push({
|
||||||
handle,
|
handle,
|
||||||
actorUrl: mentionedActor.id.href,
|
actorUrl: mentionedActor.id.href,
|
||||||
|
profileUrl: mentionedActor.url?.href || null,
|
||||||
});
|
});
|
||||||
mentionRecipients.push({
|
mentionRecipients.push({
|
||||||
handle,
|
handle,
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* Federation Management controllers — admin page for inspecting and managing
|
||||||
|
* the relationship between local content and the fediverse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getToken, validateToken } from "../csrf.js";
|
||||||
|
import { jf2ToActivityStreams } from "../jf2-to-as2.js";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const AP_COLLECTIONS = [
|
||||||
|
"ap_followers",
|
||||||
|
"ap_following",
|
||||||
|
"ap_activities",
|
||||||
|
"ap_keys",
|
||||||
|
"ap_kv",
|
||||||
|
"ap_profile",
|
||||||
|
"ap_featured",
|
||||||
|
"ap_featured_tags",
|
||||||
|
"ap_timeline",
|
||||||
|
"ap_notifications",
|
||||||
|
"ap_muted",
|
||||||
|
"ap_blocked",
|
||||||
|
"ap_interactions",
|
||||||
|
"ap_followed_tags",
|
||||||
|
"ap_messages",
|
||||||
|
"ap_explore_tabs",
|
||||||
|
"ap_reports",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/federation — main federation management page.
|
||||||
|
*/
|
||||||
|
export function federationMgmtController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const collections = application?.collections;
|
||||||
|
|
||||||
|
// Parallel: collection stats + posts + recent activities
|
||||||
|
const [collectionStats, postsResult, recentActivities] =
|
||||||
|
await Promise.all([
|
||||||
|
getCollectionStats(collections),
|
||||||
|
getPaginatedPosts(collections, request.query.page),
|
||||||
|
getRecentActivities(collections),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csrfToken = getToken(request.session);
|
||||||
|
const actorUrl = plugin._getActorUrl?.() || "";
|
||||||
|
|
||||||
|
response.render("activitypub-federation-mgmt", {
|
||||||
|
title: response.locals.__("activitypub.federationMgmt.title"),
|
||||||
|
parent: {
|
||||||
|
href: mountPath,
|
||||||
|
text: response.locals.__("activitypub.title"),
|
||||||
|
},
|
||||||
|
collectionStats,
|
||||||
|
posts: postsResult.posts,
|
||||||
|
cursor: postsResult.cursor,
|
||||||
|
recentActivities,
|
||||||
|
csrfToken,
|
||||||
|
mountPath,
|
||||||
|
publicationUrl: plugin._publicationUrl,
|
||||||
|
actorUrl,
|
||||||
|
debugDashboardEnabled: plugin.options.debugDashboard,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/federation/rebroadcast — re-send a Create activity for a post.
|
||||||
|
*/
|
||||||
|
export function rebroadcastController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response
|
||||||
|
.status(403)
|
||||||
|
.json({ success: false, error: "Invalid CSRF token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = request.body;
|
||||||
|
if (!url) {
|
||||||
|
return response
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: "Missing post URL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin._federation) {
|
||||||
|
return response
|
||||||
|
.status(503)
|
||||||
|
.json({ success: false, error: "Federation not initialized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const postsCol = application?.collections?.get("posts");
|
||||||
|
if (!postsCol) {
|
||||||
|
return response
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, error: "Posts collection not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await postsCol.findOne({ "properties.url": url });
|
||||||
|
if (!post) {
|
||||||
|
return response
|
||||||
|
.status(404)
|
||||||
|
.json({ success: false, error: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the full syndication pipeline (mention resolution, visibility,
|
||||||
|
// addressing, delivery) via the syndicator
|
||||||
|
await plugin.syndicator.syndicate(post.properties);
|
||||||
|
|
||||||
|
return response.json({ success: true, url });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/federation/ap-json — view ActivityStreams JSON for a post.
|
||||||
|
*/
|
||||||
|
export function viewApJsonController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const { url } = request.query;
|
||||||
|
if (!url) {
|
||||||
|
return response
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing url query parameter" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const postsCol = application?.collections?.get("posts");
|
||||||
|
if (!postsCol) {
|
||||||
|
return response
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Posts collection not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await postsCol.findOne({ "properties.url": url });
|
||||||
|
if (!post) {
|
||||||
|
return response.status(404).json({ error: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorUrl = plugin._getActorUrl?.() || "";
|
||||||
|
const as2 = jf2ToActivityStreams(
|
||||||
|
post.properties,
|
||||||
|
actorUrl,
|
||||||
|
plugin._publicationUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.json(as2);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/federation/broadcast-actor — broadcast an Update(Person)
|
||||||
|
* activity to all followers via Fedify.
|
||||||
|
*/
|
||||||
|
export function broadcastActorUpdateController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response
|
||||||
|
.status(403)
|
||||||
|
.json({ success: false, error: "Invalid CSRF token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin._federation) {
|
||||||
|
return response
|
||||||
|
.status(503)
|
||||||
|
.json({ success: false, error: "Federation not initialized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await plugin.broadcastActorUpdate();
|
||||||
|
|
||||||
|
return response.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/federation/lookup — resolve a URL or @user@domain handle
|
||||||
|
* via Fedify's lookupObject (authenticated document loader).
|
||||||
|
*/
|
||||||
|
export function lookupObjectController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const query = (request.query.q || "").trim();
|
||||||
|
if (!query) {
|
||||||
|
return response
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing q query parameter" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin._federation) {
|
||||||
|
return response
|
||||||
|
.status(503)
|
||||||
|
.json({ error: "Federation not initialized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 object = await ctx.lookupObject(query, { documentLoader });
|
||||||
|
|
||||||
|
if (!object) {
|
||||||
|
return response
|
||||||
|
.status(404)
|
||||||
|
.json({ error: "Could not resolve object" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonLd = await object.toJsonLd();
|
||||||
|
return response.json(jsonLd);
|
||||||
|
} catch (error) {
|
||||||
|
return response
|
||||||
|
.status(500)
|
||||||
|
.json({ error: error.message || "Lookup failed" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
async function getCollectionStats(collections) {
|
||||||
|
if (!collections) return [];
|
||||||
|
|
||||||
|
const stats = await Promise.all(
|
||||||
|
AP_COLLECTIONS.map(async (name) => {
|
||||||
|
const col = collections.get(name);
|
||||||
|
const count = col ? await col.countDocuments() : 0;
|
||||||
|
return { name, count };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPaginatedPosts(collections, pageParam) {
|
||||||
|
const postsCol = collections?.get("posts");
|
||||||
|
if (!postsCol) return { posts: [], cursor: null };
|
||||||
|
|
||||||
|
const page = Math.max(1, Number.parseInt(pageParam, 10) || 1);
|
||||||
|
const totalCount = await postsCol.countDocuments();
|
||||||
|
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||||
|
|
||||||
|
const rawPosts = await postsCol
|
||||||
|
.find()
|
||||||
|
.sort({ "properties.published": -1 })
|
||||||
|
.skip((page - 1) * PAGE_SIZE)
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const posts = rawPosts.map((post) => {
|
||||||
|
const props = post.properties || {};
|
||||||
|
const url = props.url || "";
|
||||||
|
const content = props.content?.text || props.content?.html || "";
|
||||||
|
const name =
|
||||||
|
props.name || (content ? content.slice(0, 80) : url.split("/").pop());
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
postType: props["post-type"] || "unknown",
|
||||||
|
published: props.published || null,
|
||||||
|
syndication: props.syndication || [],
|
||||||
|
deleted: props.deleted || false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const cursor = buildCursor(page, totalPages, "admin/federation");
|
||||||
|
|
||||||
|
return { posts, cursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRecentActivities(collections) {
|
||||||
|
const col = collections?.get("ap_activities");
|
||||||
|
if (!col) return [];
|
||||||
|
|
||||||
|
return col.find().sort({ receivedAt: -1 }).limit(5).toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCursor(page, totalPages, basePath) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
previous:
|
||||||
|
page > 1 ? { href: `${basePath}?page=${page - 1}` } : undefined,
|
||||||
|
next:
|
||||||
|
page < totalPages
|
||||||
|
? { href: `${basePath}?page=${page + 1}` }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
+4
-4
@@ -60,18 +60,18 @@ export function parseMentions(text) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace @user@domain patterns in HTML with linked mentions.
|
* Replace @user@domain patterns in HTML with linked mentions.
|
||||||
* resolvedMentions: [{ handle, actorUrl }]
|
* resolvedMentions: [{ handle, actorUrl, profileUrl? }]
|
||||||
* Unresolved handles get a WebFinger-style link as fallback.
|
* Uses profileUrl (human-readable) for href, falls back to Mastodon-style URL.
|
||||||
*/
|
*/
|
||||||
function linkifyMentions(html, resolvedMentions) {
|
function linkifyMentions(html, resolvedMentions) {
|
||||||
if (!html || !resolvedMentions?.length) return html;
|
if (!html || !resolvedMentions?.length) return html;
|
||||||
for (const { handle, actorUrl } of resolvedMentions) {
|
for (const { handle, profileUrl } of resolvedMentions) {
|
||||||
// Escape handle for regex (dots, hyphens)
|
// Escape handle for regex (dots, hyphens)
|
||||||
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
// Match @handle not already inside an HTML tag attribute or anchor text
|
// Match @handle not already inside an HTML tag attribute or anchor text
|
||||||
const pattern = new RegExp(`(?<!["\\/\\w])@${escaped}(?![\\w])`, "gi");
|
const pattern = new RegExp(`(?<!["\\/\\w])@${escaped}(?![\\w])`, "gi");
|
||||||
const parts = handle.split("@");
|
const parts = handle.split("@");
|
||||||
const url = actorUrl || `https://${parts[1]}/@${parts[0]}`;
|
const url = profileUrl || `https://${parts[1]}/@${parts[0]}`;
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
pattern,
|
pattern,
|
||||||
`<a href="${url}" class="mention" rel="nofollow noopener" target="_blank">@${handle}</a>`,
|
`<a href="${url}" class="mention" rel="nofollow noopener" target="_blank">@${handle}</a>`,
|
||||||
|
|||||||
@@ -322,6 +322,27 @@
|
|||||||
"deleteSuccess": "Delete activity sent to followers",
|
"deleteSuccess": "Delete activity sent to followers",
|
||||||
"deleteButton": "Delete from fediverse"
|
"deleteButton": "Delete from fediverse"
|
||||||
},
|
},
|
||||||
|
"federationMgmt": {
|
||||||
|
"title": "Federation",
|
||||||
|
"collections": "Collection health",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"broadcastActor": "Broadcast actor update",
|
||||||
|
"debugDashboard": "Debug dashboard",
|
||||||
|
"objectLookup": "Object lookup",
|
||||||
|
"lookupPlaceholder": "URL or @user@domain handle…",
|
||||||
|
"lookup": "Look up",
|
||||||
|
"lookupLoading": "Resolving…",
|
||||||
|
"postActions": "Post federation",
|
||||||
|
"viewJson": "JSON",
|
||||||
|
"rebroadcast": "Re-broadcast Create activity",
|
||||||
|
"rebroadcastShort": "Re-send",
|
||||||
|
"broadcastDelete": "Broadcast Delete activity",
|
||||||
|
"deleteShort": "Delete",
|
||||||
|
"noPosts": "No posts found.",
|
||||||
|
"apJsonTitle": "ActivityStreams JSON-LD",
|
||||||
|
"recentActivity": "Recent activity",
|
||||||
|
"viewAllActivities": "View all activities →"
|
||||||
|
},
|
||||||
"reports": {
|
"reports": {
|
||||||
"sentReport": "filed a report",
|
"sentReport": "filed a report",
|
||||||
"title": "Reports"
|
"title": "Reports"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||||
"version": "2.11.0",
|
"version": "2.12.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",
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
{% extends "document.njk" %}
|
||||||
|
|
||||||
|
{% from "heading/macro.njk" import heading with context %}
|
||||||
|
{% from "card/macro.njk" import card with context %}
|
||||||
|
{% from "badge/macro.njk" import badge with context %}
|
||||||
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
{% from "pagination/macro.njk" import pagination with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<link rel="stylesheet" href="{{ mountPath }}/assets/reader.css">
|
||||||
|
|
||||||
|
<div x-data="federationMgmt()" data-mount-path="{{ mountPath }}" data-csrf-token="{{ csrfToken }}">
|
||||||
|
|
||||||
|
{# --- Collection Health --- #}
|
||||||
|
<section class="ap-federation__section">
|
||||||
|
<h2>{{ __("activitypub.federationMgmt.collections") }}</h2>
|
||||||
|
<div class="ap-federation__stats-grid">
|
||||||
|
{% for stat in collectionStats %}
|
||||||
|
<div class="ap-federation__stat-card">
|
||||||
|
<span class="ap-federation__stat-count">{{ stat.count }}</span>
|
||||||
|
<span class="ap-federation__stat-label">{{ stat.name | replace("ap_", "") }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# --- Quick Actions --- #}
|
||||||
|
<section class="ap-federation__section">
|
||||||
|
<h2>{{ __("activitypub.federationMgmt.quickActions") }}</h2>
|
||||||
|
<div class="ap-federation__actions-row">
|
||||||
|
<button class="button" @click="broadcastActorUpdate()" :disabled="actionInProgress">
|
||||||
|
{{ __("activitypub.federationMgmt.broadcastActor") }}
|
||||||
|
</button>
|
||||||
|
{% if debugDashboardEnabled %}
|
||||||
|
<a href="{{ mountPath }}/__debug__/" class="button" target="_blank" rel="noopener">
|
||||||
|
{{ __("activitypub.federationMgmt.debugDashboard") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p x-show="actionResult" x-text="actionResult" class="ap-federation__result" x-cloak></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# --- Object Lookup --- #}
|
||||||
|
<section class="ap-federation__section">
|
||||||
|
<h2>{{ __("activitypub.federationMgmt.objectLookup") }}</h2>
|
||||||
|
<form class="ap-federation__lookup-form" @submit.prevent="lookupObject()">
|
||||||
|
<input type="text" x-model="lookupQuery"
|
||||||
|
placeholder="{{ __('activitypub.federationMgmt.lookupPlaceholder') }}"
|
||||||
|
class="ap-federation__lookup-input">
|
||||||
|
<button type="submit" class="button" :disabled="lookupLoading">
|
||||||
|
<span x-show="!lookupLoading">{{ __("activitypub.federationMgmt.lookup") }}</span>
|
||||||
|
<span x-show="lookupLoading" x-cloak>{{ __("activitypub.federationMgmt.lookupLoading") }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p x-show="lookupError" x-text="lookupError" class="ap-federation__error" x-cloak></p>
|
||||||
|
<pre x-show="lookupResult" x-text="lookupResult" class="ap-federation__json-view" x-cloak></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# --- Post Federation --- #}
|
||||||
|
<section class="ap-federation__section">
|
||||||
|
<h2>{{ __("activitypub.federationMgmt.postActions") }}</h2>
|
||||||
|
{% if posts.length > 0 %}
|
||||||
|
<div class="ap-federation__posts-list">
|
||||||
|
{% for post in posts %}
|
||||||
|
<div class="ap-federation__post-row">
|
||||||
|
<div class="ap-federation__post-info">
|
||||||
|
<a href="{{ post.url }}" class="ap-federation__post-title">{{ post.name }}</a>
|
||||||
|
<span class="ap-federation__post-meta">
|
||||||
|
{{ badge({ text: post.postType }) }}
|
||||||
|
{% if post.published %}
|
||||||
|
<time>{{ post.published | date("PP") }}</time>
|
||||||
|
{% endif %}
|
||||||
|
{% if post.deleted %}
|
||||||
|
{{ badge({ text: "deleted", color: "red" }) }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ap-federation__post-actions">
|
||||||
|
<button class="ap-federation__post-btn"
|
||||||
|
@click="viewApJson('{{ post.url }}')">
|
||||||
|
{{ __("activitypub.federationMgmt.viewJson") }}
|
||||||
|
</button>
|
||||||
|
<button class="ap-federation__post-btn"
|
||||||
|
@click="rebroadcast('{{ post.url }}')">
|
||||||
|
{{ __("activitypub.federationMgmt.rebroadcastShort") }}
|
||||||
|
</button>
|
||||||
|
<button class="ap-federation__post-btn ap-federation__post-btn--danger"
|
||||||
|
@click="broadcastDelete('{{ post.url }}')">
|
||||||
|
{{ __("activitypub.federationMgmt.deleteShort") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ pagination(cursor) if cursor }}
|
||||||
|
{% else %}
|
||||||
|
{{ prose({ text: __("activitypub.federationMgmt.noPosts") }) }}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# --- Recent Activity --- #}
|
||||||
|
<section class="ap-federation__section">
|
||||||
|
<h2>{{ __("activitypub.federationMgmt.recentActivity") }}</h2>
|
||||||
|
{% if recentActivities.length > 0 %}
|
||||||
|
{% for activity in recentActivities %}
|
||||||
|
{{ card({
|
||||||
|
title: activity.actorName or activity.actorUrl,
|
||||||
|
description: { text: activity.summary },
|
||||||
|
published: activity.receivedAt,
|
||||||
|
badges: [
|
||||||
|
{ text: activity.type },
|
||||||
|
{ text: __("activitypub.directionInbound") if activity.direction === "inbound" else __("activitypub.directionOutbound") }
|
||||||
|
]
|
||||||
|
}) }}
|
||||||
|
{% endfor %}
|
||||||
|
<p><a href="{{ mountPath }}/admin/activities">{{ __("activitypub.federationMgmt.viewAllActivities") }}</a></p>
|
||||||
|
{% else %}
|
||||||
|
{{ prose({ text: __("activitypub.noActivity") }) }}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# --- JSON Modal --- #}
|
||||||
|
<div class="ap-federation__modal-overlay" x-show="jsonModalOpen" x-cloak
|
||||||
|
@click.self="jsonModalOpen = false" @keydown.escape.window="jsonModalOpen = false">
|
||||||
|
<div class="ap-federation__modal">
|
||||||
|
<div class="ap-federation__modal-header">
|
||||||
|
<h3>{{ __("activitypub.federationMgmt.apJsonTitle") }}</h3>
|
||||||
|
<button class="ap-federation__modal-close" @click="jsonModalOpen = false">×</button>
|
||||||
|
</div>
|
||||||
|
<pre x-text="jsonModalData" class="ap-federation__json-view"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('federationMgmt', () => ({
|
||||||
|
actionInProgress: false,
|
||||||
|
actionResult: '',
|
||||||
|
lookupQuery: '',
|
||||||
|
lookupLoading: false,
|
||||||
|
lookupError: '',
|
||||||
|
lookupResult: '',
|
||||||
|
jsonModalOpen: false,
|
||||||
|
jsonModalData: '',
|
||||||
|
|
||||||
|
get mountPath() { return this.$root.dataset.mountPath; },
|
||||||
|
get csrfToken() { return this.$root.dataset.csrfToken; },
|
||||||
|
|
||||||
|
async broadcastActorUpdate() {
|
||||||
|
this.actionInProgress = true;
|
||||||
|
this.actionResult = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.mountPath + '/admin/federation/broadcast-actor', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': this.csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
this.actionResult = data.success ? 'Actor update broadcast sent.' : (data.error || 'Failed');
|
||||||
|
} catch {
|
||||||
|
this.actionResult = 'Request failed';
|
||||||
|
}
|
||||||
|
this.actionInProgress = false;
|
||||||
|
setTimeout(() => { this.actionResult = ''; }, 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
async lookupObject() {
|
||||||
|
const q = this.lookupQuery.trim();
|
||||||
|
if (!q) return;
|
||||||
|
this.lookupLoading = true;
|
||||||
|
this.lookupError = '';
|
||||||
|
this.lookupResult = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.mountPath + '/admin/federation/lookup?q=' + encodeURIComponent(q));
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
this.lookupError = data.error;
|
||||||
|
} else {
|
||||||
|
this.lookupResult = JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.lookupError = 'Request failed';
|
||||||
|
}
|
||||||
|
this.lookupLoading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewApJson(url) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.mountPath + '/admin/federation/ap-json?url=' + encodeURIComponent(url));
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
this.jsonModalData = 'Error: ' + data.error;
|
||||||
|
} else {
|
||||||
|
this.jsonModalData = JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
this.jsonModalOpen = true;
|
||||||
|
} catch {
|
||||||
|
this.jsonModalData = 'Request failed';
|
||||||
|
this.jsonModalOpen = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async rebroadcast(url) {
|
||||||
|
if (!confirm('Re-send this post to all followers?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.mountPath + '/admin/federation/rebroadcast', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': this.csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
this.actionResult = data.success ? 'Post re-broadcast sent.' : (data.error || 'Failed');
|
||||||
|
} catch {
|
||||||
|
this.actionResult = 'Request failed';
|
||||||
|
}
|
||||||
|
setTimeout(() => { this.actionResult = ''; }, 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
async broadcastDelete(url) {
|
||||||
|
if (!confirm('Broadcast Delete for this post? Remote servers will remove it.')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.mountPath + '/admin/federation/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': this.csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
this.actionResult = data.success ? 'Delete broadcast sent.' : (data.error || 'Failed');
|
||||||
|
} catch {
|
||||||
|
this.actionResult = 'Request failed';
|
||||||
|
}
|
||||||
|
setTimeout(() => { this.actionResult = ''; }, 5000);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user