@rmdes/indiekit-endpoint-activitypub 3.0.0 → 3.2.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.
@@ -6,7 +6,9 @@
6
6
  */
7
7
  import express from "express";
8
8
  import { serializeCredentialAccount, serializeAccount } from "../entities/account.js";
9
+ import { serializeStatus } from "../entities/status.js";
9
10
  import { accountId, remoteActorId } from "../helpers/id-mapping.js";
11
+ import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
10
12
 
11
13
  const router = express.Router(); // eslint-disable-line new-cap
12
14
 
@@ -168,17 +170,187 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
168
170
  }
169
171
  }
170
172
 
171
- // Try timeline authors
172
- const timelineItem = await collections.ap_timeline.findOne({
173
- $expr: { $ne: [{ $type: "$author.url" }, "missing"] },
174
- });
175
- // For now, if not found in known actors, return 404
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
+ }
189
+ }
190
+
176
191
  return res.status(404).json({ error: "Record not found" });
177
192
  } catch (error) {
178
193
  next(error);
179
194
  }
180
195
  });
181
196
 
197
+ // ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
198
+
199
+ router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
200
+ try {
201
+ const { id } = req.params;
202
+ const collections = req.app.locals.mastodonCollections;
203
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
204
+ const limit = parseLimit(req.query.limit);
205
+
206
+ // Resolve account ID to an author URL
207
+ const actorUrl = await resolveActorUrl(id, collections);
208
+ if (!actorUrl) {
209
+ return res.status(404).json({ error: "Record not found" });
210
+ }
211
+
212
+ // Build filter for this author's posts
213
+ const baseFilter = {
214
+ "author.url": actorUrl,
215
+ isContext: { $ne: true },
216
+ };
217
+
218
+ // Mastodon filters
219
+ if (req.query.only_media === "true") {
220
+ baseFilter.$or = [
221
+ { "photo.0": { $exists: true } },
222
+ { "video.0": { $exists: true } },
223
+ { "audio.0": { $exists: true } },
224
+ ];
225
+ }
226
+ if (req.query.exclude_replies === "true") {
227
+ baseFilter.inReplyTo = { $exists: false };
228
+ }
229
+ if (req.query.exclude_reblogs === "true") {
230
+ baseFilter.type = { $ne: "boost" };
231
+ }
232
+ if (req.query.pinned === "true") {
233
+ baseFilter.pinned = true;
234
+ }
235
+
236
+ const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
237
+ max_id: req.query.max_id,
238
+ min_id: req.query.min_id,
239
+ since_id: req.query.since_id,
240
+ });
241
+
242
+ let items = await collections.ap_timeline
243
+ .find(filter)
244
+ .sort(sort)
245
+ .limit(limit)
246
+ .toArray();
247
+
248
+ if (reverse) {
249
+ items.reverse();
250
+ }
251
+
252
+ // Load interaction state if authenticated
253
+ let favouritedIds = new Set();
254
+ let rebloggedIds = new Set();
255
+ let bookmarkedIds = new Set();
256
+
257
+ if (req.mastodonToken && collections.ap_interactions) {
258
+ const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
259
+ if (lookupUrls.length > 0) {
260
+ const interactions = await collections.ap_interactions
261
+ .find({ objectUrl: { $in: lookupUrls } })
262
+ .toArray();
263
+ for (const ix of interactions) {
264
+ if (ix.type === "like") favouritedIds.add(ix.objectUrl);
265
+ else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
266
+ else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
267
+ }
268
+ }
269
+ }
270
+
271
+ const statuses = items.map((item) =>
272
+ serializeStatus(item, {
273
+ baseUrl,
274
+ favouritedIds,
275
+ rebloggedIds,
276
+ bookmarkedIds,
277
+ pinnedIds: new Set(),
278
+ }),
279
+ );
280
+
281
+ setPaginationHeaders(res, req, items, limit);
282
+ res.json(statuses);
283
+ } catch (error) {
284
+ next(error);
285
+ }
286
+ });
287
+
288
+ // ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
289
+
290
+ router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
291
+ try {
292
+ const { id } = req.params;
293
+ const collections = req.app.locals.mastodonCollections;
294
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
295
+ const limit = parseLimit(req.query.limit);
296
+ const profile = await collections.ap_profile.findOne({});
297
+
298
+ // Only serve followers for the local account
299
+ if (!profile || profile._id.toString() !== id) {
300
+ return res.json([]);
301
+ }
302
+
303
+ const followers = await collections.ap_followers
304
+ .find({})
305
+ .limit(limit)
306
+ .toArray();
307
+
308
+ const accounts = followers.map((f) =>
309
+ serializeAccount(
310
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
311
+ { baseUrl },
312
+ ),
313
+ );
314
+
315
+ res.json(accounts);
316
+ } catch (error) {
317
+ next(error);
318
+ }
319
+ });
320
+
321
+ // ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
322
+
323
+ router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
324
+ try {
325
+ const { id } = req.params;
326
+ const collections = req.app.locals.mastodonCollections;
327
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
328
+ const limit = parseLimit(req.query.limit);
329
+ const profile = await collections.ap_profile.findOne({});
330
+
331
+ // Only serve following for the local account
332
+ if (!profile || profile._id.toString() !== id) {
333
+ return res.json([]);
334
+ }
335
+
336
+ const following = await collections.ap_following
337
+ .find({})
338
+ .limit(limit)
339
+ .toArray();
340
+
341
+ const accounts = following.map((f) =>
342
+ serializeAccount(
343
+ { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
344
+ { baseUrl },
345
+ ),
346
+ );
347
+
348
+ res.json(accounts);
349
+ } catch (error) {
350
+ next(error);
351
+ }
352
+ });
353
+
182
354
  // ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
