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>
This commit is contained in:
@@ -506,11 +506,18 @@ export default class ActivityPubEndpoint {
|
|||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
if (!self._fedifyMiddleware) return next();
|
if (!self._fedifyMiddleware) return next();
|
||||||
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
||||||
// Only delegate to Fedify for NodeInfo data endpoint (/nodeinfo/2.1).
|
// Delegate to Fedify for discovery endpoints:
|
||||||
// All other paths in this root-mounted router are handled by the
|
// /.well-known/webfinger — actor/resource identity resolution
|
||||||
// content negotiation catch-all below. Passing arbitrary paths like
|
// /.well-known/nodeinfo — server capabilities advertised to the fediverse
|
||||||
// /notes/... to Fedify causes harmless but noisy 404 warnings.
|
// /nodeinfo/2.1 — NodeInfo data document
|
||||||
if (!req.path.startsWith("/nodeinfo/")) return next();
|
// This router is mounted at "/" so req.url retains the full path, allowing
|
||||||
|
// Fedify to match its internal routes correctly. (routesWellKnown strips
|
||||||
|
// the /.well-known/ prefix, causing Fedify to miss the webfinger route.)
|
||||||
|
// ap-webfinger-before-auth patch
|
||||||
|
const isDiscoveryRoute =
|
||||||
|
req.path.startsWith("/nodeinfo/") ||
|
||||||
|
req.path.startsWith("/.well-known/");
|
||||||
|
if (!isDiscoveryRoute) return next();
|
||||||
return self._fedifyMiddleware(req, res, next);
|
return self._fedifyMiddleware(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -689,15 +696,30 @@ export default class ActivityPubEndpoint {
|
|||||||
remoteActor.name?.toString() ||
|
remoteActor.name?.toString() ||
|
||||||
remoteActor.preferredUsername?.toString() ||
|
remoteActor.preferredUsername?.toString() ||
|
||||||
actorUrl;
|
actorUrl;
|
||||||
const actorHandle =
|
let _enrichedAvatar = "";
|
||||||
actorInfo.handle ||
|
try {
|
||||||
remoteActor.preferredUsername?.toString() ||
|
if (typeof remoteActor.getIcon === "function") {
|
||||||
"";
|
const _iconObj = await remoteActor.getIcon();
|
||||||
const avatar =
|
_enrichedAvatar = _iconObj?.url?.href || "";
|
||||||
actorInfo.photo ||
|
}
|
||||||
(remoteActor.icon
|
} catch { /* icon fetch failed */ }
|
||||||
? (await remoteActor.icon)?.url?.href || ""
|
let _enrichedHandle = "";
|
||||||
: "");
|
try {
|
||||||
|
const _username = remoteActor.preferredUsername?.toString() || "";
|
||||||
|
if (_username && actorUrl) {
|
||||||
|
const _domain = new URL(actorUrl).hostname;
|
||||||
|
_enrichedHandle = `@${_username}@${_domain}`;
|
||||||
|
}
|
||||||
|
} catch { /* URL parse failed */ }
|
||||||
|
let _enrichedBanner = "";
|
||||||
|
try {
|
||||||
|
if (typeof remoteActor.getImage === "function") {
|
||||||
|
const _imgObj = await remoteActor.getImage();
|
||||||
|
_enrichedBanner = _imgObj?.url?.href || "";
|
||||||
|
}
|
||||||
|
} catch { /* banner fetch failed */ }
|
||||||
|
const actorHandle = actorInfo.handle || _enrichedHandle || remoteActor.preferredUsername?.toString() || "";
|
||||||
|
const avatar = actorInfo.photo || _enrichedAvatar || "";
|
||||||
const inbox = remoteActor.inboxId?.href || "";
|
const inbox = remoteActor.inboxId?.href || "";
|
||||||
const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || "";
|
const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || "";
|
||||||
|
|
||||||
@@ -711,6 +733,7 @@ export default class ActivityPubEndpoint {
|
|||||||
avatar,
|
avatar,
|
||||||
inbox,
|
inbox,
|
||||||
sharedInbox,
|
sharedInbox,
|
||||||
|
banner: _enrichedBanner || "",
|
||||||
followedAt: new Date().toISOString(),
|
followedAt: new Date().toISOString(),
|
||||||
source: "reader",
|
source: "reader",
|
||||||
},
|
},
|
||||||
@@ -1295,7 +1318,7 @@ export default class ActivityPubEndpoint {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this._federation = federation;
|
this._federation = federation;
|
||||||
this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}));
|
this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}), this._publicationUrl); // ap-base-url patch
|
||||||
|
|
||||||
// Expose signed avatar resolver for cross-plugin use (e.g., conversations backfill)
|
// Expose signed avatar resolver for cross-plugin use (e.g., conversations backfill)
|
||||||
Indiekit.config.application.resolveActorAvatar = async (actorUrl) => {
|
Indiekit.config.application.resolveActorAvatar = async (actorUrl) => {
|
||||||
@@ -1348,6 +1371,7 @@ export default class ActivityPubEndpoint {
|
|||||||
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
|
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
|
||||||
loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
|
loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
|
||||||
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
|
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
|
||||||
|
broadcastDelete: (url) => pluginRef.broadcastDelete(url),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Indiekit.addEndpoint({
|
Indiekit.addEndpoint({
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ import { lookupWithSecurity } from "../lookup-helpers.js";
|
|||||||
import { addNotification } from "../storage/notifications.js";
|
import { addNotification } from "../storage/notifications.js";
|
||||||
import { createContext, getHandle, isFederationReady } from "../federation-actions.js";
|
import { createContext, getHandle, isFederationReady } from "../federation-actions.js";
|
||||||
|
|
||||||
|
|
||||||
|
const _mpInternalBase = (() => {
|
||||||
|
if (process.env.INTERNAL_FETCH_URL) return process.env.INTERNAL_FETCH_URL.replace(/\/+$/, "");
|
||||||
|
const port = process.env.PORT || "3000";
|
||||||
|
return `http://localhost:${port}`;
|
||||||
|
})();
|
||||||
|
const _mpPublicBase = (
|
||||||
|
process.env.PUBLICATION_URL || process.env.SITE_URL || ""
|
||||||
|
).replace(/\/+$/, "");
|
||||||
|
function _toInternalUrl(url) {
|
||||||
|
if (!_mpPublicBase || !url.startsWith(_mpPublicBase)) return url;
|
||||||
|
return _mpInternalBase + url.slice(_mpPublicBase.length);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch syndication targets from the Micropub config endpoint.
|
* Fetch syndication targets from the Micropub config endpoint.
|
||||||
* @param {object} application - Indiekit application locals
|
* @param {object} application - Indiekit application locals
|
||||||
@@ -21,9 +35,9 @@ async function getSyndicationTargets(application, token) {
|
|||||||
|
|
||||||
if (!micropubEndpoint) return [];
|
if (!micropubEndpoint) return [];
|
||||||
|
|
||||||
const micropubUrl = micropubEndpoint.startsWith("http")
|
const micropubUrl = _toInternalUrl(micropubEndpoint.startsWith("http")
|
||||||
? micropubEndpoint
|
? micropubEndpoint
|
||||||
: new URL(micropubEndpoint, application.url).href;
|
: new URL(micropubEndpoint, application.url).href);
|
||||||
|
|
||||||
const configUrl = `${micropubUrl}?q=config`;
|
const configUrl = `${micropubUrl}?q=config`;
|
||||||
const configResponse = await fetch(configUrl, {
|
const configResponse = await fetch(configUrl, {
|
||||||
@@ -146,10 +160,12 @@ export function composeController(mountPath, plugin) {
|
|||||||
? await getSyndicationTargets(application, token)
|
? await getSyndicationTargets(application, token)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Pre-check syndication targets based on their configured checked state // [patch] ap-compose-default-checked
|
// Default-check only AP (Fedify) and Bluesky targets
|
||||||
for (const target of syndicationTargets) { // [patch] ap-compose-default-checked
|
// "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky
|
||||||
target.defaultChecked = target.checked === true; // [patch] ap-compose-default-checked
|
for (const target of syndicationTargets) {
|
||||||
} // [patch] ap-compose-default-checked
|
const name = target.name || "";
|
||||||
|
target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
|
||||||
|
}
|
||||||
|
|
||||||
const csrfToken = getToken(request.session);
|
const csrfToken = getToken(request.session);
|
||||||
|
|
||||||
@@ -300,9 +316,9 @@ export function submitComposeController(mountPath, plugin) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const micropubUrl = micropubEndpoint.startsWith("http")
|
const micropubUrl = _toInternalUrl(micropubEndpoint.startsWith("http")
|
||||||
? micropubEndpoint
|
? micropubEndpoint
|
||||||
: new URL(micropubEndpoint, application.url).href;
|
: new URL(micropubEndpoint, application.url).href);
|
||||||
|
|
||||||
const token = request.session?.access_token;
|
const token = request.session?.access_token;
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import { Buffer } from "node:buffer";
|
|||||||
* @param {import("express").Request} req - Express request
|
* @param {import("express").Request} req - Express request
|
||||||
* @returns {Request} Standard Request object
|
* @returns {Request} Standard Request object
|
||||||
*/
|
*/
|
||||||
export function fromExpressRequest(req) {
|
export function fromExpressRequest(req, baseUrl) { // ap-base-url patch
|
||||||
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
|
const url = baseUrl
|
||||||
|
? `${baseUrl.replace(/\/$/, "")}${req.originalUrl}` // ap-base-url patch
|
||||||
|
: `${req.protocol}://${req.get("host")}${req.originalUrl}`;
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
for (const [key, value] of Object.entries(req.headers)) {
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -28,6 +30,18 @@ export function fromExpressRequest(req) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalise "host" to the public hostname so Fedify's HTTP Signature
|
||||||
|
// verifier reconstructs the same signed-string the remote server created.
|
||||||
|
// Without this, nginx may forward an internal Host (e.g. "10.100.0.20")
|
||||||
|
// which doesn't match what the sender signed, causing every inbox POST
|
||||||
|
// to fail with "Failed to verify the request's HTTP Signatures".
|
||||||
|
if (baseUrl) {
|
||||||
|
try {
|
||||||
|
const _canonicalHost = new URL(baseUrl).host; // e.g. "blog.giersig.eu"
|
||||||
|
headers.set("host", _canonicalHost);
|
||||||
|
} catch { /* invalid baseUrl — leave header as-is */ }
|
||||||
|
}
|
||||||
|
|
||||||
let body;
|
let body;
|
||||||
if (req.method === "GET" || req.method === "HEAD") {
|
if (req.method === "GET" || req.method === "HEAD") {
|
||||||
body = undefined;
|
body = undefined;
|
||||||
|
|||||||
@@ -363,6 +363,9 @@ export function setupFederation(options) {
|
|||||||
`${mountPath}/users/{identifier}/inbox`,
|
`${mountPath}/users/{identifier}/inbox`,
|
||||||
`${mountPath}/inbox`,
|
`${mountPath}/inbox`,
|
||||||
);
|
);
|
||||||
|
// Expose publicationUrl on collections so inbox handlers can gate
|
||||||
|
// notifications/timeline-storage to our own content only.
|
||||||
|
collections._publicationUrl = publicationUrl;
|
||||||
registerInboxListeners(inboxChain, {
|
registerInboxListeners(inboxChain, {
|
||||||
collections,
|
collections,
|
||||||
handle,
|
handle,
|
||||||
|
|||||||
+70
-20
@@ -160,14 +160,26 @@ function isDirectMessage(object, ourActorUrl, followersUrl) {
|
|||||||
* @param {object} object - Fedify object (Note, Article, etc.)
|
* @param {object} object - Fedify object (Note, Article, etc.)
|
||||||
* @returns {"public"|"unlisted"|"private"|"direct"}
|
* @returns {"public"|"unlisted"|"private"|"direct"}
|
||||||
*/
|
*/
|
||||||
function computeVisibility(object) {
|
function computeVisibility(object, actorContext) {
|
||||||
const to = new Set((object.toIds || []).map((u) => u.href));
|
const to = new Set((object.toIds || []).map((u) => u.href));
|
||||||
const cc = new Set((object.ccIds || []).map((u) => u.href));
|
const cc = new Set((object.ccIds || []).map((u) => u.href));
|
||||||
|
|
||||||
if (to.has(PUBLIC)) return "public";
|
if (to.has(PUBLIC)) return "public";
|
||||||
if (cc.has(PUBLIC)) return "unlisted";
|
if (cc.has(PUBLIC)) return "unlisted";
|
||||||
// Without knowing the remote actor's followers URL, we can't distinguish
|
// When actor context is available, use isDirectMessage logic to distinguish
|
||||||
// "private" (followers-only) from "direct". Both are non-public.
|
// "direct" (addressed to specific actors only) from "private" (followers-only).
|
||||||
|
if (actorContext?.ourActorUrl) {
|
||||||
|
const allAddressed = [
|
||||||
|
...to, ...cc,
|
||||||
|
...(object.btoIds || []).map((u) => u.href),
|
||||||
|
...(object.bccIds || []).map((u) => u.href),
|
||||||
|
];
|
||||||
|
const hasPublic = allAddressed.some((u) => u === PUBLIC || u === "as:Public");
|
||||||
|
const hasFollowers = actorContext.followersUrl && allAddressed.includes(actorContext.followersUrl);
|
||||||
|
if (!hasPublic && !hasFollowers && allAddressed.includes(actorContext.ourActorUrl)) {
|
||||||
|
return "direct";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Without actor context, can't distinguish "private" from "direct".
|
||||||
if (to.size > 0 || cc.size > 0) return "private";
|
if (to.size > 0 || cc.size > 0) return "private";
|
||||||
return "direct";
|
return "direct";
|
||||||
}
|
}
|
||||||
@@ -650,7 +662,7 @@ export async function handleCreate(item, collections, ctx, handle) {
|
|||||||
const ourActorUrl = ctx.getActorUri(handle).href;
|
const ourActorUrl = ctx.getActorUri(handle).href;
|
||||||
const followersUrl = ctx.getFollowersUri(handle)?.href || "";
|
const followersUrl = ctx.getFollowersUri(handle)?.href || "";
|
||||||
|
|
||||||
if (isDirectMessage(object, ourActorUrl, followersUrl)) {
|
if (computeVisibility(object, { ourActorUrl, followersUrl }) === "direct") {
|
||||||
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
|
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
|
||||||
const rawHtml = object.content?.toString() || "";
|
const rawHtml = object.content?.toString() || "";
|
||||||
const contentHtml = sanitizeContent(rawHtml);
|
const contentHtml = sanitizeContent(rawHtml);
|
||||||
@@ -809,7 +821,7 @@ export async function handleCreate(item, collections, ctx, handle) {
|
|||||||
actorFallback: actorObj,
|
actorFallback: actorObj,
|
||||||
documentLoader: authLoader,
|
documentLoader: authLoader,
|
||||||
});
|
});
|
||||||
timelineItem.visibility = computeVisibility(object);
|
timelineItem.visibility = computeVisibility(object, { ourActorUrl, followersUrl });
|
||||||
await addTimelineItem(collections, timelineItem);
|
await addTimelineItem(collections, timelineItem);
|
||||||
|
|
||||||
// Fire-and-forget OG unfurling for notes and articles (not boosts)
|
// Fire-and-forget OG unfurling for notes and articles (not boosts)
|
||||||
@@ -831,6 +843,19 @@ export async function handleCreate(item, collections, ctx, handle) {
|
|||||||
// Log extraction errors but don't fail the entire handler
|
// Log extraction errors but don't fail the entire handler
|
||||||
console.error("[inbox-handlers] Failed to store timeline item:", error);
|
console.error("[inbox-handlers] Failed to store timeline item:", error);
|
||||||
}
|
}
|
||||||
|
} else if (pubUrl && inReplyTo && inReplyTo.startsWith(pubUrl)) {
|
||||||
|
// Reply to our post from a non-followed account — store in timeline
|
||||||
|
// so it appears in the Mastodon client API's conversation/notification view.
|
||||||
|
try {
|
||||||
|
const timelineItem = await extractObjectData(object, {
|
||||||
|
actorFallback: actorObj,
|
||||||
|
documentLoader: authLoader,
|
||||||
|
});
|
||||||
|
timelineItem.visibility = computeVisibility(object, { ourActorUrl, followersUrl });
|
||||||
|
await addTimelineItem(collections, timelineItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[inbox-handlers] Failed to store reply timeline item:", error.message);
|
||||||
|
}
|
||||||
} else if (collections.ap_followed_tags) {
|
} else if (collections.ap_followed_tags) {
|
||||||
// Not a followed account — check if the post's hashtags match any followed tags
|
// Not a followed account — check if the post's hashtags match any followed tags
|
||||||
// so tagged posts from across the fediverse appear in the timeline
|
// so tagged posts from across the fediverse appear in the timeline
|
||||||
@@ -850,7 +875,7 @@ export async function handleCreate(item, collections, ctx, handle) {
|
|||||||
actorFallback: actorObj,
|
actorFallback: actorObj,
|
||||||
documentLoader: authLoader,
|
documentLoader: authLoader,
|
||||||
});
|
});
|
||||||
timelineItem.visibility = computeVisibility(object);
|
timelineItem.visibility = computeVisibility(object, { ourActorUrl, followersUrl });
|
||||||
await addTimelineItem(collections, timelineItem);
|
await addTimelineItem(collections, timelineItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -986,22 +1011,47 @@ export async function handleUpdate(item, collections, ctx, handle) {
|
|||||||
|
|
||||||
const existing = await collections.ap_followers.findOne({ actorUrl });
|
const existing = await collections.ap_followers.findOne({ actorUrl });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
let _uAvatar = "";
|
||||||
|
try {
|
||||||
|
if (typeof actorObj.getIcon === "function") {
|
||||||
|
const _uIcon = await actorObj.getIcon();
|
||||||
|
_uAvatar = _uIcon?.url?.href || "";
|
||||||
|
}
|
||||||
|
} catch { /* icon fetch failed */ }
|
||||||
|
let _uHandle = "";
|
||||||
|
try {
|
||||||
|
const _uUsername = actorObj.preferredUsername?.toString() || "";
|
||||||
|
if (_uUsername && actorUrl) {
|
||||||
|
const _uDomain = new URL(actorUrl).hostname;
|
||||||
|
_uHandle = `@${_uUsername}@${_uDomain}`;
|
||||||
|
}
|
||||||
|
} catch { /* URL parse failed */ }
|
||||||
|
let _uBanner = "";
|
||||||
|
try {
|
||||||
|
if (typeof actorObj.getImage === "function") {
|
||||||
|
const _uImg = await actorObj.getImage();
|
||||||
|
_uBanner = _uImg?.url?.href || "";
|
||||||
|
}
|
||||||
|
} catch { /* banner fetch failed */ }
|
||||||
|
const _updateFields = {
|
||||||
|
name: actorObj.name?.toString() || actorObj.preferredUsername?.toString() || actorUrl,
|
||||||
|
handle: _uHandle || actorObj.preferredUsername?.toString() || "",
|
||||||
|
avatar: _uAvatar,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (_uBanner) _updateFields.banner = _uBanner;
|
||||||
await collections.ap_followers.updateOne(
|
await collections.ap_followers.updateOne(
|
||||||
{ actorUrl },
|
{ actorUrl },
|
||||||
{
|
{ $set: _updateFields },
|
||||||
$set: {
|
|
||||||
name:
|
|
||||||
actorObj.name?.toString() ||
|
|
||||||
actorObj.preferredUsername?.toString() ||
|
|
||||||
actorUrl,
|
|
||||||
handle: actorObj.preferredUsername?.toString() || "",
|
|
||||||
avatar: actorObj.icon
|
|
||||||
? (await actorObj.icon)?.url?.href || ""
|
|
||||||
: "",
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
// Also update ap_following if we follow this actor
|
||||||
|
const existingFollowing = await collections.ap_following.findOne({ actorUrl });
|
||||||
|
if (existingFollowing) {
|
||||||
|
await collections.ap_following.updateOne(
|
||||||
|
{ actorUrl },
|
||||||
|
{ $set: _updateFields },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-12
@@ -23,7 +23,6 @@ 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";
|
||||||
@@ -54,6 +53,16 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Follow, async (ctx, follow) => {
|
.on(Follow, async (ctx, follow) => {
|
||||||
const actorUrl = follow.actorId?.href || "";
|
const actorUrl = follow.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
|
|
||||||
|
// Reject self-follows: if the follower is our own actor, skip.
|
||||||
|
// Self-follows cause infinite delivery retries because Fedify
|
||||||
|
// tries to POST to our own shared inbox, which is unreachable
|
||||||
|
// from within the jail (no outbound internet).
|
||||||
|
if (collections._publicationUrl && actorUrl.startsWith(collections._publicationUrl)) {
|
||||||
|
console.info(`[ActivityPub] Ignoring self-follow from ${actorUrl}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
await touchKeyFreshness(collections, actorUrl);
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
@@ -67,13 +76,35 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
followerActor.preferredUsername?.toString() ||
|
followerActor.preferredUsername?.toString() ||
|
||||||
followerUrl;
|
followerUrl;
|
||||||
|
|
||||||
|
// Enrich avatar and handle using proper Fedify async getters
|
||||||
|
let _fAvatar = "";
|
||||||
|
try {
|
||||||
|
if (typeof followerActor.getIcon === "function") {
|
||||||
|
const _fIcon = await followerActor.getIcon();
|
||||||
|
_fAvatar = _fIcon?.url?.href || "";
|
||||||
|
}
|
||||||
|
} catch { /* icon fetch failed */ }
|
||||||
|
let _fHandle = "";
|
||||||
|
try {
|
||||||
|
const _fUsername = followerActor.preferredUsername?.toString() || "";
|
||||||
|
if (_fUsername && followerUrl) {
|
||||||
|
const _fDomain = new URL(followerUrl).hostname;
|
||||||
|
_fHandle = `@${_fUsername}@${_fDomain}`;
|
||||||
|
}
|
||||||
|
} catch { /* URL parse failed */ }
|
||||||
|
let _fBanner = "";
|
||||||
|
try {
|
||||||
|
if (typeof followerActor.getImage === "function") {
|
||||||
|
const _fImg = await followerActor.getImage();
|
||||||
|
_fBanner = _fImg?.url?.href || "";
|
||||||
|
}
|
||||||
|
} catch { /* banner fetch failed */ }
|
||||||
const followerData = {
|
const followerData = {
|
||||||
actorUrl: followerUrl,
|
actorUrl: followerUrl,
|
||||||
handle: followerActor.preferredUsername?.toString() || "",
|
handle: _fHandle || followerActor.preferredUsername?.toString() || "",
|
||||||
name: followerName,
|
name: followerName,
|
||||||
avatar: followerActor.icon
|
avatar: _fAvatar,
|
||||||
? (await followerActor.icon)?.url?.href || ""
|
banner: _fBanner,
|
||||||
: "",
|
|
||||||
inbox: followerActor.inbox?.id?.href || "",
|
inbox: followerActor.inbox?.id?.href || "",
|
||||||
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
|
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
|
||||||
};
|
};
|
||||||
@@ -353,11 +384,5 @@ 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 () => {});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -314,7 +314,7 @@ export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, opt
|
|||||||
// Only send Announce if repost-of is an ActivityPub URL.
|
// Only send Announce if repost-of is an ActivityPub URL.
|
||||||
// Non-AP URLs (web articles) cannot be federated as a boost — fall
|
// Non-AP URLs (web articles) cannot be federated as a boost — fall
|
||||||
// through to Create(Note) which renders as "🔁 <link>" on the fediverse.
|
// through to Create(Note) which renders as "🔁 <link>" on the fediverse.
|
||||||
if (await isApUrl(repostOf)) { // [patch] ap-repost-announce-fix
|
if (await isApUrl(repostOf)) {
|
||||||
const actorPath = new URL(actorUrl).pathname;
|
const actorPath = new URL(actorUrl).pathname;
|
||||||
const mp = actorPath.replace(/\/users\/[^/]+$/, "");
|
const mp = actorPath.replace(/\/users\/[^/]+$/, "");
|
||||||
const postRelPath = (properties.url || "")
|
const postRelPath = (properties.url || "")
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (recipient) {
|
if (recipient) {
|
||||||
try { // [patch] ap-interactions-send-guard
|
try {
|
||||||
await ctx.sendActivity({ identifier: handle }, recipient, like, {
|
await ctx.sendActivity({ identifier: handle }, recipient, like, {
|
||||||
orderingKey: targetUrl,
|
orderingKey: targetUrl,
|
||||||
});
|
});
|
||||||
@@ -178,7 +178,7 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send to followers
|
// Send to followers
|
||||||
try { // [patch] ap-interactions-send-guard
|
try {
|
||||||
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
|
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
|
||||||
preferSharedInbox: true,
|
preferSharedInbox: true,
|
||||||
syncCollection: true,
|
syncCollection: true,
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect
|
|||||||
|
|
||||||
// Use signed→unsigned fallback so servers rejecting signed GETs still resolve
|
// Use signed→unsigned fallback so servers rejecting signed GETs still resolve
|
||||||
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
||||||
const actor = await lookupWithSecurity(ctx, actorUri, { documentLoader });
|
// Timeout guard: cap actor fetch at 8 s so hung lookups fail fast.
|
||||||
if (!actor) return null;
|
const _aLookupTimeout = (p, ms = 8000) => { const t = new Promise((_, rej) => setTimeout(() => rej(new Error("actor lookup timeout")), ms)); p.catch(() => {}); return Promise.race([p, t]); };
|
||||||
|
const actor = await _aLookupTimeout(lookupWithSecurity(ctx, actorUri, { documentLoader })).catch(err => { console.warn(`[Mastodon API] Actor lookup failed for ${acct}: ${err.message}`); return null; });
|
||||||
|
if (!actor) { console.warn(`[Mastodon API] lookupWithSecurity returned null for ${acct}`); return null; }
|
||||||
|
|
||||||
// Extract data from the Fedify actor object
|
// Extract data from the Fedify actor object
|
||||||
const name = actor.name?.toString() || actor.preferredUsername?.toString() || "";
|
const name = actor.name?.toString() || actor.preferredUsername?.toString() || "";
|
||||||
@@ -69,21 +71,24 @@ 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) => { // [patch] ap-resolve-account-timeout-safe
|
const withTimeout = (promise, ms = 5000) =>
|
||||||
const abort = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms));
|
Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]);
|
||||||
promise.catch(() => {}); // suppress unhandled rejection if timeout settles first
|
|
||||||
return Promise.race([promise, abort]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch collection counts in parallel (max 5 s each) [patch] ap-resolve-account-timeout-safe
|
let followersCount = 0;
|
||||||
const [followersResult, followingResult, outboxResult] = await Promise.allSettled([
|
let followingCount = 0;
|
||||||
withTimeout(actor.getFollowers()),
|
let statusesCount = 0;
|
||||||
withTimeout(actor.getFollowing()),
|
try {
|
||||||
withTimeout(actor.getOutbox()),
|
const followers = await withTimeout(actor.getFollowers());
|
||||||
]);
|
if (followers?.totalItems != null) followersCount = followers.totalItems;
|
||||||
const followersCount = followersResult.status === "fulfilled" && followersResult.value?.totalItems != null ? followersResult.value.totalItems : 0;
|
} catch { /* ignore */ }
|
||||||
const followingCount = followingResult.status === "fulfilled" && followingResult.value?.totalItems != null ? followingResult.value.totalItems : 0;
|
try {
|
||||||
const statusesCount = outboxResult.status === "fulfilled" && outboxResult.value?.totalItems != null ? outboxResult.value.totalItems : 0;
|
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 */ }
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -138,14 +143,13 @@ 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);
|
||||||
await collections.ap_actor_cache.updateOne(
|
collections.ap_actor_cache.updateOne(
|
||||||
{ _id: hashId },
|
{ _id: hashId },
|
||||||
{ $set: { actorUrl, updatedAt: new Date() } },
|
{ $set: { actorUrl, updatedAt: new Date() } },
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
).catch(() => {}); // non-fatal, but now awaited so entry exists before response
|
).catch(() => {}); // fire-and-forget, non-fatal
|
||||||
}
|
}
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:ac
|
|||||||
|
|
||||||
// Check if it's the local profile
|
// Check if it's the local profile
|
||||||
const profile = await collections.ap_profile.findOne({});
|
const profile = await collections.ap_profile.findOne({});
|
||||||
if (profile && profile._id.toString() === id) {
|
if (profile && remoteActorId(profile.url) === id) {
|
||||||
const [statuses, followers, following] = await Promise.all([
|
const [statuses, followers, following] = await Promise.all([
|
||||||
collections.ap_timeline.countDocuments({ "author.url": profile.url }),
|
collections.ap_timeline.countDocuments({ "author.url": profile.url }),
|
||||||
collections.ap_followers.countDocuments({}),
|
collections.ap_followers.countDocuments({}),
|
||||||
@@ -389,6 +389,20 @@ router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:ac
|
|||||||
return res.json(account);
|
return res.json(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache fallback: actor not in followers/following/timeline,
|
||||||
|
// but may have been resolved via /lookup and cached in ap_actor_cache
|
||||||
|
let cachedActorUrl = getActorUrlFromId(id);
|
||||||
|
if (!cachedActorUrl && collections.ap_actor_cache) {
|
||||||
|
const cached = await collections.ap_actor_cache.findOne({ _id: id });
|
||||||
|
if (cached?.actorUrl) cachedActorUrl = cached.actorUrl;
|
||||||
|
}
|
||||||
|
if (cachedActorUrl) {
|
||||||
|
const cachedAccount = await resolveRemoteAccount(
|
||||||
|
cachedActorUrl, pluginOptions, baseUrl, collections,
|
||||||
|
);
|
||||||
|
if (cachedAccount) return res.json(cachedAccount);
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(404).json({ error: "Record not found" });
|
return res.status(404).json({ error: "Record not found" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -456,23 +470,15 @@ 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 urlToUid = new Map(); // [patch] ap-interactions-accounts-uid
|
const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
|
||||||
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) {
|
||||||
const uid = urlToUid.get(ix.objectUrl) || ix.objectUrl;
|
if (ix.type === "like") favouritedIds.add(ix.objectUrl);
|
||||||
if (ix.type === "like") favouritedIds.add(uid);
|
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
|
||||||
else if (ix.type === "boost") rebloggedIds.add(uid);
|
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
|
||||||
else if (ix.type === "bookmark") bookmarkedIds.add(uid);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,7 +511,7 @@ router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read"
|
|||||||
const profile = await collections.ap_profile.findOne({});
|
const profile = await collections.ap_profile.findOne({});
|
||||||
|
|
||||||
// Only serve followers for the local account
|
// Only serve followers for the local account
|
||||||
if (!profile || profile._id.toString() !== id) {
|
if (!profile || remoteActorId(profile.url) !== id) {
|
||||||
return res.json([]);
|
return res.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,7 +544,7 @@ router.get("/api/v1/accounts/:id/following", tokenRequired, scopeRequired("read"
|
|||||||
const profile = await collections.ap_profile.findOne({});
|
const profile = await collections.ap_profile.findOne({});
|
||||||
|
|
||||||
// Only serve following for the local account
|
// Only serve following for the local account
|
||||||
if (!profile || profile._id.toString() !== id) {
|
if (!profile || remoteActorId(profile.url) !== id) {
|
||||||
return res.json([]);
|
return res.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ function resolveInternalTypes(mastodonTypes) {
|
|||||||
async function batchFetchStatuses(collections, notifications) {
|
async function batchFetchStatuses(collections, notifications) {
|
||||||
const statusMap = new Map();
|
const statusMap = new Map();
|
||||||
|
|
||||||
const targetUrls = [ // [patch] ap-notifications-status-lookup
|
const targetUrls = [
|
||||||
...new Set(
|
...new Set(
|
||||||
notifications
|
notifications
|
||||||
.flatMap((n) => [n.targetUrl, n.url])
|
.flatMap((n) => [n.targetUrl, n.url])
|
||||||
|
|||||||
@@ -109,7 +109,42 @@ router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search")
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
results.statuses = items.map((item) =>
|
// URL resolve: find post by AP URL before content search.
|
||||||
|
if (resolve && query.startsWith("http")) {
|
||||||
|
const resolvedItem = await collections.ap_timeline.findOne({
|
||||||
|
isContext: { $ne: true },
|
||||||
|
$or: [{ uid: query }, { url: query }],
|
||||||
|
});
|
||||||
|
if (resolvedItem) {
|
||||||
|
results.statuses.push(serializeStatus(resolvedItem, {
|
||||||
|
baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(),
|
||||||
|
bookmarkedIds: new Set(), pinnedIds: new Set(),
|
||||||
|
}));
|
||||||
|
} else if (pluginOptions.federation) {
|
||||||
|
try {
|
||||||
|
const { lookupWithSecurity } = await import("../../lookup-helpers.js");
|
||||||
|
const { extractObjectData } = await import("../../timeline-store.js");
|
||||||
|
const { addTimelineItem } = await import("../../storage/timeline.js");
|
||||||
|
const _rCtx = pluginOptions.federation.createContext(new URL(pluginOptions.publicationUrl), { handle: pluginOptions.handle, publicationUrl: pluginOptions.publicationUrl });
|
||||||
|
const _rDl = await _rCtx.getDocumentLoader({ identifier: pluginOptions.handle });
|
||||||
|
const _rObj = await lookupWithSecurity(_rCtx, new URL(query), { documentLoader: _rDl });
|
||||||
|
if (_rObj) {
|
||||||
|
const _rData = await extractObjectData(_rObj, { documentLoader: _rDl });
|
||||||
|
if (!_rData?.uid) throw new Error("remote AP object has no uid");
|
||||||
|
const _rStored = await addTimelineItem(collections, _rData);
|
||||||
|
if (_rStored) {
|
||||||
|
results.statuses.push(serializeStatus(_rStored, {
|
||||||
|
baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(),
|
||||||
|
bookmarkedIds: new Set(), pinnedIds: new Set(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_rErr) {
|
||||||
|
console.warn(`[Mastodon API] search resolve remote fetch failed for ${query}: ${_rErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.statuses.push(...items.map((item) =>
|
||||||
serializeStatus(item, {
|
serializeStatus(item, {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
favouritedIds: new Set(),
|
favouritedIds: new Set(),
|
||||||
@@ -117,7 +152,7 @@ router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search")
|
|||||||
bookmarkedIds: new Set(),
|
bookmarkedIds: new Set(),
|
||||||
pinnedIds: new Set(),
|
pinnedIds: new Set(),
|
||||||
}),
|
}),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Hashtag search ──────────────────────────────────────────────────
|
// ─── Hashtag search ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -126,16 +126,33 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read",
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Serialize all items
|
// Serialize all items
|
||||||
const emptyInteractions = {
|
|
||||||
favouritedIds: new Set(),
|
|
||||||
rebloggedIds: new Set(),
|
|
||||||
bookmarkedIds: new Set(),
|
|
||||||
pinnedIds: new Set(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const allItems = [...ancestors, ...descendants];
|
const allItems = [...ancestors, ...descendants];
|
||||||
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
|
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
|
||||||
const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap };
|
|
||||||
|
// Load real interaction state for thread context
|
||||||
|
const ctxFavouritedIds = new Set();
|
||||||
|
const ctxRebloggedIds = new Set();
|
||||||
|
const ctxBookmarkedIds = new Set();
|
||||||
|
if (allItems.length > 0 && collections.ap_interactions) {
|
||||||
|
const ctxUrlToUid = new Map();
|
||||||
|
for (const ci of allItems) {
|
||||||
|
if (ci.uid) { ctxUrlToUid.set(ci.uid, ci.uid); }
|
||||||
|
if (ci.url && ci.url !== ci.uid) { ctxUrlToUid.set(ci.url, ci.uid || ci.url); }
|
||||||
|
}
|
||||||
|
const ctxLookupUrls = [...ctxUrlToUid.keys()];
|
||||||
|
if (ctxLookupUrls.length > 0) {
|
||||||
|
const ctxInteractions = await collections.ap_interactions
|
||||||
|
.find({ objectUrl: { $in: ctxLookupUrls } })
|
||||||
|
.toArray();
|
||||||
|
for (const ci of ctxInteractions) {
|
||||||
|
const uid = ctxUrlToUid.get(ci.objectUrl) || ci.objectUrl;
|
||||||
|
if (ci.type === "like") ctxFavouritedIds.add(uid);
|
||||||
|
else if (ci.type === "boost") ctxRebloggedIds.add(uid);
|
||||||
|
else if (ci.type === "bookmark") ctxBookmarkedIds.add(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const serializeOpts = { baseUrl, favouritedIds: ctxFavouritedIds, rebloggedIds: ctxRebloggedIds, bookmarkedIds: ctxBookmarkedIds, pinnedIds: new Set(), replyIdMap, replyAccountIdMap };
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
|
ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
|
||||||
@@ -440,13 +457,42 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
|||||||
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
|
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
|
||||||
|
|
||||||
// Return a minimal status to the Mastodon client.
|
// Return a minimal status to the Mastodon client.
|
||||||
// No timeline entry is created here — the post will appear in the timeline
|
// Eagerly insert own post into ap_timeline so the Mastodon client can resolve
|
||||||
// after the normal flow: Eleventy rebuild → syndication webhook → AP delivery.
|
// in_reply_to_id for this post immediately, without waiting for the build webhook.
|
||||||
|
// The AP syndicator will upsert the same uid later via $setOnInsert (no-op).
|
||||||
const profile = await collections.ap_profile.findOne({});
|
const profile = await collections.ap_profile.findOne({});
|
||||||
const handle = pluginOptions.handle || "user";
|
const handle = pluginOptions.handle || "user";
|
||||||
|
let _tlItem = null;
|
||||||
|
try {
|
||||||
|
const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })();
|
||||||
|
_tlItem = await addTimelineItem(collections, {
|
||||||
|
uid: postUrl,
|
||||||
|
url: postUrl,
|
||||||
|
type: data.properties["post-type"] || "note",
|
||||||
|
content: { text: contentText, html: `<p>${contentHtml}</p>` },
|
||||||
|
author: {
|
||||||
|
name: profile?.name || handle,
|
||||||
|
url: profile?.url || publicationUrl,
|
||||||
|
photo: profile?.icon || "",
|
||||||
|
handle: `@${handle}@${_ph}`,
|
||||||
|
emojis: [],
|
||||||
|
bot: false,
|
||||||
|
},
|
||||||
|
published: data.properties.published || new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
inReplyTo: inReplyTo || null,
|
||||||
|
inReplyToId: inReplyToId || null,
|
||||||
|
visibility: jf2.visibility || "public",
|
||||||
|
sensitive: jf2.sensitive === "true",
|
||||||
|
category: [],
|
||||||
|
counts: { likes: 0, boosts: 0, replies: 0 },
|
||||||
|
});
|
||||||
|
} catch (tlErr) {
|
||||||
|
console.warn(`[Mastodon API] Failed to pre-insert own post into timeline: ${tlErr.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
const statusResponse = {
|
const statusResponse = {
|
||||||
id: String(Date.now()),
|
id: _tlItem?._id?.toString() || String(Date.now()),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
content: `<p>${contentHtml}</p>`,
|
content: `<p>${contentHtml}</p>`,
|
||||||
url: postUrl,
|
url: postUrl,
|
||||||
@@ -559,6 +605,14 @@ router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "wri
|
|||||||
// Delete from timeline
|
// Delete from timeline
|
||||||
await collections.ap_timeline.deleteOne({ _id: item._id });
|
await collections.ap_timeline.deleteOne({ _id: item._id });
|
||||||
|
|
||||||
|
// Broadcast AP Delete activity to followers
|
||||||
|
const _pluginOpts = req.app.locals.mastodonPluginOptions || {};
|
||||||
|
if (_pluginOpts.broadcastDelete && postUrl) {
|
||||||
|
_pluginOpts.broadcastDelete(postUrl).catch((err) =>
|
||||||
|
console.warn(`[Mastodon API] broadcastDelete failed for ${postUrl}: ${err.message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up interactions
|
// Clean up interactions
|
||||||
if (collections.ap_interactions && item.uid) {
|
if (collections.ap_interactions && item.uid) {
|
||||||
await collections.ap_interactions.deleteMany({ objectUrl: item.uid });
|
await collections.ap_interactions.deleteMany({ objectUrl: item.uid });
|
||||||
@@ -1111,8 +1165,12 @@ router.get("/api/v1/statuses/:id/card", async (req, res, next) => {
|
|||||||
*/
|
*/
|
||||||
async function findTimelineItemById(collection, id) {
|
async function findTimelineItemById(collection, id) {
|
||||||
try {
|
try {
|
||||||
return await collection.findOne({ _id: new ObjectId(id) });
|
const _oid = new ObjectId(id);
|
||||||
} catch {
|
const _doc = await collection.findOne({ _id: _oid });
|
||||||
|
if (!_doc) console.warn(`[Mastodon API] findTimelineItemById: no item for id=${id}`);
|
||||||
|
return _doc;
|
||||||
|
} catch (_fErr) {
|
||||||
|
console.warn(`[Mastodon API] findTimelineItemById: invalid id=${id}: ${_fErr.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-52
@@ -382,8 +382,205 @@ router.get("/api/v1/scheduled_statuses", (req, res) => {
|
|||||||
|
|
||||||
// ─── Conversations ──────────────────────────────────────────────────────────
|
// ─── Conversations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/conversations", (req, res) => {
|
// ─── Conversations (Direct Messages) ────────────────────────────────────────
|
||||||
res.json([]);
|
// Real implementation replacing the empty stub.
|
||||||
|
// Reads from ap_messages collection, groups by conversationId (actor URL).
|
||||||
|
|
||||||
|
router.get("/api/v1/conversations", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const collections = req.app.locals.mastodonCollections;
|
||||||
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||||
|
const { serializeAccount } = await import("../entities/account.js");
|
||||||
|
const { remoteActorId } = await import("../helpers/id-mapping.js");
|
||||||
|
const { parseLimit } = await import("../helpers/pagination.js");
|
||||||
|
if (!collections?.ap_messages) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
const limit = parseLimit(req.query.limit, 20);
|
||||||
|
// Aggregate conversations: group by conversationId, get last message + unread count
|
||||||
|
const pipeline = [
|
||||||
|
{ $sort: { published: -1 } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: "$conversationId",
|
||||||
|
lastMessageId: { $first: "$_id" },
|
||||||
|
lastUid: { $first: "$uid" },
|
||||||
|
lastContent: { $first: "$content" },
|
||||||
|
lastPublished: { $first: "$published" },
|
||||||
|
actorUrl: { $first: "$actorUrl" },
|
||||||
|
actorName: { $first: "$actorName" },
|
||||||
|
actorPhoto: { $first: "$actorPhoto" },
|
||||||
|
actorHandle: { $first: "$actorHandle" },
|
||||||
|
unreadCount: {
|
||||||
|
$sum: { $cond: [{ $eq: ["$read", false] }, 1, 0] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { lastPublished: -1 } },
|
||||||
|
];
|
||||||
|
// Apply cursor pagination on the aggregation result
|
||||||
|
if (req.query.max_id) {
|
||||||
|
pipeline.splice(0, 0, {
|
||||||
|
$match: { _id: { $lt: req.query.max_id } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pipeline.push({ $limit: limit });
|
||||||
|
const conversations = await collections.ap_messages
|
||||||
|
.aggregate(pipeline)
|
||||||
|
.toArray();
|
||||||
|
const result = conversations.map((conv) => {
|
||||||
|
const convId = remoteActorId(conv._id || conv.actorUrl);
|
||||||
|
// Build a minimal Mastodon Status for last_status
|
||||||
|
const lastStatus = {
|
||||||
|
id: conv.lastMessageId.toString(),
|
||||||
|
created_at: conv.lastPublished || new Date().toISOString(),
|
||||||
|
in_reply_to_id: null,
|
||||||
|
in_reply_to_account_id: null,
|
||||||
|
sensitive: false,
|
||||||
|
spoiler_text: "",
|
||||||
|
visibility: "direct",
|
||||||
|
language: null,
|
||||||
|
uri: conv.lastUid || "",
|
||||||
|
url: conv.lastUid || "",
|
||||||
|
replies_count: 0,
|
||||||
|
reblogs_count: 0,
|
||||||
|
favourites_count: 0,
|
||||||
|
edited_at: null,
|
||||||
|
favourited: false,
|
||||||
|
reblogged: false,
|
||||||
|
muted: false,
|
||||||
|
bookmarked: false,
|
||||||
|
pinned: false,
|
||||||
|
content: conv.lastContent?.html || conv.lastContent?.text || "",
|
||||||
|
filtered: null,
|
||||||
|
reblog: null,
|
||||||
|
application: null,
|
||||||
|
account: serializeAccount(
|
||||||
|
{
|
||||||
|
name: conv.actorName,
|
||||||
|
url: conv.actorUrl,
|
||||||
|
photo: conv.actorPhoto,
|
||||||
|
handle: conv.actorHandle,
|
||||||
|
},
|
||||||
|
{ baseUrl },
|
||||||
|
),
|
||||||
|
media_attachments: [],
|
||||||
|
mentions: [],
|
||||||
|
tags: [],
|
||||||
|
emojis: [],
|
||||||
|
card: null,
|
||||||
|
poll: null,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: convId,
|
||||||
|
unread: conv.unreadCount > 0,
|
||||||
|
last_status: lastStatus,
|
||||||
|
accounts: [
|
||||||
|
serializeAccount(
|
||||||
|
{
|
||||||
|
name: conv.actorName,
|
||||||
|
url: conv.actorUrl,
|
||||||
|
photo: conv.actorPhoto,
|
||||||
|
handle: conv.actorHandle,
|
||||||
|
},
|
||||||
|
{ baseUrl },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Set Link header for pagination
|
||||||
|
if (result.length === limit && conversations.length > 0) {
|
||||||
|
const lastConv = conversations[conversations.length - 1];
|
||||||
|
const maxId = remoteActorId(lastConv._id || lastConv.actorUrl);
|
||||||
|
res.set("Link", `<${baseUrl}/api/v1/conversations?max_id=${maxId}>; rel="next"`);
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark conversation as read
|
||||||
|
router.post("/api/v1/conversations/:id/read", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const collections = req.app.locals.mastodonCollections;
|
||||||
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||||
|
const { serializeAccount } = await import("../entities/account.js");
|
||||||
|
const { remoteActorId } = await import("../helpers/id-mapping.js");
|
||||||
|
if (!collections?.ap_messages) {
|
||||||
|
return res.status(404).json({ error: "Not found" });
|
||||||
|
}
|
||||||
|
// Find the conversation partner whose hashed actorUrl matches the :id
|
||||||
|
const allPartners = await collections.ap_messages.aggregate([
|
||||||
|
{ $group: { _id: "$conversationId" } },
|
||||||
|
]).toArray();
|
||||||
|
const partner = allPartners.find(
|
||||||
|
(p) => remoteActorId(p._id) === req.params.id
|
||||||
|
);
|
||||||
|
if (!partner) {
|
||||||
|
return res.status(404).json({ error: "Conversation not found" });
|
||||||
|
}
|
||||||
|
// Mark all messages from this partner as read
|
||||||
|
await collections.ap_messages.updateMany(
|
||||||
|
{ conversationId: partner._id, read: false },
|
||||||
|
{ $set: { read: true } },
|
||||||
|
);
|
||||||
|
// Return the updated conversation
|
||||||
|
const lastMsg = await collections.ap_messages
|
||||||
|
.findOne({ conversationId: partner._id }, { sort: { published: -1 } });
|
||||||
|
if (!lastMsg) {
|
||||||
|
return res.status(404).json({ error: "No messages" });
|
||||||
|
}
|
||||||
|
const convId = remoteActorId(partner._id);
|
||||||
|
const account = serializeAccount(
|
||||||
|
{
|
||||||
|
name: lastMsg.actorName,
|
||||||
|
url: lastMsg.actorUrl,
|
||||||
|
photo: lastMsg.actorPhoto,
|
||||||
|
handle: lastMsg.actorHandle,
|
||||||
|
},
|
||||||
|
{ baseUrl },
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
id: convId,
|
||||||
|
unread: false,
|
||||||
|
last_status: {
|
||||||
|
id: lastMsg._id.toString(),
|
||||||
|
created_at: lastMsg.published || new Date().toISOString(),
|
||||||
|
in_reply_to_id: null,
|
||||||
|
in_reply_to_account_id: null,
|
||||||
|
sensitive: false,
|
||||||
|
spoiler_text: "",
|
||||||
|
visibility: "direct",
|
||||||
|
language: null,
|
||||||
|
uri: lastMsg.uid || "",
|
||||||
|
url: lastMsg.uid || "",
|
||||||
|
replies_count: 0,
|
||||||
|
reblogs_count: 0,
|
||||||
|
favourites_count: 0,
|
||||||
|
edited_at: null,
|
||||||
|
favourited: false,
|
||||||
|
reblogged: false,
|
||||||
|
muted: false,
|
||||||
|
bookmarked: false,
|
||||||
|
pinned: false,
|
||||||
|
content: lastMsg.content?.html || lastMsg.content?.text || "",
|
||||||
|
filtered: null,
|
||||||
|
reblog: null,
|
||||||
|
application: null,
|
||||||
|
account,
|
||||||
|
media_attachments: [],
|
||||||
|
mentions: [],
|
||||||
|
tags: [],
|
||||||
|
emojis: [],
|
||||||
|
card: null,
|
||||||
|
poll: null,
|
||||||
|
},
|
||||||
|
accounts: [account],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Domain blocks ──────────────────────────────────────────────────────────
|
// ─── Domain blocks ──────────────────────────────────────────────────────────
|
||||||
@@ -447,57 +644,7 @@ router.get("/api/v1/endorsements", (req, res) => {
|
|||||||
res.json([]);
|
res.json([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Account statuses ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const collections = req.app.locals.mastodonCollections;
|
|
||||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
||||||
|
|
||||||
// Try to find the profile to see if this is the local user
|
|
||||||
const profile = await collections.ap_profile.findOne({});
|
|
||||||
const isLocal = profile && profile._id.toString() === req.params.id;
|
|
||||||
|
|
||||||
if (isLocal && profile?.url) {
|
|
||||||
// Return statuses authored by local user
|
|
||||||
const { serializeStatus } = await import("../entities/status.js");
|
|
||||||
const { parseLimit } = await import("../helpers/pagination.js");
|
|
||||||
|
|
||||||
const limit = parseLimit(req.query.limit);
|
|
||||||
const items = await collections.ap_timeline
|
|
||||||
.find({ "author.url": profile.url, isContext: { $ne: true } })
|
|
||||||
.sort({ _id: -1 })
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
const statuses = items.map((item) =>
|
|
||||||
serializeStatus(item, {
|
|
||||||
baseUrl,
|
|
||||||
favouritedIds: new Set(),
|
|
||||||
rebloggedIds: new Set(),
|
|
||||||
bookmarkedIds: new Set(),
|
|
||||||
pinnedIds: new Set(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.json(statuses);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote account or unknown — return empty
|
|
||||||
res.json([]);
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Account followers/following ────────────────────────────────────────────
|
|
||||||
|
|
||||||
router.get("/api/v1/accounts/:id/followers", (req, res) => {
|
|
||||||
res.json([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/api/v1/accounts/:id/following", (req, res) => {
|
|
||||||
res.json([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
+17
-13
@@ -41,19 +41,23 @@ 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
|
// Skip location checkins — they have a JF2 `location` property.
|
||||||
// ap_activities is the authoritative record of "already federated".
|
if (properties.location) {
|
||||||
try {
|
console.info(`[ActivityPub] Skipping syndication for location checkin: ${properties.url}`);
|
||||||
const existingActivity = await plugin._collections.ap_activities?.findOne({
|
return undefined;
|
||||||
direction: "outbound",
|
}
|
||||||
type: { $in: ["Create", "Announce", "Update"] },
|
|
||||||
objectUrl: properties.url,
|
// Skip draft posts — they should not be federated to followers.
|
||||||
});
|
if (properties["post-status"] === "draft") {
|
||||||
if (existingActivity) {
|
console.info(`[ActivityPub] Skipping syndication for draft post: ${properties.url}`);
|
||||||
console.info(`[ActivityPub] Skipping duplicate syndication for ${properties.url} — already sent (${existingActivity.type})`);
|
return undefined;
|
||||||
return properties.url || undefined;
|
}
|
||||||
}
|
|
||||||
} catch { /* DB unavailable — proceed */ }
|
// Skip unlisted posts — they should not be federated to followers.
|
||||||
|
if (properties.visibility === "unlisted") {
|
||||||
|
console.info(`[ActivityPub] Skipping syndication for unlisted post: ${properties.url}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const actorUrl = plugin._getActorUrl();
|
const actorUrl = plugin._getActorUrl();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ 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
|
// Preserve items the user has interacted with (liked, bookmarked, boosted).
|
||||||
// Deleting them would silently remove entries from the Favourites/Bookmarks pages.
|
// Deleting them would silently remove entries from the Favourites/Bookmarks pages.
|
||||||
let interactedUids = new Set();
|
let interactedUids = new Set();
|
||||||
if (removedUids.length > 0 && collections.ap_interactions) {
|
if (removedUids.length > 0 && collections.ap_interactions) {
|
||||||
|
|||||||
Reference in New Issue
Block a user