@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(
|
|
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(
|
|
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(
|
|
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
|
+
"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",
|