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:
@@ -87,6 +87,7 @@ import {
|
||||
} from "./lib/controllers/refollow.js";
|
||||
import { startBatchRefollow } from "./lib/batch-refollow.js";
|
||||
import { logActivity } from "./lib/activity-log.js";
|
||||
import { resolveAuthor } from "./lib/resolve-author.js";
|
||||
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
|
||||
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
|
||||
|
||||
@@ -406,6 +407,22 @@ export default class ActivityPubEndpoint {
|
||||
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 {
|
||||
const actorUrl = self._getActorUrl();
|
||||
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
|
||||
* re-fetch the actor object (picking up profile changes, new featured
|
||||
|
||||
+28
-10
@@ -43,16 +43,34 @@ export function migratePostController(mountPath, pluginOptions) {
|
||||
let result = null;
|
||||
|
||||
const aliasUrl = request.body.aliasUrl?.trim();
|
||||
if (aliasUrl && profileCollection) {
|
||||
await profileCollection.updateOne(
|
||||
{},
|
||||
{ $set: { alsoKnownAs: [aliasUrl] } },
|
||||
{ upsert: true },
|
||||
);
|
||||
result = {
|
||||
type: "success",
|
||||
text: response.locals.__("activitypub.migrate.aliasSuccess"),
|
||||
};
|
||||
const submittedAliasField = Object.prototype.hasOwnProperty.call(
|
||||
request.body || {},
|
||||
"aliasUrl",
|
||||
);
|
||||
|
||||
// allow clearing alsoKnownAs alias by submitting empty value
|
||||
if (profileCollection && submittedAliasField) {
|
||||
if (aliasUrl) {
|
||||
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
|
||||
|
||||
+22
-4
@@ -88,7 +88,14 @@ export function setupFederation(options) {
|
||||
},
|
||||
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"],
|
||||
sinks: ["console"],
|
||||
lowestLevel: resolvedLevel,
|
||||
@@ -598,10 +605,16 @@ function setupOutbox(federation, mountPath, handle, collections) {
|
||||
|
||||
const pageSize = 20;
|
||||
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
|
||||
.find()
|
||||
.find(federationVisibilityQuery)
|
||||
.sort({ "properties.published": -1 })
|
||||
.skip(skip)
|
||||
.limit(pageSize)
|
||||
@@ -633,7 +646,10 @@ function setupOutbox(federation, mountPath, handle, collections) {
|
||||
if (identifier !== handle) return 0;
|
||||
const postsCollection = collections.posts;
|
||||
if (!postsCollection) return 0;
|
||||
return await postsCollection.countDocuments();
|
||||
return await postsCollection.countDocuments({
|
||||
"properties.post-status": { $ne: "draft" },
|
||||
"properties.visibility": { $ne: "unlisted" },
|
||||
});
|
||||
})
|
||||
.setFirstCursor(async () => "0");
|
||||
}
|
||||
@@ -645,6 +661,8 @@ function setupObjectDispatchers(federation, mountPath, handle, collections, publ
|
||||
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
|
||||
const post = await collections.posts.findOne({ "properties.url": postUrl });
|
||||
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 activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
|
||||
// Only Create activities wrap Note/Article objects
|
||||
|
||||
Reference in New Issue
Block a user