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:
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user