@rmdes/indiekit-endpoint-activitypub 3.10.5 → 3.11.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
@@ -959,6 +959,15 @@ export default class ActivityPubEndpoint {
959
959
  Indiekit.addCollection("ap_markers");
960
960
  // Tombstones for soft-deleted posts (FEP-4f05)
961
961
  Indiekit.addCollection("ap_tombstones");
962
+ // Media attachments (Mastodon API upload)
963
+ Indiekit.addCollection("ap_media");
964
+ // Status edit history
965
+ Indiekit.addCollection("ap_status_edits");
966
+ // Idempotency keys for Mastodon API
967
+ Indiekit.addCollection("ap_idempotency");
968
+ // Filters and filter keywords
969
+ Indiekit.addCollection("ap_filters");
970
+ Indiekit.addCollection("ap_filter_keywords");
962
971
 
963
972
  // Store collection references (posts resolved lazily)
964
973
  const indiekitCollections = Indiekit.collections;
@@ -997,6 +1006,15 @@ export default class ActivityPubEndpoint {
997
1006
  ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
998
1007
  ap_markers: indiekitCollections.get("ap_markers"),
999
1008
  ap_tombstones: indiekitCollections.get("ap_tombstones"),
1009
+ // Media attachments (Mastodon API upload)
1010
+ ap_media: indiekitCollections.get("ap_media"),
1011
+ // Status edit history
1012
+ ap_status_edits: indiekitCollections.get("ap_status_edits"),
1013
+ // Idempotency keys for Mastodon API
1014
+ ap_idempotency: indiekitCollections.get("ap_idempotency"),
1015
+ // Filters and filter keywords
1016
+ ap_filters: indiekitCollections.get("ap_filters"),
1017
+ ap_filter_keywords: indiekitCollections.get("ap_filter_keywords"),
1000
1018
  get posts() {
1001
1019
  return indiekitCollections.get("posts");
1002
1020
  },
@@ -144,6 +144,7 @@ export function composeController(mountPath, plugin) {
144
144
  syndicationTargets,
145
145
  csrfToken,
146
146
  mountPath,
147
+ mediaEndpoint: application.mediaEndpoint || "",
147
148
  });
148
149
  } catch (error) {
149
150
  next(error);
@@ -167,7 +168,7 @@ export function submitComposeController(mountPath, plugin) {
167
168
  }
168
169
 
169
170
  const { application } = request.app.locals;
170
- const { content, visibility, summary } = request.body;
171
+ const { content, visibility, summary, photo, category } = request.body;
171
172
  const cwEnabled = request.body["cw-enabled"];
172
173
  const inReplyTo = request.body["in-reply-to"];
173
174
  const syndicateTo = request.body["mp-syndicate-to"];
@@ -228,6 +229,21 @@ export function submitComposeController(mountPath, plugin) {
228
229
  }
229
230
  }
230
231
 
