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`,