From 6238e7d4e5e7556b7ca4551bb2b703b8a1ba9802 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 14 Mar 2026 21:28:24 +0100 Subject: [PATCH 1/3] feat: visibility/CW compose controls, @mention support (v2.11.0) Add visibility and content warning controls to the reply compose form. Add @user@domain mention parsing, WebFinger resolution, Mention tags, inbox delivery, and content linkification for outbound posts. Confab-Link: http://localhost:8080/sessions/cc343b15-8d10-43cd-a48f-ca912eb79b83 --- assets/reader.css | 52 ++++++++++++++++++ index.js | 61 ++++++++++++++++++++- lib/controllers/compose.js | 12 ++++- lib/jf2-to-as2.js | 99 ++++++++++++++++++++++++++++++++++- locales/en.json | 8 ++- package.json | 2 +- views/activitypub-compose.njk | 29 ++++++++++ 7 files changed, 258 insertions(+), 5 deletions(-) diff --git a/assets/reader.css b/assets/reader.css index 6a988d9..daf3c47 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -1094,6 +1094,58 @@ outline-offset: -2px; } +.ap-compose__cw { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.ap-compose__cw-toggle { + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: var(--font-size-s); + color: var(--color-on-offset); +} + +.ap-compose__cw-input { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + background: var(--color-offset); + color: var(--color-on-background); + font: inherit; + font-size: var(--font-size-s); + padding: var(--space-s); + width: 100%; +} + +.ap-compose__cw-input:focus { + border-color: var(--color-primary); + outline: none; +} + +.ap-compose__visibility { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + display: flex; + flex-wrap: wrap; + gap: var(--space-s) var(--space-m); + padding: var(--space-m); +} + +.ap-compose__visibility legend { + font-weight: 600; +} + +.ap-compose__visibility-option { + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: var(--font-size-s); +} + .ap-compose__syndication { border: var(--border-width-thin) solid var(--color-outline); border-radius: var(--border-radius-small); diff --git a/index.js b/index.js index 1971978..a17c364 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ import { import { jf2ToActivityStreams, jf2ToAS2Activity, + parseMentions, } from "./lib/jf2-to-as2.js"; import { dashboardController } from "./lib/controllers/dashboard.js"; import { @@ -467,6 +468,40 @@ export default class ActivityPubEndpoint { } } + // Resolve @user@domain mentions in content via WebFinger + const contentText = properties.content?.html || properties.content || ""; + const mentionHandles = parseMentions(contentText); + const resolvedMentions = []; + const mentionRecipients = []; + + for (const { handle } of mentionHandles) { + try { + const mentionedActor = await ctx.lookupObject( + new URL(`acct:${handle}`), + ); + if (mentionedActor?.id) { + resolvedMentions.push({ + handle, + actorUrl: mentionedActor.id.href, + }); + mentionRecipients.push({ + handle, + actorUrl: mentionedActor.id.href, + actor: mentionedActor, + }); + console.info( + `[ActivityPub] Resolved mention @${handle} → ${mentionedActor.id.href}`, + ); + } + } catch (error) { + console.warn( + `[ActivityPub] Could not resolve mention @${handle}: ${error.message}`, + ); + // Still add with no actorUrl so it gets a fallback link + resolvedMentions.push({ handle, actorUrl: null }); + } + } + const activity = jf2ToAS2Activity( properties, actorUrl, @@ -475,6 +510,7 @@ export default class ActivityPubEndpoint { replyToActorUrl: replyToActor?.url, replyToActorHandle: replyToActor?.handle, visibility: self.options.defaultVisibility, + mentions: resolvedMentions, }, ); @@ -529,12 +565,35 @@ export default class ActivityPubEndpoint { } } + // Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above) + for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) { + if (replyToActor?.url === mUrl) continue; + try { + await ctx.sendActivity( + { identifier: handle }, + mActor, + activity, + { orderingKey: properties.url }, + ); + console.info( + `[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`, + ); + } catch (error) { + console.warn( + `[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`, + ); + } + } + // Determine activity type name const typeName = activity.constructor?.name || "Create"; const replyNote = replyToActor ? ` (reply to ${replyToActor.url})` : ""; + const mentionNote = mentionRecipients.length > 0 + ? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})` + : ""; await logActivity(self._collections.ap_activities, { direction: "outbound", @@ -542,7 +601,7 @@ export default class ActivityPubEndpoint { actorUrl: self._publicationUrl, objectUrl: properties.url, targetUrl: properties["in-reply-to"] || undefined, - summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`, + summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`, }); console.info( diff --git a/lib/controllers/compose.js b/lib/controllers/compose.js index c916a60..3d76f5a 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -168,7 +168,8 @@ export function submitComposeController(mountPath, plugin) { } const { application } = request.app.locals; - const { content } = request.body; + const { content, visibility, summary } = request.body; + const cwEnabled = request.body["cw-enabled"]; const inReplyTo = request.body["in-reply-to"]; const syndicateTo = request.body["mp-syndicate-to"]; @@ -209,6 +210,15 @@ export function submitComposeController(mountPath, plugin) { micropubData.append("in-reply-to", inReplyTo); } + if (visibility && visibility !== "public") { + micropubData.append("visibility", visibility); + } + + if (cwEnabled && summary && summary.trim()) { + micropubData.append("summary", summary.trim()); + micropubData.append("sensitive", "true"); + } + if (syndicateTo) { const targets = Array.isArray(syndicateTo) ? syndicateTo diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index 5def897..60d83e9 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -36,6 +36,50 @@ function linkifyUrls(html) { ); } +/** + * Parse @user@domain mention patterns from text content. + * Returns array of { handle: "user@domain", username: "user", domain: "domain.tld" }. + */ +export function parseMentions(text) { + if (!text) return []; + // Strip HTML tags for parsing + const plain = text.replace(/<[^>]*>/g, " "); + const mentionRegex = /(?@${handle}`, + ); + } + return html; +} + // --------------------------------------------------------------------------- // Plain JSON-LD (content negotiation on individual post URLs) // --------------------------------------------------------------------------- @@ -137,10 +181,27 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, optio } const tags = buildPlainTags(properties, publicationUrl, object.tag); + + // Add Mention tags + cc addressing + content linkification for @mentions + const resolvedMentions = options.mentions || []; + for (const { handle, actorUrl: mentionUrl } of resolvedMentions) { + if (mentionUrl) { + tags.push({ type: "Mention", href: mentionUrl, name: `@${handle}` }); + if (!object.cc.includes(mentionUrl)) { + object.cc.push(mentionUrl); + } + } + } + if (tags.length > 0) { object.tag = tags; } + // Linkify @mentions in content (resolved get actor links, unresolved get profile links) + if (resolvedMentions.length > 0 && object.content) { + object.content = linkifyMentions(object.content, resolvedMentions); + } + return { "@context": "https://www.w3.org/ns/activitystreams", type: "Create", @@ -292,7 +353,7 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = noteOptions.attachments = fedifyAttachments; } - // Tags: hashtags + Mention for reply addressing + // Tags: hashtags + Mention for reply addressing + @mentions const fedifyTags = buildFedifyTags(properties, publicationUrl, postType); if (replyToActorUrl) { @@ -304,10 +365,46 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = ); } + // Add Mention tags + cc addressing for resolved @mentions + const resolvedMentions = options.mentions || []; + const ccUrls = []; + for (const { handle, actorUrl: mentionUrl } of resolvedMentions) { + if (mentionUrl) { + // Skip if same as replyToActorUrl (already added above) + const alreadyTagged = replyToActorUrl && mentionUrl === replyToActorUrl; + if (!alreadyTagged) { + fedifyTags.push( + new Mention({ + href: new URL(mentionUrl), + name: `@${handle}`, + }), + ); + } + ccUrls.push(new URL(mentionUrl)); + } + } + + // Merge mention actors into cc/ccs + if (ccUrls.length > 0) { + if (noteOptions.ccs) { + noteOptions.ccs = [...noteOptions.ccs, ...ccUrls]; + } else if (noteOptions.cc) { + noteOptions.ccs = [noteOptions.cc, ...ccUrls]; + delete noteOptions.cc; + } else { + noteOptions.ccs = ccUrls; + } + } + if (fedifyTags.length > 0) { noteOptions.tags = fedifyTags; } + // Linkify @mentions in content + if (resolvedMentions.length > 0 && noteOptions.content) { + noteOptions.content = linkifyMentions(noteOptions.content, resolvedMentions); + } + const object = isArticle ? new Article(noteOptions) : new Note(noteOptions); diff --git a/locales/en.json b/locales/en.json index 84eda29..c69308b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -145,7 +145,13 @@ "syndicateLabel": "Syndicate to", "submitMicropub": "Post reply", "cancel": "Cancel", - "errorEmpty": "Reply content cannot be empty" + "errorEmpty": "Reply content cannot be empty", + "visibilityLabel": "Visibility", + "visibilityPublic": "Public", + "visibilityUnlisted": "Unlisted", + "visibilityFollowers": "Followers only", + "cwLabel": "Content warning", + "cwPlaceholder": "Write your warning here…" }, "notifications": { "title": "Notifications", diff --git a/package.json b/package.json index 1994a8e..0e84266 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "2.10.0", + "version": "2.11.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-compose.njk b/views/activitypub-compose.njk index 1e21f95..8d43a3f 100644 --- a/views/activitypub-compose.njk +++ b/views/activitypub-compose.njk @@ -27,6 +27,18 @@ {% endif %} + {# Content warning toggle + summary #} +
+ + +
+ {# Content textarea #}
+ {# Visibility #} +
+ {{ __("activitypub.compose.visibilityLabel") }} + + + +
+ {# Syndication targets #} {% if syndicationTargets.length > 0 %}
From 19aa83ab8d0810005c34ac673769fbf5eff1c998 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 15 Mar 2026 16:32:14 +0100 Subject: [PATCH 2/3] 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 %} From 670745007989cadab60c89d49c096c1fdd8f36fb Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 15 Mar 2026 17:27:27 +0100 Subject: [PATCH 3/3] fix: federation-mgmt extends ap-reader.njk layout for proper CSS loading (v2.12.1) Confab-Link: http://localhost:8080/sessions/c2335791-4b8c-44a6-b1b7-8d0fa8d7f647 --- package.json | 2 +- views/activitypub-federation-mgmt.njk | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b89a6df..18e2775 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "2.12.0", + "version": "2.12.1", "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 index 55bdd8c..dd52522 100644 --- a/views/activitypub-federation-mgmt.njk +++ b/views/activitypub-federation-mgmt.njk @@ -1,14 +1,11 @@ -{% extends "document.njk" %} +{% extends "layouts/ap-reader.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 %} - - +{% block readercontent %}
{# --- Collection Health --- #}