56a8b08498
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>
315 lines
9.8 KiB
JavaScript
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" });
|
|
}
|