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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user