@rmdes/indiekit-endpoint-activitypub 3.12.3 → 3.12.5

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,158 @@
1
+ /**
2
+ * Keyword filter helpers for Mastodon Client API v2.
3
+ *
4
+ * Loads active filters from MongoDB and applies them to serialized
5
+ * Mastodon Status objects, following the v2 filter spec:
6
+ * - filterAction "hide" → status removed from results
7
+ * - filterAction "warn" → status kept with `filtered` array attached
8
+ */
9
+
10
+ /**
11
+ * Strip HTML tags from a string for plain-text keyword matching.
12
+ *
13
+ * @param {string} html - HTML string
14
+ * @returns {string} Plain text
15
+ */
16
+ function stripHtml(html) {
17
+ if (!html) return "";
18
+ return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
19
+ }
20
+
21
+ /**
22
+ * Compile a regex from a list of keyword documents.
23
+ *
24
+ * Keywords with `wholeWord: true` are wrapped in `\b` word boundaries.
25
+ * Keywords with `wholeWord: false` are matched as plain substrings.
26
+ * Returns null if there are no keywords.
27
+ *
28
+ * @param {Array<{keyword: string, wholeWord: boolean}>} keywords
29
+ * @returns {RegExp|null}
30
+ */
31
+ function compileKeywordRegex(keywords) {
32
+ if (!keywords || keywords.length === 0) return null;
33
+
34
+ const parts = keywords.map((kw) => {
35
+ const escaped = kw.keyword.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
36
+ return kw.wholeWord ? `\\b${escaped}\\b` : escaped;
37
+ });
38
+
39
+ return new RegExp(parts.join("|"), "i");
40
+ }
41
+
42
+ /**
43
+ * Load active filters for a given context from MongoDB.
44
+ *
45
+ * Skips expired filters. For each filter, loads its keywords and compiles
46
+ * a single regex from all of them.
47
+ *
48
+ * @param {object} collections - MongoDB collections (must have ap_filters, ap_filter_keywords)
49
+ * @param {string} context - Filter context to match ("home", "public", "notifications", "thread")
50
+ * @returns {Promise<Array<{id: string, title: string, context: string[], filterAction: string, expiresAt: string|null, regex: RegExp|null, keywords: Array}>>}
51
+ */
52
+ export async function loadUserFilters(collections, context) {
53
+ if (!collections.ap_filters) return [];
54
+
55
+ const now = new Date().toISOString();
56
+
57
+ // Load filters that include this context, skipping expired ones
58
+ const filterDocs = await collections.ap_filters
59
+ .find({ context })
60
+ .toArray();
61
+
62
+ const activeFilters = filterDocs.filter((f) => {
63
+ if (!f.expiresAt) return true;
64
+ return f.expiresAt > now;
65
+ });
66
+
67
+ if (activeFilters.length === 0) return [];
68
+
69
+ const result = [];
70
+
71
+ for (const filter of activeFilters) {
72
+ const keywords = collections.ap_filter_keywords
73
+ ? await collections.ap_filter_keywords
74
+ .find({ filterId: filter._id })
75
+ .toArray()
76
+ : [];
77
+
78
+ const regex = compileKeywordRegex(keywords);
79
+
80
+ result.push({
81
+ id: filter._id.toString(),
82
+ title: filter.title || "",
83
+ context: filter.context || [],
84
+ filterAction: filter.filterAction || "warn",
85
+ expiresAt: filter.expiresAt || null,
86
+ regex,
87
+ keywords,
88
+ });
89
+ }
90
+
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Apply compiled filters to an array of serialized Mastodon statuses.
96
+ *
97
+ * - "hide" filters: matching statuses are removed entirely
98
+ * - "warn" filters: matching statuses get a `filtered` array attached
99
+ *
100
+ * @param {Array<object>} statuses - Serialized Mastodon Status objects
101
+ * @param {Array<object>} filters - Compiled filter objects from loadUserFilters()
102
+ * @returns {Array<object>} Processed statuses (hide-matched ones removed)
103
+ */
104
+ export function applyFilters(statuses, filters) {
105
+ if (!filters || filters.length === 0) return statuses;
106
+
107
+ const result = [];
108
+
109
+ for (const status of statuses) {
110
+ const text = stripHtml(status.content || "");
111
+ let hidden = false;
112
+
113
+ for (const filter of filters) {
114
+ if (!filter.regex) continue;
115
+
116
+ const match = text.match(filter.regex);
117
+ if (!match) continue;
118
+
119
+ if (filter.filterAction === "hide") {
120
+ hidden = true;
121
+ break;
122
+ }
123
+
124
+ // filterAction === "warn" — attach filtered metadata
125
+ const matchedKeywords = filter.keywords
126
+ .filter((kw) => {
127
+ const escaped = kw.keyword.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
128
+ const kwRegex = new RegExp(
129
+ kw.wholeWord ? `\\b${escaped}\\b` : escaped,
130
+ "i",
131
+ );
132
+ return kwRegex.test(text);
133
+ })
134
+ .map((kw) => kw.keyword);
135
+
136
+ if (!status.filtered) {
137
+ status.filtered = [];
138
+ }
139
+
140
+ status.filtered.push({
141
+ filter: {
142
+ id: filter.id,
143
+ title: filter.title,
144
+ context: filter.context,
145
+ filter_action: filter.filterAction,
146
+ expires_at: filter.expiresAt,
147
+ },
148
+ keyword_matches: matchedKeywords,
149
+ });
150
+ }
151
+
152
+ if (!hidden) {
153
+ result.push(status);
154
+ }
155
+ }
156
+
157
+ return result;
158
+ }
@@ -467,7 +467,6 @@ router.post("/oauth/token", async (req, res, next) => {
467
467
  accessToken,
468
468
  createdAt: new Date(),
469
469
  grantType: "client_credentials",
470
- expiresAt: new Date(Date.now() + 3600 * 1000),
471
470
  });
472
471
 
473
472
  return res.json({
@@ -475,7 +474,6 @@ router.post("/oauth/token", async (req, res, next) => {
475
474
  token_type: "Bearer",
476
475
  scope: "read",
477
476
  created_at: Math.floor(Date.now() / 1000),
478
- expires_in: 3600,
479
477
  });
480
478
  }
481
479
 
@@ -510,9 +508,9 @@ router.post("/oauth/token", async (req, res, next) => {
510
508
  $set: {
511
509
  accessToken: newAccessToken,
512
510
  refreshToken: newRefreshToken,
513
- expiresAt: new Date(Date.now() + 3600 * 1000),
514
511
  refreshExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000),
515
512
  },
513
+ $unset: { expiresAt: "" },
516
514
  },
517
515
  );
518
516
 
@@ -522,7 +520,6 @@ router.post("/oauth/token", async (req, res, next) => {
522
520
  scope: existing.scopes.join(" "),
523
521
  created_at: Math.floor(existing.createdAt.getTime() / 1000),
524
522
  refresh_token: newRefreshToken,
525
- expires_in: 3600,
526
523
  });
527
524
  }
