@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,207 @@
1
+ /**
2
+ * Instance info endpoints for Mastodon Client API.
3
+ *
4
+ * GET /api/v2/instance — v2 format (primary)
5
+ * GET /api/v1/instance — v1 format (fallback for older clients)
6
+ */
7
+ import express from "express";
8
+ import { serializeAccount } from "../entities/account.js";
9
+
10
+ const router = express.Router(); // eslint-disable-line new-cap
11
+
12
+ // ─── GET /api/v2/instance ────────────────────────────────────────────────────
13
+
14
+ router.get("/api/v2/instance", async (req, res, next) => {
15
+ try {
16
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
17
+ const domain = req.get("host");
18
+ const collections = req.app.locals.mastodonCollections;
19
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
20
+
21
+ const profile = await collections.ap_profile.findOne({});
22
+ const contactAccount = profile
23
+ ? serializeAccount(profile, {
24
+ baseUrl,
25
+ isLocal: true,
26
+ handle: pluginOptions.handle || "user",
27
+ })
28
+ : null;
29
+
30
+ res.json({
31
+ domain,
32
+ title: profile?.name || domain,
33
+ version: "4.0.0 (compatible; Indiekit ActivityPub)",
34
+ source_url: "https://github.com/getindiekit/indiekit",
35
+ description: profile?.summary || `An Indiekit instance at ${domain}`,
36
+ usage: {
37
+ users: {
38
+ active_month: 1,
39
+ },
40
+ },
41
+ thumbnail: {
42
+ url: profile?.icon || `${baseUrl}/favicon.ico`,
43
+ blurhash: null,
44
+ versions: {},
45
+ },
46
+ icon: [],
47
+ languages: ["en"],
48
+ configuration: {
49
+ urls: {
50
+ streaming: "",
51
+ },
52
+ accounts: {
53
+ max_featured_tags: 10,
54
+ max_pinned_statuses: 10,
55
+ },
56
+ statuses: {
57
+ max_characters: 5000,
58
+ max_media_attachments: 4,
59
+ characters_reserved_per_url: 23,
60
+ },
61
+ media_attachments: {
62
+ supported_mime_types: [
63
+ "image/jpeg",
64
+ "image/png",
65
+ "image/gif",
66
+ "image/webp",
67
+ "video/mp4",
68
+ "video/webm",
69
+ "audio/mpeg",
70
+ "audio/ogg",
71
+ ],
72
+ image_size_limit: 16_777_216,
73
+ image_matrix_limit: 16_777_216,
74
+ video_size_limit: 67_108_864,
75
+ video_frame_rate_limit: 60,
76
+ video_matrix_limit: 16_777_216,
77
+ },
78
+ polls: {
79
+ max_options: 4,
80
+ max_characters_per_option: 50,
81
+ min_expiration: 300,
82
+ max_expiration: 2_592_000,
83
+ },
84
+ translation: {
85
+ enabled: false,
86
+ },
87
+ vapid: {
88
+ public_key: "",
89
+ },
90
+ },
91
+ registrations: {
92
+ enabled: false,
93
+ approval_required: true,
94
+ message: null,
95
+ url: null,
96
+ },
97
+ api_versions: {
98
+ mastodon: 0,
99
+ },
100
+ contact: {
101
+ email: "",
102
+ account: contactAccount,
103
+ },
104
+ rules: [],
105
+ });
106
+ } catch (error) {
107
+ next(error);
108
+ }
109
+ });
110
+
111
+ // ─── GET /api/v1/instance ────────────────────────────────────────────────────
112
+
113
+ router.get("/api/v1/instance", async (req, res, next) => {
114
+ try {
115
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
116
+ const domain = req.get("host");
117
+ const collections = req.app.locals.mastodonCollections;
118
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
119
+
120
+ const profile = await collections.ap_profile.findOne({});
121
+
122
+ // Get approximate counts
123
+ let statusCount = 0;
124
+ let domainCount = 0;
125
+ try {
126
+ statusCount = await collections.ap_timeline.countDocuments({});
127
+ // Rough domain count from unique follower domains
128
+ const followers = await collections.ap_followers
129
+ .find({}, { projection: { actorUrl: 1 } })
130
+ .toArray();
131
+ const domains = new Set(
132
+ followers
133
+ .map((f) => {
134
+ try {
135
+ return new URL(f.actorUrl).hostname;
136
+ } catch {
137
+ return null;
138
+ }
139
+ })
140
+ .filter(Boolean),
141
+ );
142
+ domainCount = domains.size;
143
+ } catch {
144
+ // Non-critical
145
+ }
146
+
147
+ res.json({
148
+ uri: domain,
149
+ title: profile?.name || domain,
150
+ short_description: profile?.summary || "",
151
+ description: profile?.summary || `An Indiekit instance at ${domain}`,
152
+ email: "",
153
+ version: "4.0.0 (compatible; Indiekit ActivityPub)",
154
+ urls: {
155
+ streaming_api: "",
156
+ },
157
+ stats: {
158
+ user_count: 1,
159
+ status_count: statusCount,
160
+ domain_count: domainCount,
161
+ },
162
+ thumbnail: profile?.icon || null,
163
+ languages: ["en"],
164
+ registrations: false,
165
+ approval_required: true,
166
+ invites_enabled: false,
167
+ configuration: {
168
+ statuses: {
169
+ max_characters: 5000,
170
+ max_media_attachments: 4,
171
+ characters_reserved_per_url: 23,
172
+ },
173
+ media_attachments: {
174
+ supported_mime_types: [
175
+ "image/jpeg",
176
+ "image/png",
177
+ "image/gif",
178
+ "image/webp",
179
+ ],
180
+ image_size_limit: 16_777_216,
181
+ image_matrix_limit: 16_777_216,
182
+ video_size_limit: 67_108_864,
183
+ video_frame_rate_limit: 60,
184
+ video_matrix_limit: 16_777_216,
185
+ },
186
+ polls: {
187
+ max_options: 4,
188
+ max_characters_per_option: 50,
189
+ min_expiration: 300,
190
+ max_expiration: 2_592_000,
191
+ },
192
+ },
193
+ contact_account: profile
194
+ ? serializeAccount(profile, {
195
+ baseUrl,
196
+ isLocal: true,
197
+ handle: pluginOptions.handle || "user",
198
+ })
199
+ : null,
200
+ rules: [],
201
+ });
202
+ } catch (error) {
203
+ next(error);
204
+ }
205
+ });
206
+
207
+ export default router;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Media endpoints for Mastodon Client API.
3
+ *
4
+ * POST /api/v2/media — upload media attachment (stub — returns 422 until storage is configured)
5
+ * POST /api/v1/media — legacy upload endpoint (redirects to v2)
6
+ * GET /api/v1/media/:id — get media attachment status
7
+ * PUT /api/v1/media/:id — update media metadata (description/focus)
8
+ */
9
+ import express from "express";
10
+
11
+ const router = express.Router(); // eslint-disable-line new-cap
12
+
13
+ // ─── POST /api/v2/media ─────────────────────────────────────────────────────
14
+
15
+ router.post("/api/v2/media", (req, res) => {
16
+ // Media upload requires multer/multipart handling + storage backend.
17
+ // For now, return 422 so clients show a user-friendly error.
18
+ res.status(422).json({
19
+ error: "Media uploads are not yet supported on this server",
20
+ });
21
+ });
22
+
23
+ // ─── POST /api/v1/media (legacy) ────────────────────────────────────────────
24
+
25
+ router.post("/api/v1/media", (req, res) => {
26
+ res.status(422).json({
27
+ error: "Media uploads are not yet supported on this server",
28
+ });
29
+ });
30
+
31
+ // ─── GET /api/v1/media/:id ──────────────────────────────────────────────────
32
+
33
+ router.get("/api/v1/media/:id", (req, res) => {
34
+ res.status(404).json({ error: "Record not found" });
35
+ });
36
+
37
+ // ─── PUT /api/v1/media/:id ──────────────────────────────────────────────────
38
+
39
+ router.put("/api/v1/media/:id", (req, res) => {
40
+ res.status(404).json({ error: "Record not found" });
41
+ });
42
+
43
+ export default router;
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Notification endpoints for Mastodon Client API.
3
+ *
4
+ * GET /api/v1/notifications — list notifications with pagination
5
+ * GET /api/v1/notifications/:id — single notification
6
+ * POST /api/v1/notifications/clear — clear all notifications
7
+ * POST /api/v1/notifications/:id/dismiss — dismiss single notification
8
+ */
9
+ import express from "express";
10
+ import { ObjectId } from "mongodb";
11
+ import { serializeNotification } from "../entities/notification.js";
12
+ import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
13
+
14
+ const router = express.Router(); // eslint-disable-line new-cap
15
+
16
+ /**
17
+ * Mastodon type -> internal type reverse mapping for filtering.
18
+ */
19
+ const REVERSE_TYPE_MAP = {
20
+ favourite: "like",
21
+ reblog: "boost",
22
+ follow: "follow",
23
+ follow_request: "follow_request",
24
+ mention: { $in: ["reply", "mention", "dm"] },
25
+ poll: "poll",
26
+ update: "update",
27
+ "admin.report": "report",
28
+ };
29
+
30
+ // ─── GET /api/v1/notifications ──────────────────────────────────────────────
31
+
32
+ router.get("/api/v1/notifications", async (req, res, next) => {
33
+ try {
34
+ const token = req.mastodonToken;
35
+ if (!token) {
36
+ return res.status(401).json({ error: "The access token is invalid" });
37
+ }
38
+
39
+ const collections = req.app.locals.mastodonCollections;
40
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
41
+ const limit = parseLimit(req.query.limit);
42
+
43
+ // Build base filter
44
+ const baseFilter = {};
45
+
46
+ // types[] — include only these Mastodon types
47
+ const includeTypes = normalizeArray(req.query["types[]"] || req.query.types);
48
+ if (includeTypes.length > 0) {
49
+ const internalTypes = resolveInternalTypes(includeTypes);
50
+ if (internalTypes.length > 0) {
51
+ baseFilter.type = { $in: internalTypes };
52
+ }
53
+ }
54
+
55
+ // exclude_types[] — exclude these Mastodon types
56
+ const excludeTypes = normalizeArray(req.query["exclude_types[]"] || req.query.exclude_types);
57
+ if (excludeTypes.length > 0) {
58
+ const excludeInternal = resolveInternalTypes(excludeTypes);
59
+ if (excludeInternal.length > 0) {
60
+ baseFilter.type = { ...baseFilter.type, $nin: excludeInternal };
61
+ }
62
+ }
63
+
64
+ // Apply cursor pagination
65
+ const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
66
+ max_id: req.query.max_id,
67
+ min_id: req.query.min_id,
68
+ since_id: req.query.since_id,
69
+ });
70
+
71
+ let items = await collections.ap_notifications
72
+ .find(filter)
73
+ .sort(sort)
74
+ .limit(limit)
75
+ .toArray();
76
+
77
+ if (reverse) {
78
+ items.reverse();
79
+ }
80
+
81
+ // Batch-fetch referenced timeline items to avoid N+1
82
+ const statusMap = await batchFetchStatuses(collections, items);
83
+
84
+ // Serialize notifications
85
+ const notifications = items.map((notif) =>
86
+ serializeNotification(notif, {
87
+ baseUrl,
88
+ statusMap,
89
+ interactionState: {
90
+ favouritedIds: new Set(),
91
+ rebloggedIds: new Set(),
92
+ bookmarkedIds: new Set(),
93
+ },
94
+ }),
95
+ ).filter(Boolean);
96
+
97
+ // Set pagination headers
98
+ setPaginationHeaders(res, req, items, limit);
99
+
100
+ res.json(notifications);
101
+ } catch (error) {
102
+ next(error);
103
+ }
104
+ });
105
+
106
+ // ─── GET /api/v1/notifications/:id ──────────────────────────────────────────
107
+
108
+ router.get("/api/v1/notifications/:id", async (req, res, next) => {
109
+ try {
110
+ const token = req.mastodonToken;
111
+ if (!token) {
112
+ return res.status(401).json({ error: "The access token is invalid" });
113
+ }
114
+
115
+ const collections = req.app.locals.mastodonCollections;
116
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
117
+
118
+ let objectId;
119
+ try {
120
+ objectId = new ObjectId(req.params.id);
121
+ } catch {
122
+ return res.status(404).json({ error: "Record not found" });
123
+ }
124
+
125
+ const notif = await collections.ap_notifications.findOne({ _id: objectId });
126
+ if (!notif) {
127
+ return res.status(404).json({ error: "Record not found" });
128
+ }
129
+
130
+ const statusMap = await batchFetchStatuses(collections, [notif]);
131
+
132
+ const notification = serializeNotification(notif, {
133
+ baseUrl,
134
+ statusMap,
135
+ interactionState: {
136
+ favouritedIds: new Set(),
137
+ rebloggedIds: new Set(),
138
+ bookmarkedIds: new Set(),
139
+ },
140
+ });
141
+
142
+ res.json(notification);
143
+ } catch (error) {
144
+ next(error);
145
+ }
146
+ });
147
+
148
+ // ─── POST /api/v1/notifications/clear ───────────────────────────────────────
149
+
150
+ router.post("/api/v1/notifications/clear", 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 collections = req.app.locals.mastodonCollections;
158
+ await collections.ap_notifications.deleteMany({});
159
+ res.json({});
160
+ } catch (error) {
161
+ next(error);
162
+ }
163
+ });
164
+
165
+ // ─── POST /api/v1/notifications/:id/dismiss ─────────────────────────────────
166
+
167
+ router.post("/api/v1/notifications/:id/dismiss", async (req, res, next) => {
168
+ try {
169
+ const token = req.mastodonToken;
170
+ if (!token) {
171
+ return res.status(401).json({ error: "The access token is invalid" });
172
+ }
173
+
174
+ const collections = req.app.locals.mastodonCollections;
175
+
176
+ let objectId;
177
+ try {
178
+ objectId = new ObjectId(req.params.id);
179
+ } catch {
180
+ return res.status(404).json({ error: "Record not found" });
181
+ }
182
+
183
+ await collections.ap_notifications.deleteOne({ _id: objectId });
184
+ res.json({});
185
+ } catch (error) {
186
+ next(error);
187
+ }
188
+ });
189
+
190
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Normalize query param to array (handles string or array).
194
+ */
195
+ function normalizeArray(param) {
196
+ if (!param) return [];
197
+ return Array.isArray(param) ? param : [param];
198
+ }
199
+
200
+ /**
201
+ * Convert Mastodon notification types to internal types.
202
+ */
203
+ function resolveInternalTypes(mastodonTypes) {
204
+ const result = [];
205
+ for (const t of mastodonTypes) {
206
+ const mapped = REVERSE_TYPE_MAP[t];
207
+ if (mapped) {
208
+ if (mapped.$in) {
209
+ result.push(...mapped.$in);
210
+ } else {
211
+ result.push(mapped);
212
+ }
213
+ }
214
+ }
215
+ return result;
216
+ }
217
+
218
+ /**
219
+ * Batch-fetch timeline items referenced by notifications.
220
+ *
221
+ * @param {object} collections
222
+ * @param {Array} notifications
223
+ * @returns {Promise<Map<string, object>>} Map of targetUrl -> timeline item
224
+ */
225
+ async function batchFetchStatuses(collections, notifications) {
226
+ const statusMap = new Map();
227
+
228
+ const targetUrls = [
229
+ ...new Set(
230
+ notifications
231
+ .map((n) => n.targetUrl)
232
+ .filter(Boolean),
233
+ ),
234
+ ];
235
+
236
+ if (targetUrls.length === 0 || !collections.ap_timeline) {
237
+ return statusMap;
238
+ }
239
+
240
+ const items = await collections.ap_timeline
241
+ .find({
242
+ $or: [
243
+ { uid: { $in: targetUrls } },
244
+ { url: { $in: targetUrls } },
245
+ ],
246
+ })
247
+ .toArray();
248
+
249
+ for (const item of items) {
250
+ if (item.uid) statusMap.set(item.uid, item);
251
+ if (item.url) statusMap.set(item.url, item);
252
+ }
253
+
254
+ return statusMap;
255
+ }
256
+
257
+ export default router;