mastodon api: add GET /api/v1/accounts/:id/boosts endpoint

Returns own boosts from ap_interactions + ap_timeline as Mastodon-compatible
status array with reblog wrappers. Chronicle PAI uses this to archive all
own boost activity from Indiekit instances.

Params: limit, since_date, max_date (ISO 8601)
This commit is contained in:
svemagie
2026-05-23 12:04:41 +02:00
parent d5119e6ba6
commit f2defa37bb
+113
View File
@@ -503,6 +503,119 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read",
} }
}); });
// ─── GET /api/v1/accounts/:id/boosts ────────────────────────────────────────
// Chronicle PAI extension — returns own boosts from ap_interactions + ap_timeline.
// ap_interactions stores {objectUrl, createdAt} for each own Announce activity.
// ap_timeline stores the boosted post (with its original author).
// Returns Mastodon-compatible status array with reblog wrapper (like /statuses).
router.get("/api/v1/accounts/:id/boosts", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
// Pagination and since filter
const maxDate = req.query.max_date ? new Date(req.query.max_date) : null;
const sinceDate = req.query.since_date ? new Date(req.query.since_date) : null;
// Build filter for ap_interactions
const interactionFilter = { type: "boost" };
if (sinceDate) interactionFilter.createdAt = { ...interactionFilter.createdAt, $gt: sinceDate.toISOString() };
if (maxDate) interactionFilter.createdAt = { ...interactionFilter.createdAt, $lt: maxDate.toISOString() };
const interactions = await collections.ap_interactions
.find(interactionFilter)
.sort({ createdAt: -1 })
.limit(limit)
.toArray();
if (interactions.length === 0) {
return res.json([]);
}
// Look up each boosted post in ap_timeline by objectUrl
const objectUrls = interactions.map((i) => i.objectUrl);
const timelineItems = await collections.ap_timeline
.find({ uid: { $in: objectUrls } })
.toArray();
// Build lookup map for O(1) access
const timelineMap = new Map(timelineItems.map((item) => [item.uid, item]));
// Get own actor profile for the boost wrapper account
const profile = await collections.ap_profile.findOne({});
const statuses = interactions.flatMap((interaction) => {
const original = timelineMap.get(interaction.objectUrl);
if (!original) return []; // post not in local timeline, skip
const innerStatus = serializeStatus(original, {
baseUrl,
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
pinnedIds: new Set(),
});
// Wrap in boost envelope — outer status represents the boost action
return [{
id: `boost-${interaction._id || interaction.objectUrl}`,
created_at: interaction.createdAt,
in_reply_to_id: null,
in_reply_to_account_id: null,
sensitive: false,
spoiler_text: "",
visibility: "public",
language: null,
uri: interaction.objectUrl,
url: interaction.objectUrl,
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
edited_at: null,
favourited: false,
reblogged: true,
muted: false,
bookmarked: false,
pinned: false,
content: "",
filtered: [],
reblog: innerStatus,
application: null,
account: profile ? {
id: profile._id?.toString() || "1",
username: profile.preferredUsername || "user",
acct: profile.preferredUsername || "user",
display_name: profile.name || "",
url: profile.url || baseUrl,
avatar: profile.icon?.url || "",
avatar_static: profile.icon?.url || "",
header: "",
header_static: "",
note: "",
emojis: [],
fields: [],
followers_count: 0,
following_count: 0,
statuses_count: 0,
created_at: new Date().toISOString(),
} : null,
media_attachments: [],
mentions: [],
tags: [],
emojis: [],
card: null,
poll: null,
}];
});
res.json(statuses);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/:id/followers ───────────────────────────────────── // ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => { router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {