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)
: [];
// Default-check only AP (Fedify) and Bluesky targets
// "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky
for (const target of syndicationTargets) {
const name = target.name || "";
target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
}
// Pre-check syndication targets based on their configured checked state // [patch] ap-compose-default-checked
for (const target of syndicationTargets) { // [patch] ap-compose-default-checked
target.defaultChecked = target.checked === true; // [patch] ap-compose-default-checked
} // [patch] ap-compose-default-checked
const csrfToken = getToken(request.session);
+8 -1
View File
@@ -23,6 +23,7 @@ import {
Remove,
Undo,
Update,
View, // View imported
} from "@fedify/fedify/vocab";
import { isServerBlocked } from "./storage/server-blocks.js";
@@ -352,5 +353,11 @@ export function registerInboxListeners(inboxChain, options) {
actorUrl,
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
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;
const repostContent = properties.content?.html || properties.content || "";
if (!repostContent) {
// Pure repost — send as a native Announce (boost) so remote servers
// can display it as a boost of the original post.
return new Announce({
actor: actorUri,
object: new URL(repostOf),
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
});
// Only send Announce if repost-of is an ActivityPub URL.
// Non-AP URLs (web articles) cannot be federated as a boost — fall
// through to Create(Note) which renders as "🔁 <link>" on the fediverse.
if (await isApUrl(repostOf)) { // [patch] ap-repost-announce-fix
const actorPath = new URL(actorUrl).pathname;
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.
}
+12 -8
View File
@@ -55,9 +55,11 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
});
if (recipient) {
await ctx.sendActivity({ identifier: handle }, recipient, like, {
orderingKey: targetUrl,
});
try { // [patch] ap-interactions-send-guard
await ctx.sendActivity({ identifier: handle }, recipient, like, {
orderingKey: targetUrl,
});
} catch { /* delivery failed — interaction still recorded locally */ }
}
if (interactions) {
@@ -176,11 +178,13 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
});
// Send to followers
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: targetUrl,
});
try { // [patch] ap-interactions-send-guard
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: targetUrl,
});
} catch { /* delivery failed — interaction still recorded locally */ }
// Also send directly to the original post author (best-effort, 5 s cap)
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 */ }
// Get collection counts (followers, following, outbox) — with 5 s timeout each
const withTimeout = (promise, ms = 5000) =>
Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]);
const withTimeout = (promise, ms = 5000) => { // [patch] ap-resolve-account-timeout-safe
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;
let followingCount = 0;
let statusesCount = 0;
try {
const followers = await withTimeout(actor.getFollowers());
if (followers?.totalItems != null) followersCount = followers.totalItems;
} catch { /* ignore */ }
try {
const following = await withTimeout(actor.getFollowing());
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 */ }
// Fetch collection counts in parallel (max 5 s each) [patch] ap-resolve-account-timeout-safe
const [followersResult, followingResult, outboxResult] = await Promise.allSettled([
withTimeout(actor.getFollowers()),
withTimeout(actor.getFollowing()),
withTimeout(actor.getOutbox()),
]);
const followersCount = followersResult.status === "fulfilled" && followersResult.value?.totalItems != null ? followersResult.value.totalItems : 0;
const followingCount = followingResult.status === "fulfilled" && followingResult.value?.totalItems != null ? followingResult.value.totalItems : 0;
const statusesCount = outboxResult.status === "fulfilled" && outboxResult.value?.totalItems != null ? outboxResult.value.totalItems : 0;
// Get published/created date — normalize to UTC ISO so clients display it correctly.
// 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
// [patch] ap-actor-cache-await
if (collections?.ap_actor_cache && actorUrl) {
const hashId = remoteActorId(actorUrl);
collections.ap_actor_cache.updateOne(
await collections.ap_actor_cache.updateOne(
{ _id: hashId },
{ $set: { actorUrl, updatedAt: new Date() } },
{ upsert: true },
).catch(() => {}); // fire-and-forget, non-fatal
).catch(() => {}); // non-fatal, but now awaited so entry exists before response
}
return account;
+12 -4
View File
@@ -456,15 +456,23 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read",
let bookmarkedIds = new Set();
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) {
const interactions = await collections.ap_interactions
.find({ objectUrl: { $in: lookupUrls } })
.toArray();
for (const ix of interactions) {
if (ix.type === "like") favouritedIds.add(ix.objectUrl);
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
const uid = urlToUid.get(ix.objectUrl) || ix.objectUrl;
if (ix.type === "like") favouritedIds.add(uid);
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) {
const statusMap = new Map();
const targetUrls = [
const targetUrls = [ // [patch] ap-notifications-status-lookup
...new Set(
notifications
.map((n) => n.targetUrl)
.flatMap((n) => [n.targetUrl, n.url])
.filter(Boolean),
),
];
+14
View File
@@ -41,6 +41,20 @@ export function createSyndicator(plugin) {
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 {
const actorUrl = plugin._getActorUrl();
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);
// 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
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
let interactionsRemoved = 0;
if (removedUids.length > 0 && collections.ap_interactions) {
if (uidsToDelete.length > 0 && collections.ap_interactions) {
const interactionResult = await collections.ap_interactions.deleteMany({
objectUrl: { $in: removedUids },
objectUrl: { $in: uidsToDelete },
});
interactionsRemoved = interactionResult.deletedCount || 0;
}