From 40eb2f8f095dd4789a85af05af26dbcdf0d9f381 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 1 Apr 2026 15:12:27 +0200 Subject: [PATCH] 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 --- index.js | 1 + lib/mastodon/helpers/id-mapping.js | 21 +++++---- lib/mastodon/routes/accounts.js | 24 ++++++++-- lib/mastodon/routes/statuses.js | 70 ++++++++++++++++++------------ package-lock.json | 4 +- package.json | 2 +- 6 files changed, 78 insertions(+), 44 deletions(-) diff --git a/index.js b/index.js index 75cf524..b41e949 100644 --- a/index.js +++ b/index.js @@ -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(), }, }); diff --git a/lib/mastodon/helpers/id-mapping.js b/lib/mastodon/helpers/id-mapping.js index 76e6d23..31de062 100644 --- a/lib/mastodon/helpers/id-mapping.js +++ b/lib/mastodon/helpers/id-mapping.js @@ -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"; } diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index f47cbbf..37432e4 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -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); } diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index fe7fd71..72c333e 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -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 }, ), diff --git a/package-lock.json b/package-lock.json index c003eb2..fadd5e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f039954..5d7e57c 100644 --- a/package.json +++ b/package.json @@ -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",