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, federation: this._federation,
followActor: (url, info) => pluginRef.followActor(url, info), followActor: (url, info) => pluginRef.followActor(url, info),
unfollowActor: (url) => pluginRef.unfollowActor(url), unfollowActor: (url) => pluginRef.unfollowActor(url),
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
loadRsaKey: () => pluginRef._loadRsaPrivateKey(), loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
}, },
}); });
+10 -11
View File
@@ -1,15 +1,16 @@
/** /**
* Deterministic ID mapping for Mastodon Client API. * Deterministic ID mapping for Mastodon Client API.
* *
* Local accounts use MongoDB _id.toString(). * All accounts (local and remote) use sha256(actorUrl).slice(0, 24)
* Remote actors use sha256(actorUrl).slice(0, 24) for stable IDs * for stable, consistent IDs. This ensures verify_credentials and
* without requiring a dedicated accounts collection. * 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"; import crypto from "node:crypto";
/** /**
* Generate a deterministic ID for a remote actor URL. * Generate a deterministic ID for an actor URL.
* @param {string} actorUrl - The remote actor's URL * @param {string} actorUrl - The actor's URL
* @returns {string} 24-character hex ID * @returns {string} 24-character hex ID
*/ */
export function remoteActorId(actorUrl) { export function remoteActorId(actorUrl) {
@@ -18,15 +19,13 @@ export function remoteActorId(actorUrl) {
/** /**
* Get the Mastodon API ID for an account. * 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 {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} * @returns {string}
*/ */
export function accountId(actor, isLocal = false) { export function accountId(actor, _isLocal = false) {
if (isLocal && actor._id) {
return actor._id.toString();
}
// Remote actors: use URL-based deterministic hash
const url = actor.url || actor.actorUrl || ""; const url = actor.url || actor.actorUrl || "";
return url ? remoteActorId(url) : "0"; 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) { if (Object.keys(update).length > 0 && collections.ap_profile) {
await collections.ap_profile.updateOne({}, { $set: update }); 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 // Return updated credential account
@@ -313,12 +320,23 @@ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired
? await collections.ap_profile.findOne({}) ? 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( const { serializeCredentialAccount } = await import(
"../entities/account.js" "../entities/account.js"
); );
res.json( res.json(serializeCredentialAccount(profile, { baseUrl, handle, counts }));
await serializeCredentialAccount(profile, { baseUrl, collections }),
);
} catch (error) { } catch (error) {
next(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; : new URL(application.micropubEndpoint, application.url).href;
const token = const token =
req.session?.access_token || req.mastodonToken?.accessToken; req.session?.access_token ||
req.mastodonToken?.indieauthToken ||
req.mastodonToken?.accessToken;
if (token) { if (token) {
const updatePayload = { const updatePayload = {
action: "update", action: "update",
@@ -464,6 +466,13 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
if (statusText !== undefined) { if (statusText !== undefined) {
updatePayload.replace.content = [statusText]; 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 { try {
await fetch(micropubUrl, { await fetch(micropubUrl, {
@@ -513,13 +522,16 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
const updated = await collections.ap_timeline.findOne({ const updated = await collections.ap_timeline.findOne({
_id: item._id, _id: item._id,
}); });
const { serializeStatus, setLocalIdentity } = await import( const interactionState = await loadItemInteractions(collections, updated);
"../entities/status.js" const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [updated]);
);
const handle = pluginOptions.actor?.handle || "";
setLocalIdentity(localPublicationUrl, handle);
const serialized = serializeStatus(updated, { baseUrl }); const serialized = serializeStatus(updated, {
baseUrl,
...interactionState,
pinnedIds: new Set(),
replyIdMap,
replyAccountIdMap,
});
res.json(serialized); res.json(serialized);
} catch (error) { } catch (error) {
next(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" }); if (!item) return res.status(404).json({ error: "Record not found" });
const uid = item.uid || item.url; const targetUrl = item.uid || item.url;
if (!uid || !collections.ap_interactions) return res.json([]); if (!targetUrl || !collections.ap_notifications) return res.json([]);
const interactions = await collections.ap_interactions // Incoming likes are stored as notifications by the inbox handler
.find({ objectUrl: uid, type: "like" }) const notifications = await collections.ap_notifications
.find({ targetUrl, type: "like" })
.limit(40) .limit(40)
.toArray(); .toArray();
const { serializeAccount } = await import("../entities/account.js"); const { serializeAccount } = await import("../entities/account.js");
const accounts = interactions const accounts = notifications
.filter((i) => i.actorUrl || i.actorName) .filter((n) => n.actorUrl)
.map((i) => .map((n) =>
serializeAccount( serializeAccount(
{ {
url: i.actorUrl, url: n.actorUrl,
name: i.actorName || "", name: n.actorName || "",
handle: i.actorHandle || "", handle: n.actorHandle || "",
photo: n.actorPhoto || "",
}, },
{ baseUrl, isLocal: false }, { 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" }); if (!item) return res.status(404).json({ error: "Record not found" });
const uid = item.uid || item.url; const targetUrl = item.uid || item.url;
if (!uid || !collections.ap_interactions) return res.json([]); if (!targetUrl || !collections.ap_notifications) return res.json([]);
const interactions = await collections.ap_interactions // Incoming boosts are stored as notifications by the inbox handler
.find({ objectUrl: uid, type: "boost" }) const notifications = await collections.ap_notifications
.find({ targetUrl, type: "boost" })
.limit(40) .limit(40)
.toArray(); .toArray();
const { serializeAccount } = await import("../entities/account.js"); const { serializeAccount } = await import("../entities/account.js");
const accounts = interactions const accounts = notifications
.filter((i) => i.actorUrl || i.actorName) .filter((n) => n.actorUrl)
.map((i) => .map((n) =>
serializeAccount( serializeAccount(
{ {
url: i.actorUrl, url: n.actorUrl,
name: i.actorName || "", name: n.actorName || "",
handle: i.actorHandle || "", handle: n.actorHandle || "",
photo: n.actorPhoto || "",
}, },
{ baseUrl, isLocal: false }, { baseUrl, isLocal: false },
), ),
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.11.5", "version": "3.13.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.11.5", "version": "3.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fedify/debugger": "^2.1.0", "@fedify/debugger": "^2.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "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.", "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",