130 lines
5.1 KiB
JavaScript
130 lines
5.1 KiB
JavaScript
/**
|
|
* Outbox backfill — fetch recent posts from a remote actor's outbox
|
|
* and store them in the local ap_timeline after a follow.
|
|
*
|
|
* Runs asynchronously (fire-and-forget) — never blocks followActor().
|
|
* Fetches at most one page (limit 20) from the outbox's `first` page.
|
|
* Uses extractObjectData() from timeline-store for consistent normalization.
|
|
*/
|
|
|
|
import { Note, Article, Create, Announce } from "@fedify/fedify/vocab";
|
|
import { extractObjectData } from "./timeline-store.js";
|
|
import { addTimelineItem } from "./storage/timeline.js";
|
|
|
|
/**
|
|
* Fetch up to `limit` recent posts from `actorUrl`'s AP outbox and
|
|
* store them in ap_timeline. Non-blocking — call without await.
|
|
*
|
|
* @param {object} options
|
|
* @param {string} options.actorUrl - Remote actor AP URL
|
|
* @param {object} options.remoteActor - Resolved Fedify actor object
|
|
* @param {object} options.ctx - Fedify context (for documentLoader)
|
|
* @param {string} options.handle - Local actor handle (for signed fetches)
|
|
* @param {object} options.collections - MongoDB collections
|
|
* @param {number} [options.limit=20] - Max posts to backfill
|
|
*/
|
|
export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, collections, limit = 20 }) {
|
|
if (!collections?.ap_timeline) return;
|
|
|
|
const withTimeout = (p, ms = 8000) =>
|
|
Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms))]);
|
|
|
|
try {
|
|
const documentLoader = await withTimeout(ctx.getDocumentLoader({ identifier: handle }));
|
|
|
|
// Fetch outbox collection — cap at 8 s
|
|
const outbox = await withTimeout(remoteActor.getOutbox({ documentLoader })).catch(() => null);
|
|
if (!outbox) return;
|
|
|
|
// Guard: if outbox reports more than 500 items without pagination, skip the direct-items path
|
|
// to avoid materialising a huge in-memory array from a hostile server.
|
|
const totalItems = typeof outbox.totalItems === "number" ? outbox.totalItems : null;
|
|
|
|
// Collect activity items — try direct items first (only for small collections), then first-page
|
|
const activities = [];
|
|
let itemSource = null;
|
|
if (totalItems === null || totalItems <= 100) {
|
|
try {
|
|
const direct = outbox.getItems?.({ documentLoader });
|
|
if (direct) itemSource = direct;
|
|
} catch { /* not directly iterable */ }
|
|
}
|
|
|
|
if (!itemSource) {
|
|
// Paginated — navigate to first page
|
|
const firstPage = await withTimeout(outbox.getFirst({ documentLoader })).catch(() => null);
|
|
if (firstPage?.getItems) itemSource = firstPage.getItems({ documentLoader });
|
|
}
|
|
|
|
if (!itemSource) return;
|
|
|
|
try {
|
|
for await (const item of itemSource) {
|
|
activities.push(item);
|
|
if (activities.length >= limit) break;
|
|
}
|
|
} catch (iterErr) {
|
|
console.debug(`[ActivityPub] Outbox backfill iterator stopped for ${actorUrl}: ${iterErr?.message}`);
|
|
}
|
|
|
|
if (activities.length === 0) return;
|
|
|
|
let stored = 0;
|
|
for (const activity of activities) {
|
|
try {
|
|
// Only handle Create(Note/Article) and Announce activities
|
|
let object = null;
|
|
let boostedBy = null;
|
|
|
|
if (activity instanceof Create) {
|
|
object = await withTimeout(activity.getObject({ documentLoader }), 5000).catch(() => null);
|
|
} else if (activity instanceof Announce) {
|
|
object = await withTimeout(activity.getObject({ documentLoader }), 5000).catch(() => null);
|
|
if (object) {
|
|
const _username = remoteActor.preferredUsername?.toString() || "";
|
|
const _domain = (() => { try { return new URL(actorUrl).hostname; } catch { return ""; } })();
|
|
let _photo = "";
|
|
try { const _icon = await remoteActor.getIcon(); _photo = _icon?.url?.href || ""; } catch { /* ignore */ }
|
|
boostedBy = {
|
|
name: remoteActor.name?.toString() || _username || "",
|
|
url: actorUrl,
|
|
photo: _photo,
|
|
handle: _username && _domain ? `@${_username}@${_domain}` : "",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!object || !(object instanceof Note || object instanceof Article)) continue;
|
|
|
|
const uid = object.id?.href;
|
|
if (!uid) continue;
|
|
|
|
// Skip if already in timeline
|
|
const exists = await collections.ap_timeline.findOne({ uid }, { projection: { _id: 1 } });
|
|
if (exists) continue;
|
|
|
|
const item = await extractObjectData(object, {
|
|
documentLoader,
|
|
actorFallback: remoteActor,
|
|
boostedBy,
|
|
boostedAt: boostedBy ? new Date().toISOString() : undefined,
|
|
});
|
|
|
|
// Ensure visibility field (extractObjectData doesn't set it)
|
|
if (!item.visibility) item.visibility = "public";
|
|
|
|
await addTimelineItem({ ap_timeline: collections.ap_timeline }, item);
|
|
stored++;
|
|
} catch (itemErr) {
|
|
console.debug(`[ActivityPub] Outbox backfill skipped item from ${actorUrl}: ${itemErr?.message}`);
|
|
}
|
|
}
|
|
|
|
if (stored > 0) {
|
|
console.info(`[ActivityPub] Outbox backfill: stored ${stored} posts from ${actorUrl}`);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`[ActivityPub] Outbox backfill failed for ${actorUrl}:`, error?.message);
|
|
}
|
|
}
|