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