feat: integrate docloader loglevel, unlisted guards, alias-clear, likePost/boostPost

federation-setup.js:
- Suppress fedify docloader logs below fatal level to reduce noise from
  deleted remote actors (404/410)
- Add visibility:unlisted guard to outbox dispatcher, counter, and
  resolvePost object dispatcher

controllers/migrate.js:
- Allow clearing alsoKnownAs by detecting submitted empty aliasUrl field
  via hasOwnProperty check (previously only set when non-empty)

index.js:
- Add resolveAuthor import
- Skip federation for unlisted posts in syndicate()
- Add likePost(postUrl, collections) — sends AP Like activity to author
- Add boostPost(postUrl, collections) — sends AP Announce to followers
  and directly to the post author's inbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-13 08:08:23 +01:00
parent 445aab5632
commit 39d45ec04e
3 changed files with 193 additions and 14 deletions
+143
View File
@@ -87,6 +87,7 @@ import {
} from "./lib/controllers/refollow.js"; } from "./lib/controllers/refollow.js";
import { startBatchRefollow } from "./lib/batch-refollow.js"; import { startBatchRefollow } from "./lib/batch-refollow.js";
import { logActivity } from "./lib/activity-log.js"; import { logActivity } from "./lib/activity-log.js";
import { resolveAuthor } from "./lib/resolve-author.js";
import { scheduleCleanup } from "./lib/timeline-cleanup.js"; import { scheduleCleanup } from "./lib/timeline-cleanup.js";
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js"; import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
@@ -406,6 +407,22 @@ export default class ActivityPubEndpoint {
return undefined; return undefined;
} }
const visibility = String(properties?.visibility || "").toLowerCase();
if (visibility === "unlisted") {
console.info(
"[ActivityPub] Skipping federation for unlisted post: " +
(properties?.url || "unknown"),
);
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: self._publicationUrl,
objectUrl: properties?.url,
summary: "Syndication skipped: post visibility is unlisted",
}).catch(() => {});
return undefined;
}
try { try {
const actorUrl = self._getActorUrl(); const actorUrl = self._getActorUrl();
const handle = self.options.actor.handle; const handle = self.options.actor.handle;
@@ -730,6 +747,132 @@ export default class ActivityPubEndpoint {
} }
} }
/**
* Send a native AP Like activity for a post URL (called programmatically,
* e.g. when a like is created via Micropub).
* @param {string} postUrl - URL of the post being liked
* @param {object} [collections] - MongoDB collections map (application.collections)
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async likePost(postUrl, collections) {
if (!this._federation) {
return { ok: false, error: "Federation not initialized" };
}
try {
const { Like } = await import("@fedify/fedify/vocab");
const handle = this.options.actor.handle;
const ctx = this._federation.createContext(
new URL(this._publicationUrl),
{ handle, publicationUrl: this._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
const cols = collections || this._collections;
const recipient = await resolveAuthor(postUrl, ctx, documentLoader, cols);
if (!recipient) {
return { ok: false, error: `Could not resolve post author for ${postUrl}` };
}
const uuid = crypto.randomUUID();
const activityId = `${this._publicationUrl.replace(/\/$/, "")}/activitypub/likes/${uuid}`;
const like = new Like({
id: new URL(activityId),
actor: ctx.getActorUri(handle),
object: new URL(postUrl),
});
await ctx.sendActivity({ identifier: handle }, recipient, like, {
orderingKey: postUrl,
});
const interactions = cols?.get?.("ap_interactions") || this._collections.ap_interactions;
if (interactions) {
await interactions.updateOne(
{ objectUrl: postUrl, type: "like" },
{ $set: { objectUrl: postUrl, type: "like", activityId, recipientUrl: recipient.id?.href || "", createdAt: new Date().toISOString() } },
{ upsert: true },
);
}
console.info(`[ActivityPub] Sent Like for ${postUrl}`);
return { ok: true };
} catch (error) {
console.error(`[ActivityPub] likePost failed for ${postUrl}:`, error.message);
return { ok: false, error: error.message };
}
}
/**
* Send a native AP Announce (boost) activity for a post URL (called
* programmatically, e.g. when a repost is created via Micropub).
* @param {string} postUrl - URL of the post being boosted
* @param {object} [collections] - MongoDB collections map (application.collections)
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async boostPost(postUrl, collections) {
if (!this._federation) {
return { ok: false, error: "Federation not initialized" };
}
try {
const { Announce } = await import("@fedify/fedify/vocab");
const handle = this.options.actor.handle;
const ctx = this._federation.createContext(
new URL(this._publicationUrl),
{ handle, publicationUrl: this._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
const cols = collections || this._collections;
const uuid = crypto.randomUUID();
const activityId = `${this._publicationUrl.replace(/\/$/, "")}/activitypub/boosts/${uuid}`;
const publicAddress = new URL("https://www.w3.org/ns/activitystreams#Public");
const followersUri = ctx.getFollowersUri(handle);
const announce = new Announce({
id: new URL(activityId),
actor: ctx.getActorUri(handle),
object: new URL(postUrl),
to: publicAddress,
cc: followersUri,
});
// Broadcast to followers
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: postUrl,
});
// Also deliver directly to original post author
const recipient = await resolveAuthor(postUrl, ctx, documentLoader, cols);
if (recipient) {
await ctx.sendActivity({ identifier: handle }, recipient, announce, {
orderingKey: postUrl,
}).catch((err) => {
console.warn(`[ActivityPub] Direct boost delivery to author failed: ${err.message}`);
});
}
const interactions = cols?.get?.("ap_interactions") || this._collections.ap_interactions;
if (interactions) {
await interactions.updateOne(
{ objectUrl: postUrl, type: "boost" },
{ $set: { objectUrl: postUrl, type: "boost", activityId, createdAt: new Date().toISOString() } },
{ upsert: true },
);
}
console.info(`[ActivityPub] Sent Announce (boost) for ${postUrl}`);
return { ok: true };
} catch (error) {
console.error(`[ActivityPub] boostPost failed for ${postUrl}:`, error.message);
return { ok: false, error: error.message };
}
}
/** /**
* Send an Update(Person) activity to all followers so remote servers * Send an Update(Person) activity to all followers so remote servers
* re-fetch the actor object (picking up profile changes, new featured * re-fetch the actor object (picking up profile changes, new featured
+28 -10
View File
@@ -43,16 +43,34 @@ export function migratePostController(mountPath, pluginOptions) {
let result = null; let result = null;
const aliasUrl = request.body.aliasUrl?.trim(); const aliasUrl = request.body.aliasUrl?.trim();
if (aliasUrl && profileCollection) { const submittedAliasField = Object.prototype.hasOwnProperty.call(
await profileCollection.updateOne( request.body || {},
{}, "aliasUrl",
{ $set: { alsoKnownAs: [aliasUrl] } }, );
{ upsert: true },
); // allow clearing alsoKnownAs alias by submitting empty value
result = { if (profileCollection && submittedAliasField) {
type: "success", if (aliasUrl) {
text: response.locals.__("activitypub.migrate.aliasSuccess"), await profileCollection.updateOne(
}; {},
{ $set: { alsoKnownAs: [aliasUrl] } },
{ upsert: true },
);
result = {
type: "success",
text: response.locals.__("activitypub.migrate.aliasSuccess"),
};
} else {
await profileCollection.updateOne(
{},
{ $set: { alsoKnownAs: [] } },
{ upsert: true },
);
result = {
type: "success",
text: "Alias removed - alsoKnownAs is now empty.",
};
}
} }
const profile = profileCollection const profile = profileCollection
+22 -4
View File
@@ -88,7 +88,14 @@ export function setupFederation(options) {
}, },
loggers: [ loggers: [
{ {
// All Fedify logs — federation, vocab, delivery, HTTP signatures // Noise guard: remote deleted actors often return 404/410 on fetch.
// Keep only fatal events for the docloader category.
category: ["fedify", "runtime", "docloader"],
sinks: ["console"],
lowestLevel: "fatal",
},
{
// All remaining Fedify logs - federation, vocab, delivery, signatures.
category: ["fedify"], category: ["fedify"],
sinks: ["console"], sinks: ["console"],
lowestLevel: resolvedLevel, lowestLevel: resolvedLevel,
@@ -598,10 +605,16 @@ function setupOutbox(federation, mountPath, handle, collections) {
const pageSize = 20; const pageSize = 20;
const skip = cursor ? Number.parseInt(cursor, 10) : 0; const skip = cursor ? Number.parseInt(cursor, 10) : 0;
const total = await postsCollection.countDocuments(); const federationVisibilityQuery = {
"properties.post-status": { $ne: "draft" },
"properties.visibility": { $ne: "unlisted" },
};
const total = await postsCollection.countDocuments(
federationVisibilityQuery,
);
const posts = await postsCollection const posts = await postsCollection
.find() .find(federationVisibilityQuery)
.sort({ "properties.published": -1 }) .sort({ "properties.published": -1 })
.skip(skip) .skip(skip)
.limit(pageSize) .limit(pageSize)
@@ -633,7 +646,10 @@ function setupOutbox(federation, mountPath, handle, collections) {
if (identifier !== handle) return 0; if (identifier !== handle) return 0;
const postsCollection = collections.posts; const postsCollection = collections.posts;
if (!postsCollection) return 0; if (!postsCollection) return 0;
return await postsCollection.countDocuments(); return await postsCollection.countDocuments({
"properties.post-status": { $ne: "draft" },
"properties.visibility": { $ne: "unlisted" },
});
}) })
.setFirstCursor(async () => "0"); .setFirstCursor(async () => "0");
} }
@@ -645,6 +661,8 @@ function setupObjectDispatchers(federation, mountPath, handle, collections, publ
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`; const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
const post = await collections.posts.findOne({ "properties.url": postUrl }); const post = await collections.posts.findOne({ "properties.url": postUrl });
if (!post) return null; if (!post) return null;
if (post?.properties?.["post-status"] === "draft") return null;
if (post?.properties?.visibility === "unlisted") return null;
const actorUrl = ctx.getActorUri(handle).href; const actorUrl = ctx.getActorUri(handle).href;
const activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl); const activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
// Only Create activities wrap Note/Article objects // Only Create activities wrap Note/Article objects