@rmdes/indiekit-endpoint-activitypub 2.0.36 → 2.1.0

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.
@@ -5,117 +5,15 @@
5
5
  * Remote HTML is always passed through sanitizeContent() before storage.
6
6
  */
7
7
 
8
- import sanitizeHtml from "sanitize-html";
9
- import { sanitizeContent } from "../timeline-store.js";
10
8
  import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
11
9
  import { getToken } from "../csrf.js";
10
+ import { validateInstance, validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
12
11
 
13
12
  const FETCH_TIMEOUT_MS = 10_000;
14
13
  const MAX_RESULTS = 20;
15
14
 
16
- /**
17
- * Validate the instance parameter to prevent SSRF.
18
- * Only allows hostnames — no IPs, no localhost, no port numbers for exotic attacks.
19
- * @param {string} instance - Raw instance parameter from query string
20
- * @returns {string|null} Validated hostname or null
21
- */
22
- export function validateInstance(instance) {
23
- if (!instance || typeof instance !== "string") return null;
24
-
25
- try {
26
- // Prepend https:// to parse as URL
27
- const url = new URL(`https://${instance.trim()}`);
28
-
29
- // Must be a plain hostname — no IP addresses, no localhost
30
- const hostname = url.hostname;
31
- if (
32
- hostname === "localhost" ||
33
- hostname === "127.0.0.1" ||
34
- hostname === "0.0.0.0" ||
35
- hostname === "::1" ||
36
- hostname.startsWith("192.168.") ||
37
- hostname.startsWith("10.") ||
38
- hostname.startsWith("169.254.") ||
39
- /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
40
- /^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(hostname) || // IPv4
41
- hostname.includes("[") // IPv6
42
- ) {
43
- return null;
44
- }
45
-
46
- // Only allow the hostname (no path, no port override)
47
- return hostname;
48
- } catch {
49
- return null;
50
- }
51
- }
52
-
53
- /**
54
- * Map a Mastodon API status object to our timeline item format.
55
- * @param {object} status - Mastodon API status
56
- * @param {string} instance - Instance hostname (for handle construction)
57
- * @returns {object} Timeline item compatible with ap-item-card.njk
58
- */
59
- function mapMastodonStatusToItem(status, instance) {
60
- const account = status.account || {};
61
- const acct = account.acct || "";
62
- // Mastodon acct is "user" for local, "user@remote" for remote
63
- const handle = acct.includes("@") ? `@${acct}` : `@${acct}@${instance}`;
64
-
65
- // Map mentions — store without leading @ (template prepends it)
66
- const mentions = (status.mentions || []).map((m) => ({
67
- name: m.acct.includes("@") ? m.acct : `${m.acct}@${instance}`,
68
- url: m.url || "",
69
- }));
70
-
71
- // Map hashtags
72
- const category = (status.tags || []).map((t) => t.name || "");
73
-
74
- // Map media attachments
75
- const photo = [];
76
- const video = [];
77
- const audio = [];
78
- for (const att of status.media_attachments || []) {
79
- const url = att.url || att.remote_url || "";
80
- if (!url) continue;
81
- if (att.type === "image" || att.type === "gifv") {
82
- photo.push(url);
83
- } else if (att.type === "video") {
84
- video.push(url);
85
- } else if (att.type === "audio") {
86
- audio.push(url);
87
- }
88
- }
89
-
90
- return {
91
- uid: status.url || status.uri || "",
92
- url: status.url || status.uri || "",
93
- type: "note",
94
- name: "",
95
- content: {
96
- text: (status.content || "").replace(/<[^>]*>/g, ""),
97
- html: sanitizeContent(status.content || ""),
98
- },
99
- summary: status.spoiler_text || "",
100
- sensitive: status.sensitive || false,
101
- published: status.created_at || new Date().toISOString(),
102
- author: {
103
- name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
104
- url: account.url || "",
105
- photo: account.avatar || account.avatar_static || "",
106
- handle,
107
- },
108
- category,
109
- mentions,
110
- photo,
111
- video,
112
- audio,
113
- inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
114
- createdAt: new Date().toISOString(),
115
- // Explore-specific: track source instance
116
- _explore: true,
117
- };
118
- }
15
+ // Re-export validateInstance for backward compatibility (used by tabs.js, index.js)
16
+ export { validateInstance } from "./explore-utils.js";
119
17
 
120
18
  export function exploreController(mountPath) {
121
19
  return async (request, response, next) => {
@@ -123,24 +21,10 @@ export function exploreController(mountPath) {
123
21
  const rawInstance = request.query.instance || "";
124
22
  const scope = request.query.scope === "federated" ? "federated" : "local";
125
23
  const maxId = request.query.max_id || "";
126
- const activeTab = request.query.tab === "decks" ? "decks" : "search";
127
-
128
- // Fetch deck list for both tabs (needed for star button state + deck tab)
129
- const { application } = request.app.locals;
130
- const decksCollection = application?.collections?.get("ap_decks");
131
- let decks = [];
132
- try {
133
- decks = await decksCollection
134
- .find({})
135
- .sort({ addedAt: 1 })
136
- .toArray();
137
- } catch {
138
- // Collection unavailable — non-fatal, decks defaults to []
139
- }
24
+ const rawHashtag = request.query.hashtag || "";
25
+ const hashtag = rawHashtag ? validateHashtag(rawHashtag) : null;
140
26
 
141
27
  const csrfToken = getToken(request.session);
142
- const deckCount = decks.length;
143
-
144
28
  const readerParent = { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") };
145
29
 
146
30
  // No instance specified — render clean initial page (no error)
@@ -150,14 +34,11 @@ export function exploreController(mountPath) {
150
34
  readerParent,
151
35
  instance: "",
152
36
  scope,
37
+ hashtag: hashtag || "",
153
38
  items: [],
154
39
  maxId: null,
155
40
  error: null,
156
41
  mountPath,
157
- activeTab,
158
- decks,
159
- deckCount,
160
- isInDeck: false,
161
42
  csrfToken,
162
43
  });
163
44
  }
@@ -169,22 +50,25 @@ export function exploreController(mountPath) {
169
50
  readerParent,
170
51
  instance: rawInstance,
171
52
  scope,
53
+ hashtag: hashtag || "",
172
54
  items: [],
173
55
  maxId: null,
174
56
  error: response.locals.__("activitypub.reader.explore.invalidInstance"),
175
57
  mountPath,
176
- activeTab,
177
- decks,
178
- deckCount,
179
- isInDeck: false,
180
58
  csrfToken,
181
59
  });
182
60
  }
183
61
 
184
- // Fetch public timeline from remote instance
62
+ // Build API URL: hashtag timeline or public timeline
185
63
  const isLocal = scope === "local";
186
- const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
187
- apiUrl.searchParams.set("local", isLocal ? "true" : "false");
64
+ let apiUrl;
65
+ if (hashtag) {
66
+ apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
67
+ apiUrl.searchParams.set("local", isLocal ? "true" : "false");
68
+ } else {
69
+ apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
70
+ apiUrl.searchParams.set("local", isLocal ? "true" : "false");
71
+ }
188
72
  apiUrl.searchParams.set("limit", String(MAX_RESULTS));
189
73
  if (maxId) apiUrl.searchParams.set("max_id", maxId);
190
74
 
@@ -226,23 +110,16 @@ export function exploreController(mountPath) {
226
110
  error = msg;
227
111
  }
228
112
 
229
- const isInDeck = decks.some(
230
- (d) => d.domain === instance && d.scope === scope,
231
- );
232
-
233
113
  response.render("activitypub-explore", {
234
114
  title: response.locals.__("activitypub.reader.explore.title"),
235
115
  readerParent,
236
116
  instance,
237
117
  scope,
118
+ hashtag: hashtag || "",
238
119
  items,
239
120
  maxId: nextMaxId,
240
121
  error,
241
122
  mountPath,
242
- activeTab,
243
- decks,
244
- deckCount,
245
- isInDeck,
246
123
  csrfToken,
247
124
  // Pass empty interactionMap — explore posts are not in our DB
248
125
  interactionMap: {},
@@ -263,15 +140,24 @@ export function exploreApiController(mountPath) {
263
140
  const rawInstance = request.query.instance || "";
264
141
  const scope = request.query.scope === "federated" ? "federated" : "local";
265
142
  const maxId = request.query.max_id || "";
143
+ const rawHashtag = request.query.hashtag || "";
144
+ const hashtag = rawHashtag ? validateHashtag(rawHashtag) : null;
266
145
 
267
146
  const instance = validateInstance(rawInstance);
268
147
  if (!instance) {
269
148
  return response.status(400).json({ error: "Invalid instance" });
270
149
  }
271
150
 
151
+ // Build API URL: hashtag timeline or public timeline
272
152
  const isLocal = scope === "local";
273
- const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
274
- apiUrl.searchParams.set("local", isLocal ? "true" : "false");
153
+ let apiUrl;
154
+ if (hashtag) {
155
+ apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
156
+ apiUrl.searchParams.set("local", isLocal ? "true" : "false");
157
+ } else {
158
+ apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
159
+ apiUrl.searchParams.set("local", isLocal ? "true" : "false");
160
+ }
275
161
  apiUrl.searchParams.set("limit", String(MAX_RESULTS));
276
162
  if (maxId) apiUrl.searchParams.set("max_id", maxId);
277
163
 
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Hashtag explore API — aggregates a hashtag timeline across all pinned instance tabs.
3
+ *
4
+ * GET /admin/reader/api/explore/hashtag
5
+ * ?hashtag={tag}
6
+ * &cursors={json} — JSON-encoded { domain: maxId } cursor map for pagination
7
+ *
8
+ * Returns JSON:
9
+ * {
10
+ * html: string, — server-rendered HTML cards
11
+ * cursors: { [domain]: string|null }, — updated cursor map
12
+ * sources: { [domain]: "ok" | "error:N" },
13
+ * instancesQueried: number,
14
+ * instancesTotal: number,
15
+ * instanceLabels: string[],
16
+ * }
17
+ */
18
+
19
+ import { validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
20
+
21
+ const FETCH_TIMEOUT_MS = 10_000;
22
+ const PAGE_SIZE = 20;
23
+ const MAX_HASHTAG_INSTANCES = 10;
24
+
25
+ /**
26
+ * Fetch hashtag timeline from one instance.
27
+ * Returns { statuses, nextMaxId, error }.
28
+ */
29
+ async function fetchHashtagFromInstance(domain, scope, hashtag, maxId) {
30
+ try {
31
+ const isLocal = scope === "local";
32
+ const url = new URL(
33
+ `https://${domain}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`
34
+ );
35
+ url.searchParams.set("local", isLocal ? "true" : "false");
36
+ url.searchParams.set("limit", "20");
37
+ if (maxId) url.searchParams.set("max_id", maxId);
38
+
39
+ const controller = new AbortController();
40
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
41
+
42
+ const res = await fetch(url.toString(), {
43
+ headers: { Accept: "application/json" },
44
+ signal: controller.signal,
45
+ });
46
+ clearTimeout(timeoutId);
47
+
48
+ if (!res.ok) {
49
+ return { statuses: [], nextMaxId: null, error: `error:${res.status}` };
50
+ }
51
+
52
+ const statuses = await res.json();
53
+ if (!Array.isArray(statuses)) {
54
+ return { statuses: [], nextMaxId: null, error: "error:invalid" };
55
+ }
56
+
57
+ const nextMaxId =
58
+ statuses.length === 20 && statuses.length > 0
59
+ ? statuses[statuses.length - 1].id || null
60
+ : null;
61
+
62
+ return { statuses, nextMaxId, error: null };
63
+ } catch {
64
+ return { statuses: [], nextMaxId: null, error: "error:timeout" };
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Hashtag explore API controller.
70
+ * Queries up to MAX_HASHTAG_INSTANCES pinned instance tabs in parallel.
71
+ */
72
+ export function hashtagExploreApiController(mountPath) {
73
+ return async (request, response, next) => {
74
+ try {
75
+ // Validate hashtag
76
+ const rawHashtag = request.query.hashtag || "";
77
+ const hashtag = validateHashtag(rawHashtag);
78
+ if (!hashtag) {
79
+ return response.status(400).json({ error: "Invalid hashtag" });
80
+ }
81
+
82
+ const tabsCollection = request.app.locals.application?.collections?.get("ap_explore_tabs");
83
+ if (!tabsCollection) {
84
+ return response.json({
85
+ html: "", cursors: {}, sources: {}, instancesQueried: 0, instancesTotal: 0, instanceLabels: [],
86
+ });
87
+ }
88
+
89
+ // Parse cursors map — { [domain]: maxId | null }
90
+ let cursors = {};
91
+ try {
92
+ const raw = request.query.cursors || "{}";
93
+ const parsed = JSON.parse(raw);
94
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
95
+ cursors = parsed;
96
+ }
97
+ } catch {
98
+ // Invalid JSON — use empty cursors (start from beginning)
99
+ }
100
+
101
+ // Load instance tabs, capped at MAX_HASHTAG_INSTANCES by order
102
+ const instanceTabs = await tabsCollection
103
+ .find({ type: "instance" })
104
+ .sort({ order: 1 })
105
+ .limit(MAX_HASHTAG_INSTANCES)
106
+ .toArray();
107
+
108
+ const instancesTotal = await tabsCollection.countDocuments({
109
+ type: "instance",
110
+ });
111
+
112
+ if (instanceTabs.length === 0) {
113
+ return response.json({
114
+ html: "",
115
+ cursors: {},
116
+ sources: {},
117
+ instancesQueried: 0,
118
+ instancesTotal,
119
+ instanceLabels: [],
120
+ });
121
+ }
122
+
123
+ // Fetch from all instances in parallel
124
+ const fetchResults = await Promise.allSettled(
125
+ instanceTabs.map((tab) =>
126
+ fetchHashtagFromInstance(
127
+ tab.domain,
128
+ tab.scope,
129
+ hashtag,
130
+ cursors[tab.domain] || null
131
+ )
132
+ )
133
+ );
134
+
135
+ // Build sources map and collect all statuses with their domain
136
+ const sources = {};
137
+ const updatedCursors = {};
138
+ const allItems = [];
139
+
140
+ for (let i = 0; i < instanceTabs.length; i++) {
141
+ const tab = instanceTabs[i];
142
+ const result = fetchResults[i];
143
+
144
+ if (result.status === "fulfilled") {
145
+ const { statuses, nextMaxId, error } = result.value;
146
+ sources[tab.domain] = error || "ok";
147
+ updatedCursors[tab.domain] = nextMaxId;
148
+
149
+ if (!error) {
150
+ for (const status of statuses) {
151
+ allItems.push({ status, domain: tab.domain });
152
+ }
153
+ }
154
+ } else {
155
+ sources[tab.domain] = "error:rejected";
156
+ updatedCursors[tab.domain] = cursors[tab.domain] || null;
157
+ }
158
+ }
159
+
160
+ // Merge by published date descending
161
+ allItems.sort((a, b) => {
162
+ const dateA = new Date(a.status.created_at || 0).getTime();
163
+ const dateB = new Date(b.status.created_at || 0).getTime();
164
+ return dateB - dateA;
165
+ });
166
+
167
+ // Deduplicate by post URL (first occurrence wins)
168
+ const seenUrls = new Set();
169
+ const dedupedItems = [];
170
+ for (const { status, domain } of allItems) {
171
+ const uid = status.url || status.uri || "";
172
+ if (uid && seenUrls.has(uid)) continue;
173
+ if (uid) seenUrls.add(uid);
174
+ dedupedItems.push({ status, domain });
175
+ }
176
+
177
+ // Paginate: take first PAGE_SIZE items
178
+ const pageItems = dedupedItems.slice(0, PAGE_SIZE);
179
+
180
+ // Map to timeline item format
181
+ const items = pageItems.map(({ status, domain }) =>
182
+ mapMastodonStatusToItem(status, domain)
183
+ );
184
+
185
+ // Render HTML AFTER merge/dedup/paginate (don't waste CPU on discarded items)
186
+ const templateData = {
187
+ ...response.locals,
188
+ mountPath,
189
+ csrfToken: "",
190
+ interactionMap: {},
191
+ };
192
+
193
+ const htmlParts = await Promise.all(
194
+ items.map(
195
+ (item) =>
196
+ new Promise((resolve, reject) => {
197
+ request.app.render(
198
+ "partials/ap-item-card.njk",
199
+ { ...templateData, item },
200
+ (err, html) => {
201
+ if (err) reject(err);
202
+ else resolve(html);
203
+ }
204
+ );
205
+ })
206
+ )
207
+ );
208
+
209
+ const instanceLabels = instanceTabs.map((t) => t.domain);
210
+
211
+ response.json({
212
+ html: htmlParts.join(""),
213
+ cursors: updatedCursors,
214
+ sources,
215
+ instancesQueried: instanceTabs.length,
216
+ instancesTotal,
217
+ instanceLabels,
218
+ });
219
+ } catch (error) {
220
+ next(error);
221
+ }
222
+ };
223
+ }