@rmdes/indiekit-endpoint-activitypub 2.0.26 → 2.0.28

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,170 @@
1
+ /**
2
+ * JSON API timeline endpoint — returns pre-rendered HTML cards for infinite scroll AJAX loads.
3
+ */
4
+
5
+ import { getTimelineItems } from "../storage/timeline.js";
6
+ import { getToken } from "../csrf.js";
7
+ import {
8
+ getMutedUrls,
9
+ getMutedKeywords,
10
+ getBlockedUrls,
11
+ getFilterMode,
12
+ } from "../storage/moderation.js";
13
+
14
+ export function apiTimelineController(mountPath) {
15
+ return async (request, response, next) => {
16
+ try {
17
+ const { application } = request.app.locals;
18
+ const collections = {
19
+ ap_timeline: application?.collections?.get("ap_timeline"),
20
+ };
21
+
22
+ // Query parameters
23
+ const tab = request.query.tab || "notes";
24
+ const tag = typeof request.query.tag === "string" ? request.query.tag.trim() : "";
25
+ const before = request.query.before;
26
+ const limit = 20;
27
+
28
+ // Build storage query options (same logic as readerController)
29
+ const options = { before, limit };
30
+
31
+ if (tag) {
32
+ options.tag = tag;
33
+ } else {
34
+ if (tab === "notes") {
35
+ options.type = "note";
36
+ options.excludeReplies = true;
37
+ } else if (tab === "articles") {
38
+ options.type = "article";
39
+ } else if (tab === "boosts") {
40
+ options.type = "boost";
41
+ }
42
+ }
43
+
44
+ const result = await getTimelineItems(collections, options);
45
+
46
+ // Client-side tab filtering for types not supported by storage
47
+ let items = result.items;
48
+ if (!tag) {
49
+ if (tab === "replies") {
50
+ items = items.filter((item) => item.inReplyTo);
51
+ } else if (tab === "media") {
52
+ items = items.filter(
53
+ (item) =>
54
+ (item.photo && item.photo.length > 0) ||
55
+ (item.video && item.video.length > 0) ||
56
+ (item.audio && item.audio.length > 0)
57
+ );
58
+ }
59
+ }
60
+
61
+ // Apply moderation filters
62
+ const modCollections = {
63
+ ap_muted: application?.collections?.get("ap_muted"),
64
+ ap_blocked: application?.collections?.get("ap_blocked"),
65
+ ap_profile: application?.collections?.get("ap_profile"),
66
+ };
67
+ const [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
68
+ await Promise.all([
69
+ getMutedUrls(modCollections),
70
+ getMutedKeywords(modCollections),
71
+ getBlockedUrls(modCollections),
72
+ getFilterMode(modCollections),
73
+ ]);
74
+ const blockedSet = new Set(blockedUrls);
75
+ const mutedSet = new Set(mutedUrls);
76
+
77
+ if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
78
+ items = items.filter((item) => {
79
+ if (item.author?.url && blockedSet.has(item.author.url)) {
80
+ return false;
81
+ }
82
+
83
+ const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
84
+ let matchedKeyword = null;
85
+ if (mutedKeywords.length > 0) {
86
+ const searchable = [item.content?.text, item.name, item.summary]
87
+ .filter(Boolean)
88
+ .join(" ")
89
+ .toLowerCase();
90
+ if (searchable) {
91
+ matchedKeyword = mutedKeywords.find((kw) =>
92
+ searchable.includes(kw.toLowerCase())
93
+ );
94
+ }
95
+ }
96
+
97
+ if (isMutedActor || matchedKeyword) {
98
+ if (filterMode === "warn") {
99
+ item._moderated = true;
100
+ item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
101
+ if (matchedKeyword) item._moderationKeyword = matchedKeyword;
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ return true;
108
+ });
109
+ }
110
+
111
+ // Get interaction state
112
+ const interactionsCol = application?.collections?.get("ap_interactions");
113
+ const interactionMap = {};
114
+
115
+ if (interactionsCol) {
116
+ const lookupUrls = new Set();
117
+ const objectUrlToUid = new Map();
118
+ for (const item of items) {
119
+ const uid = item.uid;
120
+ const displayUrl = item.url || item.originalUrl;
121
+ if (uid) { lookupUrls.add(uid); objectUrlToUid.set(uid, uid); }
122
+ if (displayUrl) { lookupUrls.add(displayUrl); objectUrlToUid.set(displayUrl, uid || displayUrl); }
123
+ }
124
+ if (lookupUrls.size > 0) {
125
+ const interactions = await interactionsCol
126
+ .find({ objectUrl: { $in: [...lookupUrls] } })
127
+ .toArray();
128
+ for (const interaction of interactions) {
129
+ const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
130
+ if (!interactionMap[key]) interactionMap[key] = {};
131
+ interactionMap[key][interaction.type] = true;
132
+ }
133
+ }
134
+ }
135
+
136
+ const csrfToken = getToken(request.session);
137
+
138
+ // Render each card server-side using the same Nunjucks template
139
+ // Merge response.locals so that i18n (__), mountPath, etc. are available
140
+ const templateData = {
141
+ ...response.locals,
142
+ mountPath,
143
+ csrfToken,
144
+ interactionMap,
145
+ };
146
+
147
+ const htmlParts = await Promise.all(
148
+ items.map((item) => {
149
+ return new Promise((resolve, reject) => {
150
+ request.app.render(
151
+ "partials/ap-item-card.njk",
152
+ { ...templateData, item },
153
+ (err, html) => {
154
+ if (err) reject(err);
155
+ else resolve(html);
156
+ }
157
+ );
158
+ });
159
+ })
160
+ );
161
+
162
+ response.json({
163
+ html: htmlParts.join(""),
164
+ before: result.before,
165
+ });
166
+ } catch (error) {
167
+ next(error);
168
+ }
169
+ };
170
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Explore controller — browse public timelines from remote Mastodon-compatible instances.
3
+ *
4
+ * All remote API calls are server-side (no CORS issues).
5
+ * Remote HTML is always passed through sanitizeContent() before storage.
6
+ */
7
+
8
+ import sanitizeHtml from "sanitize-html";
9
+ import { sanitizeContent } from "../timeline-store.js";
10
+
11
+ const FETCH_TIMEOUT_MS = 10_000;
12
+ const MAX_RESULTS = 20;
13
+
14
+ /**
15
+ * Validate the instance parameter to prevent SSRF.
16
+ * Only allows hostnames — no IPs, no localhost, no port numbers for exotic attacks.
17
+ * @param {string} instance - Raw instance parameter from query string
18
+ * @returns {string|null} Validated hostname or null
19
+ */
20
+ function validateInstance(instance) {
21
+ if (!instance || typeof instance !== "string") return null;
22
+
23
+ try {
24
+ // Prepend https:// to parse as URL
25
+ const url = new URL(`https://${instance.trim()}`);
26
+
27
+ // Must be a plain hostname — no IP addresses, no localhost
28
+ const hostname = url.hostname;
29
+ if (
30
+ hostname === "localhost" ||
31
+ hostname === "127.0.0.1" ||
32
+ hostname === "0.0.0.0" ||
33
+ hostname === "::1" ||
34
+ hostname.startsWith("192.168.") ||
35
+ hostname.startsWith("10.") ||
36
+ hostname.startsWith("169.254.") ||
37
+ /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
38
+ /^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(hostname) || // IPv4
39
+ hostname.includes("[") // IPv6
40
+ ) {
41
+ return null;
42
+ }
43
+
44
+ // Only allow the hostname (no path, no port override)
45
+ return hostname;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Map a Mastodon API status object to our timeline item format.
53
+ * @param {object} status - Mastodon API status
54
+ * @param {string} instance - Instance hostname (for handle construction)
55
+ * @returns {object} Timeline item compatible with ap-item-card.njk
56
+ */
57
+ function mapMastodonStatusToItem(status, instance) {
58
+ const account = status.account || {};
59
+ const acct = account.acct || "";
60
+ // Mastodon acct is "user" for local, "user@remote" for remote
61
+ const handle = acct.includes("@") ? `@${acct}` : `@${acct}@${instance}`;
62
+
63
+ // Map mentions — store without leading @ (template prepends it)
64
+ const mentions = (status.mentions || []).map((m) => ({
65
+ name: m.acct.includes("@") ? m.acct : `${m.acct}@${instance}`,
66
+ url: m.url || "",
67
+ }));
68
+
69
+ // Map hashtags
70
+ const category = (status.tags || []).map((t) => t.name || "");
71
+
72
+ // Map media attachments
73
+ const photo = [];
74
+ const video = [];
75
+ const audio = [];
76
+ for (const att of status.media_attachments || []) {
77
+ const url = att.url || att.remote_url || "";
78
+ if (!url) continue;
79
+ if (att.type === "image" || att.type === "gifv") {
80
+ photo.push(url);
81
+ } else if (att.type === "video") {
82
+ video.push(url);
83
+ } else if (att.type === "audio") {
84
+ audio.push(url);
85
+ }
86
+ }
87
+
88
+ return {
89
+ uid: status.url || status.uri || "",
90
+ url: status.url || status.uri || "",
91
+ type: "note",
92
+ name: "",
93
+ content: {
94
+ text: (status.content || "").replace(/<[^>]*>/g, ""),
95
+ html: sanitizeContent(status.content || ""),
96
+ },
97
+ summary: status.spoiler_text || "",
98
+ sensitive: status.sensitive || false,
99
+ published: status.created_at || new Date().toISOString(),
100
+ author: {
101
+ name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
102
+ url: account.url || "",
103
+ photo: account.avatar || account.avatar_static || "",
104
+ handle,
105
+ },
106
+ category,
107
+ mentions,
108
+ photo,
109
+ video,
110
+ audio,
111
+ inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
112
+ createdAt: new Date().toISOString(),
113
+ // Explore-specific: track source instance
114
+ _explore: true,
115
+ };
116
+ }
117
+
118
+ export function exploreController(mountPath) {
119
+ return async (request, response, next) => {
120
+ try {
121
+ const rawInstance = request.query.instance || "";
122
+ const scope = request.query.scope === "federated" ? "federated" : "local";
123
+ const maxId = request.query.max_id || "";
124
+
125
+ // No instance specified — render clean initial page (no error)
126
+ if (!rawInstance.trim()) {
127
+ return response.render("activitypub-explore", {
128
+ title: response.locals.__("activitypub.reader.explore.title"),
129
+ instance: "",
130
+ scope,
131
+ items: [],
132
+ maxId: null,
133
+ error: null,
134
+ mountPath,
135
+ });
136
+ }
137
+
138
+ const instance = validateInstance(rawInstance);
139
+ if (!instance) {
140
+ return response.render("activitypub-explore", {
141
+ title: response.locals.__("activitypub.reader.explore.title"),
142
+ instance: rawInstance,
143
+ scope,
144
+ items: [],
145
+ maxId: null,
146
+ error: response.locals.__("activitypub.reader.explore.invalidInstance"),
147
+ mountPath,
148
+ });
149
+ }
150
+
151
+ // Fetch public timeline from remote instance
152
+ const isLocal = scope === "local";
153
+ const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
154
+ apiUrl.searchParams.set("local", isLocal ? "true" : "false");
155
+ apiUrl.searchParams.set("limit", String(MAX_RESULTS));
156
+ if (maxId) apiUrl.searchParams.set("max_id", maxId);
157
+
158
+ let items = [];
159
+ let nextMaxId = null;
160
+ let error = null;
161
+
162
+ try {
163
+ const controller = new AbortController();
164
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
165
+
166
+ const fetchRes = await fetch(apiUrl.toString(), {
167
+ headers: { Accept: "application/json" },
168
+ signal: controller.signal,
169
+ });
170
+ clearTimeout(timeoutId);
171
+
172
+ if (!fetchRes.ok) {
173
+ throw new Error(`Remote instance returned HTTP ${fetchRes.status}`);
174
+ }
175
+
176
+ const statuses = await fetchRes.json();
177
+
178
+ if (!Array.isArray(statuses)) {
179
+ throw new Error("Unexpected API response format");
180
+ }
181
+
182
+ items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
183
+
184
+ // Get next max_id from last item for pagination
185
+ if (statuses.length === MAX_RESULTS && statuses.length > 0) {
186
+ const last = statuses[statuses.length - 1];
187
+ nextMaxId = last.id || null;
188
+ }
189
+ } catch (fetchError) {
190
+ const msg = fetchError.name === "AbortError"
191
+ ? response.locals.__("activitypub.reader.explore.timeout")
192
+ : response.locals.__("activitypub.reader.explore.loadError");
193
+ error = msg;
194
+ }
195
+
196
+ response.render("activitypub-explore", {
197
+ title: response.locals.__("activitypub.reader.explore.title"),
198
+ instance,
199
+ scope,
200
+ items,
201
+ maxId: nextMaxId,
202
+ error,
203
+ mountPath,
204
+ // Pass empty interactionMap — explore posts are not in our DB
205
+ interactionMap: {},
206
+ csrfToken: "",
207
+ });
208
+ } catch (error) {
209
+ next(error);
210
+ }
211
+ };
212
+ }
213
+
214
+ /**
215
+ * AJAX API endpoint for explore page infinite scroll.
216
+ * Returns JSON { html, maxId }.
217
+ */
218
+ export function exploreApiController(mountPath) {
219
+ return async (request, response, next) => {
220
+ try {
221
+ const rawInstance = request.query.instance || "";
222
+ const scope = request.query.scope === "federated" ? "federated" : "local";
223
+ const maxId = request.query.max_id || "";
224
+
225
+ const instance = validateInstance(rawInstance);
226
+ if (!instance) {
227
+ return response.status(400).json({ error: "Invalid instance" });
228
+ }
229
+
230
+ const isLocal = scope === "local";
231
+ const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
232
+ apiUrl.searchParams.set("local", isLocal ? "true" : "false");
233
+ apiUrl.searchParams.set("limit", String(MAX_RESULTS));
234
+ if (maxId) apiUrl.searchParams.set("max_id", maxId);
235
+
236
+ const controller = new AbortController();
237
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
238
+
239
+ const fetchRes = await fetch(apiUrl.toString(), {
240
+ headers: { Accept: "application/json" },
241
+ signal: controller.signal,
242
+ });
243
+ clearTimeout(timeoutId);
244
+
245
+ if (!fetchRes.ok) {
246
+ return response.status(502).json({ error: `Remote returned ${fetchRes.status}` });
247
+ }
248
+
249
+ const statuses = await fetchRes.json();
250
+ if (!Array.isArray(statuses)) {
251
+ return response.status(502).json({ error: "Unexpected API response" });
252
+ }
253
+
254
+ const items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
255
+
256
+ let nextMaxId = null;
257
+ if (statuses.length === MAX_RESULTS && statuses.length > 0) {
258
+ const last = statuses[statuses.length - 1];
259
+ nextMaxId = last.id || null;
260
+ }
261
+
262
+ // Render each card server-side
263
+ const templateData = {
264
+ ...response.locals,
265
+ mountPath,
266
+ csrfToken: "",
267
+ interactionMap: {},
268
+ };
269
+
270
+ const htmlParts = await Promise.all(
271
+ items.map((item) => {
272
+ return new Promise((resolve, reject) => {
273
+ request.app.render(
274
+ "partials/ap-item-card.njk",
275
+ { ...templateData, item },
276
+ (err, html) => {
277
+ if (err) reject(err);
278
+ else resolve(html);
279
+ }
280
+ );
281
+ });
282
+ })
283
+ );
284
+
285
+ response.json({
286
+ html: htmlParts.join(""),
287
+ maxId: nextMaxId,
288
+ });
289
+ } catch (error) {
290
+ next(error);
291
+ }
292
+ };
293
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Hashtag follow/unfollow controllers
3
+ */
4
+
5
+ import { validateToken } from "../csrf.js";
6
+ import { followTag, unfollowTag } from "../storage/followed-tags.js";
7
+
8
+ export function followTagController(mountPath) {
9
+ return async (request, response, next) => {
10
+ try {
11
+ const { application } = request.app.locals;
12
+
13
+ // CSRF validation
14
+ if (!validateToken(request)) {
15
+ return response.status(403).json({ error: "Invalid CSRF token" });
16
+ }
17
+
18
+ const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
19
+ if (!tag) {
20
+ return response.redirect(`${mountPath}/admin/reader`);
21
+ }
22
+
23
+ const collections = {
24
+ ap_followed_tags: application?.collections?.get("ap_followed_tags"),
25
+ };
26
+
27
+ await followTag(collections, tag);
28
+
29
+ return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
30
+ } catch (error) {
31
+ next(error);
32
+ }
33
+ };
34
+ }
35
+
36
+ export function unfollowTagController(mountPath) {
37
+ return async (request, response, next) => {
38
+ try {
39
+ const { application } = request.app.locals;
40
+
41
+ // CSRF validation
42
+ if (!validateToken(request)) {
43
+ return response.status(403).json({ error: "Invalid CSRF token" });
44
+ }
45
+
46
+ const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
47
+ if (!tag) {
48
+ return response.redirect(`${mountPath}/admin/reader`);
49
+ }
50
+
51
+ const collections = {
52
+ ap_followed_tags: application?.collections?.get("ap_followed_tags"),
53
+ };
54
+
55
+ await unfollowTag(collections, tag);
56
+
57
+ return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
58
+ } catch (error) {
59
+ next(error);
60
+ }
61
+ };
62
+ }
@@ -18,6 +18,7 @@ import {
18
18
  getBlockedUrls,
19
19
  getFilterMode,
20
20
  } from "../storage/moderation.js";
21
+ import { getFollowedTags } from "../storage/followed-tags.js";
21
22
 
22
23
  // Re-export controllers from split modules for backward compatibility
23
24
  export {
@@ -38,6 +39,7 @@ export function readerController(mountPath) {
38
39
  const collections = {
39
40
  ap_timeline: application?.collections?.get("ap_timeline"),
40
41
  ap_notifications: application?.collections?.get("ap_notifications"),
42
+ ap_followed_tags: application?.collections?.get("ap_followed_tags"),
41
43
  };
42
44
 
43
45
  // Query parameters
@@ -191,6 +193,14 @@ export function readerController(mountPath) {
191
193
  // CSRF token for interaction forms
192
194
  const csrfToken = getToken(request.session);
193
195
 
196
+ // Followed tags for sidebar
197
+ let followedTags = [];
198
+ try {
199
+ followedTags = await getFollowedTags(collections);
200
+ } catch {
201
+ // Non-critical — collection may not exist yet
202
+ }
203
+
194
204
  response.render("activitypub-reader", {
195
205
  title: response.locals.__("activitypub.reader.title"),
196
206
  items,
@@ -201,6 +211,7 @@ export function readerController(mountPath) {
201
211
  interactionMap,
202
212
  csrfToken,
203
213
  mountPath,
214
+ followedTags,
204
215
  });
205
216
  } catch (error) {
206
217
  next(error);