fix: integrate 10 bug-fix patches from indiekit-server postinstall

- resolve-account: await ap_actor_cache write; fix withTimeout unhandled
  rejection; parallel Promise.allSettled for follower/following/outbox counts
- notifications: collect both notif.targetUrl and notif.url for status lookup
- syndicator: dedup check against ap_activities before re-federating
- inbox-listeners: drop PeerTube View (WatchAction) activities silently
- interactions: wrap like/boost sendActivity in try/catch
- timeline-cleanup: preserve items the user has liked/boosted/bookmarked
- accounts: urlToUid Map for normalized interaction state lookups
- jf2-to-as2: handle array repost-of; Announce only for AP URLs
- compose: use target.checked === true instead of hardcoded actor names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-04-15 09:48:59 +02:00
parent e13becf8ad
commit 547fb449a3
9 changed files with 109 additions and 52 deletions
+4 -6
View File
@@ -146,12 +146,10 @@ export function composeController(mountPath, plugin) {
? await getSyndicationTargets(application, token) ? await getSyndicationTargets(application, token)
: []; : [];
// Default-check only AP (Fedify) and Bluesky targets // Pre-check syndication targets based on their configured checked state // [patch] ap-compose-default-checked
// "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky for (const target of syndicationTargets) { // [patch] ap-compose-default-checked
for (const target of syndicationTargets) { target.defaultChecked = target.checked === true; // [patch] ap-compose-default-checked
const name = target.name || ""; } // [patch] ap-compose-default-checked
target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
}
const csrfToken = getToken(request.session); const csrfToken = getToken(request.session);
+8 -1
View File
@@ -23,6 +23,7 @@ import {
Remove, Remove,
Undo, Undo,
Update, Update,
View, // View imported
} from "@fedify/fedify/vocab"; } from "@fedify/fedify/vocab";
import { isServerBlocked } from "./storage/server-blocks.js"; import { isServerBlocked } from "./storage/server-blocks.js";
@@ -352,5 +353,11 @@ export function registerInboxListeners(inboxChain, options) {
actorUrl, actorUrl,
rawJson: await flag.toJsonLd(), rawJson: await flag.toJsonLd(),
}); });
}); })
// ── View (PeerTube watch) ─────────────────────────────────────────────
// PeerTube broadcasts View (WatchAction) activities to all followers
// whenever someone watches a video. Fedify has no built-in handler for
// this type, producing noisy "Unsupported activity type" log errors.
// Silently accept and discard. // PeerTube View handler
.on(View, async () => {});
} }
+23 -9
View File
@@ -305,19 +305,33 @@ export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, opt
// Reposts are always public — upstream @rmdes addressing // Reposts are always public — upstream @rmdes addressing
if (postType === "repost") { if (postType === "repost") {
const repostOf = properties["repost-of"]; const repostOf = Array.isArray(properties["repost-of"])
? properties["repost-of"][0]
: properties["repost-of"];
if (!repostOf) return null; if (!repostOf) return null;
const repostContent = properties.content?.html || properties.content || ""; const repostContent = properties.content?.html || properties.content || "";
if (!repostContent) { if (!repostContent) {
// Pure repost — send as a native Announce (boost) so remote servers // Only send Announce if repost-of is an ActivityPub URL.
// can display it as a boost of the original post. // Non-AP URLs (web articles) cannot be federated as a boost — fall
return new Announce({ // through to Create(Note) which renders as "🔁 <link>" on the fediverse.
actor: actorUri, if (await isApUrl(repostOf)) { // [patch] ap-repost-announce-fix
object: new URL(repostOf), const actorPath = new URL(actorUrl).pathname;
to: new URL("https://www.w3.org/ns/activitystreams#Public"), const mp = actorPath.replace(/\/users\/[^/]+$/, "");
}); const postRelPath = (properties.url || "")
.replace(publicationUrl.replace(/\/$/, ""), "")
.replace(/^\//, "")
.replace(/\/$/, "");
const announceId = `${publicationUrl.replace(/\/$/, "")}${mp}/activities/boost/${postRelPath}`;
return new Announce({
id: new URL(announceId),
actor: actorUri,
object: new URL(repostOf),
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
cc: new URL(`${actorUrl.replace(/\/$/, "")}/followers`),
});
}
} }
// Has commentary — fall through to Create(Note) so the text is federated. // Has commentary or non-AP repost-of URL — fall through to Create(Note) so the text is federated.
// The note content block below handles the "repost" post-type. // The note content block below handles the "repost" post-type.
} }
+12 -8
View File
@@ -55,9 +55,11 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
}); });
if (recipient) { if (recipient) {
await ctx.sendActivity({ identifier: handle }, recipient, like, { try { // [patch] ap-interactions-send-guard
orderingKey: targetUrl, await ctx.sendActivity({ identifier: handle }, recipient, like, {
}); orderingKey: targetUrl,
});
} catch { /* delivery failed — interaction still recorded locally */ }
} }
if (interactions) { if (interactions) {
@@ -176,11 +178,13 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
}); });
// Send to followers // Send to followers
await ctx.sendActivity({ identifier: handle }, "followers", announce, { try { // [patch] ap-interactions-send-guard
preferSharedInbox: true, await ctx.sendActivity({ identifier: handle }, "followers", announce, {
syncCollection: true, preferSharedInbox: true,
orderingKey: targetUrl, syncCollection: true,
}); orderingKey: targetUrl,
});
} catch { /* delivery failed — interaction still recorded locally */ }
// Also send directly to the original post author (best-effort, 5 s cap) // Also send directly to the original post author (best-effort, 5 s cap)
const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
+17 -19
View File
@@ -69,24 +69,21 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect
} catch { /* ignore */ } } catch { /* ignore */ }
// Get collection counts (followers, following, outbox) — with 5 s timeout each // Get collection counts (followers, following, outbox) — with 5 s timeout each
const withTimeout = (promise, ms = 5000) => const withTimeout = (promise, ms = 5000) => { // [patch] ap-resolve-account-timeout-safe
Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]); const abort = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms));
promise.catch(() => {}); // suppress unhandled rejection if timeout settles first
return Promise.race([promise, abort]);
};
let followersCount = 0; // Fetch collection counts in parallel (max 5 s each) [patch] ap-resolve-account-timeout-safe
let followingCount = 0; const [followersResult, followingResult, outboxResult] = await Promise.allSettled([
let statusesCount = 0; withTimeout(actor.getFollowers()),
try { withTimeout(actor.getFollowing()),
const followers = await withTimeout(actor.getFollowers()); withTimeout(actor.getOutbox()),
if (followers?.totalItems != null) followersCount = followers.totalItems; ]);
} catch { /* ignore */ } const followersCount = followersResult.status === "fulfilled" && followersResult.value?.totalItems != null ? followersResult.value.totalItems : 0;
try { const followingCount = followingResult.status === "fulfilled" && followingResult.value?.totalItems != null ? followingResult.value.totalItems : 0;
const following = await withTimeout(actor.getFollowing()); const statusesCount = outboxResult.status === "fulfilled" && outboxResult.value?.totalItems != null ? outboxResult.value.totalItems : 0;
if (following?.totalItems != null) followingCount = following.totalItems;
} catch { /* ignore */ }
try {
const outbox = await withTimeout(actor.getOutbox());
if (outbox?.totalItems != null) statusesCount = outbox.totalItems;
} catch { /* ignore */ }
// Get published/created date — normalize to UTC ISO so clients display it correctly. // Get published/created date — normalize to UTC ISO so clients display it correctly.
// Temporal.Instant.toString() preserves the original timezone offset; // Temporal.Instant.toString() preserves the original timezone offset;
@@ -141,13 +138,14 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect
}); });
// Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts
// [patch] ap-actor-cache-await
if (collections?.ap_actor_cache && actorUrl) { if (collections?.ap_actor_cache && actorUrl) {
const hashId = remoteActorId(actorUrl); const hashId = remoteActorId(actorUrl);
collections.ap_actor_cache.updateOne( await collections.ap_actor_cache.updateOne(
{ _id: hashId }, { _id: hashId },
{ $set: { actorUrl, updatedAt: new Date() } }, { $set: { actorUrl, updatedAt: new Date() } },
{ upsert: true }, { upsert: true },
).catch(() => {}); // fire-and-forget, non-fatal ).catch(() => {}); // non-fatal, but now awaited so entry exists before response
} }
return account; return account;
+12 -4
View File
@@ -456,15 +456,23 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read",
let bookmarkedIds = new Set(); let bookmarkedIds = new Set();
if (req.mastodonToken && collections.ap_interactions) { if (req.mastodonToken && collections.ap_interactions) {
const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean)); const urlToUid = new Map(); // [patch] ap-interactions-accounts-uid
for (const i of items) {
if (i.uid) {
urlToUid.set(i.uid, i.uid);
if (i.url && i.url !== i.uid) urlToUid.set(i.url, i.uid);
}
}
const lookupUrls = [...urlToUid.keys()];
if (lookupUrls.length > 0) { if (lookupUrls.length > 0) {
const interactions = await collections.ap_interactions const interactions = await collections.ap_interactions
.find({ objectUrl: { $in: lookupUrls } }) .find({ objectUrl: { $in: lookupUrls } })
.toArray(); .toArray();
for (const ix of interactions) { for (const ix of interactions) {
if (ix.type === "like") favouritedIds.add(ix.objectUrl); const uid = urlToUid.get(ix.objectUrl) || ix.objectUrl;
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl); if (ix.type === "like") favouritedIds.add(uid);
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl); else if (ix.type === "boost") rebloggedIds.add(uid);
else if (ix.type === "bookmark") bookmarkedIds.add(uid);
} }
} }
} }
+2 -2
View File
@@ -207,10 +207,10 @@ function resolveInternalTypes(mastodonTypes) {
async function batchFetchStatuses(collections, notifications) { async function batchFetchStatuses(collections, notifications) {
const statusMap = new Map(); const statusMap = new Map();
const targetUrls = [ const targetUrls = [ // [patch] ap-notifications-status-lookup
...new Set( ...new Set(
notifications notifications
.map((n) => n.targetUrl) .flatMap((n) => [n.targetUrl, n.url])
.filter(Boolean), .filter(Boolean),
), ),
]; ];
+14
View File
@@ -41,6 +41,20 @@ export function createSyndicator(plugin) {
return undefined; return undefined;
} }
// Dedup: skip re-federation if we've already sent an activity for this URL. // [patch] ap-syndicate-dedup
// ap_activities is the authoritative record of "already federated".
try {
const existingActivity = await plugin._collections.ap_activities?.findOne({
direction: "outbound",
type: { $in: ["Create", "Announce", "Update"] },
objectUrl: properties.url,
});
if (existingActivity) {
console.info(`[ActivityPub] Skipping duplicate syndication for ${properties.url} — already sent (${existingActivity.type})`);
return properties.url || undefined;
}
} catch { /* DB unavailable — proceed */ }
try { try {
const actorUrl = plugin._getActorUrl(); const actorUrl = plugin._getActorUrl();
const handle = plugin.options.actor.handle; const handle = plugin.options.actor.handle;
+17 -3
View File
@@ -53,16 +53,30 @@ export async function cleanupTimeline(collections, retentionLimit) {
const removedUids = toDelete.map((item) => item.uid).filter(Boolean); const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
// Preserve items the user has interacted with (liked, bookmarked, boosted). // [patch] ap-interactions-cleanup-preserve
// Deleting them would silently remove entries from the Favourites/Bookmarks pages.
let interactedUids = new Set();
if (removedUids.length > 0 && collections.ap_interactions) {
const interacted = await collections.ap_interactions.distinct("objectUrl");
interactedUids = new Set(interacted);
}
const itemsToDelete = toDelete.filter((item) => !interactedUids.has(item.uid));
const uidsToDelete = itemsToDelete.map((item) => item.uid).filter(Boolean);
if (!itemsToDelete.length) {
return { removed: 0, interactionsRemoved: 0 };
}
// Delete old timeline items by UID // Delete old timeline items by UID
const deleteResult = await collections.ap_timeline.deleteMany({ const deleteResult = await collections.ap_timeline.deleteMany({
_id: { $in: toDelete.map((item) => item._id) }, _id: { $in: itemsToDelete.map((item) => item._id) },
}); });
// Clean up stale interactions for removed items // Clean up stale interactions for removed items
let interactionsRemoved = 0; let interactionsRemoved = 0;
if (removedUids.length > 0 && collections.ap_interactions) { if (uidsToDelete.length > 0 && collections.ap_interactions) {
const interactionResult = await collections.ap_interactions.deleteMany({ const interactionResult = await collections.ap_interactions.deleteMany({
objectUrl: { $in: removedUids }, objectUrl: { $in: uidsToDelete },
}); });
interactionsRemoved = interactionResult.deletedCount || 0; interactionsRemoved = interactionResult.deletedCount || 0;
} }