import express from "express"; import { setupFederation, buildPersonActor } from "./lib/federation-setup.js"; import { createMastodonRouter } from "./lib/mastodon/router.js"; import { setLocalIdentity } from "./lib/mastodon/entities/status.js"; import { initRedisCache } from "./lib/redis-cache.js"; import { createIndexes } from "./lib/init-indexes.js"; import { lookupWithSecurity } from "./lib/lookup-helpers.js"; import { needsDirectFollow, sendDirectFollow, sendDirectUnfollow, } from "./lib/direct-follow.js"; import { createFedifyMiddleware, } from "./lib/federation-bridge.js"; import { jf2ToActivityStreams, jf2ToAS2Activity, } from "./lib/jf2-to-as2.js"; import { createSyndicator } from "./lib/syndicator.js"; import { dashboardController } from "./lib/controllers/dashboard.js"; import { readerController, notificationsController, markAllNotificationsReadController, clearAllNotificationsController, deleteNotificationController, composeController, submitComposeController, remoteProfileController, followController, unfollowController, postDetailController, } from "./lib/controllers/reader.js"; import { likeController, unlikeController, boostController, unboostController, } from "./lib/controllers/interactions.js"; import { muteController, unmuteController, blockController, unblockController, blockServerController, unblockServerController, moderationController, filterModeController, } from "./lib/controllers/moderation.js"; import { followersController } from "./lib/controllers/followers.js"; import { approveFollowController, rejectFollowController, } from "./lib/controllers/follow-requests.js"; import { followingController } from "./lib/controllers/following.js"; import { activitiesController } from "./lib/controllers/activities.js"; import { migrateGetController, migratePostController, migrateImportController, } from "./lib/controllers/migrate.js"; import { profileGetController, profilePostController, } from "./lib/controllers/profile.js"; import { featuredGetController, featuredPinController, featuredUnpinController, } from "./lib/controllers/featured.js"; import { featuredTagsGetController, featuredTagsAddController, featuredTagsRemoveController, } from "./lib/controllers/featured-tags.js"; import { resolveController } from "./lib/controllers/resolve.js"; import { tagTimelineController } from "./lib/controllers/tag-timeline.js"; import { apiTimelineController, countNewController, markReadController } from "./lib/controllers/api-timeline.js"; import { exploreController, exploreApiController, instanceSearchApiController, instanceCheckApiController, popularAccountsApiController, } from "./lib/controllers/explore.js"; import { followTagController, unfollowTagController, followTagGloballyController, unfollowTagGloballyController, } from "./lib/controllers/follow-tag.js"; import { listTabsController, addTabController, removeTabController, reorderTabsController, } from "./lib/controllers/tabs.js"; import { hashtagExploreApiController } from "./lib/controllers/hashtag-explore.js"; import { publicProfileController } from "./lib/controllers/public-profile.js"; import { messagesController, messageComposeController, submitMessageController, markAllMessagesReadController, clearAllMessagesController, deleteMessageController, } from "./lib/controllers/messages.js"; import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js"; import { myProfileController } from "./lib/controllers/my-profile.js"; import { refollowPauseController, refollowResumeController, refollowStatusController, } from "./lib/controllers/refollow.js"; import { startBatchRefollow } from "./lib/batch-refollow.js"; import { logActivity } from "./lib/activity-log.js"; import { batchBroadcast } from "./lib/batch-broadcast.js"; import { scheduleCleanup } from "./lib/timeline-cleanup.js"; import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js"; import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js"; import { scheduleKeyRefresh } from "./lib/key-refresh.js"; import { startInboxProcessor } from "./lib/inbox-queue.js"; import { deleteFederationController } from "./lib/controllers/federation-delete.js"; import { federationMgmtController, rebroadcastController, viewApJsonController, broadcastActorUpdateController, lookupObjectController, } from "./lib/controllers/federation-mgmt.js"; const defaults = { mountPath: "/activitypub", actor: { handle: "rick", name: "", summary: "", icon: "", }, checked: true, alsoKnownAs: "", activityRetentionDays: 90, storeRawActivities: false, redisUrl: "", parallelWorkers: 5, actorType: "Person", logLevel: "warning", timelineRetention: 1000, notificationRetentionDays: 30, debugDashboard: false, debugPassword: "", defaultVisibility: "public", // "public" | "unlisted" | "followers" }; export default class ActivityPubEndpoint { name = "ActivityPub endpoint"; constructor(options = {}) { this.options = { ...defaults, ...options }; this.options.actor = { ...defaults.actor, ...options.actor }; this.mountPath = this.options.mountPath; this._publicationUrl = ""; this._collections = {}; this._federation = null; this._fedifyMiddleware = null; } get navigationItems() { return [ { href: this.options.mountPath, text: "activitypub.title", requiresDatabase: true, }, { href: `${this.options.mountPath}/admin/reader`, text: "activitypub.reader.title", requiresDatabase: true, }, { href: `${this.options.mountPath}/admin/federation`, text: "activitypub.federationMgmt.title", requiresDatabase: true, }, ]; } /** * WebFinger + NodeInfo discovery — mounted at /.well-known/ * Fedify handles these automatically via federation.fetch(). */ get routesWellKnown() { const router = express.Router(); // eslint-disable-line new-cap const self = this; router.use((req, res, next) => { if (!self._fedifyMiddleware) return next(); return self._fedifyMiddleware(req, res, next); }); return router; } /** * Public federation routes — mounted at mountPath. * Fedify handles actor, inbox, outbox, followers, following. */ get routesPublic() { const router = express.Router(); // eslint-disable-line new-cap const self = this; router.use((req, res, next) => { if (!self._fedifyMiddleware) return next(); // Skip Fedify for admin UI routes — they're handled by the // authenticated `routes` getter, not the federation layer. if (req.path.startsWith("/admin")) return next(); // Fedify's acceptsJsonLd() treats Accept: */* as NOT accepting JSON-LD // (it only returns true for explicit application/activity+json etc.). // Remote servers fetching actor URLs for HTTP Signature verification // (e.g. tags.pub) often omit Accept or use */* — they get HTML back // instead of the actor JSON, causing "public key not found" errors. // Fix: for GET requests to actor paths, upgrade ambiguous Accept headers // to application/activity+json so Fedify serves JSON-LD. Explicit // text/html requests (browsers) are unaffected. if (req.method === "GET" && /^\/users\/[^/]+\/?$/.test(req.path)) { const accept = req.get("accept") || ""; if (!accept.includes("text/html") && !accept.includes("application/xhtml+xml")) { req.headers["accept"] = "application/activity+json"; } } return self._fedifyMiddleware(req, res, next); }); // Authorize interaction — remote follow / subscribe endpoint. // Remote servers redirect users here via the WebFinger subscribe template. router.get("/authorize_interaction", authorizeInteractionController(self)); // HTML fallback for actor URL — serve a public profile page. // Fedify only serves JSON-LD; browsers get 406 and fall through here. router.get("/users/:identifier", publicProfileController(self)); // Catch-all for federation paths that Fedify didn't handle (e.g. GET // on inbox). Without this, they fall through to Indiekit's auth // middleware and redirect to the login page. router.all("/users/:identifier/inbox", (req, res) => { res .status(405) .set("Allow", "POST") .type("application/activity+json") .json({ error: "Method Not Allowed", message: "The inbox only accepts POST requests", }); }); router.all("/inbox", (req, res) => { res .status(405) .set("Allow", "POST") .type("application/activity+json") .json({ error: "Method Not Allowed", message: "The shared inbox only accepts POST requests", }); }); return router; } /** * Authenticated admin routes — mounted at mountPath, behind IndieAuth. */ get routes() { const router = express.Router(); // eslint-disable-line new-cap const mp = this.options.mountPath; router.get("/", dashboardController(mp)); router.get("/admin/reader", readerController(mp)); router.get("/admin/reader/tag", tagTimelineController(mp)); router.get("/admin/reader/api/timeline", apiTimelineController(mp)); router.get("/admin/reader/api/timeline/count-new", countNewController()); router.post("/admin/reader/api/timeline/mark-read", markReadController()); router.get("/admin/reader/explore", exploreController(mp)); router.get("/admin/reader/api/explore", exploreApiController(mp)); router.get("/admin/reader/api/explore/hashtag", hashtagExploreApiController(mp)); router.get("/admin/reader/api/instances", instanceSearchApiController(mp)); router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp)); router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp)); router.get("/admin/reader/api/tabs", listTabsController(mp)); router.post("/admin/reader/api/tabs", addTabController(mp)); router.post("/admin/reader/api/tabs/remove", removeTabController(mp)); router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp)); router.post("/admin/reader/follow-tag", followTagController(mp)); router.post("/admin/reader/unfollow-tag", unfollowTagController(mp)); router.post("/admin/reader/follow-tag-global", followTagGloballyController(mp, this)); router.post("/admin/reader/unfollow-tag-global", unfollowTagGloballyController(mp, this)); router.get("/admin/reader/notifications", notificationsController(mp)); router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp)); router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp)); router.post("/admin/reader/notifications/delete", deleteNotificationController(mp)); router.get("/admin/reader/messages", messagesController(mp)); router.get("/admin/reader/messages/compose", messageComposeController(mp, this)); router.post("/admin/reader/messages/compose", submitMessageController(mp, this)); router.post("/admin/reader/messages/mark-read", markAllMessagesReadController(mp)); router.post("/admin/reader/messages/clear", clearAllMessagesController(mp)); router.post("/admin/reader/messages/delete", deleteMessageController(mp)); router.get("/admin/reader/compose", composeController(mp, this)); router.post("/admin/reader/compose", submitComposeController(mp, this)); router.post("/admin/reader/like", likeController(mp, this)); router.post("/admin/reader/unlike", unlikeController(mp, this)); router.post("/admin/reader/boost", boostController(mp, this)); router.post("/admin/reader/unboost", unboostController(mp, this)); router.get("/admin/reader/resolve", resolveController(mp, this)); router.get("/admin/reader/profile", remoteProfileController(mp, this)); router.get("/admin/reader/post", postDetailController(mp, this)); router.post("/admin/reader/follow", followController(mp, this)); router.post("/admin/reader/unfollow", unfollowController(mp, this)); router.get("/admin/reader/moderation", moderationController(mp)); router.post("/admin/reader/moderation/filter-mode", filterModeController(mp)); router.post("/admin/reader/mute", muteController(mp, this)); router.post("/admin/reader/unmute", unmuteController(mp, this)); router.post("/admin/reader/block", blockController(mp, this)); router.post("/admin/reader/unblock", unblockController(mp, this)); router.post("/admin/reader/block-server", blockServerController(mp)); router.post("/admin/reader/unblock-server", unblockServerController(mp)); router.get("/admin/followers", followersController(mp)); router.post("/admin/followers/approve", approveFollowController(mp, this)); router.post("/admin/followers/reject", rejectFollowController(mp, this)); router.get("/admin/following", followingController(mp)); router.get("/admin/activities", activitiesController(mp)); router.get("/admin/featured", featuredGetController(mp)); router.post("/admin/featured/pin", featuredPinController(mp, this)); router.post("/admin/featured/unpin", featuredUnpinController(mp, this)); router.get("/admin/tags", featuredTagsGetController(mp)); router.post("/admin/tags/add", featuredTagsAddController(mp, this)); router.post("/admin/tags/remove", featuredTagsRemoveController(mp, this)); router.get("/admin/profile", profileGetController(mp)); router.post("/admin/profile", profilePostController(mp, this)); router.get("/admin/my-profile", myProfileController(this)); router.get("/admin/migrate", migrateGetController(mp, this.options)); router.post("/admin/migrate", migratePostController(mp, this.options)); router.post( "/admin/migrate/import", migrateImportController(mp, this.options), ); router.post("/admin/refollow/pause", refollowPauseController(mp, this)); router.post("/admin/refollow/resume", refollowResumeController(mp, this)); router.get("/admin/refollow/status", refollowStatusController(mp)); router.post("/admin/federation/delete", deleteFederationController(mp, this)); router.get("/admin/federation", federationMgmtController(mp, this)); router.post("/admin/federation/rebroadcast", rebroadcastController(mp, this)); router.get("/admin/federation/ap-json", viewApJsonController(mp, this)); router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, this)); router.get("/admin/federation/lookup", lookupObjectController(mp, this)); return router; } /** * Content negotiation — serves AS2 JSON for ActivityPub clients * requesting individual post URLs. Also handles NodeInfo data * at /nodeinfo/2.1 (delegated to Fedify). */ get contentNegotiationRoutes() { const router = express.Router(); // eslint-disable-line new-cap const self = this; // Let Fedify handle NodeInfo data (/nodeinfo/2.1) // Only pass GET/HEAD requests — POST/PUT/DELETE must not go through // Fedify here, because fromExpressRequest() consumes the body stream, // breaking Express body-parsed routes downstream (e.g. admin forms). router.use((req, res, next) => { if (!self._fedifyMiddleware) return next(); if (req.method !== "GET" && req.method !== "HEAD") return next(); // Only delegate to Fedify for NodeInfo data endpoint (/nodeinfo/2.1). // All other paths in this root-mounted router are handled by the // content negotiation catch-all below. Passing arbitrary paths like // /notes/... to Fedify causes harmless but noisy 404 warnings. if (!req.path.startsWith("/nodeinfo/")) return next(); return self._fedifyMiddleware(req, res, next); }); // Content negotiation for AP clients on regular URLs router.get("{*path}", async (req, res, next) => { const accept = req.headers.accept || ""; const isActivityPub = accept.includes("application/activity+json") || accept.includes("application/ld+json"); if (!isActivityPub) { return next(); } try { // Root URL — redirect to Fedify actor if (req.path === "/") { const actorPath = `${self.options.mountPath}/users/${self.options.actor.handle}`; return res.redirect(actorPath); } // Post URLs — look up in database and convert to AS2 const { application } = req.app.locals; const postsCollection = application?.collections?.get("posts"); if (!postsCollection) { return next(); } const requestUrl = `${self._publicationUrl}${req.path.slice(1)}`; const post = await postsCollection.findOne({ "properties.url": requestUrl, }); if (!post || post.properties?.deleted) { return next(); } const actorUrl = self._getActorUrl(); const activity = jf2ToActivityStreams( post.properties, actorUrl, self._publicationUrl, { visibility: self.options.defaultVisibility }, ); const object = activity.object || activity; res.set("Content-Type", "application/activity+json"); return res.json({ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", ], ...object, }); } catch { return next(); } }); return router; } /** * Syndicator — delivers posts to ActivityPub followers via Fedify. */ get syndicator() { return createSyndicator(this); } /** * Send a Follow activity to a remote actor and store in ap_following. * @param {string} actorUrl - The remote actor's URL * @param {object} [actorInfo] - Optional pre-fetched actor info * @param {string} [actorInfo.name] - Actor display name * @param {string} [actorInfo.handle] - Actor handle * @param {string} [actorInfo.photo] - Actor avatar URL * @returns {Promise<{ok: boolean, error?: string}>} */ /** * Load the RSA private key from ap_keys for direct HTTP Signature signing. * @returns {Promise} */ async _loadRsaPrivateKey() { try { const keyDoc = await this._collections.ap_keys.findOne({ privateKeyPem: { $exists: true }, }); if (!keyDoc?.privateKeyPem) return null; const pemBody = keyDoc.privateKeyPem .replace(/-----[^-]+-----/g, "") .replace(/\s/g, ""); return await crypto.subtle.importKey( "pkcs8", Buffer.from(pemBody, "base64"), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["sign"], ); } catch (error) { console.error("[ActivityPub] Failed to load RSA key:", error.message); return null; } } async followActor(actorUrl, actorInfo = {}) { if (!this._federation) { return { ok: false, error: "Federation not initialized" }; } try { const { Follow } = await import("@fedify/fedify/vocab"); const handle = this.options.actor.handle; const ctx = this._federation.createContext( new URL(this._publicationUrl), { handle, publicationUrl: this._publicationUrl }, ); // Resolve the remote actor to get their inbox // lookupWithSecurity handles signed→unsigned fallback automatically const documentLoader = await ctx.getDocumentLoader({ identifier: handle, }); const remoteActor = await lookupWithSecurity(ctx, actorUrl, { documentLoader, }); if (!remoteActor) { return { ok: false, error: "Could not resolve remote actor" }; } // Send Follow activity if (needsDirectFollow(actorUrl)) { // tags.pub rejects Fedify's LD Signature context (identity/v1). // Send a minimal signed Follow directly, bypassing the outbox pipeline. // See: https://github.com/social-web-foundation/tags.pub/issues/10 const rsaKey = await this._loadRsaPrivateKey(); if (!rsaKey) { return { ok: false, error: "No RSA key available for direct follow" }; } const result = await sendDirectFollow({ actorUri: ctx.getActorUri(handle).href, targetActorUrl: actorUrl, inboxUrl: remoteActor.inboxId?.href, keyId: `${ctx.getActorUri(handle).href}#main-key`, privateKey: rsaKey, }); if (!result.ok) { return { ok: false, error: result.error }; } } else { const follow = new Follow({ actor: ctx.getActorUri(handle), object: new URL(actorUrl), }); await ctx.sendActivity({ identifier: handle }, remoteActor, follow, { orderingKey: actorUrl, }); } // Store in ap_following const name = actorInfo.name || remoteActor.name?.toString() || remoteActor.preferredUsername?.toString() || actorUrl; const actorHandle = actorInfo.handle || remoteActor.preferredUsername?.toString() || ""; const avatar = actorInfo.photo || (remoteActor.icon ? (await remoteActor.icon)?.url?.href || "" : ""); const inbox = remoteActor.inboxId?.href || ""; const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || ""; await this._collections.ap_following.updateOne( { actorUrl }, { $set: { actorUrl, handle: actorHandle, name, avatar, inbox, sharedInbox, followedAt: new Date().toISOString(), source: "reader", }, }, { upsert: true }, ); console.info(`[ActivityPub] Sent Follow to ${actorUrl}`); await logActivity(this._collections.ap_activities, { direction: "outbound", type: "Follow", actorUrl: this._publicationUrl, objectUrl: actorUrl, actorName: name, summary: `Sent Follow to ${name} (${actorUrl})`, }); return { ok: true }; } catch (error) { console.error(`[ActivityPub] Follow failed for ${actorUrl}:`, error.message); await logActivity(this._collections.ap_activities, { direction: "outbound", type: "Follow", actorUrl: this._publicationUrl, objectUrl: actorUrl, summary: `Follow failed for ${actorUrl}: ${error.message}`, }).catch(() => {}); return { ok: false, error: error.message }; } } /** * Send an Undo(Follow) activity and remove from ap_following. * @param {string} actorUrl - The remote actor's URL * @returns {Promise<{ok: boolean, error?: string}>} */ async unfollowActor(actorUrl) { if (!this._federation) { return { ok: false, error: "Federation not initialized" }; } try { const { Follow, Undo } = await import("@fedify/fedify/vocab"); const handle = this.options.actor.handle; const ctx = this._federation.createContext( new URL(this._publicationUrl), { handle, publicationUrl: this._publicationUrl }, ); // Use authenticated document loader for servers requiring Authorized Fetch const documentLoader = await ctx.getDocumentLoader({ identifier: handle, }); const remoteActor = await lookupWithSecurity(ctx,actorUrl, { documentLoader, }); if (!remoteActor) { // Even if we can't resolve, remove locally await this._collections.ap_following.deleteOne({ actorUrl }); await logActivity(this._collections.ap_activities, { direction: "outbound", type: "Undo(Follow)", actorUrl: this._publicationUrl, objectUrl: actorUrl, summary: `Removed ${actorUrl} locally (could not resolve remote actor)`, }).catch(() => {}); return { ok: true }; } if (needsDirectFollow(actorUrl)) { // tags.pub rejects Fedify's LD Signature context (identity/v1). // See: https://github.com/social-web-foundation/tags.pub/issues/10 const rsaKey = await this._loadRsaPrivateKey(); if (rsaKey) { const result = await sendDirectUnfollow({ actorUri: ctx.getActorUri(handle).href, targetActorUrl: actorUrl, inboxUrl: remoteActor.inboxId?.href, keyId: `${ctx.getActorUri(handle).href}#main-key`, privateKey: rsaKey, }); if (!result.ok) { console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`); } } } else { const follow = new Follow({ actor: ctx.getActorUri(handle), object: new URL(actorUrl), }); const undo = new Undo({ actor: ctx.getActorUri(handle), object: follow, }); await ctx.sendActivity({ identifier: handle }, remoteActor, undo, { orderingKey: actorUrl, }); } await this._collections.ap_following.deleteOne({ actorUrl }); console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`); await logActivity(this._collections.ap_activities, { direction: "outbound", type: "Undo(Follow)", actorUrl: this._publicationUrl, objectUrl: actorUrl, summary: `Sent Undo(Follow) to ${actorUrl}`, }); return { ok: true }; } catch (error) { console.error(`[ActivityPub] Unfollow failed for ${actorUrl}:`, error.message); await logActivity(this._collections.ap_activities, { direction: "outbound", type: "Undo(Follow)", actorUrl: this._publicationUrl, objectUrl: actorUrl, summary: `Unfollow failed for ${actorUrl}: ${error.message}`, }).catch(() => {}); // Remove locally even if remote delivery fails await this._collections.ap_following.deleteOne({ actorUrl }).catch(() => {}); 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 * collections, attachments, etc.). * * Delivery is batched to avoid a thundering herd: hundreds of remote * servers simultaneously re-fetching the actor, featured posts, and * featured tags after receiving the Update all at once. */ async broadcastActorUpdate() { if (!this._federation) return; try { const { Update } = 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 actor = await buildPersonActor( ctx, handle, this._collections, this.options.actorType, ); if (!actor) { console.warn("[ActivityPub] broadcastActorUpdate: could not build actor"); return; } const update = new Update({ actor: ctx.getActorUri(handle), object: actor, }); await batchBroadcast({ federation: this._federation, collections: this._collections, publicationUrl: this._publicationUrl, handle, activity: update, label: "Update(Person)", objectUrl: this._getActorUrl(), }); } catch (error) { console.error("[ActivityPub] broadcastActorUpdate failed:", error.message); } } /** * Send Delete activity to all followers for a removed post. * Mirrors broadcastActorUpdate() pattern: batch delivery with shared inbox dedup. * @param {string} postUrl - Full URL of the deleted post */ async broadcastDelete(postUrl) { if (!this._federation) return; try { const { Delete } = 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 del = new Delete({ actor: ctx.getActorUri(handle), object: new URL(postUrl), }); await batchBroadcast({ federation: this._federation, collections: this._collections, publicationUrl: this._publicationUrl, handle, activity: del, label: "Delete", objectUrl: postUrl, }); } catch (error) { console.warn("[ActivityPub] broadcastDelete failed:", error.message); } } /** * Called by post-content.js when a Micropub delete succeeds. * Broadcasts an ActivityPub Delete activity to all followers. * @param {string} url - Full URL of the deleted post */ async delete(url) { await this.broadcastDelete(url).catch((err) => console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`) ); } /** * Called by post-content.js when a Micropub update succeeds. * Broadcasts an ActivityPub Update activity for the post to all followers. * @param {object} properties - JF2 post properties (must include url) */ async update(properties) { await this.broadcastPostUpdate(properties).catch((err) => console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`) ); } /** * Send an Update activity to all followers for a modified post. * Mirrors broadcastDelete() pattern: batch delivery with shared inbox dedup. * @param {object} properties - JF2 post properties */ async broadcastPostUpdate(properties) { if (!this._federation) return; try { const { Update } = await import("@fedify/fedify/vocab"); const actorUrl = this._getActorUrl(); const handle = this.options.actor.handle; const ctx = this._federation.createContext( new URL(this._publicationUrl), { handle, publicationUrl: this._publicationUrl }, ); const createActivity = jf2ToAS2Activity( properties, actorUrl, this._publicationUrl, { visibility: this.options.defaultVisibility }, ); if (!createActivity) { console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`); return; } const noteObject = await createActivity.getObject(); const activity = new Update({ actor: ctx.getActorUri(handle), object: noteObject, }); await batchBroadcast({ federation: this._federation, collections: this._collections, publicationUrl: this._publicationUrl, handle, activity, label: "Update(Note)", objectUrl: properties.url, }); } catch (error) { console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message); } } /** * Build the full actor URL from config. * @returns {string} */ _getActorUrl() { const base = this._publicationUrl.replace(/\/$/, ""); return `${base}${this.options.mountPath}/users/${this.options.actor.handle}`; } init(Indiekit) { // Store publication URL for later use this._publicationUrl = Indiekit.publication?.me ? Indiekit.publication.me.endsWith("/") ? Indiekit.publication.me : `${Indiekit.publication.me}/` : ""; // Register MongoDB collections Indiekit.addCollection("ap_followers"); Indiekit.addCollection("ap_following"); Indiekit.addCollection("ap_activities"); Indiekit.addCollection("ap_keys"); Indiekit.addCollection("ap_kv"); Indiekit.addCollection("ap_profile"); Indiekit.addCollection("ap_featured"); Indiekit.addCollection("ap_featured_tags"); // Reader collections Indiekit.addCollection("ap_timeline"); Indiekit.addCollection("ap_notifications"); Indiekit.addCollection("ap_muted"); Indiekit.addCollection("ap_blocked"); Indiekit.addCollection("ap_interactions"); Indiekit.addCollection("ap_followed_tags"); // Message collections Indiekit.addCollection("ap_messages"); // Explore tab collections Indiekit.addCollection("ap_explore_tabs"); // Reports collection Indiekit.addCollection("ap_reports"); // Pending follow requests (manual approval) Indiekit.addCollection("ap_pending_follows"); // Server-level blocks Indiekit.addCollection("ap_blocked_servers"); // Key freshness tracking for proactive refresh Indiekit.addCollection("ap_key_freshness"); // Async inbox processing queue Indiekit.addCollection("ap_inbox_queue"); // Mastodon Client API collections Indiekit.addCollection("ap_oauth_apps"); Indiekit.addCollection("ap_oauth_tokens"); Indiekit.addCollection("ap_markers"); // Store collection references (posts resolved lazily) const indiekitCollections = Indiekit.collections; this._collections = { ap_followers: indiekitCollections.get("ap_followers"), ap_following: indiekitCollections.get("ap_following"), ap_activities: indiekitCollections.get("ap_activities"), ap_keys: indiekitCollections.get("ap_keys"), ap_kv: indiekitCollections.get("ap_kv"), ap_profile: indiekitCollections.get("ap_profile"), ap_featured: indiekitCollections.get("ap_featured"), ap_featured_tags: indiekitCollections.get("ap_featured_tags"), // Reader collections ap_timeline: indiekitCollections.get("ap_timeline"), ap_notifications: indiekitCollections.get("ap_notifications"), ap_muted: indiekitCollections.get("ap_muted"), ap_blocked: indiekitCollections.get("ap_blocked"), ap_interactions: indiekitCollections.get("ap_interactions"), ap_followed_tags: indiekitCollections.get("ap_followed_tags"), // Message collections ap_messages: indiekitCollections.get("ap_messages"), // Explore tab collections ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"), // Reports collection ap_reports: indiekitCollections.get("ap_reports"), // Pending follow requests (manual approval) ap_pending_follows: indiekitCollections.get("ap_pending_follows"), // Server-level blocks ap_blocked_servers: indiekitCollections.get("ap_blocked_servers"), // Key freshness tracking ap_key_freshness: indiekitCollections.get("ap_key_freshness"), // Async inbox processing queue ap_inbox_queue: indiekitCollections.get("ap_inbox_queue"), // Mastodon Client API collections ap_oauth_apps: indiekitCollections.get("ap_oauth_apps"), ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"), ap_markers: indiekitCollections.get("ap_markers"), get posts() { return indiekitCollections.get("posts"); }, _publicationUrl: this._publicationUrl, }; // Create indexes (idempotent — safe on every startup) createIndexes(this._collections, { activityRetentionDays: this.options.activityRetentionDays, notificationRetentionDays: this.options.notificationRetentionDays, }); // Seed actor profile from config on first run this._seedProfile().catch((error) => { console.warn("[ActivityPub] Profile seed failed:", error.message); }); // Initialize Redis cache for plugin-level KV (fedidb, batch-refollow, etc.) if (this.options.redisUrl) { initRedisCache(this.options.redisUrl); } // Set up Fedify Federation instance const { federation } = setupFederation({ collections: this._collections, mountPath: this.options.mountPath, handle: this.options.actor.handle, storeRawActivities: this.options.storeRawActivities, redisUrl: this.options.redisUrl, publicationUrl: this._publicationUrl, parallelWorkers: this.options.parallelWorkers, actorType: this.options.actorType, logLevel: this.options.logLevel, debugDashboard: this.options.debugDashboard, debugPassword: this.options.debugPassword, }); this._federation = federation; this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({})); // Expose signed avatar resolver for cross-plugin use (e.g., conversations backfill) Indiekit.config.application.resolveActorAvatar = async (actorUrl) => { try { 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 actor = await lookupWithSecurity(ctx,new URL(actorUrl), { documentLoader, }); if (!actor) return ""; const { extractActorInfo } = await import("./lib/timeline-store.js"); const info = await extractActorInfo(actor, { documentLoader }); return info.photo || ""; } catch { return ""; } }; // Register as endpoint (mounts routesPublic, routesWellKnown, routes) Indiekit.addEndpoint(this); // Content negotiation + NodeInfo — virtual endpoint at root Indiekit.addEndpoint({ name: "ActivityPub content negotiation", mountPath: "/", routesPublic: this.contentNegotiationRoutes, }); // Set local identity for own-post detection in status serialization setLocalIdentity(this._publicationUrl, this.options.actor?.handle || "user"); // Mastodon Client API — virtual endpoint at root // Mastodon-compatible clients (Phanpy, Elk, etc.) expect /api/v1/*, // /api/v2/*, /oauth/* at the domain root, not under /activitypub. const pluginRef = this; const mastodonRouter = createMastodonRouter({ collections: this._collections, pluginOptions: { handle: this.options.actor?.handle || "user", publicationUrl: this._publicationUrl, federation: this._federation, followActor: (url, info) => pluginRef.followActor(url, info), unfollowActor: (url) => pluginRef.unfollowActor(url), loadRsaKey: () => pluginRef._loadRsaPrivateKey(), }, }); Indiekit.addEndpoint({ name: "Mastodon Client API", mountPath: "/", routesPublic: mastodonRouter, }); // Register syndicator (appears in post editing UI) Indiekit.addSyndicator(this.syndicator); // Start batch re-follow processor after federation settles const refollowOptions = { federation: this._federation, collections: this._collections, handle: this.options.actor.handle, publicationUrl: this._publicationUrl, }; setTimeout(() => { startBatchRefollow(refollowOptions).catch((error) => { console.error("[ActivityPub] Batch refollow start failed:", error.message); }); }, 10_000); // Run one-time migrations (idempotent — safe to run on every startup) console.info("[ActivityPub] Init: starting post-refollow setup"); runSeparateMentionsMigration(this._collections).then(({ skipped, updated }) => { if (!skipped) { console.log(`[ActivityPub] Migration separate-mentions: updated ${updated} timeline items`); } }).catch((error) => { console.error("[ActivityPub] Migration separate-mentions failed:", error.message); }); // Schedule timeline retention cleanup (runs on startup + every 24h) if (this.options.timelineRetention > 0) { scheduleCleanup(this._collections, this.options.timelineRetention); } // Load server blocks into Redis for fast inbox checks loadBlockedServersToRedis(this._collections).catch((error) => { console.warn("[ActivityPub] Failed to load blocked servers to Redis:", error.message); }); // Schedule proactive key refresh for stale follower keys (runs on startup + every 24h) const keyRefreshHandle = this.options.actor.handle; const keyRefreshFederation = this._federation; const keyRefreshPubUrl = this._publicationUrl; scheduleKeyRefresh( this._collections, () => keyRefreshFederation?.createContext(new URL(keyRefreshPubUrl), { handle: keyRefreshHandle, publicationUrl: keyRefreshPubUrl, }), keyRefreshHandle, ); // Backfill ap_timeline from posts collection (idempotent, runs on every startup) import("./lib/mastodon/backfill-timeline.js").then(({ backfillTimeline }) => { // Delay to let MongoDB connections settle setTimeout(() => { backfillTimeline(this._collections).then(({ total, inserted, skipped }) => { if (inserted > 0) { console.log(`[Mastodon API] Timeline backfill: ${inserted} posts added (${skipped} already existed, ${total} total)`); } }).catch((error) => { console.warn("[Mastodon API] Timeline backfill failed:", error.message); }); }, 5000); }); // Start async inbox queue processor (processes one item every 3s) console.info("[ActivityPub] Init: starting inbox queue processor"); this._inboxProcessorInterval = startInboxProcessor( this._collections, () => this._federation?.createContext(new URL(this._publicationUrl), { handle: this.options.actor.handle, publicationUrl: this._publicationUrl, }), this.options.actor.handle, ); } /** * Seed the ap_profile collection from config options on first run. * Only creates a profile if none exists — preserves UI edits. */ async _seedProfile() { const { ap_profile } = this._collections; const existing = await ap_profile.findOne({}); if (existing) { return; } const profile = { name: this.options.actor.name || this.options.actor.handle, summary: this.options.actor.summary || "", url: this._publicationUrl, icon: this.options.actor.icon || "", manuallyApprovesFollowers: false, createdAt: new Date().toISOString(), }; // Only include alsoKnownAs if explicitly configured if (this.options.alsoKnownAs) { profile.alsoKnownAs = Array.isArray(this.options.alsoKnownAs) ? this.options.alsoKnownAs : [this.options.alsoKnownAs]; } await ap_profile.insertOne(profile); } }