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