From 19aa83ab8d0810005c34ac673769fbf5eff1c998 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 15 Mar 2026 16:32:14 +0100 Subject: [PATCH] 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 --- assets/reader.css | 218 ++++++++++++++++++ index.js | 50 +++++ lib/controllers/federation-mgmt.js | 310 ++++++++++++++++++++++++++ lib/jf2-to-as2.js | 8 +- locales/en.json | 21 ++ package.json | 2 +- views/activitypub-federation-mgmt.njk | 248 +++++++++++++++++++++ 7 files changed, 852 insertions(+), 5 deletions(-) create mode 100644 lib/controllers/federation-mgmt.js create mode 100644 views/activitypub-federation-mgmt.njk diff --git a/assets/reader.css b/assets/reader.css index daf3c47..36155a4 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -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; + } +} + diff --git a/index.js b/index.js index a17c364..5298da8 100644 --- a/index.js +++ b/index.js @@ -99,6 +99,13 @@ import { logActivity } from "./lib/activity-log.js"; import { scheduleCleanup } from "./lib/timeline-cleanup.js"; import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js"; import { deleteFederationController } from "./lib/controllers/federation-delete.js"; +import { + federationMgmtController, + rebroadcastController, + viewApJsonController, + broadcastActorUpdateController, + lookupObjectController, +} from "./lib/controllers/federation-mgmt.js"; const defaults = { mountPath: "/activitypub", @@ -169,6 +176,11 @@ export default class ActivityPubEndpoint { text: "activitypub.myProfile.title", 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.get("/admin/refollow/status", refollowStatusController(mp)); 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; } @@ -326,6 +343,38 @@ export default class ActivityPubEndpoint { const router = express.Router(); // eslint-disable-line new-cap 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) // Only pass GET/HEAD requests — POST/PUT/DELETE must not go through // Fedify here, because fromExpressRequest() consumes the body stream, @@ -483,6 +532,7 @@ export default class ActivityPubEndpoint { resolvedMentions.push({ handle, actorUrl: mentionedActor.id.href, + profileUrl: mentionedActor.url?.href || null, }); mentionRecipients.push({ handle, diff --git a/lib/controllers/federation-mgmt.js b/lib/controllers/federation-mgmt.js new file mode 100644 index 0000000..e0030fa --- /dev/null +++ b/lib/controllers/federation-mgmt.js @@ -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, + }; +} diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index 60d83e9..c991dbe 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -60,18 +60,18 @@ export function parseMentions(text) { /** * Replace @user@domain patterns in HTML with linked mentions. - * resolvedMentions: [{ handle, actorUrl }] - * Unresolved handles get a WebFinger-style link as fallback. + * resolvedMentions: [{ handle, actorUrl, profileUrl? }] + * Uses profileUrl (human-readable) for href, falls back to Mastodon-style URL. */ function linkifyMentions(html, resolvedMentions) { if (!html || !resolvedMentions?.length) return html; - for (const { handle, actorUrl } of resolvedMentions) { + for (const { handle, profileUrl } of resolvedMentions) { // Escape handle for regex (dots, hyphens) const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Match @handle not already inside an HTML tag attribute or anchor text const pattern = new RegExp(`(?@${handle}`, diff --git a/locales/en.json b/locales/en.json index c69308b..7034ee0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -322,6 +322,27 @@ "deleteSuccess": "Delete activity sent to followers", "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": { "sentReport": "filed a report", "title": "Reports" diff --git a/package.json b/package.json index 0e84266..b89a6df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "indiekit", diff --git a/views/activitypub-federation-mgmt.njk b/views/activitypub-federation-mgmt.njk new file mode 100644 index 0000000..55bdd8c --- /dev/null +++ b/views/activitypub-federation-mgmt.njk @@ -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 %} + + +
+ + {# --- Collection Health --- #} +
+

{{ __("activitypub.federationMgmt.collections") }}

+
+ {% for stat in collectionStats %} +
+ {{ stat.count }} + {{ stat.name | replace("ap_", "") }} +
+ {% endfor %} +
+
+ + {# --- Quick Actions --- #} +
+

{{ __("activitypub.federationMgmt.quickActions") }}

+
+ + {% if debugDashboardEnabled %} + + {{ __("activitypub.federationMgmt.debugDashboard") }} + + {% endif %} +
+

+
+ + {# --- Object Lookup --- #} +
+

{{ __("activitypub.federationMgmt.objectLookup") }}

+
+ + +
+

+

+  
+ + {# --- Post Federation --- #} +
+

{{ __("activitypub.federationMgmt.postActions") }}

+ {% if posts.length > 0 %} +
+ {% for post in posts %} +
+ +
+ + + +
+
+ {% endfor %} +
+ {{ pagination(cursor) if cursor }} + {% else %} + {{ prose({ text: __("activitypub.federationMgmt.noPosts") }) }} + {% endif %} +
+ + {# --- Recent Activity --- #} +
+

{{ __("activitypub.federationMgmt.recentActivity") }}

+ {% 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 %} +

{{ __("activitypub.federationMgmt.viewAllActivities") }}

+ {% else %} + {{ prose({ text: __("activitypub.noActivity") }) }} + {% endif %} +
+ + {# --- JSON Modal --- #} +
+
+
+

{{ __("activitypub.federationMgmt.apJsonTitle") }}

+ +
+

+    
+
+ +
+ + +{% endblock %}