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 <a rel="me"> HTML for Mastodon rel=me
  verification; plain text values pass through unchanged
- Bump version to 1.1.1
This commit is contained in:
Ricardo
2026-02-21 12:19:22 +01:00
parent 4e514235c2
commit 55e9311c4a
6 changed files with 119 additions and 12 deletions
+58 -5
View File
@@ -189,13 +189,13 @@ export default class ActivityPubEndpoint {
router.get("/admin/following", followingController(mp)); router.get("/admin/following", followingController(mp));
router.get("/admin/activities", activitiesController(mp)); router.get("/admin/activities", activitiesController(mp));
router.get("/admin/featured", featuredGetController(mp)); router.get("/admin/featured", featuredGetController(mp));
router.post("/admin/featured/pin", featuredPinController(mp)); router.post("/admin/featured/pin", featuredPinController(mp, this));
router.post("/admin/featured/unpin", featuredUnpinController(mp)); router.post("/admin/featured/unpin", featuredUnpinController(mp, this));
router.get("/admin/tags", featuredTagsGetController(mp)); router.get("/admin/tags", featuredTagsGetController(mp));
router.post("/admin/tags/add", featuredTagsAddController(mp)); router.post("/admin/tags/add", featuredTagsAddController(mp, this));
router.post("/admin/tags/remove", featuredTagsRemoveController(mp)); router.post("/admin/tags/remove", featuredTagsRemoveController(mp, this));
router.get("/admin/profile", profileGetController(mp)); 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.get("/admin/migrate", migrateGetController(mp, this.options));
router.post("/admin/migrate", migratePostController(mp, this.options)); router.post("/admin/migrate", migratePostController(mp, this.options));
router.post( 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. * Build the full actor URL from config.
* @returns {string} * @returns {string}
+12 -2
View File
@@ -24,7 +24,7 @@ export function featuredTagsGetController(mountPath) {
}; };
} }
export function featuredTagsAddController(mountPath) { export function featuredTagsAddController(mountPath, plugin) {
return async (request, response, next) => { return async (request, response, next) => {
try { try {
const { application } = request.app.locals; const { application } = request.app.locals;
@@ -44,6 +44,11 @@ export function featuredTagsAddController(mountPath) {
{ upsert: true }, { upsert: true },
); );
// Notify followers so they re-fetch featured tags
if (plugin?.broadcastActorUpdate) {
plugin.broadcastActorUpdate().catch(() => {});
}
response.redirect(`${mountPath}/admin/tags`); response.redirect(`${mountPath}/admin/tags`);
} catch (error) { } catch (error) {
next(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) => { return async (request, response, next) => {
try { try {
const { application } = request.app.locals; const { application } = request.app.locals;
@@ -63,6 +68,11 @@ export function featuredTagsRemoveController(mountPath) {
await collection.deleteOne({ tag }); await collection.deleteOne({ tag });
// Notify followers so they re-fetch featured tags
if (plugin?.broadcastActorUpdate) {
plugin.broadcastActorUpdate().catch(() => {});
}
response.redirect(`${mountPath}/admin/tags`); response.redirect(`${mountPath}/admin/tags`);
} catch (error) { } catch (error) {
next(error); next(error);
+12 -2
View File
@@ -69,7 +69,7 @@ export function featuredGetController(mountPath) {
}; };
} }
export function featuredPinController(mountPath) { export function featuredPinController(mountPath, plugin) {
return async (request, response, next) => { return async (request, response, next) => {
try { try {
const { application } = request.app.locals; const { application } = request.app.locals;
@@ -90,6 +90,11 @@ export function featuredPinController(mountPath) {
{ upsert: true }, { upsert: true },
); );
// Notify followers so they re-fetch the featured collection
if (plugin?.broadcastActorUpdate) {
plugin.broadcastActorUpdate().catch(() => {});
}
response.redirect(`${mountPath}/admin/featured`); response.redirect(`${mountPath}/admin/featured`);
} catch (error) { } catch (error) {
next(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) => { return async (request, response, next) => {
try { try {
const { application } = request.app.locals; const { application } = request.app.locals;
@@ -109,6 +114,11 @@ export function featuredUnpinController(mountPath) {
await collection.deleteOne({ postUrl }); await collection.deleteOne({ postUrl });
// Notify followers so they re-fetch the featured collection
if (plugin?.broadcastActorUpdate) {
plugin.broadcastActorUpdate().catch(() => {});
}
response.redirect(`${mountPath}/admin/featured`); response.redirect(`${mountPath}/admin/featured`);
} catch (error) { } catch (error) {
next(error); next(error);
+8 -1
View File
@@ -29,7 +29,7 @@ export function profileGetController(mountPath) {
}; };
} }
export function profilePostController(mountPath) { export function profilePostController(mountPath, plugin) {
return async (request, response, next) => { return async (request, response, next) => {
try { try {
const { application } = request.app.locals; const { application } = request.app.locals;
@@ -79,6 +79,13 @@ export function profilePostController(mountPath) {
await profileCollection.updateOne({}, update, { upsert: true }); 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({}); const profile = await profileCollection.findOne({});
response.render("activitypub-profile", { response.render("activitypub-profile", {
+28 -1
View File
@@ -183,7 +183,11 @@ export function setupFederation(options) {
if (profile.attachments?.length > 0) { if (profile.attachments?.length > 0) {
personOptions.attachments = profile.attachments.map( 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
return `<a href="${escaped}" rel="me">${escaped}</a>`;
}
// Plain text (e.g. pronouns) — return as-is
return trimmed;
}
function guessImageMediaType(url) { function guessImageMediaType(url) {
const ext = url.split(".").pop()?.toLowerCase(); const ext = url.split(".").pop()?.toLowerCase();
const types = { const types = {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "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.", "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",