mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
likePost, unlikePost and boostPost all call resolveAuthor() which makes up to 3 signed HTTP requests to the remote server (post fetch, author actor fetch, getAttributedTo) with no timeout. If the remote server is slow or unreachable, the favourite/reblog HTTP response hangs until the Node.js socket default times out (~2 min). Mastodon clients (Phanpy, Elk) give up much sooner and show "Failed to load post". Fix: wrap every resolveAuthor() call in a Promise.race() with a 5 s timeout. The interaction is still recorded in ap_interactions and the Like/Announce activity is still sent when recipient resolution succeeds within the window; if it times out, AP delivery is silently skipped (the local record is kept — the client sees a successful ⭐). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
299 lines
9.2 KiB
JavaScript
299 lines
9.2 KiB
JavaScript
/**
|
|
* Shared interaction logic for like/unlike, boost/unboost, bookmark/unbookmark.
|
|
*
|
|
* Extracted from admin controllers (interactions-like.js, interactions-boost.js)
|
|
* so that both the admin UI and Mastodon Client API can reuse the same core logic.
|
|
*
|
|
* Each function accepts a context object instead of Express req/res,
|
|
* making them transport-agnostic.
|
|
*/
|
|
|
|
import { resolveAuthor } from "../../resolve-author.js";
|
|
|
|
/**
|
|
* Like a post — send Like activity and track in ap_interactions.
|
|
*
|
|
* @param {object} params
|
|
* @param {string} params.targetUrl - URL of the post to like
|
|
* @param {object} params.federation - Fedify federation instance
|
|
* @param {string} params.handle - Local actor handle
|
|
* @param {string} params.publicationUrl - Publication base URL
|
|
* @param {object} params.collections - MongoDB collections (Map or object)
|
|
* @param {object} params.interactions - ap_interactions collection
|
|
* @returns {Promise<{ activityId: string }>}
|
|
*/
|
|
export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
|
const { Like } = await import("@fedify/fedify/vocab");
|
|
const ctx = federation.createContext(
|
|
new URL(publicationUrl),
|
|
{ handle, publicationUrl },
|
|
);
|
|
|
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
// resolveAuthor makes up to 3 signed HTTP requests to the remote server.
|
|
// Cap at 5 s so a slow/unreachable remote never blocks the client response.
|
|
let recipient = null;
|
|
try {
|
|
recipient = await Promise.race([
|
|
resolveAuthor(targetUrl, ctx, documentLoader, collections),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("resolveAuthor timeout")), 5000)),
|
|
]);
|
|
} catch { /* skip AP delivery — interaction is still recorded locally */ }
|
|
|
|
const uuid = crypto.randomUUID();
|
|
const baseUrl = publicationUrl.replace(/\/$/, "");
|
|
const activityId = `${baseUrl}/activitypub/likes/${uuid}`;
|
|
|
|
const like = new Like({
|
|
id: new URL(activityId),
|
|
actor: ctx.getActorUri(handle),
|
|
object: new URL(targetUrl),
|
|
});
|
|
|
|
if (recipient) {
|
|
await ctx.sendActivity({ identifier: handle }, recipient, like, {
|
|
orderingKey: targetUrl,
|
|
});
|
|
}
|
|
|
|
if (interactions) {
|
|
await interactions.updateOne(
|
|
{ objectUrl: targetUrl, type: "like" },
|
|
{
|
|
$set: {
|
|
objectUrl: targetUrl,
|
|
type: "like",
|
|
activityId,
|
|
recipientUrl: recipient?.id?.href || "",
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
{ upsert: true },
|
|
);
|
|
}
|
|
|
|
return { activityId };
|
|
}
|
|
|
|
/**
|
|
* Unlike a post — send Undo(Like) activity and remove from ap_interactions.
|
|
*
|
|
* @param {object} params
|
|
* @param {string} params.targetUrl - URL of the post to unlike
|
|
* @param {object} params.federation - Fedify federation instance
|
|
* @param {string} params.handle - Local actor handle
|
|
* @param {string} params.publicationUrl - Publication base URL
|
|
* @param {object} params.collections - MongoDB collections
|
|
* @param {object} params.interactions - ap_interactions collection
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
|
const existing = interactions
|
|
? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
|
|
: null;
|
|
|
|
if (!existing) {
|
|
return;
|
|
}
|
|
|
|
const { Like, Undo } = await import("@fedify/fedify/vocab");
|
|
const ctx = federation.createContext(
|
|
new URL(publicationUrl),
|
|
{ handle, publicationUrl },
|
|
);
|
|
|
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
let recipient = null;
|
|
try {
|
|
recipient = await Promise.race([
|
|
resolveAuthor(targetUrl, ctx, documentLoader, collections),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("resolveAuthor timeout")), 5000)),
|
|
]);
|
|
} catch { /* skip AP delivery */ }
|
|
|
|
if (recipient) {
|
|
const like = new Like({
|
|
id: existing.activityId ? new URL(existing.activityId) : undefined,
|
|
actor: ctx.getActorUri(handle),
|
|
object: new URL(targetUrl),
|
|
});
|
|
|
|
const undo = new Undo({
|
|
actor: ctx.getActorUri(handle),
|
|
object: like,
|
|
});
|
|
|
|
await ctx.sendActivity({ identifier: handle }, recipient, undo, {
|
|
orderingKey: targetUrl,
|
|
});
|
|
}
|
|
|
|
if (interactions) {
|
|
await interactions.deleteOne({ objectUrl: targetUrl, type: "like" });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Boost a post — send Announce activity and track in ap_interactions.
|
|
*
|
|
* @param {object} params
|
|
* @param {string} params.targetUrl - URL of the post to boost
|
|
* @param {object} params.federation - Fedify federation instance
|
|
* @param {string} params.handle - Local actor handle
|
|
* @param {string} params.publicationUrl - Publication base URL
|
|
* @param {object} params.collections - MongoDB collections
|
|
* @param {object} params.interactions - ap_interactions collection
|
|
* @returns {Promise<{ activityId: string }>}
|
|
*/
|
|
export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
|
const { Announce } = await import("@fedify/fedify/vocab");
|
|
const ctx = federation.createContext(
|
|
new URL(publicationUrl),
|
|
{ handle, publicationUrl },
|
|
);
|
|
|
|
const uuid = crypto.randomUUID();
|
|
const baseUrl = publicationUrl.replace(/\/$/, "");
|
|
const activityId = `${baseUrl}/activitypub/boosts/${uuid}`;
|
|
|
|
const publicAddress = new URL("https://www.w3.org/ns/activitystreams#Public");
|
|
const followersUri = ctx.getFollowersUri(handle);
|
|
|
|
const announce = new Announce({
|
|
id: new URL(activityId),
|
|
actor: ctx.getActorUri(handle),
|
|
object: new URL(targetUrl),
|
|
to: publicAddress,
|
|
cc: followersUri,
|
|
});
|
|
|
|
// Send to followers
|
|
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
|
|
preferSharedInbox: true,
|
|
syncCollection: true,
|
|
orderingKey: targetUrl,
|
|
});
|
|
|
|
// Also send directly to the original post author (best-effort, 5 s cap)
|
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
let recipient = null;
|
|
try {
|
|
recipient = await Promise.race([
|
|
resolveAuthor(targetUrl, ctx, documentLoader, collections),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("resolveAuthor timeout")), 5000)),
|
|
]);
|
|
} catch { /* skip author delivery — follower delivery already happened */ }
|
|
if (recipient) {
|
|
try {
|
|
await ctx.sendActivity({ identifier: handle }, recipient, announce, {
|
|
orderingKey: targetUrl,
|
|
});
|
|
} catch {
|
|
// Non-critical — follower delivery already happened
|
|
}
|
|
}
|
|
|
|
if (interactions) {
|
|
await interactions.updateOne(
|
|
{ objectUrl: targetUrl, type: "boost" },
|
|
{
|
|
$set: {
|
|
objectUrl: targetUrl,
|
|
type: "boost",
|
|
activityId,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
{ upsert: true },
|
|
);
|
|
}
|
|
|
|
return { activityId };
|
|
}
|
|
|
|
/**
|
|
* Unboost a post — send Undo(Announce) activity and remove from ap_interactions.
|
|
*
|
|
* @param {object} params
|
|
* @param {string} params.targetUrl - URL of the post to unboost
|
|
* @param {object} params.federation - Fedify federation instance
|
|
* @param {string} params.handle - Local actor handle
|
|
* @param {string} params.publicationUrl - Publication base URL
|
|
* @param {object} params.interactions - ap_interactions collection
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function unboostPost({ targetUrl, federation, handle, publicationUrl, interactions }) {
|
|
const existing = interactions
|
|
? await interactions.findOne({ objectUrl: targetUrl, type: "boost" })
|
|
: null;
|
|
|
|
if (!existing) {
|
|
return;
|
|
}
|
|
|
|
const { Announce, Undo } = await import("@fedify/fedify/vocab");
|
|
const ctx = federation.createContext(
|
|
new URL(publicationUrl),
|
|
{ handle, publicationUrl },
|
|
);
|
|
|
|
const announce = new Announce({
|
|
id: existing.activityId ? new URL(existing.activityId) : undefined,
|
|
actor: ctx.getActorUri(handle),
|
|
object: new URL(targetUrl),
|
|
});
|
|
|
|
const undo = new Undo({
|
|
actor: ctx.getActorUri(handle),
|
|
object: announce,
|
|
});
|
|
|
|
await ctx.sendActivity({ identifier: handle }, "followers", undo, {
|
|
preferSharedInbox: true,
|
|
syncCollection: true,
|
|
orderingKey: targetUrl,
|
|
});
|
|
|
|
if (interactions) {
|
|
await interactions.deleteOne({ objectUrl: targetUrl, type: "boost" });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bookmark a post — local-only, no federation.
|
|
*
|
|
* @param {object} params
|
|
* @param {string} params.targetUrl - URL of the post to bookmark
|
|
* @param {object} params.interactions - ap_interactions collection
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function bookmarkPost({ targetUrl, interactions }) {
|
|
if (!interactions) return;
|
|
|
|
await interactions.updateOne(
|
|
{ objectUrl: targetUrl, type: "bookmark" },
|
|
{
|
|
$set: {
|
|
objectUrl: targetUrl,
|
|
type: "bookmark",
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
{ upsert: true },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remove a bookmark — local-only, no federation.
|
|
*
|
|
* @param {object} params
|
|
* @param {string} params.targetUrl - URL of the post to unbookmark
|
|
* @param {object} params.interactions - ap_interactions collection
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function unbookmarkPost({ targetUrl, interactions }) {
|
|
if (!interactions) return;
|
|
|
|
await interactions.deleteOne({ objectUrl: targetUrl, type: "bookmark" });
|
|
}
|