@rmdes/indiekit-endpoint-activitypub 3.12.4 → 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
+ }
@@ -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.4",
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",