Files
indiekit-endpoint-activitypub/lib/controllers/explore.js
Ricardo af2f899073 refactor: unify reader and explore processing pipeline (Release 0)
Extract shared item-processing.js module with postProcessItems(),
applyModerationFilters(), buildInteractionMap(), applyTabFilter(),
renderItemCards(), and loadModerationData(). All controllers (reader,
api-timeline, explore, hashtag-explore, tag-timeline) now flow through
the same pipeline.

Unify Alpine.js infinite scroll into single parameterized
apInfiniteScroll component configured via data attributes, replacing
the separate apExploreScroll component.

Also adds fetchAndStoreQuote() for quote enrichment and on-demand
quote fetching in post-detail controller.

Bump version to 2.5.0.

Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
2026-03-03 12:48:40 +01:00

245 lines
7.7 KiB
JavaScript

/**
* Explore controller — browse public timelines from remote Mastodon-compatible instances.
*
* All remote API calls are server-side (no CORS issues).
* Remote HTML is always passed through sanitizeContent() before storage.
*/
import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
import { getToken } from "../csrf.js";
import { validateInstance, validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
import { postProcessItems, renderItemCards } from "../item-processing.js";
const FETCH_TIMEOUT_MS = 10_000;
const MAX_RESULTS = 20;
// Re-export validateInstance for backward compatibility (used by tabs.js, index.js)
export { validateInstance } from "./explore-utils.js";
/**
* Fetch statuses from a remote Mastodon-compatible instance.
*
* @param {string} instance - Validated hostname
* @param {object} options
* @param {string} [options.scope] - "local" or "federated"
* @param {string} [options.hashtag] - Validated hashtag (no #)
* @param {string} [options.maxId] - Pagination cursor
* @param {number} [options.limit] - Max results
* @returns {Promise<{ items: Array, nextMaxId: string|null }>}
*/
export async function fetchMastodonTimeline(instance, { scope = "local", hashtag, maxId, limit = MAX_RESULTS } = {}) {
const isLocal = scope === "local";
let apiUrl;
if (hashtag) {
apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
} else {
apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
}
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
apiUrl.searchParams.set("limit", String(limit));
if (maxId) apiUrl.searchParams.set("max_id", maxId);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const fetchRes = await fetch(apiUrl.toString(), {
headers: { Accept: "application/json" },
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!fetchRes.ok) {
throw new Error(`Remote instance returned HTTP ${fetchRes.status}`);
}
const statuses = await fetchRes.json();
if (!Array.isArray(statuses)) {
throw new Error("Unexpected API response format");
}
const items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
const nextMaxId =
statuses.length === limit && statuses.length > 0
? statuses[statuses.length - 1].id || null
: null;
return { items, nextMaxId };
}
export function exploreController(mountPath) {
return async (request, response, next) => {
try {
const rawInstance = request.query.instance || "";
const scope = request.query.scope === "federated" ? "federated" : "local";
const maxId = request.query.max_id || "";
const rawHashtag = request.query.hashtag || "";
const hashtag = rawHashtag ? validateHashtag(rawHashtag) : null;
const csrfToken = getToken(request.session);
const readerParent = { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") };
// No instance specified — render clean initial page (no error)
if (!rawInstance.trim()) {
return response.render("activitypub-explore", {
title: response.locals.__("activitypub.reader.explore.title"),
readerParent,
instance: "",
scope,
hashtag: hashtag || "",
items: [],
maxId: null,
error: null,
mountPath,
csrfToken,
});
}
const instance = validateInstance(rawInstance);
if (!instance) {
return response.render("activitypub-explore", {
title: response.locals.__("activitypub.reader.explore.title"),
readerParent,
instance: rawInstance,
scope,
hashtag: hashtag || "",
items: [],
maxId: null,
error: response.locals.__("activitypub.reader.explore.invalidInstance"),
mountPath,
csrfToken,
});
}
let items = [];
let nextMaxId = null;
let error = null;
try {
const result = await fetchMastodonTimeline(instance, { scope, hashtag, maxId });
const processed = await postProcessItems(result.items);
items = processed.items;
nextMaxId = result.nextMaxId;
} catch (fetchError) {
const msg = fetchError.name === "AbortError"
? response.locals.__("activitypub.reader.explore.timeout")
: response.locals.__("activitypub.reader.explore.loadError");
error = msg;
}
response.render("activitypub-explore", {
title: response.locals.__("activitypub.reader.explore.title"),
readerParent,
instance,
scope,
hashtag: hashtag || "",
items,
maxId: nextMaxId,
error,
mountPath,
csrfToken,
interactionMap: {},
});
} catch (error) {
next(error);
}
};
}
/**
* AJAX API endpoint for explore page infinite scroll.
* Returns JSON { html, maxId }.
*/
export function exploreApiController(mountPath) {
return async (request, response, next) => {
try {
const rawInstance = request.query.instance || "";
const scope = request.query.scope === "federated" ? "federated" : "local";
const maxId = request.query.max_id || "";
const rawHashtag = request.query.hashtag || "";
const hashtag = rawHashtag ? validateHashtag(rawHashtag) : null;
const instance = validateInstance(rawInstance);
if (!instance) {
return response.status(400).json({ error: "Invalid instance" });
}
const { items: rawItems, nextMaxId } = await fetchMastodonTimeline(instance, { scope, hashtag, maxId });
const { items } = await postProcessItems(rawItems);
const csrfToken = getToken(request.session);
const html = await renderItemCards(items, request, {
...response.locals,
mountPath,
csrfToken,
interactionMap: {},
});
response.json({ html, maxId: nextMaxId });
} catch (error) {
next(error);
}
};
}
/**
* AJAX API endpoint for instance autocomplete.
* Returns JSON array of matching instances from FediDB.
*/
export function instanceSearchApiController(mountPath) {
return async (request, response, next) => {
try {
const q = (request.query.q || "").trim();
if (!q || q.length < 2) {
return response.json([]);
}
const results = await searchInstances(q, 8);
response.json(results);
} catch (error) {
next(error);
}
};
}
/**
* AJAX API endpoint to check if an instance supports public timeline exploration.
* Returns JSON { supported: boolean, error: string|null }.
*/
export function instanceCheckApiController(mountPath) {
return async (request, response, next) => {
try {
const domain = (request.query.domain || "").trim().toLowerCase();
if (!domain) {
return response.status(400).json({ supported: false, error: "Missing domain" });
}
// Validate domain to prevent SSRF
const validated = validateInstance(domain);
if (!validated) {
return response.status(400).json({ supported: false, error: "Invalid domain" });
}
const result = await checkInstanceTimeline(validated);
response.json(result);
} catch (error) {
next(error);
}
};
}
/**
* AJAX API endpoint for popular fediverse accounts.
* Returns the full cached list; client-side filtering via Alpine.js.
*/
export function popularAccountsApiController(mountPath) {
return async (request, response, next) => {
try {
const accounts = await getPopularAccounts(50);
response.json(accounts);
} catch (error) {
next(error);
}
};
}