183
355
 
184
356
  router.get("/api/v1/accounts/relationships", async (req, res, next) => {
@@ -546,6 +718,22 @@ async function resolveActorUrl(id, collections) {
546
718
  }
547
719
  }
548
720
 
721
+ // Check timeline authors
722
+ const timelineItems = await collections.ap_timeline
723
+ .find({ "author.url": { $exists: true } })
724
+ .project({ "author.url": 1 })
725
+ .toArray();
726
+
727
+ const seenUrls = new Set();
728
+ for (const item of timelineItems) {
729
+ const authorUrl = item.author?.url;
730
+ if (!authorUrl || seenUrls.has(authorUrl)) continue;
731
+ seenUrls.add(authorUrl);
732
+ if (remoteActorId(authorUrl) === id) {
733
+ return authorUrl;
734
+ }
735
+ }
736
+
549
737
  return null;
550
738
  }
551
739
 
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * GET /api/v1/statuses/:id — single status
5
5
  * GET /api/v1/statuses/:id/context — thread context (ancestors + descendants)
6
+ * POST /api/v1/statuses — create post via Micropub pipeline
7
+ * DELETE /api/v1/statuses/:id — delete post via Micropub pipeline
6
8
  * POST /api/v1/statuses/:id/favourite — like a post
7
9
  * POST /api/v1/statuses/:id/unfavourite — unlike a post
8
10
  * POST /api/v1/statuses/:id/reblog — boost a post
@@ -13,12 +15,12 @@
13
15
  import express from "express";
14
16
  import { ObjectId } from "mongodb";
15
17
  import { serializeStatus } from "../entities/status.js";
16
- import { serializeAccount } from "../entities/account.js";
17
18
  import {
18
19
  likePost, unlikePost,
19
20
  boostPost, unboostPost,
20
21
  bookmarkPost, unbookmarkPost,
21
22
  } from "../helpers/interactions.js";
23
+ import { addTimelineItem } from "../../storage/timeline.js";
22
24
 
23
25
  const router = express.Router(); // eslint-disable-line new-cap
24
26
 