232
+ // Photo (from file-input component — already a URL from media endpoint)
233
+ if (photo && photo.trim()) {
234
+ micropubData.append("photo", photo.trim());
235
+ }
236
+
237
+ // Tags / categories
238
+ if (category) {
239
+ const tags = Array.isArray(category)
240
+ ? category
241
+ : category.split(",").map((t) => t.trim()).filter(Boolean);
242
+ for (const tag of tags) {
243
+ micropubData.append("category[]", tag);
244
+ }
245
+ }
246
+
231
247
  console.info(
232
248
  `[ActivityPub] Compose Micropub submission:`,
233
249
  JSON.stringify({
@@ -250,6 +250,38 @@ export function createIndexes(collections, options) {
250
250
  { url: 1 },
251
251
  { unique: true, background: true },
252
252
  );
253
+
254
+ // Media attachments (Mastodon API upload)
255
+ collections.ap_media?.createIndex(
256
+ { createdAt: 1 },
257
+ { expireAfterSeconds: 86400, background: true },
258
+ );
259
+
260
+ // Status edit history
261
+ collections.ap_status_edits?.createIndex(
262
+ { statusId: 1, editedAt: 1 },
263
+ { background: true },
264
+ );
265
+
266
+ // Idempotency keys (auto-expire after 1 hour)
267
+ collections.ap_idempotency?.createIndex(
268
+ { key: 1 },
269
+ { unique: true, background: true },
270
+ );
271
+ collections.ap_idempotency?.createIndex(
272
+ { createdAt: 1 },
273
+ { expireAfterSeconds: 3600, background: true },
274
+ );
275
+
276
+ // Filters
277
+ collections.ap_filters?.createIndex(
278
+ { createdAt: 1 },
279
+ { background: true },
280
+ );
281
+ collections.ap_filter_keywords?.createIndex(
282
+ { filterId: 1 },
283
+ { background: true },
284
+ );
253
285
  } catch {
254
286
  // Index creation failed — collections not yet available.
255
287
  // Indexes already exist from previous startups; non-fatal.
@@ -246,7 +246,7 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
246
246
  /**
247
247
  * Serialize a linkPreview object as a Mastodon PreviewCard.
248
248
  */
249
- function serializeCard(preview) {
249
+ export function serializeCard(preview) {
250
250
  if (!preview) return null;
251
251
 
252
252
  return {
@@ -20,15 +20,20 @@ import timelinesRouter from "./routes/timelines.js";
20
20
  import notificationsRouter from "./routes/notifications.js";
21
21
  import searchRouter from "./routes/search.js";
22
22
  import mediaRouter from "./routes/media.js";
23
+ import filtersRouter from "./routes/filters.js";
23
24
  import stubsRouter from "./routes/stubs.js";
24
25
 
25
- // Rate limiters for different endpoint categories
26
+ // Rate limiters for different endpoint categories.
27
+ // validate.trustProxy disabled — Indiekit sets Express trust proxy to true
28
+ // (behind Cloudron/nginx), which express-rate-limit v7+ rejects as too
29
+ // permissive. The proxy is trusted infrastructure, not user-controlled.
26
30
  const apiLimiter = rateLimit({
27
31
  windowMs: 5 * 60 * 1000, // 5 minutes
28
32
  max: 300,
29
33
  standardHeaders: true,
30
34
  legacyHeaders: false,
31
35
  message: { error: "Too many requests, please try again later" },
36
+ validate: { trustProxy: false },
32
37
  });
33
38
 
34
39
  const authLimiter = rateLimit({
@@ -37,6 +42,7 @@ const authLimiter = rateLimit({
37
42
  standardHeaders: true,
38
43
  legacyHeaders: false,
39
44
  message: { error: "Too many authentication attempts" },
45
+ validate: { trustProxy: false },
40
46
  });
41
47
 
42
48
  const appRegistrationLimiter = rateLimit({
@@ -45,6 +51,7 @@ const appRegistrationLimiter = rateLimit({
45
51
  standardHeaders: true,
46
52
  legacyHeaders: false,
47
53
  message: { error: "Too many app registrations" },
54
+ validate: { trustProxy: false },
48
55
  });
49
56
 
50
57
  /**
@@ -112,6 +119,7 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) {
112
119
  router.use(notificationsRouter);
113
120
  router.use(searchRouter);
114
121
  router.use(mediaRouter);
122
+ router.use(filtersRouter);
115
123
  router.use(stubsRouter);
116
124
 
117
125
  // ─── Catch-all for unimplemented endpoints ──────────────────────────────
@@ -153,6 +153,61 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
153
153
  }
154
154
  });
155
155
 
156
+ // ─── GET /api/v1/accounts/search ────────────────────────────────────────────
157
+ // Used by clients for @mention autocomplete in compose box.
158
+
159
+ router.get("/api/v1/accounts/search", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
160
+ try {
161
+ const collections = req.app.locals.mastodonCollections;
162
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
163
+ const query = req.query.q?.trim();
164
+ const limit = Math.min(Number.parseInt(req.query.limit, 10) || 10, 40);
165
+
166
+ if (!query) {
167
+ return res.json([]);
168
+ }
169
+
170
+ // Escape regex special characters
171
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
172
+ const regex = new RegExp(escaped, "i");
173
+
174
+ const results = new Map(); // dedupe by URL
175
+
176
+ // Search followers
177
+ if (collections.ap_followers) {
178
+ const followers = await collections.ap_followers
179
+ .find({
180
+ $or: [{ name: regex }, { handle: regex }, { actorUrl: regex }],
181
+ })
182
+ .limit(limit)
183
+ .toArray();
184
+ for (const f of followers) results.set(f.actorUrl, f);
185
+ }
186
+
187
+ // Search following
188
+ if (results.size < limit && collections.ap_following) {
189
+ const following = await collections.ap_following
190
+ .find({
191
+ $or: [{ name: regex }, { handle: regex }, { actorUrl: regex }],
192
+ })
193
+ .limit(limit - results.size)
194
+ .toArray();
195
+ for (const f of following) results.set(f.actorUrl, f);
196
+ }
197
+
198
+ const { serializeAccount } = await import("../entities/account.js");
199
+ const accounts = [...results.values()]
200
+ .slice(0, limit)
201
+ .map((actor) =>
202
+ serializeAccount(actor, { baseUrl, isLocal: false }),
203
+ );
204
+
205
+ res.json(accounts);
206
+ } catch (error) {
207
+ next(error);
208
+ }
209
+ });
210
+
156
211
  // ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
157
212
  // MUST be before /accounts/:id to prevent Express matching "relationships" as :id
158
213
 
@@ -228,6 +283,46 @@ router.get("/api/v1/accounts/familiar_followers", tokenRequired, scopeRequired("
228
283
  res.json(ids.map((id) => ({ id, accounts: [] })));
229
284
  });
230
285
 
286
+ // ─── PATCH /api/v1/accounts/update_credentials ──────────────────────────────
287
+
288
+ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired("write", "write:accounts"), async (req, res, next) => {
289
+ try {
290
+ const collections = req.app.locals.mastodonCollections;
291
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
292
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
293
+
294
+ const update = {};
295
+ if (req.body.display_name !== undefined) update.name = req.body.display_name;
296
+ if (req.body.note !== undefined) update.summary = req.body.note;
297
+ if (req.body.fields_attributes) {
298
+ update.attachments = Object.values(req.body.fields_attributes).map(
299
+ (f) => ({
300
+ name: f.name,
301
+ value: f.value,
302
+ }),
303
+ );
304
+ }
305
+
306
+ if (Object.keys(update).length > 0 && collections.ap_profile) {
307
+ await collections.ap_profile.updateOne({}, { $set: update });
308
+ }
309
+
310
+ // Return updated credential account
311
+ const profile = collections.ap_profile
312
+ ? await collections.ap_profile.findOne({})
313
+ : {};
314
+
315
+ const { serializeCredentialAccount } = await import(
316
+ "../entities/account.js"
317
+ );
318
+ res.json(
319
+ await serializeCredentialAccount(profile, { baseUrl, collections }),
320
+ );
321
+ } catch (error) {
322
+ next(error);
323
+ }
324
+ });
325
+
231
326
  // ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
232
327
 
233
328
  router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Filter endpoints for Mastodon Client API v2.
3
+ */
4
+ import express from "express";
5
+ import { ObjectId } from "mongodb";
6
+ import { tokenRequired } from "../middleware/token-required.js";
7
+ import { scopeRequired } from "../middleware/scope-required.js";
8
+
9
+ const router = express.Router(); // eslint-disable-line new-cap
10
+
11
+ /**
12
+ * Serialize a filter document with its keywords.
13
+ */
14
+ function serializeFilter(filter, keywords = []) {
15
+ return {
16
+ id: filter._id.toString(),
17
+ title: filter.title || "",
18
+ context: filter.context || [],
19
+ filter_action: filter.filterAction || "warn",
20
+ expires_at: filter.expiresAt || null,
21
+ keywords: keywords.map((kw) => ({
22
+ id: kw._id.toString(),
23
+ keyword: kw.keyword,
24
+ whole_word: kw.wholeWord ?? true,
25
+ })),
26
+ statuses: [],
27
+ };
28
+ }
29
+
30
+ // ─── GET /api/v2/filters ────────────────────────────────────────────────────
31
+
32
+ router.get("/api/v2/filters", tokenRequired, scopeRequired("read", "read:filters"), async (req, res, next) => {
33
+ try {
34
+ const collections = req.app.locals.mastodonCollections;
35
+ if (!collections.ap_filters) return res.json([]);
36
+
37
+ const filters = await collections.ap_filters.find({}).toArray();
38
+ const result = [];
39
+
40
+ for (const filter of filters) {
41
+ const keywords = collections.ap_filter_keywords
42
+ ? await collections.ap_filter_keywords
43
+ .find({ filterId: filter._id })
44
+ .toArray()
45
+ : [];
46
+ result.push(serializeFilter(filter, keywords));
47
+ }
48
+
49
+ res.json(result);
50
+ } catch (error) {
51
+ next(error);
52
+ }
53
+ });
54
+
55
+ // ─── POST /api/v2/filters ───────────────────────────────────────────────────
56
+
57
+ router.post("/api/v2/filters", tokenRequired, scopeRequired("write", "write:filters"), async (req, res, next) => {
58
+ try {
59
+ const collections = req.app.locals.mastodonCollections;
60
+ if (!collections.ap_filters) {
61
+ return res.status(500).json({ error: "Filters not available" });
62
+ }
63
+
64
+ const {
65
+ title,
66
+ context,
67
+ filter_action: filterAction = "warn",
68
+ expires_in: expiresIn,
69
+ keywords_attributes: keywordsAttributes,
70
+ } = req.body;
71
+
72
+ if (!title) {
73
+ return res.status(422).json({ error: "title is required" });
74
+ }
75
+
76
+ const expiresAt = expiresIn
77
+ ? new Date(Date.now() + Number.parseInt(expiresIn, 10) * 1000).toISOString()
78
+ : null;
79
+
80
+ const filterDoc = {
81
+ title,
82
+ context: Array.isArray(context) ? context : [context].filter(Boolean),
83
+ filterAction,
84
+ expiresAt,
85
+ createdAt: new Date().toISOString(),
86
+ };
87
+
88
+ const result = await collections.ap_filters.insertOne(filterDoc);
89
+ filterDoc._id = result.insertedId;
90
+
91
+ // Insert keywords if provided
92
+ const keywords = [];
93
+ if (keywordsAttributes && collections.ap_filter_keywords) {
94
+ const attrs = Array.isArray(keywordsAttributes)
95
+ ? keywordsAttributes
96
+ : Object.values(keywordsAttributes);
97
+ for (const attr of attrs) {
98
+ if (attr.keyword) {
99
+ const kwDoc = {
100
+ filterId: filterDoc._id,
101
+ keyword: attr.keyword,
102
+ wholeWord: attr.whole_word !== "false" && attr.whole_word !== false,
103
+ };
104
+ const kwResult = await collections.ap_filter_keywords.insertOne(kwDoc);
105
+ kwDoc._id = kwResult.insertedId;
106
+ keywords.push(kwDoc);
107
+ }
108
+ }
109
+ }
110
+
111
+ res.json(serializeFilter(filterDoc, keywords));
112
+ } catch (error) {
113
+ next(error);
114
+ }
115
+ });
116
+
117
+ // ─── GET /api/v2/filters/:id ────────────────────────────────────────────────
118
+
119
+ router.get("/api/v2/filters/:id", tokenRequired, scopeRequired("read", "read:filters"), async (req, res, next) => {
120
+ try {
121
+ const collections = req.app.locals.mastodonCollections;
122
+ let filter;
123
+ try {
124
+ filter = await collections.ap_filters?.findOne({
125
+ _id: new ObjectId(req.params.id),
126
+ });
127
+ } catch { /* invalid ObjectId */ }
128
+
129
+ if (!filter) {
130
+ return res.status(404).json({ error: "Record not found" });
131
+ }
132
+
133
+ const keywords = collections.ap_filter_keywords
134
+ ? await collections.ap_filter_keywords
135
+ .find({ filterId: filter._id })
136
+ .toArray()
137
+ : [];
138
+
139
+ res.json(serializeFilter(filter, keywords));
140
+ } catch (error) {
141
+ next(error);
142
+ }
143
+ });
144
+
145
+ // ─── PUT /api/v2/filters/:id ────────────────────────────────────────────────
146
+
147
+ router.put("/api/v2/filters/:id", tokenRequired, scopeRequired("write", "write:filters"), async (req, res, next) => {
148
+ try {
149
+ const collections = req.app.locals.mastodonCollections;
150
+ let filter;
151
+ try {
152
+ filter = await collections.ap_filters?.findOne({
153
+ _id: new ObjectId(req.params.id),
154
+ });
155
+ } catch { /* invalid ObjectId */ }
156
+
157
+ if (!filter) {
158
+ return res.status(404).json({ error: "Record not found" });
159
+ }
160
+
161
+ const update = {};
162
+ if (req.body.title !== undefined) update.title = req.body.title;
163
+ if (req.body.context !== undefined) {
164
+ update.context = Array.isArray(req.body.context)
165
+ ? req.body.context
166
+ : [req.body.context].filter(Boolean);
167
+ }
168
+ if (req.body.filter_action !== undefined) update.filterAction = req.body.filter_action;
169
+ if (req.body.expires_in !== undefined) {
170
+ update.expiresAt = req.body.expires_in
171
+ ? new Date(Date.now() + Number.parseInt(req.body.expires_in, 10) * 1000).toISOString()
172
+ : null;
173
+ }
174
+
175
+ if (Object.keys(update).length > 0) {
176
+ await collections.ap_filters.updateOne(
177
+ { _id: filter._id },
178
+ { $set: update },
179
+ );
180
+ Object.assign(filter, update);
181
+ }
182
+
183
+ const keywords = collections.ap_filter_keywords
184
+ ? await collections.ap_filter_keywords
185
+ .find({ filterId: filter._id })
186
+ .toArray()
187
+ : [];
188
+
189
+ res.json(serializeFilter(filter, keywords));
190
+ } catch (error) {
191
+ next(error);
192
+ }
193
+ });
194
+
195
+ // ─── DELETE /api/v2/filters/:id ─────────────────────────────────────────────
196
+
197
+ router.delete("/api/v2/filters/:id", tokenRequired, scopeRequired("write", "write:filters"), async (req, res, next) => {
198
+ try {
199
+ const collections = req.app.locals.mastodonCollections;
200
+ let filterId;
201
+ try {
202
+ filterId = new ObjectId(req.params.id);
203
+ } catch {
204
+ return res.status(404).json({ error: "Record not found" });
205
+ }
206
+
207
+ await collections.ap_filters?.deleteOne({ _id: filterId });
208
+ await collections.ap_filter_keywords?.deleteMany({ filterId });
209
+
210
+ res.json({});
211
+ } catch (error) {
212
+ next(error);
213
+ }
214
+ });
215
+
216
+ export default router;