@rmdes/indiekit-endpoint-activitypub 3.13.2 → 3.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +1 -0
- package/lib/mastodon/helpers/id-mapping.js +10 -11
- package/lib/mastodon/routes/accounts.js +21 -3
- package/lib/mastodon/routes/statuses.js +31 -20
- package/lib/timeline-cleanup.js +22 -9
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1119,6 +1119,7 @@ export default class ActivityPubEndpoint {
|
|
|
1119
1119
|
federation: this._federation,
|
|
1120
1120
|
followActor: (url, info) => pluginRef.followActor(url, info),
|
|
1121
1121
|
unfollowActor: (url) => pluginRef.unfollowActor(url),
|
|
1122
|
+
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
|
|
1122
1123
|
loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
|
|
1123
1124
|
},
|
|
1124
1125
|
});
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deterministic ID mapping for Mastodon Client API.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* All accounts (local and remote) use sha256(actorUrl).slice(0, 24)
|
|
5
|
+
* for stable, consistent IDs. This ensures verify_credentials and
|
|
6
|
+
* status serialization produce the same ID for the local user,
|
|
7
|
+
* even though the profile doc has _id but timeline author objects don't.
|
|
7
8
|
*/
|
|
8
9
|
import crypto from "node:crypto";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
* Generate a deterministic ID for
|
|
12
|
-
* @param {string} actorUrl - The
|
|
12
|
+
* Generate a deterministic ID for an actor URL.
|
|
13
|
+
* @param {string} actorUrl - The actor's URL
|
|
13
14
|
* @returns {string} 24-character hex ID
|
|
14
15
|
*/
|
|
15
16
|
export function remoteActorId(actorUrl) {
|
|
@@ -18,15 +19,13 @@ export function remoteActorId(actorUrl) {
|
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Get the Mastodon API ID for an account.
|
|
22
|
+
* Uses URL-based hash for all accounts (local and remote) so the ID
|
|
23
|
+
* is consistent regardless of whether the actor object has a MongoDB _id.
|
|
21
24
|
* @param {object} actor - Actor object (local profile or remote author)
|
|
22
|
-
* @param {boolean}
|
|
25
|
+
* @param {boolean} _isLocal - Unused (kept for API compatibility)
|
|
23
26
|
* @returns {string}
|
|
24
27
|
*/
|
|
25
|
-
export function accountId(actor,
|
|
26
|
-
if (isLocal && actor._id) {
|
|
27
|
-
return actor._id.toString();
|
|
28
|
-
}
|
|
29
|
-
// Remote actors: use URL-based deterministic hash
|
|
28
|
+
export function accountId(actor, _isLocal = false) {
|
|
30
29
|
const url = actor.url || actor.actorUrl || "";
|
|
31
30
|
return url ? remoteActorId(url) : "0";
|
|
32
31
|
}
|
|
@@ -306,6 +306,13 @@ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired
|
|
|
306
306
|
|
|
307
307
|
if (Object.keys(update).length > 0 && collections.ap_profile) {
|
|
308
308
|
await collections.ap_profile.updateOne({}, { $set: update });
|
|
309
|
+
|
|
310
|
+
// Broadcast Update(Person) to followers so profile changes federate
|
|
311
|
+
if (pluginOptions.broadcastActorUpdate) {
|
|
312
|
+
pluginOptions.broadcastActorUpdate().catch((err) =>
|
|
313
|
+
console.warn(`[Mastodon API] broadcastActorUpdate failed: ${err.message}`),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
309
316
|
}
|
|
310
317
|
|
|
311
318
|
// Return updated credential account
|
|
@@ -313,12 +320,23 @@ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired
|
|
|
313
320
|
? await collections.ap_profile.findOne({})
|
|
314
321
|
: {};
|
|
315
322
|
|
|
323
|
+
const handle = pluginOptions.handle || "user";
|
|
324
|
+
let counts = {};
|
|
325
|
+
try {
|
|
326
|
+
const [statuses, followers, following] = await Promise.all([
|
|
327
|
+
collections.ap_timeline.countDocuments({ "author.url": profile.url }),
|
|
328
|
+
collections.ap_followers.countDocuments({}),
|
|
329
|
+
collections.ap_following.countDocuments({}),
|
|
330
|
+
]);
|
|
331
|
+
counts = { statuses, followers, following };
|
|
332
|
+
} catch {
|
|
333
|
+
counts = { statuses: 0, followers: 0, following: 0 };
|
|
334
|
+
}
|
|
335
|
+
|
|
316
336
|
const { serializeCredentialAccount } = await import(
|
|
317
337
|
"../entities/account.js"
|
|
318
338
|
);
|
|
319
|
-
res.json(
|
|
320
|
-
await serializeCredentialAccount(profile, { baseUrl, collections }),
|
|
321
|
-
);
|
|
339
|
+
res.json(serializeCredentialAccount(profile, { baseUrl, handle, counts }));
|
|
322
340
|
} catch (error) {
|
|
323
341
|
next(error);
|
|
324
342
|
}
|
|
@@ -466,6 +466,13 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
|
|
|
466
466
|
if (statusText !== undefined) {
|
|
467
467
|
updatePayload.replace.content = [statusText];
|
|
468
468
|
}
|
|
469
|
+
if (spoilerText !== undefined) {
|
|
470
|
+
updatePayload.replace["content-warning"] = spoilerText ? [spoilerText] : [];
|
|
471
|
+
updatePayload.replace.sensitive = [spoilerText ? "true" : "false"];
|
|
472
|
+
}
|
|
473
|
+
if (sensitive !== undefined && spoilerText === undefined) {
|
|
474
|
+
updatePayload.replace.sensitive = [sensitive === true || sensitive === "true" ? "true" : "false"];
|
|
475
|
+
}
|
|
469
476
|
|
|
470
477
|
try {
|
|
471
478
|
await fetch(micropubUrl, {
|
|
@@ -604,23 +611,25 @@ router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("r
|
|
|
604
611
|
);
|
|
605
612
|
if (!item) return res.status(404).json({ error: "Record not found" });
|
|
606
613
|
|
|
607
|
-
const
|
|
608
|
-
if (!
|
|
614
|
+
const targetUrl = item.uid || item.url;
|
|
615
|
+
if (!targetUrl || !collections.ap_notifications) return res.json([]);
|
|
609
616
|
|
|
610
|
-
|
|
611
|
-
|
|
617
|
+
// Incoming likes are stored as notifications by the inbox handler
|
|
618
|
+
const notifications = await collections.ap_notifications
|
|
619
|
+
.find({ targetUrl, type: "like" })
|
|
612
620
|
.limit(40)
|
|
613
621
|
.toArray();
|
|
614
622
|
|
|
615
623
|
const { serializeAccount } = await import("../entities/account.js");
|
|
616
|
-
const accounts =
|
|
617
|
-
.filter((
|
|
618
|
-
.map((
|
|
624
|
+
const accounts = notifications
|
|
625
|
+
.filter((n) => n.actorUrl)
|
|
626
|
+
.map((n) =>
|
|
619
627
|
serializeAccount(
|
|
620
628
|
{
|
|
621
|
-
url:
|
|
622
|
-
name:
|
|
623
|
-
handle:
|
|
629
|
+
url: n.actorUrl,
|
|
630
|
+
name: n.actorName || "",
|
|
631
|
+
handle: n.actorHandle || "",
|
|
632
|
+
photo: n.actorPhoto || "",
|
|
624
633
|
},
|
|
625
634
|
{ baseUrl, isLocal: false },
|
|
626
635
|
),
|
|
@@ -645,23 +654,25 @@ router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("re
|
|
|
645
654
|
);
|
|
646
655
|
if (!item) return res.status(404).json({ error: "Record not found" });
|
|
647
656
|
|
|
648
|
-
const
|
|
649
|
-
if (!
|
|
657
|
+
const targetUrl = item.uid || item.url;
|
|
658
|
+
if (!targetUrl || !collections.ap_notifications) return res.json([]);
|
|
650
659
|
|
|
651
|
-
|
|
652
|
-
|
|
660
|
+
// Incoming boosts are stored as notifications by the inbox handler
|
|
661
|
+
const notifications = await collections.ap_notifications
|
|
662
|
+
.find({ targetUrl, type: "boost" })
|
|
653
663
|
.limit(40)
|
|
654
664
|
.toArray();
|
|
655
665
|
|
|
656
666
|
const { serializeAccount } = await import("../entities/account.js");
|
|
657
|
-
const accounts =
|
|
658
|
-
.filter((
|
|
659
|
-
.map((
|
|
667
|
+
const accounts = notifications
|
|
668
|
+
.filter((n) => n.actorUrl)
|
|
669
|
+
.map((n) =>
|
|
660
670
|
serializeAccount(
|
|
661
671
|
{
|
|
662
|
-
url:
|
|
663
|
-
name:
|
|
664
|
-
handle:
|
|
672
|
+
url: n.actorUrl,
|
|
673
|
+
name: n.actorName || "",
|
|
674
|
+
handle: n.actorHandle || "",
|
|
675
|
+
photo: n.actorPhoto || "",
|
|
665
676
|
},
|
|
666
677
|
{ baseUrl, isLocal: false },
|
|
667
678
|
),
|
package/lib/timeline-cleanup.js
CHANGED
|
@@ -19,19 +19,32 @@ export async function cleanupTimeline(collections, retentionLimit) {
|
|
|
19
19
|
return { removed: 0, interactionsRemoved: 0 };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Get the local profile URL to exempt own posts from cleanup.
|
|
23
|
+
// Own posts are your content — they should never be deleted by retention.
|
|
24
|
+
const profile = collections.ap_profile
|
|
25
|
+
? await collections.ap_profile.findOne({})
|
|
26
|
+
: null;
|
|
27
|
+
const ownerUrl = profile?.url || null;
|
|
28
|
+
|
|
29
|
+
// Only count remote posts toward retention limit
|
|
30
|
+
const remoteFilter = ownerUrl
|
|
31
|
+
? { "author.url": { $ne: ownerUrl } }
|
|
32
|
+
: {};
|
|
33
|
+
const remoteCount = await collections.ap_timeline.countDocuments(remoteFilter);
|
|
34
|
+
if (remoteCount <= retentionLimit) {
|
|
24
35
|
return { removed: 0, interactionsRemoved: 0 };
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
38
|
+
// Find remote items beyond the retention limit, sorted newest-first.
|
|
39
|
+
// Own posts are excluded from the aggregation pipeline entirely.
|
|
40
|
+
const pipeline = [
|
|
41
|
+
...(ownerUrl ? [{ $match: { "author.url": { $ne: ownerUrl } } }] : []),
|
|
42
|
+
{ $sort: { published: -1 } },
|
|
43
|
+
{ $skip: retentionLimit },
|
|
44
|
+
{ $project: { uid: 1 } },
|
|
45
|
+
];
|
|
29
46
|
const toDelete = await collections.ap_timeline
|
|
30
|
-
.aggregate(
|
|
31
|
-
{ $sort: { published: -1 } },
|
|
32
|
-
{ $skip: retentionLimit },
|
|
33
|
-
{ $project: { uid: 1 } },
|
|
34
|
-
])
|
|
47
|
+
.aggregate(pipeline)
|
|
35
48
|
.toArray();
|
|
36
49
|
|
|
37
50
|
if (!toDelete.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.4",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|