528
525
 
@@ -590,8 +587,9 @@ router.post("/oauth/token", async (req, res, next) => {
590
587
  }
591
588
  }
592
589
 
593
- // Generate access token and refresh token with expiry.
594
- const ACCESS_TOKEN_TTL = 3600 * 1000; // 1 hour
590
+ // Generate access token and refresh token.
591
+ // Access tokens do not expire (matching Mastodon behavior — valid until revoked).
592
+ // Refresh tokens expire after 90 days as a safety measure.
595
593
  const REFRESH_TOKEN_TTL = 90 * 24 * 3600 * 1000; // 90 days
596
594
  const accessToken = randomHex(64);
597
595
  const refreshToken = randomHex(64);
@@ -601,7 +599,6 @@ router.post("/oauth/token", async (req, res, next) => {
601
599
  $set: {
602
600
  accessToken,
603
601
  refreshToken,
604
- expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL),
605
602
  refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL),
606
603
  },
607
604
  },
@@ -613,7 +610,6 @@ router.post("/oauth/token", async (req, res, next) => {
613
610
  scope: grant.scopes.join(" "),
614
611
  created_at: Math.floor(grant.createdAt.getTime() / 1000),
615
612
  refresh_token: refreshToken,
616
- expires_in: 3600,
617
613
  });
