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:
svemagie
2026-04-25 16:35:01 +02:00
parent 547fb449a3
commit 56a8b08498
16 changed files with 562 additions and 176 deletions
+39 -15
View File
@@ -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({
+24 -8
View File
@@ -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;
+16 -2
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 || "")
+2 -2
View File
@@ -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,
+23 -19
View File
@@ -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;
+21 -15
View File
@@ -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([]);
} }
+1 -1
View File
@@ -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])
+37 -2
View File
@@ -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 ──────────────────────────────────────────────────
+71 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -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) {