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:
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user