Files
svemagie 56a8b08498 fix: integrate all AP runtime patches into fork source
Integrates 12 runtime patch scripts from indiekit-server directly into
the fork source code, eliminating the need for postinstall patching:

- enrich-actor-data: avatar via getIcon(), handle as @user@domain, banner via getImage()
- conversations-endpoint: real /api/v1/conversations implementation
- stubs-remove-duplicate-routes: dead route removal from stubs.js
- self-follow-guard: prevent self-follow loop
- oauth-token-expiry: clear expiresAt on token exchange
- unify-dm-visibility: unified DM visibility detection
- accounts-id-cache-fallback: check ap_actor_cache before 404
- federation-infra: federation infrastructure fixes
- mastodon-misc: miscellaneous Mastodon API fixes
- mastodon-statuses: status endpoint fixes
- syndication: syndication dedup
- startup-gate-bypass: startup gate bypass

Also strips all // [patch] markers from 16 files (including 4 from prior commit).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 16:35:01 +02:00

315 lines
9.8 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, loadRsaKey }) {
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.
const rsaKey = loadRsaKey ? await loadRsaKey() : null;
let recipient = null;
try {
recipient = await Promise.race([
resolveAuthor(targetUrl, ctx, documentLoader, collections, {
privateKey: rsaKey,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
}),
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) {
try {
await ctx.sendActivity({ identifier: handle }, recipient, like, {
orderingKey: targetUrl,
});
} catch { /* delivery failed — interaction still recorded locally */ }
}
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, loadRsaKey }) {
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 });
const rsaKey = loadRsaKey ? await loadRsaKey() : null;
let recipient = null;
try {
recipient = await Promise.race([
resolveAuthor(targetUrl, ctx, documentLoader, collections, {
privateKey: rsaKey,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
}),
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, loadRsaKey }) {
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
try {
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 });
const rsaKey = loadRsaKey ? await loadRsaKey() : null;
let recipient = null;
try {
recipient = await Promise.race([
resolveAuthor(targetUrl, ctx, documentLoader, collections, {
privateKey: rsaKey,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
}),
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" });
}