@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,380 @@
1
+ /**
2
+ * Stub and lightweight endpoints for Mastodon Client API.
3
+ *
4
+ * Some endpoints have real implementations (markers, bookmarks, favourites).
5
+ * Others return empty/minimal responses to prevent client errors.
6
+ *
7
+ * Phanpy calls these on startup, navigation, and various page loads:
8
+ * - markers (BackgroundService, every page load)
9
+ * - follow_requests (home + notifications pages)
10
+ * - announcements (notifications page)
11
+ * - custom_emojis (compose screen)
12
+ * - filters (status rendering)
13
+ * - lists (sidebar navigation)
14
+ * - mutes, blocks (nav menu)
15
+ * - featured_tags (profile view)
16
+ * - bookmarks, favourites (dedicated pages)
17
+ * - trends (explore page)
18
+ * - followed_tags (followed tags page)
19
+ * - suggestions (explore page)
20
+ */
21
+ import express from "express";
22
+ import { serializeStatus } from "../entities/status.js";
23
+ import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js";
24
+
25
+ const router = express.Router(); // eslint-disable-line new-cap
26
+
27
+ // ─── Markers ────────────────────────────────────────────────────────────────
28
+
29
+ router.get("/api/v1/markers", async (req, res, next) => {
30
+ try {
31
+ const collections = req.app.locals.mastodonCollections;
32
+ const timelines = [].concat(req.query["timeline[]"] || req.query.timeline || []);
33
+
34
+ if (!timelines.length || !collections.ap_markers) {
35
+ return res.json({});
36
+ }
37
+
38
+ const docs = await collections.ap_markers
39
+ .find({ timeline: { $in: timelines } })
40
+ .toArray();
41
+
42
+ const result = {};
43
+ for (const doc of docs) {
44
+ result[doc.timeline] = {
45
+ last_read_id: doc.last_read_id,
46
+ version: doc.version || 0,
47
+ updated_at: doc.updated_at || new Date().toISOString(),
48
+ };
49
+ }
50
+
51
+ res.json(result);
52
+ } catch (error) {
53
+ next(error);
54
+ }
55
+ });
56
+
57
+ router.post("/api/v1/markers", async (req, res, next) => {
58
+ try {
59
+ const collections = req.app.locals.mastodonCollections;
60
+ if (!collections.ap_markers) {
61
+ return res.json({});
62
+ }
63
+
64
+ const result = {};
65
+ for (const timeline of ["home", "notifications"]) {
66
+ const data = req.body[timeline];
67
+ if (!data?.last_read_id) continue;
68
+
69
+ const now = new Date().toISOString();
70
+ await collections.ap_markers.updateOne(
71
+ { timeline },
72
+ {
73
+ $set: { last_read_id: data.last_read_id, updated_at: now },
74
+ $inc: { version: 1 },
75
+ $setOnInsert: { timeline },
76
+ },
77
+ { upsert: true },
78
+ );
79
+
80
+ const doc = await collections.ap_markers.findOne({ timeline });
81
+ result[timeline] = {
82
+ last_read_id: doc.last_read_id,
83
+ version: doc.version || 0,
84
+ updated_at: doc.updated_at || now,
85
+ };
86
+ }
87
+
88
+ res.json(result);
89
+ } catch (error) {
90
+ next(error);
91
+ }
92
+ });
93
+
94
+ // ─── Follow requests ────────────────────────────────────────────────────────
95
+
96
+ router.get("/api/v1/follow_requests", (req, res) => {
97
+ res.json([]);
98
+ });
99
+
100
+ // ─── Announcements ──────────────────────────────────────────────────────────
101
+
102
+ router.get("/api/v1/announcements", (req, res) => {
103
+ res.json([]);
104
+ });
105
+
106
+ // ─── Custom emojis ──────────────────────────────────────────────────────────
107
+
108
+ router.get("/api/v1/custom_emojis", (req, res) => {
109
+ res.json([]);
110
+ });
111
+
112
+ // ─── Filters (v2) ───────────────────────────────────────────────────────────
113
+
114
+ router.get("/api/v2/filters", (req, res) => {
115
+ res.json([]);
116
+ });
117
+
118
+ router.get("/api/v1/filters", (req, res) => {
119
+ res.json([]);
120
+ });
121
+
122
+ // ─── Lists ──────────────────────────────────────────────────────────────────
123
+
124
+ router.get("/api/v1/lists", (req, res) => {
125
+ res.json([]);
126
+ });
127
+
128
+ // ─── Mutes ──────────────────────────────────────────────────────────────────
129
+
130
+ router.get("/api/v1/mutes", (req, res) => {
131
+ res.json([]);
132
+ });
133
+
134
+ // ─── Blocks ─────────────────────────────────────────────────────────────────
135
+
136
+ router.get("/api/v1/blocks", (req, res) => {
137
+ res.json([]);
138
+ });
139
+
140
+ // ─── Bookmarks ──────────────────────────────────────────────────────────────
141
+
142
+ router.get("/api/v1/bookmarks", async (req, res, next) => {
143
+ try {
144
+ const collections = req.app.locals.mastodonCollections;
145
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
146
+ const limit = parseLimit(req.query.limit);
147
+
148
+ if (!collections.ap_interactions) {
149
+ return res.json([]);
150
+ }
151
+
152
+ const baseFilter = { type: "bookmark" };
153
+ const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
154
+ max_id: req.query.max_id,
155
+ min_id: req.query.min_id,
156
+ since_id: req.query.since_id,
157
+ });
158
+
159
+ let interactions = await collections.ap_interactions
160
+ .find(filter)
161
+ .sort(sort)
162
+ .limit(limit)
163
+ .toArray();
164
+
165
+ if (reverse) interactions.reverse();
166
+
167
+ // Batch-fetch the actual timeline items
168
+ const objectUrls = interactions.map((i) => i.objectUrl).filter(Boolean);
169
+ if (!objectUrls.length) {
170
+ return res.json([]);
171
+ }
172
+
173
+ const items = await collections.ap_timeline
174
+ .find({ $or: [{ uid: { $in: objectUrls } }, { url: { $in: objectUrls } }] })
175
+ .toArray();
176
+
177
+ const itemMap = new Map();
178
+ for (const item of items) {
179
+ if (item.uid) itemMap.set(item.uid, item);
180
+ if (item.url) itemMap.set(item.url, item);
181
+ }
182
+
183
+ const statuses = [];
184
+ for (const interaction of interactions) {
185
+ const item = itemMap.get(interaction.objectUrl);
186
+ if (item) {
187
+ statuses.push(
188
+ serializeStatus(item, {
189
+ baseUrl,
190
+ favouritedIds: new Set(),
191
+ rebloggedIds: new Set(),
192
+ bookmarkedIds: new Set([item.uid]),
193
+ pinnedIds: new Set(),
194
+ }),
195
+ );
196
+ }
197
+ }
198
+
199
+ setPaginationHeaders(res, req, interactions, limit);
200
+ res.json(statuses);
201
+ } catch (error) {
202
+ next(error);
203
+ }
204
+ });
205
+
206
+ // ─── Favourites ─────────────────────────────────────────────────────────────
207
+
208
+ router.get("/api/v1/favourites", async (req, res, next) => {
209
+ try {
210
+ const collections = req.app.locals.mastodonCollections;
211
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
212
+ const limit = parseLimit(req.query.limit);
213
+
214
+ if (!collections.ap_interactions) {
215
+ return res.json([]);
216
+ }
217
+
218
+ const baseFilter = { type: "like" };
219
+ const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
220
+ max_id: req.query.max_id,
221
+ min_id: req.query.min_id,
222
+ since_id: req.query.since_id,
223
+ });
224
+
225
+ let interactions = await collections.ap_interactions
226
+ .find(filter)
227
+ .sort(sort)
228
+ .limit(limit)
229
+ .toArray();
230
+
231
+ if (reverse) interactions.reverse();
232
+
233
+ const objectUrls = interactions.map((i) => i.objectUrl).filter(Boolean);
234
+ if (!objectUrls.length) {
235
+ return res.json([]);
236
+ }
237
+
238
+ const items = await collections.ap_timeline
239
+ .find({ $or: [{ uid: { $in: objectUrls } }, { url: { $in: objectUrls } }] })
240
+ .toArray();
241
+
242
+ const itemMap = new Map();
243
+ for (const item of items) {
244
+ if (item.uid) itemMap.set(item.uid, item);
245
+ if (item.url) itemMap.set(item.url, item);
246
+ }
247
+
248
+ const statuses = [];
249
+ for (const interaction of interactions) {
250
+ const item = itemMap.get(interaction.objectUrl);
251
+ if (item) {
252
+ statuses.push(
253
+ serializeStatus(item, {
254
+ baseUrl,
255
+ favouritedIds: new Set([item.uid]),
256
+ rebloggedIds: new Set(),
257
+ bookmarkedIds: new Set(),
258
+ pinnedIds: new Set(),
259
+ }),
260
+ );
261
+ }
262
+ }
263
+
264
+ setPaginationHeaders(res, req, interactions, limit);
265
+ res.json(statuses);
266
+ } catch (error) {
267
+ next(error);
268
+ }
269
+ });
270
+
271
+ // ─── Featured tags ──────────────────────────────────────────────────────────
272
+
273
+ router.get("/api/v1/featured_tags", (req, res) => {
274
+ res.json([]);
275
+ });
276
+
277
+ // ─── Followed tags ──────────────────────────────────────────────────────────
278
+
279
+ router.get("/api/v1/followed_tags", (req, res) => {
280
+ res.json([]);
281
+ });
282
+
283
+ // ─── Suggestions ────────────────────────────────────────────────────────────
284
+
285
+ router.get("/api/v2/suggestions", (req, res) => {
286
+ res.json([]);
287
+ });
288
+
289
+ // ─── Trends ─────────────────────────────────────────────────────────────────
290
+
291
+ router.get("/api/v1/trends/statuses", (req, res) => {
292
+ res.json([]);
293
+ });
294
+
295
+ router.get("/api/v1/trends/tags", (req, res) => {
296
+ res.json([]);
297
+ });
298
+
299
+ router.get("/api/v1/trends/links", (req, res) => {
300
+ res.json([]);
301
+ });
302
+
303
+ // ─── Scheduled statuses ─────────────────────────────────────────────────────
304
+
305
+ router.get("/api/v1/scheduled_statuses", (req, res) => {
306
+ res.json([]);
307
+ });
308
+
309
+ // ─── Conversations ──────────────────────────────────────────────────────────
310
+
311
+ router.get("/api/v1/conversations", (req, res) => {
312
+ res.json([]);
313
+ });
314
+
315
+ // ─── Domain blocks ──────────────────────────────────────────────────────────
316
+
317
+ router.get("/api/v1/domain_blocks", (req, res) => {
318
+ res.json([]);
319
+ });
320
+
321
+ // ─── Endorsements ───────────────────────────────────────────────────────────
322
+
323
+ router.get("/api/v1/endorsements", (req, res) => {
324
+ res.json([]);
325
+ });
326
+
327
+ // ─── Account statuses ───────────────────────────────────────────────────────
328
+
329
+ router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
330
+ try {
331
+ const collections = req.app.locals.mastodonCollections;
332
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
333
+
334
+ // Try to find the profile to see if this is the local user
335
+ const profile = await collections.ap_profile.findOne({});
336
+ const isLocal = profile && profile._id.toString() === req.params.id;
337
+
338
+ if (isLocal && profile?.url) {
339
+ // Return statuses authored by local user
340
+ const { serializeStatus } = await import("../entities/status.js");
341
+ const { parseLimit } = await import("../helpers/pagination.js");
342
+
343
+ const limit = parseLimit(req.query.limit);
344
+ const items = await collections.ap_timeline
345
+ .find({ "author.url": profile.url, isContext: { $ne: true } })
346
+ .sort({ _id: -1 })
347
+ .limit(limit)
348
+ .toArray();
349
+
350
+ const statuses = items.map((item) =>
351
+ serializeStatus(item, {
352
+ baseUrl,
353
+ favouritedIds: new Set(),
354
+ rebloggedIds: new Set(),
355
+ bookmarkedIds: new Set(),
356
+ pinnedIds: new Set(),
357
+ }),
358
+ );
359
+
360
+ return res.json(statuses);
361
+ }
362
+
363
+ // Remote account or unknown — return empty
364
+ res.json([]);
365
+ } catch (error) {
366
+ next(error);
367
+ }
368
+ });
369
+
370
+ // ─── Account followers/following ────────────────────────────────────────────
371
+
372
+ router.get("/api/v1/accounts/:id/followers", (req, res) => {
373
+ res.json([]);
374
+ });
375
+
376
+ router.get("/api/v1/accounts/:id/following", (req, res) => {
377
+ res.json([]);
378
+ });
379
+
380
+ export default router;
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Timeline endpoints for Mastodon Client API.
3
+ *
4
+ * GET /api/v1/timelines/home — home timeline (authenticated)
5
+ * GET /api/v1/timelines/public — public/federated timeline
6
+ * GET /api/v1/timelines/tag/:hashtag — hashtag timeline
7
+ */
8
+ import express from "express";
9
+ import { serializeStatus } from "../entities/status.js";
10
+ import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
11
+ import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
12
+
13
+ const router = express.Router(); // eslint-disable-line new-cap
14
+
15
+ // ─── GET /api/v1/timelines/home ─────────────────────────────────────────────
16
+
17
+ router.get("/api/v1/timelines/home", async (req, res, next) => {
18
+ try {
19
+ const token = req.mastodonToken;
20
+ if (!token) {
21
+ return res.status(401).json({ error: "The access token is invalid" });
22
+ }
23
+
24
+ const collections = req.app.locals.mastodonCollections;
25
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
26
+ const limit = parseLimit(req.query.limit);
27
+
28
+ // Base filter: exclude context-only items and private/direct posts
29
+ const baseFilter = {
30
+ isContext: { $ne: true },
31
+ visibility: { $nin: ["direct"] },
32
+ };
33
+
34
+ // Apply cursor-based pagination
35
+ const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
36
+ max_id: req.query.max_id,
37
+ min_id: req.query.min_id,
38
+ since_id: req.query.since_id,
39
+ });
40
+
41
+ // Fetch items from timeline
42
+ let items = await collections.ap_timeline
43
+ .find(filter)
44
+ .sort(sort)
45
+ .limit(limit)
46
+ .toArray();
47
+
48
+ // Reverse if min_id was used (ascending sort → need descending order)
49
+ if (reverse) {
50
+ items.reverse();
51
+ }
52
+
53
+ // Apply mute/block filtering
54
+ const modCollections = {
55
+ ap_muted: collections.ap_muted,
56
+ ap_blocked: collections.ap_blocked,
57
+ ap_profile: collections.ap_profile,
58
+ };
59
+ const moderation = await loadModerationData(modCollections);
60
+ items = applyModerationFilters(items, moderation);
61
+
62
+ // Load interaction state (likes, boosts, bookmarks) for the authenticated user
63
+ const { favouritedIds, rebloggedIds, bookmarkedIds } = await loadInteractionState(
64
+ collections,
65
+ items,
66
+ );
67
+
68
+ // Serialize to Mastodon Status entities
69
+ const statuses = items.map((item) =>
70
+ serializeStatus(item, {
71
+ baseUrl,
72
+ favouritedIds,
73
+ rebloggedIds,
74
+ bookmarkedIds,
75
+ pinnedIds: new Set(),
76
+ }),
77
+ );
78
+
79
+ // Set pagination Link headers
80
+ setPaginationHeaders(res, req, items, limit);
81
+
82
+ res.json(statuses);
83
+ } catch (error) {
84
+ next(error);
85
+ }
86
+ });
87
+
88
+ // ─── GET /api/v1/timelines/public ───────────────────────────────────────────
89
+
90
+ router.get("/api/v1/timelines/public", async (req, res, next) => {
91
+ try {
92
+ const collections = req.app.locals.mastodonCollections;
93
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
94
+ const limit = parseLimit(req.query.limit);
95
+
96
+ // Public timeline: only public visibility, no context items
97
+ const baseFilter = {
98
+ isContext: { $ne: true },
99
+ visibility: "public",
100
+ };
101
+
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
+
118
+ if (req.query.only_media === "true") {
119
+ baseFilter.$or = [
120
+ { "photo.0": { $exists: true } },
121
+ { "video.0": { $exists: true } },
122
+ { "audio.0": { $exists: true } },
123
+ ];
124
+ }
125
+
126
+ const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
127
+ max_id: req.query.max_id,
128
+ min_id: req.query.min_id,
129
+ since_id: req.query.since_id,
130
+ });
131
+
132
+ let items = await collections.ap_timeline
133
+ .find(filter)
134
+ .sort(sort)
135
+ .limit(limit)
136
+ .toArray();
137
+
138
+ if (reverse) {
139
+ items.reverse();
140
+ }
141
+
142
+ // Apply mute/block filtering
143
+ const modCollections = {
144
+ ap_muted: collections.ap_muted,
145
+ ap_blocked: collections.ap_blocked,
146
+ ap_profile: collections.ap_profile,
147
+ };
148
+ const moderation = await loadModerationData(modCollections);
149
+ items = applyModerationFilters(items, moderation);
150
+
151
+ // Load interaction state if authenticated
152
+ let favouritedIds = new Set();
153
+ let rebloggedIds = new Set();
154
+ let bookmarkedIds = new Set();
155
+
156
+ if (req.mastodonToken) {
157
+ ({ favouritedIds, rebloggedIds, bookmarkedIds } = await loadInteractionState(
158
+ collections,
159
+ items,
160
+ ));
161
+ }
162
+
163
+ const statuses = items.map((item) =>
164
+ serializeStatus(item, {
165
+ baseUrl,
166
+ favouritedIds,
167
+ rebloggedIds,
168
+ bookmarkedIds,
169
+ pinnedIds: new Set(),
170
+ }),
171
+ );
172
+
173
+ setPaginationHeaders(res, req, items, limit);
174
+ res.json(statuses);
175
+ } catch (error) {
176
+ next(error);
177
+ }
178
+ });
179
+
180
+ // ─── GET /api/v1/timelines/tag/:hashtag ─────────────────────────────────────
181
+
182
+ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
183
+ try {
184
+ const collections = req.app.locals.mastodonCollections;
185
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
186
+ const limit = parseLimit(req.query.limit);
187
+ const hashtag = req.params.hashtag;
188
+
189
+ const baseFilter = {
190
+ isContext: { $ne: true },
191
+ visibility: { $in: ["public", "unlisted"] },
192
+ category: hashtag,
193
+ };
194
+
195
+ const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
196
+ max_id: req.query.max_id,
197
+ min_id: req.query.min_id,
198
+ since_id: req.query.since_id,
199
+ });
200
+
201
+ let items = await collections.ap_timeline
202
+ .find(filter)
203
+ .sort(sort)
204
+ .limit(limit)
205
+ .toArray();
206
+
207
+ if (reverse) {
208
+ items.reverse();
209
+ }
210
+
211
+ // Load interaction state if authenticated
212
+ let favouritedIds = new Set();
213
+ let rebloggedIds = new Set();
214
+ let bookmarkedIds = new Set();
215
+
216
+ if (req.mastodonToken) {
217
+ ({ favouritedIds, rebloggedIds, bookmarkedIds } = await loadInteractionState(
218
+ collections,
219
+ items,
220
+ ));
221
+ }
222
+
223
+ const statuses = items.map((item) =>
224
+ serializeStatus(item, {
225
+ baseUrl,
226
+ favouritedIds,
227
+ rebloggedIds,
228
+ bookmarkedIds,
229
+ pinnedIds: new Set(),
230
+ }),
231
+ );
232
+
233
+ setPaginationHeaders(res, req, items, limit);
234
+ res.json(statuses);
235
+ } catch (error) {
236
+ next(error);
237
+ }
238
+ });
239
+
240
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Load interaction state (favourited, reblogged, bookmarked) for a set of timeline items.
244
+ *
245
+ * Queries ap_interactions for likes and boosts matching the items' UIDs.
246
+ *
247
+ * @param {object} collections - MongoDB collections
248
+ * @param {Array} items - Timeline items
249
+ * @returns {Promise<{ favouritedIds: Set<string>, rebloggedIds: Set<string>, bookmarkedIds: Set<string> }>}
250
+ */
251
+ async function loadInteractionState(collections, items) {
252
+ const favouritedIds = new Set();
253
+ const rebloggedIds = new Set();
254
+ const bookmarkedIds = new Set();
255
+
256
+ if (!items.length || !collections.ap_interactions) {
257
+ return { favouritedIds, rebloggedIds, bookmarkedIds };
258
+ }
259
+
260
+ // Collect all UIDs and URLs to look up
261
+ const lookupUrls = new Set();
262
+ const urlToUid = new Map();
263
+ for (const item of items) {
264
+ if (item.uid) {
265
+ lookupUrls.add(item.uid);
266
+ urlToUid.set(item.uid, item.uid);
267
+ }
268
+ if (item.url && item.url !== item.uid) {
269
+ lookupUrls.add(item.url);
270
+ urlToUid.set(item.url, item.uid || item.url);
271
+ }
272
+ }
273
+
274
+ if (lookupUrls.size === 0) {
275
+ return { favouritedIds, rebloggedIds, bookmarkedIds };
276
+ }
277
+
278
+ const interactions = await collections.ap_interactions
279
+ .find({ objectUrl: { $in: [...lookupUrls] } })
280
+ .toArray();
281
+
282
+ for (const interaction of interactions) {
283
+ const uid = urlToUid.get(interaction.objectUrl) || interaction.objectUrl;
284
+ if (interaction.type === "like") {
285
+ favouritedIds.add(uid);
286
+ } else if (interaction.type === "boost") {
287
+ rebloggedIds.add(uid);
288
+ } else if (interaction.type === "bookmark") {
289
+ bookmarkedIds.add(uid);
290
+ }
291
+ }
292
+
293
+ return { favouritedIds, rebloggedIds, bookmarkedIds };
294
+ }
295
+
296
+ export default router;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.15.4",
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
  },