618
614
  } catch (error) {
619
615
  next(error);
@@ -11,6 +11,7 @@ import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpe
11
11
  import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
12
12
  import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
13
13
  import { enrichAccountStats } from "../helpers/enrich-accounts.js";
14
+ import { loadUserFilters, applyFilters } from "../helpers/apply-filters.js";
14
15
  import { tokenRequired } from "../middleware/token-required.js";
15
16
  import { scopeRequired } from "../middleware/scope-required.js";
16
17
 
@@ -85,10 +86,17 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
85
86
  const pluginOptions = req.app.locals.mastodonPluginOptions || {};
86
87
  await enrichAccountStats(statuses, pluginOptions, baseUrl);
87
88
 
89
+ // Apply keyword filters
90
+ let filteredStatuses = statuses;
91
+ if (collections.ap_filters) {
92
+ const filters = await loadUserFilters(collections, "home");
93
+ filteredStatuses = applyFilters(statuses, filters);
94
+ }
95
+
88
96
  // Set pagination Link headers
89
97
  setPaginationHeaders(res, req, items, limit);
90
98
 
91
- res.json(statuses);
99
+ res.json(filteredStatuses);
92
100
  } catch (error) {
93
101
  next(error);
94
102
  }
@@ -102,9 +110,10 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
102
110
  const baseUrl = `${req.protocol}://${req.get("host")}`;
103
111
  const limit = parseLimit(req.query.limit);
104
112
 
105
- // Public timeline: only public visibility, no context items
113
+ // Public timeline: only public visibility, no context items, no replies
106
114
  const baseFilter = {
107
115
  isContext: { $ne: true },
116
+ inReplyTo: { $exists: false },
108
117
  visibility: "public",
109
118
  };
110
119
 
@@ -186,8 +195,15 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
186
195
  const pluginOpts = req.app.locals.mastodonPluginOptions || {};
187
196
  await enrichAccountStats(statuses, pluginOpts, baseUrl);
188
197
 
198
+ // Apply keyword filters
199
+ let filteredStatuses = statuses;
200
+ if (collections.ap_filters) {
201
+ const filters = await loadUserFilters(collections, "public");
202
+ filteredStatuses = applyFilters(statuses, filters);
203
+ }
204
+
189
205
  setPaginationHeaders(res, req, items, limit);
190
- res.json(statuses);
206
+ res.json(filteredStatuses);
191
207
  } catch (error) {
192
208
  next(error);
193
209
  }
@@ -204,6 +220,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
204
220
 
205
221
  const baseFilter = {
206
222
  isContext: { $ne: true },
223
+ inReplyTo: { $exists: false },
207
224
  visibility: { $in: ["public", "unlisted"] },
208
225
  category: hashtag,
209
226
  };
@@ -253,8 +270,15 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
253
270
  const pluginOpts = req.app.locals.mastodonPluginOptions || {};
254
271
  await enrichAccountStats(statuses, pluginOpts, baseUrl);
255
272
 
273
+ // Apply keyword filters
274
+ let filteredStatuses = statuses;
275
+ if (collections.ap_filters) {
276
+ const filters = await loadUserFilters(collections, "public");
277
+ filteredStatuses = applyFilters(statuses, filters);
278
+ }
279
+
256
280
  setPaginationHeaders(res, req, items, limit);
257
- res.json(statuses);
281
+ res.json(filteredStatuses);
258
282
  } catch (error) {
259
283
  next(error);
260
284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.12.3",
3
+ "version": "3.12.5",
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",