integrate patches: aliases/successor, estimatedDocumentCount, key-freshness nonblocking, DM serialize id, short-post bypass, syndicate delay, unlisted guard
This commit is contained in:
@@ -554,7 +554,7 @@ function setupFollowers(federation, mountPath, handle, collections) {
|
|||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.toArray();
|
.toArray();
|
||||||
const t = await collections.ap_followers.countDocuments();
|
const t = await collections.ap_followers.estimatedDocumentCount();
|
||||||
return [d, t];
|
return [d, t];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -568,7 +568,7 @@ function setupFollowers(federation, mountPath, handle, collections) {
|
|||||||
.setCounter(async (ctx, identifier) => {
|
.setCounter(async (ctx, identifier) => {
|
||||||
if (identifier !== handle) return 0;
|
if (identifier !== handle) return 0;
|
||||||
return await cachedQuery("col:followers:count", COLLECTION_CACHE_TTL, async () => {
|
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");
|
.setFirstCursor(async () => "0");
|
||||||
@@ -589,7 +589,7 @@ function setupFollowing(federation, mountPath, handle, collections) {
|
|||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.toArray();
|
.toArray();
|
||||||
const t = await collections.ap_following.countDocuments();
|
const t = await collections.ap_following.estimatedDocumentCount();
|
||||||
return [d, t];
|
return [d, t];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -603,7 +603,7 @@ function setupFollowing(federation, mountPath, handle, collections) {
|
|||||||
.setCounter(async (ctx, identifier) => {
|
.setCounter(async (ctx, identifier) => {
|
||||||
if (identifier !== handle) return 0;
|
if (identifier !== handle) return 0;
|
||||||
return await cachedQuery("col:following:count", COLLECTION_CACHE_TTL, async () => {
|
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");
|
.setFirstCursor(async () => "0");
|
||||||
@@ -962,7 +962,10 @@ export async function buildPersonActor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (profile.alsoKnownAs?.length > 0) {
|
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) {
|
if (profile.createdAt) {
|
||||||
|
|||||||
+11
-11
@@ -63,7 +63,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
const authLoader = await getAuthLoader(ctx);
|
const authLoader = await getAuthLoader(ctx);
|
||||||
@@ -185,7 +185,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Undo, async (ctx, undo) => {
|
.on(Undo, async (ctx, undo) => {
|
||||||
const actorUrl = undo.actorId?.href || "";
|
const actorUrl = undo.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
await enqueueActivity(collections, {
|
await enqueueActivity(collections, {
|
||||||
@@ -199,7 +199,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Accept, async (ctx, accept) => {
|
.on(Accept, async (ctx, accept) => {
|
||||||
const actorUrl = accept.actorId?.href || "";
|
const actorUrl = accept.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
await enqueueActivity(collections, {
|
await enqueueActivity(collections, {
|
||||||
@@ -213,7 +213,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Reject, async (ctx, reject) => {
|
.on(Reject, async (ctx, reject) => {
|
||||||
const actorUrl = reject.actorId?.href || "";
|
const actorUrl = reject.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
await enqueueActivity(collections, {
|
await enqueueActivity(collections, {
|
||||||
@@ -227,7 +227,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Like, async (ctx, like) => {
|
.on(Like, async (ctx, like) => {
|
||||||
const actorUrl = like.actorId?.href || "";
|
const actorUrl = like.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
await enqueueActivity(collections, {
|
await enqueueActivity(collections, {
|
||||||
@@ -242,7 +242,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Announce, async (ctx, announce) => {
|
.on(Announce, async (ctx, announce) => {
|
||||||
const actorUrl = announce.actorId?.href || "";
|
const actorUrl = announce.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
await enqueueActivity(collections, {
|
await enqueueActivity(collections, {
|
||||||
@@ -257,7 +257,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Create, async (ctx, create) => {
|
.on(Create, async (ctx, create) => {
|
||||||
const actorUrl = create.actorId?.href || "";
|
const actorUrl = create.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
// Forward public replies to our posts to our followers.
|
// Forward public replies to our posts to our followers.
|
||||||
@@ -307,7 +307,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Delete, async (ctx, del) => {
|
.on(Delete, async (ctx, del) => {
|
||||||
const actorUrl = del.actorId?.href || "";
|
const actorUrl = del.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
await enqueueActivity(collections, {
|
await enqueueActivity(collections, {
|
||||||
@@ -322,7 +322,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Move, async (ctx, move) => {
|
.on(Move, async (ctx, move) => {
|
||||||
const actorUrl = move.actorId?.href || "";
|
const actorUrl = move.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
await enqueueActivity(collections, {
|
await enqueueActivity(collections, {
|
||||||
@@ -336,7 +336,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Update, async (ctx, update) => {
|
.on(Update, async (ctx, update) => {
|
||||||
const actorUrl = update.actorId?.href || "";
|
const actorUrl = update.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
await enqueueActivity(collections, {
|
await enqueueActivity(collections, {
|
||||||
@@ -376,7 +376,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
.on(Flag, async (ctx, flag) => {
|
.on(Flag, async (ctx, flag) => {
|
||||||
const actorUrl = flag.actorId?.href || "";
|
const actorUrl = flag.actorId?.href || "";
|
||||||
if (await isServerBlocked(actorUrl, collections)) return;
|
if (await isServerBlocked(actorUrl, collections)) return;
|
||||||
await touchKeyFreshness(collections, actorUrl);
|
touchKeyFreshness(collections, actorUrl).catch(() => {});
|
||||||
await resetDeliveryStrikes(collections, actorUrl);
|
await resetDeliveryStrikes(collections, actorUrl);
|
||||||
|
|
||||||
await enqueueActivity(collections, {
|
await enqueueActivity(collections, {
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ export async function backfillTimeline(collections) {
|
|||||||
}
|
}
|
||||||
: { name: "", url: "", photo: "", handle: "" };
|
: { name: "", url: "", photo: "", handle: "" };
|
||||||
|
|
||||||
// Fetch all published posts
|
// Fetch all published posts (exclude unlisted — must not appear in AP timeline)
|
||||||
const allPosts = await posts
|
const allPosts = await posts
|
||||||
.find({
|
.find({
|
||||||
"properties.post-status": { $ne: "draft" },
|
"properties.post-status": { $ne: "draft" },
|
||||||
"properties.deleted": { $exists: false },
|
"properties.deleted": { $exists: false },
|
||||||
"properties.url": { $exists: true },
|
"properties.url": { $exists: true },
|
||||||
|
"properties.visibility": { $ne: "unlisted" },
|
||||||
})
|
})
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
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(/>/g, ">")
|
||||||
|
.replace(/(https?:\/\/[^\s<>&"')]+)/g, '<a href="$1">$1</a>')
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
|
||||||
|
const _spTlItem = await addTimelineItem(collections, {
|
||||||
|
uid: _spNoteId,
|
||||||
|
url: _spNoteId,
|
||||||
|
type: "note",
|
||||||
|
content: { text: _spText, html: `<p>${_spHtml}</p>` },
|
||||||
|
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: `<p>${_spHtml}</p>`,
|
||||||
|
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)
|
// Resolve in_reply_to URL from status ID (cursor or ObjectId)
|
||||||
let inReplyTo = null;
|
let inReplyTo = null;
|
||||||
if (inReplyToId) {
|
if (inReplyToId) {
|
||||||
@@ -407,14 +507,16 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
|||||||
counts: { likes: 0, boosts: 0, replies: 0 },
|
counts: { likes: 0, boosts: 0, replies: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let storedDmItem = timelineItem;
|
||||||
try {
|
try {
|
||||||
await addTimelineItem(collections, timelineItem);
|
const _dmResult = await addTimelineItem(collections, timelineItem);
|
||||||
|
if (_dmResult?._id) storedDmItem = _dmResult;
|
||||||
} catch (storeError) {
|
} catch (storeError) {
|
||||||
console.warn("[Mastodon API] Failed to store outbound DM in timeline:", storeError.message);
|
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
|
// Return a full serialized status so clients (Phanpy, Elk) can render it
|
||||||
const status = serializeStatus(timelineItem, {
|
const status = serializeStatus(storedDmItem, {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
favouritedIds: new Set(),
|
favouritedIds: new Set(),
|
||||||
rebloggedIds: new Set(),
|
rebloggedIds: new Set(),
|
||||||
|
|||||||
+9
-1
@@ -59,6 +59,14 @@ export function createSyndicator(plugin) {
|
|||||||
return undefined;
|
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 {
|
try {
|
||||||
const actorUrl = plugin._getActorUrl();
|
const actorUrl = plugin._getActorUrl();
|
||||||
const handle = plugin.options.actor.handle;
|
const handle = plugin.options.actor.handle;
|
||||||
@@ -157,7 +165,7 @@ export function createSyndicator(plugin) {
|
|||||||
|
|
||||||
// Count followers for logging
|
// Count followers for logging
|
||||||
const followerCount =
|
const followerCount =
|
||||||
await plugin._collections.ap_followers.countDocuments();
|
await plugin._collections.ap_followers.estimatedDocumentCount();
|
||||||
|
|
||||||
console.info(
|
console.info(
|
||||||
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
|
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
|
||||||
|
|||||||
Reference in New Issue
Block a user