fix: audit fixes for account ID, update_credentials, interactions, edit payload

- Account ID: use URL-based hash for all accounts (local+remote) so
  verify_credentials and status serialization produce matching IDs.
  Clients can now show edit/delete buttons on own posts.
- update_credentials: pass handle+counts instead of collections to
  serializeCredentialAccount, add broadcastActorUpdate for federation
- favourited_by/reblogged_by: query ap_notifications (incoming) instead
  of ap_interactions (outgoing local) for who liked/boosted a post
- Status edit: send content-warning and sensitive in Micropub replace
  payload alongside content
This commit is contained in:
Ricardo
2026-04-01 15:12:27 +02:00
parent 94a38d9a51
commit 40eb2f8f09
6 changed files with 78 additions and 44 deletions
+1
View File
@@ -1119,6 +1119,7 @@ export default class ActivityPubEndpoint {
federation: this._federation,
followActor: (url, info) => pluginRef.followActor(url, info),
unfollowActor: (url) => pluginRef.unfollowActor(url),
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
},
});
+10 -11
View File
@@ -1,15 +1,16 @@
/**
* Deterministic ID mapping for Mastodon Client API.
*
* Local accounts use MongoDB _id.toString().
* Remote actors use sha256(actorUrl).slice(0, 24) for stable IDs
* without requiring a dedicated accounts collection.
* All accounts (local and remote) use sha256(actorUrl).slice(0, 24)
* for stable, consistent IDs. This ensures verify_credentials and
* status serialization produce the same ID for the local user,
* even though the profile doc has _id but timeline author objects don't.
*/
import crypto from "node:crypto";
/**
* Generate a deterministic ID for a remote actor URL.
* @param {string} actorUrl - The remote actor's URL
* Generate a deterministic ID for an actor URL.
* @param {string} actorUrl - The actor's URL
* @returns {string} 24-character hex ID
*/
export function remoteActorId(actorUrl) {
@@ -18,15 +19,13 @@ export function remoteActorId(actorUrl) {
/**
* Get the Mastodon API ID for an account.
* Uses URL-based hash for all accounts (local and remote) so the ID
* is consistent regardless of whether the actor object has a MongoDB _id.
* @param {object} actor - Actor object (local profile or remote author)
* @param {boolean} isLocal - Whether this is the local profile
* @param {boolean} _isLocal - Unused (kept for API compatibility)
* @returns {string}
*/
export function accountId(actor, isLocal = false) {
if (isLocal && actor._id) {
return actor._id.toString();
}
// Remote actors: use URL-based deterministic hash
export function accountId(actor, _isLocal = false) {
const url = actor.url || actor.actorUrl || "";
return url ? remoteActorId(url) : "0";
}
+21 -3
View File
@@ -306,6 +306,13 @@ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired
if (Object.keys(update).length > 0 && collections.ap_profile) {
await collections.ap_profile.updateOne({}, { $set: update });
// Broadcast Update(Person) to followers so profile changes federate
if (pluginOptions.broadcastActorUpdate) {
pluginOptions.broadcastActorUpdate().catch((err) =>
console.warn(`[Mastodon API] broadcastActorUpdate failed: ${err.message}`),
);
}
}
// Return updated credential account
@@ -313,12 +320,23 @@ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired
? await collections.ap_profile.findOne({})
: {};
const handle = pluginOptions.handle || "user";
let counts = {};
try {
const [statuses, followers, following] = await Promise.all([
collections.ap_timeline.countDocuments({ "author.url": profile.url }),
collections.ap_followers.countDocuments({}),
collections.ap_following.countDocuments({}),
]);
counts = { statuses, followers, following };
} catch {
counts = { statuses: 0, followers: 0, following: 0 };
}
const { serializeCredentialAccount } = await import(
"../entities/account.js"
);
res.json(
await serializeCredentialAccount(profile, { baseUrl, collections }),
);
res.json(serializeCredentialAccount(profile, { baseUrl, handle, counts }));
} catch (error) {
next(error);
}
+43 -27
View File
@@ -453,7 +453,9 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
: new URL(application.micropubEndpoint, application.url).href;
const token =
req.session?.access_token || req.mastodonToken?.accessToken;
req.session?.access_token ||
req.mastodonToken?.indieauthToken ||
req.mastodonToken?.accessToken;
if (token) {
const updatePayload = {
action: "update",
@@ -464,6 +466,13 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
if (statusText !== undefined) {
updatePayload.replace.content = [statusText];
}
if (spoilerText !== undefined) {
updatePayload.replace["content-warning"] = spoilerText ? [spoilerText] : [];
updatePayload.replace.sensitive = [spoilerText ? "true" : "false"];
}
if (sensitive !== undefined && spoilerText === undefined) {
updatePayload.replace.sensitive = [sensitive === true || sensitive === "true" ? "true" : "false"];
}
try {
await fetch(micropubUrl, {
@@ -513,13 +522,16 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
const updated = await collections.ap_timeline.findOne({
_id: item._id,
});
const { serializeStatus, setLocalIdentity } = await import(
"../entities/status.js"
);
const handle = pluginOptions.actor?.handle || "";
setLocalIdentity(localPublicationUrl, handle);
const interactionState = await loadItemInteractions(collections, updated);
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [updated]);
const serialized = serializeStatus(updated, { baseUrl });
const serialized = serializeStatus(updated, {
baseUrl,
...interactionState,
pinnedIds: new Set(),
replyIdMap,
replyAccountIdMap,
});
res.json(serialized);
} catch (error) {
next(error);
@@ -599,23 +611,25 @@ router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("r
);
if (!item) return res.status(404).json({ error: "Record not found" });
const uid = item.uid || item.url;
if (!uid || !collections.ap_interactions) return res.json([]);
const targetUrl = item.uid || item.url;
if (!targetUrl || !collections.ap_notifications) return res.json([]);
const interactions = await collections.ap_interactions
.find({ objectUrl: uid, type: "like" })
// Incoming likes are stored as notifications by the inbox handler
const notifications = await collections.ap_notifications
.find({ targetUrl, type: "like" })
.limit(40)
.toArray();
const { serializeAccount } = await import("../entities/account.js");
const accounts = interactions
.filter((i) => i.actorUrl || i.actorName)
.map((i) =>
const accounts = notifications
.filter((n) => n.actorUrl)
.map((n) =>
serializeAccount(
{
url: i.actorUrl,
name: i.actorName || "",
handle: i.actorHandle || "",
url: n.actorUrl,
name: n.actorName || "",
handle: n.actorHandle || "",
photo: n.actorPhoto || "",
},
{ baseUrl, isLocal: false },
),
@@ -640,23 +654,25 @@ router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("re
);
if (!item) return res.status(404).json({ error: "Record not found" });
const uid = item.uid || item.url;
if (!uid || !collections.ap_interactions) return res.json([]);
const targetUrl = item.uid || item.url;
if (!targetUrl || !collections.ap_notifications) return res.json([]);
const interactions = await collections.ap_interactions
.find({ objectUrl: uid, type: "boost" })
// Incoming boosts are stored as notifications by the inbox handler
const notifications = await collections.ap_notifications
.find({ targetUrl, type: "boost" })
.limit(40)
.toArray();
const { serializeAccount } = await import("../entities/account.js");
const accounts = interactions
.filter((i) => i.actorUrl || i.actorName)
.map((i) =>
const accounts = notifications
.filter((n) => n.actorUrl)
.map((n) =>
serializeAccount(
{
url: i.actorUrl,
name: i.actorName || "",
handle: i.actorHandle || "",
url: n.actorUrl,
name: n.actorName || "",
handle: n.actorHandle || "",
photo: n.actorPhoto || "",
},
{ baseUrl, isLocal: false },
),
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.11.5",
"version": "3.13.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.11.5",
"version": "3.13.3",
"license": "MIT",
"dependencies": {
"@fedify/debugger": "^2.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.13.2",
"version": "3.13.3",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",