From 2b802e157470bef81397ddae694a8f9296924d28 Mon Sep 17 00:00:00 2001
From: svemagie <869694+svemagie@users.noreply.github.com>
Date: Sun, 17 May 2026 09:58:12 +0200
Subject: [PATCH] integrate patches: aliases/successor, estimatedDocumentCount,
key-freshness nonblocking, DM serialize id, short-post bypass, syndicate
delay, unlisted guard
---
lib/federation-setup.js | 13 ++--
lib/inbox-listeners.js | 22 +++----
lib/mastodon/backfill-timeline.js | 3 +-
lib/mastodon/routes/statuses.js | 106 +++++++++++++++++++++++++++++-
lib/syndicator.js | 10 ++-
5 files changed, 134 insertions(+), 20 deletions(-)
diff --git a/lib/federation-setup.js b/lib/federation-setup.js
index f1775c3..bb3c73c 100644
--- a/lib/federation-setup.js
+++ b/lib/federation-setup.js
@@ -554,7 +554,7 @@ function setupFollowers(federation, mountPath, handle, collections) {
.skip(skip)
.limit(pageSize)
.toArray();
- const t = await collections.ap_followers.countDocuments();
+ const t = await collections.ap_followers.estimatedDocumentCount();
return [d, t];
});
@@ -568,7 +568,7 @@ function setupFollowers(federation, mountPath, handle, collections) {
.setCounter(async (ctx, identifier) => {
if (identifier !== handle) return 0;
return await cachedQuery("col:followers:count", COLLECTION_CACHE_TTL, async () => {
- return await collections.ap_followers.countDocuments();
+ return await collections.ap_followers.estimatedDocumentCount();
});
})
.setFirstCursor(async () => "0");
@@ -589,7 +589,7 @@ function setupFollowing(federation, mountPath, handle, collections) {
.skip(skip)
.limit(pageSize)
.toArray();
- const t = await collections.ap_following.countDocuments();
+ const t = await collections.ap_following.estimatedDocumentCount();
return [d, t];
});
@@ -603,7 +603,7 @@ function setupFollowing(federation, mountPath, handle, collections) {
.setCounter(async (ctx, identifier) => {
if (identifier !== handle) return 0;
return await cachedQuery("col:following:count", COLLECTION_CACHE_TTL, async () => {
- return await collections.ap_following.countDocuments();
+ return await collections.ap_following.estimatedDocumentCount();
});
})
.setFirstCursor(async () => "0");
@@ -962,7 +962,10 @@ export async function buildPersonActor(
}
if (profile.alsoKnownAs?.length > 0) {
- personOptions.alsoKnownAs = profile.alsoKnownAs.map((u) => new URL(u));
+ personOptions.aliases = profile.alsoKnownAs.map((u) => new URL(u));
+ }
+ if (profile.movedTo) {
+ personOptions.successor = new URL(profile.movedTo);
}
if (profile.createdAt) {
diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js
index 63817cc..f0c74d9 100644
--- a/lib/inbox-listeners.js
+++ b/lib/inbox-listeners.js
@@ -63,7 +63,7 @@ export function registerInboxListeners(inboxChain, options) {
return;
}
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
const authLoader = await getAuthLoader(ctx);
@@ -185,7 +185,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Undo, async (ctx, undo) => {
const actorUrl = undo.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
await enqueueActivity(collections, {
@@ -199,7 +199,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Accept, async (ctx, accept) => {
const actorUrl = accept.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
await enqueueActivity(collections, {
@@ -213,7 +213,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Reject, async (ctx, reject) => {
const actorUrl = reject.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
await enqueueActivity(collections, {
@@ -227,7 +227,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Like, async (ctx, like) => {
const actorUrl = like.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
await enqueueActivity(collections, {
@@ -242,7 +242,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Announce, async (ctx, announce) => {
const actorUrl = announce.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
await enqueueActivity(collections, {
@@ -257,7 +257,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Create, async (ctx, create) => {
const actorUrl = create.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
// Forward public replies to our posts to our followers.
@@ -307,7 +307,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Delete, async (ctx, del) => {
const actorUrl = del.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
await enqueueActivity(collections, {
@@ -322,7 +322,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Move, async (ctx, move) => {
const actorUrl = move.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
await enqueueActivity(collections, {
@@ -336,7 +336,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Update, async (ctx, update) => {
const actorUrl = update.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
await enqueueActivity(collections, {
@@ -376,7 +376,7 @@ export function registerInboxListeners(inboxChain, options) {
.on(Flag, async (ctx, flag) => {
const actorUrl = flag.actorId?.href || "";
if (await isServerBlocked(actorUrl, collections)) return;
- await touchKeyFreshness(collections, actorUrl);
+ touchKeyFreshness(collections, actorUrl).catch(() => {});
await resetDeliveryStrikes(collections, actorUrl);
await enqueueActivity(collections, {
diff --git a/lib/mastodon/backfill-timeline.js b/lib/mastodon/backfill-timeline.js
index 2ff4714..9a5f753 100644
--- a/lib/mastodon/backfill-timeline.js
+++ b/lib/mastodon/backfill-timeline.js
@@ -31,12 +31,13 @@ export async function backfillTimeline(collections) {
}
: { name: "", url: "", photo: "", handle: "" };
- // Fetch all published posts
+ // Fetch all published posts (exclude unlisted — must not appear in AP timeline)
const allPosts = await posts
.find({
"properties.post-status": { $ne: "draft" },
"properties.deleted": { $exists: false },
"properties.url": { $exists: true },
+ "properties.visibility": { $ne: "unlisted" },
})
.toArray();
diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js
index 386d0c5..a0132e8 100644
--- a/lib/mastodon/routes/statuses.js
+++ b/lib/mastodon/routes/statuses.js
@@ -201,6 +201,106 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
return res.status(422).json({ error: "Validation failed: Text content is required" });
}
+ // Short standalone posts (≤ 280 chars, no media, no reply, not a DM) skip
+ // the Micropub pipeline — they federate directly without creating a blog entry.
+ const _isShortPost =
+ !inReplyToId &&
+ (!mediaIds || mediaIds.length === 0) &&
+ visibility !== "direct" &&
+ (statusText || "").trim().length > 0 &&
+ (statusText || "").trim().length <= 280;
+
+ if (_isShortPost) {
+ const _spPluginOptions = req.app.locals.mastodonPluginOptions || {};
+ const _spPublicationUrl = (_spPluginOptions.publicationUrl || baseUrl).replace(/\/+$/, "");
+ const _spFederation = _spPluginOptions.federation;
+ const _spHandle = _spPluginOptions.handle || "user";
+ const _spHostname = (() => { try { return new URL(_spPublicationUrl).hostname; } catch { return ""; } })();
+ const _spProfile = await collections.ap_profile.findOne({});
+
+ const { randomUUID } = await import("node:crypto");
+ const _spUuid = randomUUID();
+ const _spNoteId = `${_spPublicationUrl}/activitypub/objects/note/${_spUuid}`;
+ const _spNow = new Date().toISOString();
+
+ const _spText = (statusText || "").trim();
+ const _spHtml = _spText
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/(https?:\/\/[^\s<>&"')]+)/g, '$1')
+ .replace(/\n/g, "
");
+
+ const _spTlItem = await addTimelineItem(collections, {
+ uid: _spNoteId,
+ url: _spNoteId,
+ type: "note",
+ content: { text: _spText, html: `
${_spHtml}
` }, + author: { + name: _spProfile?.name || _spHandle, + url: _spProfile?.url || _spPublicationUrl, + photo: _spProfile?.icon || "", + handle: `@${_spHandle}@${_spHostname}`, + emojis: [], + bot: false, + }, + published: _spNow, + createdAt: _spNow, + inReplyTo: null, + visibility: visibility || "public", + sensitive: sensitive === true || sensitive === "true", + category: [], + counts: { likes: 0, boosts: 0, replies: 0 }, + }); + + if (_spFederation) { + try { + const { Note, Create } = await import("@fedify/fedify/vocab"); + const _spCtx = _spFederation.createContext( + new URL(_spPublicationUrl + "/"), + { handle: _spHandle, publicationUrl: _spPublicationUrl }, + ); + const _spActorUri = _spCtx.getActorUri(_spHandle); + const _spNote = new Note({ + id: new URL(_spNoteId), + attributedTo: _spActorUri, + content: `${_spHtml}
`, + published: new Date(_spNow), + }); + const _spCreate = new Create({ + id: new URL(`${_spNoteId}#create`), + actor: _spActorUri, + object: _spNote, + }); + await _spCtx.sendActivity( + { identifier: _spHandle }, + "followers", + _spCreate, + { preferSharedInbox: true, syncCollection: true, orderingKey: _spNoteId }, + ); + console.info(`[Mastodon API] Short post federated to followers: ${_spNoteId}`); + } catch (_spApErr) { + console.warn(`[Mastodon API] Short post AP federation error: ${_spApErr.message}`); + } + } + + const _spStatus = serializeStatus(_spTlItem, { + baseUrl, + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }); + + if (idempotencyKey && collections.ap_idempotency) { + const { createHash } = await import("node:crypto"); + const _spKey = createHash("sha256").update(`${baseUrl}:${idempotencyKey}`).digest("hex"); + await collections.ap_idempotency.insertOne({ key: _spKey, response: _spStatus, createdAt: new Date() }).catch(() => {}); + } + + return res.json(_spStatus); + } + // Resolve in_reply_to URL from status ID (cursor or ObjectId) let inReplyTo = null; if (inReplyToId) { @@ -407,14 +507,16 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta counts: { likes: 0, boosts: 0, replies: 0 }, }; + let storedDmItem = timelineItem; try { - await addTimelineItem(collections, timelineItem); + const _dmResult = await addTimelineItem(collections, timelineItem); + if (_dmResult?._id) storedDmItem = _dmResult; } catch (storeError) { console.warn("[Mastodon API] Failed to store outbound DM in timeline:", storeError.message); } // Return a full serialized status so clients (Phanpy, Elk) can render it - const status = serializeStatus(timelineItem, { + const status = serializeStatus(storedDmItem, { baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(), diff --git a/lib/syndicator.js b/lib/syndicator.js index 99c2e7b..f92e390 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -59,6 +59,14 @@ export function createSyndicator(plugin) { return undefined; } + // Delay syndication to let the blog deploy pipeline finish first. + // Configurable via AP_SYNDICATE_DELAY_MS (default: 120000 = 2 min). + const _apDelay = Number.parseInt(process.env.AP_SYNDICATE_DELAY_MS ?? "120000", 10); + if (_apDelay > 0) { + console.info(`[ActivityPub] Delaying syndication by ${_apDelay}ms for ${properties.url}`); + await new Promise((resolve) => setTimeout(resolve, _apDelay)); + } + try { const actorUrl = plugin._getActorUrl(); const handle = plugin.options.actor.handle; @@ -157,7 +165,7 @@ export function createSyndicator(plugin) { // Count followers for logging const followerCount = - await plugin._collections.ap_followers.countDocuments(); + await plugin._collections.ap_followers.estimatedDocumentCount(); console.info( `[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,