@rmdes/indiekit-endpoint-activitypub 3.2.0 → 3.3.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 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
- return res.json(
139
- serializeAccount(profile, { baseUrl, isLocal: true, handle }),
140
- );
141
- }
142
-
143
- // Search known actors (followers, following, timeline authors)
144
- // by checking if the deterministic hash matches
145
- const follower = await collections.ap_followers
146
- .find({})
147
- .toArray();
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
- // Try timeline authors find any post whose author URL hashes to this ID
174
- const timelineItems = await collections.ap_timeline
175
- .find({ "author.url": { $exists: true } })
176
- .project({ author: 1 })
177
- .toArray();
178
-
179
- const seenUrls = new Set();
180
- for (const item of timelineItems) {
181
- const authorUrl = item.author?.url;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.2.0",
3
+ "version": "3.3.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",