diff --git a/index.js b/index.js index 4542da3..dad1459 100644 --- a/index.js +++ b/index.js @@ -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 diff --git a/lib/controllers/migrate.js b/lib/controllers/migrate.js index 91c38c0..4fa0016 100644 --- a/lib/controllers/migrate.js +++ b/lib/controllers/migrate.js @@ -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 diff --git a/lib/federation-setup.js b/lib/federation-setup.js index b7db806..4ab7e89 100644 --- a/lib/federation-setup.js +++ b/lib/federation-setup.js @@ -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