@rmdes/indiekit-endpoint-activitypub 3.2.0 → 3.4.0
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 +14 -0
- package/lib/mastodon/backfill-timeline.js +167 -0
- package/lib/mastodon/routes/accounts.js +65 -49
- package/lib/mastodon/routes/statuses.js +7 -2
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1552,6 +1552,20 @@ export default class ActivityPubEndpoint {
|
|
|
1552
1552
|
keyRefreshHandle,
|
|
1553
1553
|
);
|
|
1554
1554
|
|
|
1555
|
+
// Backfill ap_timeline from posts collection (idempotent, runs on every startup)
|
|
1556
|
+
import("./lib/mastodon/backfill-timeline.js").then(({ backfillTimeline }) => {
|
|
1557
|
+
// Delay to let MongoDB connections settle
|
|
1558
|
+
setTimeout(() => {
|
|
1559
|
+
backfillTimeline(this._collections).then(({ total, inserted, skipped }) => {
|
|
1560
|
+
if (inserted > 0) {
|
|
1561
|
+
console.log(`[Mastodon API] Timeline backfill: ${inserted} posts added (${skipped} already existed, ${total} total)`);
|
|
1562
|
+
}
|
|
1563
|
+
}).catch((error) => {
|
|
1564
|
+
console.warn("[Mastodon API] Timeline backfill failed:", error.message);
|
|
1565
|
+
});
|
|
1566
|
+
}, 5000);
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1555
1569
|
// Start async inbox queue processor (processes one item every 3s)
|
|
1556
1570
|
this._inboxProcessorInterval = startInboxProcessor(
|
|
1557
1571
|
this._collections,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill ap_timeline from the posts collection.
|
|
3
|
+
*
|
|
4
|
+
* Runs on startup (idempotent — uses upsert by uid).
|
|
5
|
+
* Converts Micropub JF2 posts into ap_timeline format so they
|
|
6
|
+
* appear in Mastodon Client API timelines and profile views.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Backfill ap_timeline with published posts from the posts collection.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} collections - MongoDB collections (must include posts, ap_timeline, ap_profile)
|
|
13
|
+
* @returns {Promise<{ total: number, inserted: number, skipped: number }>}
|
|
14
|
+
*/
|
|
15
|
+
export async function backfillTimeline(collections) {
|
|
16
|
+
const { posts, ap_timeline, ap_profile } = collections;
|
|
17
|
+
|
|
18
|
+
if (!posts || !ap_timeline) {
|
|
19
|
+
return { total: 0, inserted: 0, skipped: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Get local profile for author info
|
|
23
|
+
const profile = await ap_profile?.findOne({});
|
|
24
|
+
const author = profile
|
|
25
|
+
? {
|
|
26
|
+
name: profile.name || "",
|
|
27
|
+
url: profile.url || "",
|
|
28
|
+
photo: profile.icon || "",
|
|
29
|
+
handle: "",
|
|
30
|
+
}
|
|
31
|
+
: { name: "", url: "", photo: "", handle: "" };
|
|
32
|
+
|
|
33
|
+
// Fetch all published posts
|
|
34
|
+
const allPosts = await posts
|
|
35
|
+
.find({
|
|
36
|
+
"properties.post-status": { $ne: "draft" },
|
|
37
|
+
"properties.deleted": { $exists: false },
|
|
38
|
+
"properties.url": { $exists: true },
|
|
39
|
+
})
|
|
40
|
+
.toArray();
|
|
41
|
+
|
|
42
|
+
let inserted = 0;
|
|
43
|
+
let skipped = 0;
|
|
44
|
+
|
|
45
|
+
for (const post of allPosts) {
|
|
46
|
+
const props = post.properties;
|
|
47
|
+
if (!props?.url) {
|
|
48
|
+
skipped++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const uid = props.url;
|
|
53
|
+
|
|
54
|
+
// Check if already in timeline (fast path to avoid unnecessary upserts)
|
|
55
|
+
const exists = await ap_timeline.findOne({ uid }, { projection: { _id: 1 } });
|
|
56
|
+
if (exists) {
|
|
57
|
+
skipped++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Map JF2 properties to timeline item format
|
|
62
|
+
const content = normalizeContent(props.content);
|
|
63
|
+
const type = mapPostType(props["post-type"]);
|
|
64
|
+
|
|
65
|
+
const timelineItem = {
|
|
66
|
+
uid,
|
|
67
|
+
url: uid,
|
|
68
|
+
type,
|
|
69
|
+
content,
|
|
70
|
+
author,
|
|
71
|
+
published: props.published || props.date || new Date().toISOString(),
|
|
72
|
+
createdAt: props.published || props.date || new Date().toISOString(),
|
|
73
|
+
visibility: "public",
|
|
74
|
+
sensitive: false,
|
|
75
|
+
category: normalizeArray(props.category),
|
|
76
|
+
photo: normalizeMediaArray(props.photo),
|
|
77
|
+
video: normalizeMediaArray(props.video),
|
|
78
|
+
audio: normalizeMediaArray(props.audio),
|
|
79
|
+
readBy: [],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Optional fields
|
|
83
|
+
if (props.name) timelineItem.name = props.name;
|
|
84
|
+
if (props.summary) timelineItem.summary = props.summary;
|
|
85
|
+
if (props["in-reply-to"]) {
|
|
86
|
+
timelineItem.inReplyTo = Array.isArray(props["in-reply-to"])
|
|
87
|
+
? props["in-reply-to"][0]
|
|
88
|
+
: props["in-reply-to"];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const result = await ap_timeline.updateOne(
|
|
93
|
+
{ uid },
|
|
94
|
+
{ $setOnInsert: timelineItem },
|
|
95
|
+
{ upsert: true },
|
|
96
|
+
);
|
|
97
|
+
if (result.upsertedCount > 0) {
|
|
98
|
+
inserted++;
|
|
99
|
+
} else {
|
|
100
|
+
skipped++;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
skipped++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { total: allPosts.length, inserted, skipped };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Normalize content from JF2 properties to { text, html } format.
|
|
112
|
+
*/
|
|
113
|
+
function normalizeContent(content) {
|
|
114
|
+
if (!content) return { text: "", html: "" };
|
|
115
|
+
if (typeof content === "string") return { text: content, html: `<p>${content}</p>` };
|
|
116
|
+
if (typeof content === "object") {
|
|
117
|
+
return {
|
|
118
|
+
text: content.text || content.value || "",
|
|
119
|
+
html: content.html || content.text || content.value || "",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { text: "", html: "" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Map Micropub post-type to timeline type.
|
|
127
|
+
*/
|
|
128
|
+
function mapPostType(postType) {
|
|
129
|
+
switch (postType) {
|
|
130
|
+
case "article":
|
|
131
|
+
return "article";
|
|
132
|
+
case "photo":
|
|
133
|
+
case "video":
|
|
134
|
+
case "audio":
|
|
135
|
+
return "note";
|
|
136
|
+
case "reply":
|
|
137
|
+
return "note";
|
|
138
|
+
case "repost":
|
|
139
|
+
return "boost";
|
|
140
|
+
case "like":
|
|
141
|
+
return "note";
|
|
142
|
+
default:
|
|
143
|
+
return "note";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Normalize a value to an array of strings.
|
|
149
|
+
*/
|
|
150
|
+
function normalizeArray(value) {
|
|
151
|
+
if (!value) return [];
|
|
152
|
+
if (Array.isArray(value)) return value.map(String);
|
|
153
|
+
return [String(value)];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Normalize media values (can be strings or objects with url property).
|
|
158
|
+
*/
|
|
159
|
+
function normalizeMediaArray(value) {
|
|
160
|
+
if (!value) return [];
|
|
161
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
162
|
+
return arr.map((item) => {
|
|
163
|
+
if (typeof item === "string") return item;
|
|
164
|
+
if (typeof item === "object" && item.url) return item;
|
|
165
|
+
return null;
|
|
166
|
+
}).filter(Boolean);
|
|
167
|
+
}
|
|
@@ -135,57 +135,27 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
|
|
|
135
135
|
// Check if it's the local profile
|
|
136
136
|
const profile = await collections.ap_profile.findOne({});
|
|
137
137
|
if (profile && profile._id.toString() === id) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
.
|
|
147
|
-
.
|
|
148
|
-
for (const f of follower) {
|
|
149
|
-
if (remoteActorId(f.actorUrl) === id) {
|
|
150
|
-
return res.json(
|
|
151
|
-
serializeAccount(
|
|
152
|
-
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
153
|
-
{ baseUrl },
|
|
154
|
-
),
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const following = await collections.ap_following
|
|
160
|
-
.find({})
|
|
161
|
-
.toArray();
|
|
162
|
-
for (const f of following) {
|
|
163
|
-
if (remoteActorId(f.actorUrl) === id) {
|
|
164
|
-
return res.json(
|
|
165
|
-
serializeAccount(
|
|
166
|
-
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
167
|
-
{ baseUrl },
|
|
168
|
-
),
|
|
169
|
-
);
|
|
170
|
-
}
|
|
138
|
+
const [statuses, followers, following] = await Promise.all([
|
|
139
|
+
collections.ap_timeline.countDocuments({ "author.url": profile.url }),
|
|
140
|
+
collections.ap_followers.countDocuments({}),
|
|
141
|
+
collections.ap_following.countDocuments({}),
|
|
142
|
+
]);
|
|
143
|
+
const account = serializeAccount(profile, { baseUrl, isLocal: true, handle });
|
|
144
|
+
account.statuses_count = statuses;
|
|
145
|
+
account.followers_count = followers;
|
|
146
|
+
account.following_count = following;
|
|
147
|
+
return res.json(account);
|
|
171
148
|
}
|
|
172
149
|
|
|
173
|
-
//
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (!authorUrl || seenUrls.has(authorUrl)) continue;
|
|
183
|
-
seenUrls.add(authorUrl);
|
|
184
|
-
if (remoteActorId(authorUrl) === id) {
|
|
185
|
-
return res.json(
|
|
186
|
-
serializeAccount(item.author, { baseUrl }),
|
|
187
|
-
);
|
|
188
|
-
}
|
|
150
|
+
// Resolve remote actor from followers, following, or timeline
|
|
151
|
+
const { actor, actorUrl } = await resolveActorData(id, collections);
|
|
152
|
+
if (actor) {
|
|
153
|
+
const account = serializeAccount(actor, { baseUrl });
|
|
154
|
+
// Count this actor's posts in our timeline
|
|
155
|
+
account.statuses_count = await collections.ap_timeline.countDocuments({
|
|
156
|
+
"author.url": actorUrl,
|
|
157
|
+
});
|
|
158
|
+
return res.json(account);
|
|
189
159
|
}
|
|
190
160
|
|
|
191
161
|
return res.status(404).json({ error: "Record not found" });
|
|
@@ -737,4 +707,50 @@ async function resolveActorUrl(id, collections) {
|
|
|
737
707
|
return null;
|
|
738
708
|
}
|
|
739
709
|
|
|
710
|
+
/**
|
|
711
|
+
* Resolve an account ID to both actor data and URL.
|
|
712
|
+
* Returns { actor, actorUrl } or { actor: null, actorUrl: null }.
|
|
713
|
+
*/
|
|
714
|
+
async function resolveActorData(id, collections) {
|
|
715
|
+
// Check followers
|
|
716
|
+
const followers = await collections.ap_followers.find({}).toArray();
|
|
717
|
+
for (const f of followers) {
|
|
718
|
+
if (remoteActorId(f.actorUrl) === id) {
|
|
719
|
+
return {
|
|
720
|
+
actor: { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
721
|
+
actorUrl: f.actorUrl,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Check following
|
|
727
|
+
const following = await collections.ap_following.find({}).toArray();
|
|
728
|
+
for (const f of following) {
|
|
729
|
+
if (remoteActorId(f.actorUrl) === id) {
|
|
730
|
+
return {
|
|
731
|
+
actor: { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
732
|
+
actorUrl: f.actorUrl,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Check timeline authors
|
|
738
|
+
const timelineItems = await collections.ap_timeline
|
|
739
|
+
.find({ "author.url": { $exists: true } })
|
|
740
|
+
.project({ author: 1 })
|
|
741
|
+
.toArray();
|
|
742
|
+
|
|
743
|
+
const seenUrls = new Set();
|
|
744
|
+
for (const item of timelineItems) {
|
|
745
|
+
const authorUrl = item.author?.url;
|
|
746
|
+
if (!authorUrl || seenUrls.has(authorUrl)) continue;
|
|
747
|
+
seenUrls.add(authorUrl);
|
|
748
|
+
if (remoteActorId(authorUrl) === id) {
|
|
749
|
+
return { actor: item.author, actorUrl: authorUrl };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return { actor: null, actorUrl: null };
|
|
754
|
+
}
|
|
755
|
+
|
|
740
756
|
export default router;
|
|
@@ -214,9 +214,15 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
214
214
|
jf2["mp-language"] = language;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// Syndicate to AP only — posts from Mastodon clients belong to the fediverse.
|
|
218
|
+
// Never cross-post to Bluesky (conversations stay in their protocol).
|
|
219
|
+
// The publication URL is the AP syndicator's uid.
|
|
220
|
+
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
|
|
221
|
+
jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"];
|
|
222
|
+
|
|
217
223
|
// Create post via Micropub pipeline (same functions the Micropub endpoint uses)
|
|
218
224
|
// postData.create() handles: normalization, post type detection, path rendering,
|
|
219
|
-
// mp-syndicate-to
|
|
225
|
+
// mp-syndicate-to validated against configured syndicators, MongoDB posts collection
|
|
220
226
|
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
|
|
221
227
|
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
|
|
222
228
|
|
|
@@ -230,7 +236,6 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
230
236
|
// Add to ap_timeline so the post is visible in the Mastodon Client API
|
|
231
237
|
const profile = await collections.ap_profile.findOne({});
|
|
232
238
|
const handle = pluginOptions.handle || "user";
|
|
233
|
-
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
|
|
234
239
|
const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
|
|
235
240
|
|
|
236
241
|
const now = new Date().toISOString();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
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",
|