@rmdes/indiekit-endpoint-activitypub 2.15.4 → 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.
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Status endpoints for Mastodon Client API.
3
+ *
4
+ * GET /api/v1/statuses/:id — single status
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
8
+ * POST /api/v1/statuses/:id/favourite — like a post
9
+ * POST /api/v1/statuses/:id/unfavourite — unlike a post
10
+ * POST /api/v1/statuses/:id/reblog — boost a post
11
+ * POST /api/v1/statuses/:id/unreblog — unboost a post
12
+ * POST /api/v1/statuses/:id/bookmark — bookmark a post
13
+ * POST /api/v1/statuses/:id/unbookmark — remove bookmark
14
+ */
15
+ import express from "express";
16
+ import { ObjectId } from "mongodb";
17
+ import { serializeStatus } from "../entities/status.js";
18
+ import {
19
+ likePost, unlikePost,
20
+ boostPost, unboostPost,
21
+ bookmarkPost, unbookmarkPost,
22
+ } from "../helpers/interactions.js";
23
+ import { addTimelineItem } from "../../storage/timeline.js";
24
+
25
+ const router = express.Router(); // eslint-disable-line new-cap
26
+
27
+ // ─── GET /api/v1/statuses/:id ───────────────────────────────────────────────
28
+
29
+ router.get("/api/v1/statuses/:id", async (req, res, next) => {
30
+ try {
31
+ const { id } = req.params;
32
+ const collections = req.app.locals.mastodonCollections;
33
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
34
+
35
+ let objectId;
36
+ try {
37
+ objectId = new ObjectId(id);
38
+ } catch {
39
+ return res.status(404).json({ error: "Record not found" });
40
+ }
41
+
42
+ const item = await collections.ap_timeline.findOne({ _id: objectId });
43
+ if (!item) {
44
+ return res.status(404).json({ error: "Record not found" });
45
+ }
46
+
47
+ // Load interaction state if authenticated
48
+ const interactionState = await loadItemInteractions(collections, item);
49
+
50
+ const status = serializeStatus(item, {
51
+ baseUrl,
52
+ ...interactionState,
53
+ pinnedIds: new Set(),
54
+ });
55
+
56
+ res.json(status);
57
+ } catch (error) {
58
+ next(error);
59
+ }
60
+ });
61
+
62
+ // ─── GET /api/v1/statuses/:id/context ───────────────────────────────────────
63
+
64
+ router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
65
+ try {
66
+ const { id } = req.params;
67
+ const collections = req.app.locals.mastodonCollections;
68
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
69
+
70
+ let objectId;
71
+ try {
72
+ objectId = new ObjectId(id);
73
+ } catch {
74
+ return res.status(404).json({ error: "Record not found" });
75
+ }
76
+
77
+ const item = await collections.ap_timeline.findOne({ _id: objectId });
78
+ if (!item) {
79
+ return res.status(404).json({ error: "Record not found" });
80
+ }
81
+
82
+ // Find ancestors: walk up the inReplyTo chain
83
+ const ancestors = [];
84
+ let currentReplyTo = item.inReplyTo;
85
+ const visited = new Set();
86
+
87
+ while (currentReplyTo && ancestors.length < 40) {
88
+ if (visited.has(currentReplyTo)) break;
89
+ visited.add(currentReplyTo);
90
+
91
+ const parent = await collections.ap_timeline.findOne({
92
+ $or: [{ uid: currentReplyTo }, { url: currentReplyTo }],
93
+ });
94
+ if (!parent) break;
95
+
96
+ ancestors.unshift(parent);
97
+ currentReplyTo = parent.inReplyTo;
98
+ }
99
+
100
+ // Find descendants: items that reply to this post's uid or url
101
+ const targetUrls = [item.uid, item.url].filter(Boolean);
102
+ let descendants = [];
103
+
104
+ if (targetUrls.length > 0) {
105
+ // Get direct replies first
106
+ const directReplies = await collections.ap_timeline
107
+ .find({ inReplyTo: { $in: targetUrls } })
108
+ .sort({ _id: 1 })
109
+ .limit(60)
110
+ .toArray();
111
+
112
+ descendants = directReplies;
113
+
114
+ // Also fetch replies to direct replies (2 levels deep)
115
+ if (directReplies.length > 0) {
116
+ const replyUrls = directReplies
117
+ .flatMap((r) => [r.uid, r.url].filter(Boolean));
118
+ const nestedReplies = await collections.ap_timeline
119
+ .find({ inReplyTo: { $in: replyUrls } })
120
+ .sort({ _id: 1 })
121
+ .limit(60)
122
+ .toArray();
123
+ descendants.push(...nestedReplies);
124
+ }
125
+ }
126
+
127
+ // Serialize all items
128
+ const emptyInteractions = {
129
+ favouritedIds: new Set(),
130
+ rebloggedIds: new Set(),
131
+ bookmarkedIds: new Set(),
132
+ pinnedIds: new Set(),
133
+ };
134
+
135
+ const serializeOpts = { baseUrl, ...emptyInteractions };
136
+
137
+ res.json({
138
+ ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
139
+ descendants: descendants.map((d) => serializeStatus(d, serializeOpts)),
140
+ });
141
+ } catch (error) {
142
+ next(error);
143
+ }
144
+ });
145
+
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.
149
+
150
+ router.post("/api/v1/statuses", async (req, res, next) => {
151
+ try {
152
+ const token = req.mastodonToken;
153
+ if (!token) {
154
+ return res.status(401).json({ error: "The access token is invalid" });
155
+ }
156
+
157
+ const { application, publication } = req.app.locals;
158
+ const collections = req.app.locals.mastodonCollections;
159
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
160
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
161
+
162
+ const {
163
+ status: statusText,
164
+ spoiler_text: spoilerText,
165
+ visibility = "public",
166
+ sensitive = false,
167
+ language,
168
+ in_reply_to_id: inReplyToId,
169
+ media_ids: mediaIds,
170
+ } = req.body;
171
+
172
+ if (!statusText && (!mediaIds || mediaIds.length === 0)) {
173
+ return res.status(422).json({ error: "Validation failed: Text content is required" });
174
+ }
175
+
176
+ // Resolve in_reply_to URL from timeline ObjectId
177
+ let inReplyTo = null;
178
+ if (inReplyToId) {
179
+ try {
180
+ const replyItem = await collections.ap_timeline.findOne({
181
+ _id: new ObjectId(inReplyToId),
182
+ });
183
+ if (replyItem) {
184
+ inReplyTo = replyItem.uid || replyItem.url;
185
+ }
186
+ } catch {
187
+ // Invalid ObjectId — ignore
188
+ }
189
+ }
190
+
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
231
+ const profile = await collections.ap_profile.findOne({});
232
+ const handle = pluginOptions.handle || "user";
233
+ const publicationUrl = pluginOptions.publicationUrl || baseUrl;
234
+ const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
235
+
236
+ const now = new Date().toISOString();
237
+ const timelineItem = await addTimelineItem(collections, {
238
+ uid: postUrl,
239
+ url: postUrl,
240
+ type: data.properties["post-type"] || "note",
241
+ content: data.properties.content || { text: statusText || "", html: "" },
242
+ summary: spoilerText || "",
243
+ sensitive: sensitive === true || sensitive === "true",
244
+ visibility: visibility || "public",
245
+ language: language || null,
246
+ inReplyTo,
247
+ published: data.properties.published || now,
248
+ createdAt: now,
249
+ author: {
250
+ name: profile?.name || handle,
251
+ url: actorUrl,
252
+ photo: profile?.icon || "",
253
+ handle: `@${handle}`,
254
+ emojis: [],
255
+ bot: false,
256
+ },
257
+ photo: data.properties.photo || [],
258
+ video: data.properties.video || [],
259
+ audio: data.properties.audio || [],
260
+ category: data.properties.category || [],
261
+ counts: { replies: 0, boosts: 0, likes: 0 },
262
+ linkPreviews: [],
263
+ mentions: [],
264
+ emojis: [],
265
+ });
266
+
267
+ // Serialize and return
268
+ const serialized = serializeStatus(timelineItem, {
269
+ baseUrl,
270
+ favouritedIds: new Set(),
271
+ rebloggedIds: new Set(),
272
+ bookmarkedIds: new Set(),
273
+ pinnedIds: new Set(),
274
+ });
275
+
276
+ res.json(serialized);
277
+ } catch (error) {
278
+ next(error);
279
+ }
280
+ });
281
+
282
+ // ─── DELETE /api/v1/statuses/:id ────────────────────────────────────────────
283
+ // Deletes via Micropub pipeline (removes content file + MongoDB post) and
284
+ // cleans up the ap_timeline entry.
285
+
286
+ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
287
+ try {
288
+ const token = req.mastodonToken;
289
+ if (!token) {
290
+ return res.status(401).json({ error: "The access token is invalid" });
291
+ }
292
+
293
+ const { application, publication } = req.app.locals;
294
+ const { id } = req.params;
295
+ const collections = req.app.locals.mastodonCollections;
296
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
297
+
298
+ let objectId;
299
+ try {
300
+ objectId = new ObjectId(id);
301
+ } catch {
302
+ return res.status(404).json({ error: "Record not found" });
303
+ }
304
+
305
+ const item = await collections.ap_timeline.findOne({ _id: objectId });
306
+ if (!item) {
307
+ return res.status(404).json({ error: "Record not found" });
308
+ }
309
+
310
+ // Verify ownership — only allow deleting own posts
311
+ const profile = await collections.ap_profile.findOne({});
312
+ if (profile && item.author?.url !== profile.url) {
313
+ return res.status(403).json({ error: "This action is not allowed" });
314
+ }
315
+
316
+ // Serialize before deleting (Mastodon returns the deleted status with text source)
317
+ const serialized = serializeStatus(item, {
318
+ baseUrl,
319
+ favouritedIds: new Set(),
320
+ rebloggedIds: new Set(),
321
+ bookmarkedIds: new Set(),
322
+ pinnedIds: new Set(),
323
+ });
324
+ serialized.text = item.content?.text || "";
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
+
343
+ // Delete from timeline
344
+ await collections.ap_timeline.deleteOne({ _id: objectId });
345
+
346
+ // Clean up interactions
347
+ if (collections.ap_interactions && item.uid) {
348
+ await collections.ap_interactions.deleteMany({ objectUrl: item.uid });
349
+ }
350
+
351
+ res.json(serialized);
352
+ } catch (error) {
353
+ next(error);
354
+ }
355
+ });
356
+
357
+ // ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
358
+
359
+ router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => {
360
+ // Stub — we don't track who favourited remotely
361
+ res.json([]);
362
+ });
363
+
364
+ // ─── GET /api/v1/statuses/:id/reblogged_by ──────────────────────────────────
365
+
366
+ router.get("/api/v1/statuses/:id/reblogged_by", async (req, res) => {
367
+ // Stub — we don't track who boosted remotely
368
+ res.json([]);
369
+ });
370
+
371
+ // ─── POST /api/v1/statuses/:id/favourite ────────────────────────────────────
372
+
373
+ router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => {
374
+ try {
375
+ const token = req.mastodonToken;
376
+ if (!token) {
377
+ return res.status(401).json({ error: "The access token is invalid" });
378
+ }
379
+
380
+ const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
381
+ if (!item) {
382
+ return res.status(404).json({ error: "Record not found" });
383
+ }
384
+
385
+ const opts = getFederationOpts(req);
386
+ await likePost({
387
+ targetUrl: item.uid || item.url,
388
+ ...opts,
389
+ interactions: collections.ap_interactions,
390
+ });
391
+
392
+ const interactionState = await loadItemInteractions(collections, item);
393
+ // Force favourited=true since we just liked it
394
+ interactionState.favouritedIds.add(item.uid);
395
+
396
+ res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
397
+ } catch (error) {
398
+ next(error);
399
+ }
400
+ });
401
+
402
+ // ─── POST /api/v1/statuses/:id/unfavourite ──────────────────────────────────
403
+
404
+ router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => {
405
+ try {
406
+ const token = req.mastodonToken;
407
+ if (!token) {
408
+ return res.status(401).json({ error: "The access token is invalid" });
409
+ }
410
+
411
+ const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
412
+ if (!item) {
413
+ return res.status(404).json({ error: "Record not found" });
414
+ }
415
+
416
+ const opts = getFederationOpts(req);
417
+ await unlikePost({
418
+ targetUrl: item.uid || item.url,
419
+ ...opts,
420
+ interactions: collections.ap_interactions,
421
+ });
422
+
423
+ const interactionState = await loadItemInteractions(collections, item);
424
+ interactionState.favouritedIds.delete(item.uid);
425
+
426
+ res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
427
+ } catch (error) {
428
+ next(error);
429
+ }
430
+ });
431
+
432
+ // ─── POST /api/v1/statuses/:id/reblog ───────────────────────────────────────
433
+
434
+ router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => {
435
+ try {
436
+ const token = req.mastodonToken;
437
+ if (!token) {
438
+ return res.status(401).json({ error: "The access token is invalid" });
439
+ }
440
+
441
+ const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
442
+ if (!item) {
443
+ return res.status(404).json({ error: "Record not found" });
444
+ }
445
+
446
+ const opts = getFederationOpts(req);
447
+ await boostPost({
448
+ targetUrl: item.uid || item.url,
449
+ ...opts,
450
+ interactions: collections.ap_interactions,
451
+ });
452
+
453
+ const interactionState = await loadItemInteractions(collections, item);
454
+ interactionState.rebloggedIds.add(item.uid);
455
+
456
+ res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
457
+ } catch (error) {
458
+ next(error);
459
+ }
460
+ });
461
+
462
+ // ─── POST /api/v1/statuses/:id/unreblog ─────────────────────────────────────
463
+
464
+ router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => {
465
+ try {
466
+ const token = req.mastodonToken;
467
+ if (!token) {
468
+ return res.status(401).json({ error: "The access token is invalid" });
469
+ }
470
+
471
+ const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
472
+ if (!item) {
473
+ return res.status(404).json({ error: "Record not found" });
474
+ }
475
+
476
+ const opts = getFederationOpts(req);
477
+ await unboostPost({
478
+ targetUrl: item.uid || item.url,
479
+ ...opts,
480
+ interactions: collections.ap_interactions,
481
+ });
482
+
483
+ const interactionState = await loadItemInteractions(collections, item);
484
+ interactionState.rebloggedIds.delete(item.uid);
485
+
486
+ res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
487
+ } catch (error) {
488
+ next(error);
489
+ }
490
+ });
491
+
492
+ // ─── POST /api/v1/statuses/:id/bookmark ─────────────────────────────────────
493
+
494
+ router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => {
495
+ try {
496
+ const token = req.mastodonToken;
497
+ if (!token) {
498
+ return res.status(401).json({ error: "The access token is invalid" });
499
+ }
500
+
501
+ const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
502
+ if (!item) {
503
+ return res.status(404).json({ error: "Record not found" });
504
+ }
505
+
506
+ await bookmarkPost({
507
+ targetUrl: item.uid || item.url,
508
+ interactions: collections.ap_interactions,
509
+ });
510
+
511
+ const interactionState = await loadItemInteractions(collections, item);
512
+ interactionState.bookmarkedIds.add(item.uid);
513
+
514
+ res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
515
+ } catch (error) {
516
+ next(error);
517
+ }
518
+ });
519
+
520
+ // ─── POST /api/v1/statuses/:id/unbookmark ───────────────────────────────────
521
+
522
+ router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => {
523
+ try {
524
+ const token = req.mastodonToken;
525
+ if (!token) {
526
+ return res.status(401).json({ error: "The access token is invalid" });
527
+ }
528
+
529
+ const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
530
+ if (!item) {
531
+ return res.status(404).json({ error: "Record not found" });
532
+ }
533
+
534
+ await unbookmarkPost({
535
+ targetUrl: item.uid || item.url,
536
+ interactions: collections.ap_interactions,
537
+ });
538
+
539
+ const interactionState = await loadItemInteractions(collections, item);
540
+ interactionState.bookmarkedIds.delete(item.uid);
541
+
542
+ res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
543
+ } catch (error) {
544
+ next(error);
545
+ }
546
+ });
547
+
548
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
549
+
550
+ /**
551
+ * Resolve a timeline item from the :id param, plus common context.
552
+ */
553
+ async function resolveStatusForInteraction(req) {
554
+ const collections = req.app.locals.mastodonCollections;
555
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
556
+
557
+ let objectId;
558
+ try {
559
+ objectId = new ObjectId(req.params.id);
560
+ } catch {
561
+ return { item: null, collections, baseUrl };
562
+ }
563
+
564
+ const item = await collections.ap_timeline.findOne({ _id: objectId });
565
+ return { item, collections, baseUrl };
566
+ }
567
+
568
+ /**
569
+ * Build federation options from request context for interaction helpers.
570
+ */
571
+ function getFederationOpts(req) {
572
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
573
+ return {
574
+ federation: pluginOptions.federation,
575
+ handle: pluginOptions.handle || "user",
576
+ publicationUrl: pluginOptions.publicationUrl,
577
+ collections: req.app.locals.mastodonCollections,
578
+ };
579
+ }
580
+
581
+ async function loadItemInteractions(collections, item) {
582
+ const favouritedIds = new Set();
583
+ const rebloggedIds = new Set();
584
+ const bookmarkedIds = new Set();
585
+
586
+ if (!collections.ap_interactions || !item.uid) {
587
+ return { favouritedIds, rebloggedIds, bookmarkedIds };
588
+ }
589
+
590
+ const lookupUrls = [item.uid, item.url].filter(Boolean);
591
+ const interactions = await collections.ap_interactions
592
+ .find({ objectUrl: { $in: lookupUrls } })
593
+ .toArray();
594
+
595
+ for (const i of interactions) {
596
+ const uid = item.uid;
597
+ if (i.type === "like") favouritedIds.add(uid);
598
+ else if (i.type === "boost") rebloggedIds.add(uid);
599
+ else if (i.type === "bookmark") bookmarkedIds.add(uid);
600
+ }
601
+
602
+ return { favouritedIds, rebloggedIds, bookmarkedIds };
603
+ }
604
+
605
+ export default router;