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