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";
|
} 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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user