@@ -142,6 +144,8 @@ router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
142
144
  });
143
145
 
144
146
  // ─── POST /api/v1/statuses ───────────────────────────────────────────────────
147
+ // Creates a post via the Micropub pipeline so it goes through the full flow:
148
+ // Micropub → content file → Eleventy build → syndication → AP federation.
145
149
 
146
150
  router.post("/api/v1/statuses", async (req, res, next) => {
147
151
  try {
@@ -150,6 +154,7 @@ router.post("/api/v1/statuses", async (req, res, next) => {
150
154
  return res.status(401).json({ error: "The access token is invalid" });
151
155
  }
152
156
 
157
+ const { application, publication } = req.app.locals;
153
158
  const collections = req.app.locals.mastodonCollections;
154
159
  const pluginOptions = req.app.locals.mastodonPluginOptions || {};
155
160
  const baseUrl = `${req.protocol}://${req.get("host")}`;
@@ -168,7 +173,7 @@ router.post("/api/v1/statuses", async (req, res, next) => {
168
173
  return res.status(422).json({ error: "Validation failed: Text content is required" });
169
174
  }
170
175
 
171
- // Resolve in_reply_to if provided
176
+ // Resolve in_reply_to URL from timeline ObjectId
172
177
  let inReplyTo = null;
173
178
  if (inReplyToId) {
174
179
  try {
@@ -183,33 +188,63 @@ router.post("/api/v1/statuses", async (req, res, next) => {
183
188
  }
184
189
  }
185
190
 
186
- // Load local profile for the author field
191
+ // Build JF2 properties for the Micropub pipeline
192
+ const jf2 = {
193
+ type: "entry",
194
+ content: statusText || "",
195
+ };
196
+
197
+ if (inReplyTo) {
198
+ jf2["in-reply-to"] = inReplyTo;
199
+ }
200
+
201
+ if (spoilerText) {
202
+ jf2.summary = spoilerText;
203
+ }
204
+
205
+ if (sensitive === true || sensitive === "true") {
206
+ jf2.sensitive = "true";
207
+ }
208
+
209
+ if (visibility && visibility !== "public") {
210
+ jf2.visibility = visibility;
211
+ }
212
+
213
+ if (language) {
214
+ jf2["mp-language"] = language;
215
+ }
216
+
217
+ // Create post via Micropub pipeline (same functions the Micropub endpoint uses)
218
+ // postData.create() handles: normalization, post type detection, path rendering,
219
+ // mp-syndicate-to auto-set (from checked syndicators), MongoDB posts collection
220
+ const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
221
+ const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
222
+
223
+ const data = await postData.create(application, publication, jf2);
224
+ // postContent.create() handles: template rendering, file creation in store
225
+ await postContent.create(publication, data);
226
+
227
+ const postUrl = data.properties.url;
228
+ console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
229
+
230
+ // Add to ap_timeline so the post is visible in the Mastodon Client API
187
231
  const profile = await collections.ap_profile.findOne({});
188
232
  const handle = pluginOptions.handle || "user";
189
233
  const publicationUrl = pluginOptions.publicationUrl || baseUrl;
190
234
  const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
191
235
 
192
- // Generate post ID and URL
193
- const postId = crypto.randomUUID();
194
- const postUrl = `${publicationUrl.replace(/\/$/, "")}/posts/${postId}`;
195
- const uid = postUrl;
196
-
197
- // Build the timeline item
198
236
  const now = new Date().toISOString();
199
- const timelineItem = {
200
- uid,
237
+ const timelineItem = await addTimelineItem(collections, {
238
+ uid: postUrl,
201
239
  url: postUrl,
202
- type: "note",
203
- content: {
204
- text: statusText || "",
205
- html: linkifyAndParagraph(statusText || ""),
206
- },
240
+ type: data.properties["post-type"] || "note",
241
+ content: data.properties.content || { text: statusText || "", html: "" },
207
242
  summary: spoilerText || "",
208
243
  sensitive: sensitive === true || sensitive === "true",
209
244
  visibility: visibility || "public",
210
245
  language: language || null,
211
246
  inReplyTo,
212
- published: now,
247
+ published: data.properties.published || now,
213
248
  createdAt: now,
214
249
  author: {
215
250
  name: profile?.name || handle,
@@ -219,26 +254,15 @@ router.post("/api/v1/statuses", async (req, res, next) => {
219
254
  emojis: [],
220
255
  bot: false,
221
256
  },
222
- photo: [],
223
- video: [],
224
- audio: [],
225
- category: extractHashtags(statusText || ""),
257
+ photo: data.properties.photo || [],
258
+ video: data.properties.video || [],
259
+ audio: data.properties.audio || [],
260
+ category: data.properties.category || [],
226
261
  counts: { replies: 0, boosts: 0, likes: 0 },
227
262
  linkPreviews: [],
228
263
  mentions: [],
229
264
  emojis: [],
230
- };
231
-
232
- // Insert into timeline
233
- const result = await collections.ap_timeline.insertOne(timelineItem);
234
- timelineItem._id = result.insertedId;
235
-
236
- // Trigger federation asynchronously (don't block the response)
237
- if (pluginOptions.federation) {
238
- federatePost(timelineItem, pluginOptions).catch((err) => {
239
- console.error("[Mastodon API] Federation failed:", err.message);
240
- });
241
- }
265
+ });
242
266
 
243
267
  // Serialize and return
244
268
  const serialized = serializeStatus(timelineItem, {
@@ -256,6 +280,8 @@ router.post("/api/v1/statuses", async (req, res, next) => {
256
280
  });
257
281
 
258
282
  // ─── DELETE /api/v1/statuses/:id ────────────────────────────────────────────
283
+ // Deletes via Micropub pipeline (removes content file + MongoDB post) and
284
+ // cleans up the ap_timeline entry.
259
285
 
260
286
  router.delete("/api/v1/statuses/:id", async (req, res, next) => {
261
287
  try {
@@ -264,6 +290,7 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
264
290
  return res.status(401).json({ error: "The access token is invalid" });
265
291
  }
266
292
 
293
+ const { application, publication } = req.app.locals;
267
294
  const { id } = req.params;
268
295
  const collections = req.app.locals.mastodonCollections;
269
296
  const baseUrl = `${req.protocol}://${req.get("host")}`;
@@ -296,6 +323,23 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
296
323
  });
297
324
  serialized.text = item.content?.text || "";
298
325
 
326
+ // Delete via Micropub pipeline (removes content file from store + MongoDB posts)
327
+ const postUrl = item.uid || item.url;
328
+ try {
329
+ const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
330
+ const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
331
+
332
+ const existingPost = await postData.read(application, postUrl);
333
+ if (existingPost) {
334
+ const deletedData = await postData.delete(application, postUrl);
335
+ await postContent.delete(publication, deletedData);
336
+ console.info(`[Mastodon API] Deleted post via Micropub: ${postUrl}`);
337
+ }
338
+ } catch (err) {
339
+ // Log but don't block — the post may not exist in Micropub (e.g. old pre-pipeline posts)
340
+ console.warn(`[Mastodon API] Micropub delete failed for ${postUrl}: ${err.message}`);
341
+ }
342
+
299
343
  // Delete from timeline
300
344
  await collections.ap_timeline.deleteOne({ _id: objectId });
301
345
 
@@ -304,8 +348,6 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
304
348
  await collections.ap_interactions.deleteMany({ objectUrl: item.uid });
305
349
  }
306
350
 
307
- // TODO: Broadcast Delete activity via federation
308
-
309
351
  res.json(serialized);
310
352
  } catch (error) {
311
353
  next(error);
@@ -560,75 +602,4 @@ async function loadItemInteractions(collections, item) {
560
602
  return { favouritedIds, rebloggedIds, bookmarkedIds };
561
603
  }
562
604
 
563
- /**
564
- * Convert plain text to basic HTML (paragraphs + linkified URLs).
565
- */
566
- function linkifyAndParagraph(text) {
567
- if (!text) return "";
568
- const paragraphs = text.split(/\n\n+/).filter(Boolean);
569
- return paragraphs
570
- .map((p) => {
571
- const withBreaks = p.replace(/\n/g, "<br>");
572
- const linked = withBreaks.replace(
573
- /(?<![=">])(https?:\/\/[^\s<"]+)/g,
574
- '<a href="$1">$1</a>',
575
- );
576
- return `<p>${linked}</p>`;
577
- })
578
- .join("");
579
- }
580
-
581
- /**
582
- * Extract #hashtags from text content.
583
- */
584
- function extractHashtags(text) {
585
- if (!text) return [];
586
- const tags = [];
587
- const regex = /#([\w]+)/g;
588
- let match;
589
- while ((match = regex.exec(text)) !== null) {
590
- tags.push(match[1]);
591
- }
592
- return [...new Set(tags)];
593
- }
594
-
595
- /**
596
- * Federate a newly created post via ActivityPub.
597
- * Runs asynchronously — errors logged, don't block API response.
598
- */
599
- async function federatePost(item, pluginOptions) {
600
- const { jf2ToAS2Activity } = await import("../../jf2-to-as2.js");
601
-
602
- const handle = pluginOptions.handle || "user";
603
- const publicationUrl = pluginOptions.publicationUrl;
604
- const federation = pluginOptions.federation;
605
- const actorUrl = `${publicationUrl.replace(/\/$/, "")}/users/${handle}`;
606
-
607
- const ctx = federation.createContext(
608
- new URL(publicationUrl),
609
- { handle, publicationUrl },
610
- );
611
-
612
- const properties = {
613
- "post-type": "note",
614
- url: item.url,
615
- content: item.content,
616
- summary: item.summary || undefined,
617
- "in-reply-to": item.inReplyTo || undefined,
618
- category: item.category,
619
- visibility: item.visibility,
620
- };
621
-
622
- const activity = jf2ToAS2Activity(properties, actorUrl, publicationUrl, {
623
- visibility: item.visibility,
624
- });
625
-
626
- if (activity) {
627
- await ctx.sendActivity({ identifier: handle }, "followers", activity, {
628
- preferSharedInbox: true,
629
- });
630
- console.info(`[Mastodon API] Federated post: ${item.url}`);
631
- }
632
- }
633
-
634
605
  export default router;
@@ -99,7 +99,22 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
99
99
  visibility: "public",
100
100
  };
101
101
 
102
- // Only original posts (exclude boosts from public timeline unless local=true)
102
+ // Local timeline: only posts from the local instance author
103
+ if (req.query.local === "true") {
104
+ const profile = await collections.ap_profile.findOne({});
105
+ if (profile?.url) {
106
+ baseFilter["author.url"] = profile.url;
107
+ }
108
+ }
109
+
110
+ // Remote-only: exclude local author posts
111
+ if (req.query.remote === "true") {
112
+ const profile = await collections.ap_profile.findOne({});
113
+ if (profile?.url) {
114
+ baseFilter["author.url"] = { $ne: profile.url };
115
+ }
116
+ }
117
+
103
118
  if (req.query.only_media === "true") {
104
119
  baseFilter.$or = [
105
120
  { "photo.0": { $exists: true } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.0.0",
3
+ "version": "3.2.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",
@@ -47,6 +47,7 @@
47
47
  "unfurl.js": "^6.4.0"
48
48
  },
49
49
  "peerDependencies": {
50
+ "@indiekit/endpoint-micropub": "^1.0.0-beta.25",
50
51
  "@indiekit/error": "^1.0.0-beta.25",
51
52
  "@indiekit/frontend": "^1.0.0-beta.25"
52
53
  },