@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.
- package/assets/reader-infinite-scroll.js +6 -0
- package/assets/reader-tabs.js +643 -0
- package/assets/reader.css +222 -117
- package/index.js +26 -14
- package/lib/controllers/explore-utils.js +122 -0
- package/lib/controllers/explore.js +28 -142
- package/lib/controllers/hashtag-explore.js +223 -0
- package/lib/controllers/tabs.js +245 -0
- package/locales/en.json +16 -13
- package/package.json +1 -1
- package/views/activitypub-explore.njk +364 -193
- package/views/layouts/ap-reader.njk +2 -2
- package/assets/reader-decks.js +0 -212
- package/lib/controllers/decks.js +0 -137
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
62
|
+
// Build API URL: hashtag timeline or public timeline
|
|
185
63
|
const isLocal = scope === "local";
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
+
}
|