From 55e9311c4a41b4f08e81eb70f76eb4850c23f50a Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Feb 2026 12:19:22 +0100 Subject: [PATCH] feat: broadcast Update(Person) on profile/featured/tags changes, fix rel=me - Add broadcastActorUpdate() method that sends Update(Person) to all followers so remote servers re-fetch the actor object - Profile, featured pin/unpin, and featured tags add/remove controllers now trigger the broadcast after changes - Wrap URL attachment values in HTML for Mastodon rel=me verification; plain text values pass through unchanged - Bump version to 1.1.1 --- index.js | 63 +++++++++++++++++++++++++++++--- lib/controllers/featured-tags.js | 14 ++++++- lib/controllers/featured.js | 14 ++++++- lib/controllers/profile.js | 9 ++++- lib/federation-setup.js | 29 ++++++++++++++- package.json | 2 +- 6 files changed, 119 insertions(+), 12 deletions(-) diff --git a/index.js b/index.js index 5dda6e8..17d60f7 100644 --- a/index.js +++ b/index.js @@ -189,13 +189,13 @@ export default class ActivityPubEndpoint { router.get("/admin/following", followingController(mp)); router.get("/admin/activities", activitiesController(mp)); router.get("/admin/featured", featuredGetController(mp)); - router.post("/admin/featured/pin", featuredPinController(mp)); - router.post("/admin/featured/unpin", featuredUnpinController(mp)); + router.post("/admin/featured/pin", featuredPinController(mp, this)); + router.post("/admin/featured/unpin", featuredUnpinController(mp, this)); router.get("/admin/tags", featuredTagsGetController(mp)); - router.post("/admin/tags/add", featuredTagsAddController(mp)); - router.post("/admin/tags/remove", featuredTagsRemoveController(mp)); + router.post("/admin/tags/add", featuredTagsAddController(mp, this)); + router.post("/admin/tags/remove", featuredTagsRemoveController(mp, this)); router.get("/admin/profile", profileGetController(mp)); - router.post("/admin/profile", profilePostController(mp)); + router.post("/admin/profile", profilePostController(mp, this)); router.get("/admin/migrate", migrateGetController(mp, this.options)); router.post("/admin/migrate", migratePostController(mp, this.options)); router.post( @@ -633,6 +633,59 @@ export default class ActivityPubEndpoint { } } + /** + * Send an Update(Person) activity to all followers so remote servers + * re-fetch the actor object (picking up profile changes, new featured + * collections, attachments, etc.). + */ + async broadcastActorUpdate() { + if (!this._federation) return; + + try { + const { Update } = await import("@fedify/fedify"); + const handle = this.options.actor.handle; + const ctx = this._federation.createContext( + new URL(this._publicationUrl), + { handle, publicationUrl: this._publicationUrl }, + ); + + // Retrieve the full actor from the dispatcher (same object remote + // servers will get when they re-fetch the actor URL) + const actor = await ctx.getActor(handle); + if (!actor) { + console.warn("[ActivityPub] broadcastActorUpdate: could not build actor"); + return; + } + + const update = new Update({ + actor: ctx.getActorUri(handle), + object: actor, + }); + + await ctx.sendActivity( + { identifier: handle }, + "followers", + update, + { preferSharedInbox: true }, + ); + + console.info("[ActivityPub] Sent Update(Person) to followers"); + + await logActivity(this._collections.ap_activities, { + direction: "outbound", + type: "Update", + actorUrl: this._publicationUrl, + objectUrl: this._getActorUrl(), + summary: "Sent Update(Person) to followers", + }).catch(() => {}); + } catch (error) { + console.error( + "[ActivityPub] broadcastActorUpdate failed:", + error.message, + ); + } + } + /** * Build the full actor URL from config. * @returns {string} diff --git a/lib/controllers/featured-tags.js b/lib/controllers/featured-tags.js index 8a69fd2..1bb796d 100644 --- a/lib/controllers/featured-tags.js +++ b/lib/controllers/featured-tags.js @@ -24,7 +24,7 @@ export function featuredTagsGetController(mountPath) { }; } -export function featuredTagsAddController(mountPath) { +export function featuredTagsAddController(mountPath, plugin) { return async (request, response, next) => { try { const { application } = request.app.locals; @@ -44,6 +44,11 @@ export function featuredTagsAddController(mountPath) { { upsert: true }, ); + // Notify followers so they re-fetch featured tags + if (plugin?.broadcastActorUpdate) { + plugin.broadcastActorUpdate().catch(() => {}); + } + response.redirect(`${mountPath}/admin/tags`); } catch (error) { next(error); @@ -51,7 +56,7 @@ export function featuredTagsAddController(mountPath) { }; } -export function featuredTagsRemoveController(mountPath) { +export function featuredTagsRemoveController(mountPath, plugin) { return async (request, response, next) => { try { const { application } = request.app.locals; @@ -63,6 +68,11 @@ export function featuredTagsRemoveController(mountPath) { await collection.deleteOne({ tag }); + // Notify followers so they re-fetch featured tags + if (plugin?.broadcastActorUpdate) { + plugin.broadcastActorUpdate().catch(() => {}); + } + response.redirect(`${mountPath}/admin/tags`); } catch (error) { next(error); diff --git a/lib/controllers/featured.js b/lib/controllers/featured.js index c17104d..45098be 100644 --- a/lib/controllers/featured.js +++ b/lib/controllers/featured.js @@ -69,7 +69,7 @@ export function featuredGetController(mountPath) { }; } -export function featuredPinController(mountPath) { +export function featuredPinController(mountPath, plugin) { return async (request, response, next) => { try { const { application } = request.app.locals; @@ -90,6 +90,11 @@ export function featuredPinController(mountPath) { { upsert: true }, ); + // Notify followers so they re-fetch the featured collection + if (plugin?.broadcastActorUpdate) { + plugin.broadcastActorUpdate().catch(() => {}); + } + response.redirect(`${mountPath}/admin/featured`); } catch (error) { next(error); @@ -97,7 +102,7 @@ export function featuredPinController(mountPath) { }; } -export function featuredUnpinController(mountPath) { +export function featuredUnpinController(mountPath, plugin) { return async (request, response, next) => { try { const { application } = request.app.locals; @@ -109,6 +114,11 @@ export function featuredUnpinController(mountPath) { await collection.deleteOne({ postUrl }); + // Notify followers so they re-fetch the featured collection + if (plugin?.broadcastActorUpdate) { + plugin.broadcastActorUpdate().catch(() => {}); + } + response.redirect(`${mountPath}/admin/featured`); } catch (error) { next(error); diff --git a/lib/controllers/profile.js b/lib/controllers/profile.js index e372690..d63a41c 100644 --- a/lib/controllers/profile.js +++ b/lib/controllers/profile.js @@ -29,7 +29,7 @@ export function profileGetController(mountPath) { }; } -export function profilePostController(mountPath) { +export function profilePostController(mountPath, plugin) { return async (request, response, next) => { try { const { application } = request.app.locals; @@ -79,6 +79,13 @@ export function profilePostController(mountPath) { await profileCollection.updateOne({}, update, { upsert: true }); + // Send Update(Person) to followers so remote servers re-fetch the actor + if (plugin?.broadcastActorUpdate) { + plugin.broadcastActorUpdate().catch((error) => { + console.warn("[ActivityPub] Profile update broadcast failed:", error.message); + }); + } + const profile = await profileCollection.findOne({}); response.render("activitypub-profile", { diff --git a/lib/federation-setup.js b/lib/federation-setup.js index 7f78801..9ac7074 100644 --- a/lib/federation-setup.js +++ b/lib/federation-setup.js @@ -183,7 +183,11 @@ export function setupFederation(options) { if (profile.attachments?.length > 0) { personOptions.attachments = profile.attachments.map( - (att) => new PropertyValue({ name: att.name, value: att.value }), + (att) => + new PropertyValue({ + name: att.name, + value: formatAttachmentValue(att.value), + }), ); } @@ -689,6 +693,29 @@ async function importPkcs8Pem(pem) { ); } +/** + * Format an attachment value for ActivityPub PropertyValue. + * If the value looks like a URL, wrap it in an HTML anchor tag with rel="me" + * so Mastodon can verify profile link ownership. Plain text values pass through. + */ +function formatAttachmentValue(value) { + if (!value) return ""; + const trimmed = value.trim(); + // Already contains HTML — pass through + if (trimmed.startsWith("<")) return trimmed; + // URL — wrap in anchor with rel="me" + if (/^https?:\/\//i.test(trimmed)) { + const escaped = trimmed + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + return `${escaped}`; + } + // Plain text (e.g. pronouns) — return as-is + return trimmed; +} + function guessImageMediaType(url) { const ext = url.split(".").pop()?.toLowerCase(); const types = { diff --git a/package.json b/package.json index 1eafa60..246745e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.1.0", + "version": "1.1.1", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit",