mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
Remove the fragile middleware in contentNegotiationRoutes that wrapped res.json to detect successful Micropub delete responses. Replace it with clean delete() and update() lifecycle methods on ActivityPubEndpoint that are called directly by post-content.js via callSyndicatorHook. Also adds broadcastPostUpdate() to send Update activities for edited posts, mirroring the broadcastDelete() batch-delivery pattern.
1792 lines
63 KiB
JavaScript
1792 lines
63 KiB
JavaScript
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 { 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,
|
|
parseMentions,
|
|
} from "./lib/jf2-to-as2.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 { 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/reader/notifications`,
|
|
text: "activitypub.notifications.title",
|
|
requiresDatabase: true,
|
|
},
|
|
{
|
|
href: `${this.options.mountPath}/admin/reader/messages`,
|
|
text: "activitypub.messages.title",
|
|
requiresDatabase: true,
|
|
},
|
|
{
|
|
href: `${this.options.mountPath}/admin/reader/moderation`,
|
|
text: "activitypub.moderation.title",
|
|
requiresDatabase: true,
|
|
},
|
|
{
|
|
href: `${this.options.mountPath}/admin/my-profile`,
|
|
text: "activitypub.myProfile.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() {
|
|
const self = this;
|
|
return {
|
|
name: "ActivityPub syndicator",
|
|
options: { checked: self.options.checked },
|
|
|
|
get info() {
|
|
const hostname = self._publicationUrl
|
|
? new URL(self._publicationUrl).hostname
|
|
: "example.com";
|
|
return {
|
|
checked: self.options.checked,
|
|
name: `@${self.options.actor.handle}@${hostname}`,
|
|
uid: self._publicationUrl || "https://example.com/",
|
|
service: {
|
|
name: "ActivityPub (Fediverse)",
|
|
photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
|
|
url: self._publicationUrl || "https://example.com/",
|
|
},
|
|
};
|
|
},
|
|
|
|
async syndicate(properties) {
|
|
if (!self._federation) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const actorUrl = self._getActorUrl();
|
|
const handle = self.options.actor.handle;
|
|
|
|
const ctx = self._federation.createContext(
|
|
new URL(self._publicationUrl),
|
|
{ handle, publicationUrl: self._publicationUrl },
|
|
);
|
|
|
|
// For replies, resolve the original post author for proper
|
|
// addressing (CC) and direct inbox delivery
|
|
let replyToActor = null;
|
|
if (properties["in-reply-to"]) {
|
|
try {
|
|
const remoteObject = await lookupWithSecurity(ctx,
|
|
new URL(properties["in-reply-to"]),
|
|
);
|
|
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
|
const author = await remoteObject.getAttributedTo();
|
|
const authorActor = Array.isArray(author) ? author[0] : author;
|
|
if (authorActor?.id) {
|
|
replyToActor = {
|
|
url: authorActor.id.href,
|
|
handle: authorActor.preferredUsername || null,
|
|
recipient: authorActor,
|
|
};
|
|
console.info(
|
|
`[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Resolve @user@domain mentions in content via WebFinger
|
|
const contentText = properties.content?.html || properties.content || "";
|
|
const mentionHandles = parseMentions(contentText);
|
|
const resolvedMentions = [];
|
|
const mentionRecipients = [];
|
|
|
|
for (const { handle } of mentionHandles) {
|
|
try {
|
|
const mentionedActor = await lookupWithSecurity(ctx,
|
|
new URL(`acct:${handle}`),
|
|
);
|
|
if (mentionedActor?.id) {
|
|
resolvedMentions.push({
|
|
handle,
|
|
actorUrl: mentionedActor.id.href,
|
|
profileUrl: mentionedActor.url?.href || null,
|
|
});
|
|
mentionRecipients.push({
|
|
handle,
|
|
actorUrl: mentionedActor.id.href,
|
|
actor: mentionedActor,
|
|
});
|
|
console.info(
|
|
`[ActivityPub] Resolved mention @${handle} → ${mentionedActor.id.href}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
|
|
);
|
|
// Still add with no actorUrl so it gets a fallback link
|
|
resolvedMentions.push({ handle, actorUrl: null });
|
|
}
|
|
}
|
|
|
|
const activity = jf2ToAS2Activity(
|
|
properties,
|
|
actorUrl,
|
|
self._publicationUrl,
|
|
{
|
|
replyToActorUrl: replyToActor?.url,
|
|
replyToActorHandle: replyToActor?.handle,
|
|
visibility: self.options.defaultVisibility,
|
|
mentions: resolvedMentions,
|
|
},
|
|
);
|
|
|
|
if (!activity) {
|
|
await logActivity(self._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: "Syndicate",
|
|
actorUrl: self._publicationUrl,
|
|
objectUrl: properties.url,
|
|
summary: `Syndication skipped: could not convert post to AS2`,
|
|
});
|
|
return undefined;
|
|
}
|
|
|
|
// Count followers for logging
|
|
const followerCount =
|
|
await self._collections.ap_followers.countDocuments();
|
|
|
|
console.info(
|
|
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
|
|
);
|
|
|
|
// Send to followers via shared inboxes with collection sync (FEP-8fcf)
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
"followers",
|
|
activity,
|
|
{
|
|
preferSharedInbox: true,
|
|
syncCollection: true,
|
|
orderingKey: properties.url,
|
|
},
|
|
);
|
|
|
|
// For replies, also deliver to the original post author's inbox
|
|
// so their server can thread the reply under the original post
|
|
if (replyToActor?.recipient) {
|
|
try {
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
replyToActor.recipient,
|
|
activity,
|
|
{ orderingKey: properties.url },
|
|
);
|
|
console.info(
|
|
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
|
|
);
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
|
|
for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
|
|
if (replyToActor?.url === mUrl) continue;
|
|
try {
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
mActor,
|
|
activity,
|
|
{ orderingKey: properties.url },
|
|
);
|
|
console.info(
|
|
`[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
|
|
);
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Determine activity type name
|
|
const typeName =
|
|
activity.constructor?.name || "Create";
|
|
const replyNote = replyToActor
|
|
? ` (reply to ${replyToActor.url})`
|
|
: "";
|
|
const mentionNote = mentionRecipients.length > 0
|
|
? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
|
|
: "";
|
|
|
|
await logActivity(self._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: typeName,
|
|
actorUrl: self._publicationUrl,
|
|
objectUrl: properties.url,
|
|
targetUrl: properties["in-reply-to"] || undefined,
|
|
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
|
|
});
|
|
|
|
console.info(
|
|
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
|
|
);
|
|
|
|
return properties.url || undefined;
|
|
} catch (error) {
|
|
console.error("[ActivityPub] Syndication failed:", error.message);
|
|
await logActivity(self._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: "Syndicate",
|
|
actorUrl: self._publicationUrl,
|
|
objectUrl: properties.url,
|
|
summary: `Syndication failed: ${error.message}`,
|
|
}).catch(() => {});
|
|
return undefined;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<CryptoKey|null>}
|
|
*/
|
|
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 },
|
|
);
|
|
|
|
// Build the full actor object (same as what the dispatcher serves).
|
|
// Note: ctx.getActor() only exists on RequestContext, not the base
|
|
// Context returned by createContext(), so we use the shared helper.
|
|
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,
|
|
});
|
|
|
|
// Fetch followers and deduplicate by shared inbox so each remote
|
|
// server only gets one delivery (same as preferSharedInbox but
|
|
// gives us control over batching).
|
|
const followers = await this._collections.ap_followers
|
|
.find({})
|
|
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
|
|
.toArray();
|
|
|
|
// Group by shared inbox (or direct inbox if none)
|
|
const inboxMap = new Map();
|
|
for (const f of followers) {
|
|
const key = f.sharedInbox || f.inbox;
|
|
if (key && !inboxMap.has(key)) {
|
|
inboxMap.set(key, f);
|
|
}
|
|
}
|
|
|
|
const uniqueRecipients = [...inboxMap.values()];
|
|
const BATCH_SIZE = 25;
|
|
const BATCH_DELAY_MS = 5000;
|
|
let delivered = 0;
|
|
let failed = 0;
|
|
|
|
console.info(
|
|
`[ActivityPub] Broadcasting Update(Person) to ${uniqueRecipients.length} ` +
|
|
`unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
|
|
);
|
|
|
|
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
|
|
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
|
|
|
|
// Build Fedify-compatible Recipient objects:
|
|
// extractInboxes() reads: recipient.id, recipient.inboxId,
|
|
// recipient.endpoints?.sharedInbox
|
|
const recipients = batch.map((f) => ({
|
|
id: new URL(f.actorUrl),
|
|
inboxId: new URL(f.inbox || f.sharedInbox),
|
|
endpoints: f.sharedInbox
|
|
? { sharedInbox: new URL(f.sharedInbox) }
|
|
: undefined,
|
|
}));
|
|
|
|
try {
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
recipients,
|
|
update,
|
|
{ preferSharedInbox: true },
|
|
);
|
|
delivered += batch.length;
|
|
} catch (error) {
|
|
failed += batch.length;
|
|
console.warn(
|
|
`[ActivityPub] Batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
|
|
);
|
|
}
|
|
|
|
// Stagger batches so remote servers don't all re-fetch at once
|
|
if (i + BATCH_SIZE < uniqueRecipients.length) {
|
|
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
}
|
|
}
|
|
|
|
console.info(
|
|
`[ActivityPub] Update(Person) broadcast complete: ` +
|
|
`${delivered} delivered, ${failed} failed`,
|
|
);
|
|
|
|
await logActivity(this._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: "Update",
|
|
actorUrl: this._publicationUrl,
|
|
objectUrl: this._getActorUrl(),
|
|
summary: `Sent Update(Person) to ${delivered}/${uniqueRecipients.length} inboxes`,
|
|
}).catch(() => {});
|
|
} 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),
|
|
});
|
|
|
|
const followers = await this._collections.ap_followers
|
|
.find({})
|
|
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
|
|
.toArray();
|
|
|
|
const inboxMap = new Map();
|
|
for (const f of followers) {
|
|
const key = f.sharedInbox || f.inbox;
|
|
if (key && !inboxMap.has(key)) {
|
|
inboxMap.set(key, f);
|
|
}
|
|
}
|
|
|
|
const uniqueRecipients = [...inboxMap.values()];
|
|
const BATCH_SIZE = 25;
|
|
const BATCH_DELAY_MS = 5000;
|
|
let delivered = 0;
|
|
let failed = 0;
|
|
|
|
console.info(
|
|
`[ActivityPub] Broadcasting Delete for ${postUrl} to ${uniqueRecipients.length} ` +
|
|
`unique inboxes (${followers.length} followers)`,
|
|
);
|
|
|
|
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
|
|
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
|
|
const recipients = batch.map((f) => ({
|
|
id: new URL(f.actorUrl),
|
|
inboxId: new URL(f.inbox || f.sharedInbox),
|
|
endpoints: f.sharedInbox
|
|
? { sharedInbox: new URL(f.sharedInbox) }
|
|
: undefined,
|
|
}));
|
|
|
|
try {
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
recipients,
|
|
del,
|
|
{ preferSharedInbox: true },
|
|
);
|
|
delivered += batch.length;
|
|
} catch (error) {
|
|
failed += batch.length;
|
|
console.warn(
|
|
`[ActivityPub] Delete batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
|
|
);
|
|
}
|
|
|
|
if (i + BATCH_SIZE < uniqueRecipients.length) {
|
|
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
}
|
|
}
|
|
|
|
console.info(
|
|
`[ActivityPub] Delete broadcast complete for ${postUrl}: ${delivered} delivered, ${failed} failed`,
|
|
);
|
|
|
|
await logActivity(this._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: "Delete",
|
|
actorUrl: this._publicationUrl,
|
|
objectUrl: postUrl,
|
|
summary: `Sent Delete for ${postUrl} to ${delivered} inboxes`,
|
|
}).catch(() => {});
|
|
} 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 actorUrl = this._getActorUrl();
|
|
const activity = jf2ToAS2Activity(
|
|
properties,
|
|
actorUrl,
|
|
this._publicationUrl,
|
|
{ visibility: this.options.defaultVisibility },
|
|
);
|
|
|
|
if (!activity) {
|
|
console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`);
|
|
return;
|
|
}
|
|
|
|
const handle = this.options.actor.handle;
|
|
const ctx = this._federation.createContext(
|
|
new URL(this._publicationUrl),
|
|
{ handle, publicationUrl: this._publicationUrl },
|
|
);
|
|
|
|
const followers = await this._collections.ap_followers
|
|
.find({})
|
|
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
|
|
.toArray();
|
|
|
|
const inboxMap = new Map();
|
|
for (const f of followers) {
|
|
const key = f.sharedInbox || f.inbox;
|
|
if (key && !inboxMap.has(key)) {
|
|
inboxMap.set(key, f);
|
|
}
|
|
}
|
|
|
|
const uniqueRecipients = [...inboxMap.values()];
|
|
const BATCH_SIZE = 25;
|
|
const BATCH_DELAY_MS = 5000;
|
|
let delivered = 0;
|
|
let failed = 0;
|
|
|
|
console.info(
|
|
`[ActivityPub] Broadcasting Update for ${properties.url} to ${uniqueRecipients.length} unique inboxes`,
|
|
);
|
|
|
|
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
|
|
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
|
|
const recipients = batch.map((f) => ({
|
|
id: new URL(f.actorUrl),
|
|
inboxId: new URL(f.inbox || f.sharedInbox),
|
|
endpoints: f.sharedInbox
|
|
? { sharedInbox: new URL(f.sharedInbox) }
|
|
: undefined,
|
|
}));
|
|
|
|
try {
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
recipients,
|
|
activity,
|
|
{ preferSharedInbox: true },
|
|
);
|
|
delivered += batch.length;
|
|
} catch (error) {
|
|
failed += batch.length;
|
|
console.warn(
|
|
`[ActivityPub] Update batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
|
|
);
|
|
}
|
|
|
|
if (i + BATCH_SIZE < uniqueRecipients.length) {
|
|
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
}
|
|
}
|
|
|
|
console.info(
|
|
`[ActivityPub] Update broadcast complete for ${properties.url}: ${delivered} delivered, ${failed} failed`,
|
|
);
|
|
|
|
await logActivity(this._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: "Update",
|
|
actorUrl: this._publicationUrl,
|
|
objectUrl: properties.url,
|
|
summary: `Sent Update for ${properties.url} to ${delivered} inboxes`,
|
|
}).catch(() => {});
|
|
} 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 — wrapped in try-catch because collection references
|
|
// may be undefined if MongoDB hasn't finished connecting yet.
|
|
// Indexes are idempotent; they'll be created on next successful startup.
|
|
try {
|
|
// TTL index for activity cleanup (MongoDB handles expiry automatically)
|
|
const retentionDays = this.options.activityRetentionDays;
|
|
if (retentionDays > 0) {
|
|
this._collections.ap_activities.createIndex(
|
|
{ receivedAt: 1 },
|
|
{ expireAfterSeconds: retentionDays * 86_400 },
|
|
);
|
|
}
|
|
|
|
// Performance indexes for inbox handlers and batch refollow
|
|
this._collections.ap_followers.createIndex(
|
|
{ actorUrl: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
this._collections.ap_following.createIndex(
|
|
{ actorUrl: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
this._collections.ap_following.createIndex(
|
|
{ source: 1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_activities.createIndex(
|
|
{ objectUrl: 1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_activities.createIndex(
|
|
{ type: 1, actorUrl: 1, objectUrl: 1 },
|
|
{ background: true },
|
|
);
|
|
|
|
// Reader indexes (timeline, notifications, moderation, interactions)
|
|
this._collections.ap_timeline.createIndex(
|
|
{ uid: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
this._collections.ap_timeline.createIndex(
|
|
{ published: -1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_timeline.createIndex(
|
|
{ "author.url": 1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_timeline.createIndex(
|
|
{ type: 1, published: -1 },
|
|
{ background: true },
|
|
);
|
|
|
|
this._collections.ap_notifications.createIndex(
|
|
{ uid: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
this._collections.ap_notifications.createIndex(
|
|
{ published: -1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_notifications.createIndex(
|
|
{ read: 1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_notifications.createIndex(
|
|
{ type: 1, published: -1 },
|
|
{ background: true },
|
|
);
|
|
|
|
// TTL index for notification cleanup
|
|
const notifRetention = this.options.notificationRetentionDays;
|
|
if (notifRetention > 0) {
|
|
this._collections.ap_notifications.createIndex(
|
|
{ createdAt: 1 },
|
|
{ expireAfterSeconds: notifRetention * 86_400 },
|
|
);
|
|
}
|
|
|
|
// Message indexes
|
|
this._collections.ap_messages.createIndex(
|
|
{ uid: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
this._collections.ap_messages.createIndex(
|
|
{ published: -1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_messages.createIndex(
|
|
{ read: 1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_messages.createIndex(
|
|
{ conversationId: 1, published: -1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_messages.createIndex(
|
|
{ direction: 1 },
|
|
{ background: true },
|
|
);
|
|
// TTL index for message cleanup (reuse notification retention)
|
|
if (notifRetention > 0) {
|
|
this._collections.ap_messages.createIndex(
|
|
{ createdAt: 1 },
|
|
{ expireAfterSeconds: notifRetention * 86_400 },
|
|
);
|
|
}
|
|
|
|
// Muted collection — sparse unique indexes (allow multiple null values)
|
|
this._collections.ap_muted
|
|
.dropIndex("url_1")
|
|
.catch(() => {})
|
|
.then(() =>
|
|
this._collections.ap_muted.createIndex(
|
|
{ url: 1 },
|
|
{ unique: true, sparse: true, background: true },
|
|
),
|
|
)
|
|
.catch(() => {});
|
|
this._collections.ap_muted
|
|
.dropIndex("keyword_1")
|
|
.catch(() => {})
|
|
.then(() =>
|
|
this._collections.ap_muted.createIndex(
|
|
{ keyword: 1 },
|
|
{ unique: true, sparse: true, background: true },
|
|
),
|
|
)
|
|
.catch(() => {});
|
|
|
|
this._collections.ap_blocked.createIndex(
|
|
{ url: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
|
|
this._collections.ap_interactions.createIndex(
|
|
{ objectUrl: 1, type: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
this._collections.ap_interactions.createIndex(
|
|
{ type: 1 },
|
|
{ background: true },
|
|
);
|
|
|
|
// Followed hashtags — unique on tag (case-insensitive via normalization at write time)
|
|
this._collections.ap_followed_tags.createIndex(
|
|
{ tag: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
|
|
// Tag filtering index on timeline
|
|
this._collections.ap_timeline.createIndex(
|
|
{ category: 1, published: -1 },
|
|
{ background: true },
|
|
);
|
|
|
|
// Explore tab indexes
|
|
// Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs.
|
|
// ALL insertions must explicitly set all four fields (unused fields = null)
|
|
// because MongoDB treats missing fields differently from null in unique indexes.
|
|
this._collections.ap_explore_tabs.createIndex(
|
|
{ type: 1, domain: 1, scope: 1, hashtag: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
// Order index for efficient sorting of tab bar
|
|
this._collections.ap_explore_tabs.createIndex(
|
|
{ order: 1 },
|
|
{ background: true },
|
|
);
|
|
|
|
// ap_reports indexes
|
|
if (notifRetention > 0) {
|
|
this._collections.ap_reports.createIndex(
|
|
{ createdAt: 1 },
|
|
{ expireAfterSeconds: notifRetention * 86_400 },
|
|
);
|
|
}
|
|
this._collections.ap_reports.createIndex(
|
|
{ reporterUrl: 1 },
|
|
{ background: true },
|
|
);
|
|
this._collections.ap_reports.createIndex(
|
|
{ reportedUrls: 1 },
|
|
{ background: true },
|
|
);
|
|
// Pending follow requests — unique on actorUrl
|
|
this._collections.ap_pending_follows.createIndex(
|
|
{ actorUrl: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
this._collections.ap_pending_follows.createIndex(
|
|
{ requestedAt: -1 },
|
|
{ background: true },
|
|
);
|
|
// Server-level blocks
|
|
this._collections.ap_blocked_servers.createIndex(
|
|
{ hostname: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
// Key freshness tracking
|
|
this._collections.ap_key_freshness.createIndex(
|
|
{ actorUrl: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
|
|
// Inbox queue indexes
|
|
this._collections.ap_inbox_queue.createIndex(
|
|
{ status: 1, receivedAt: 1 },
|
|
{ background: true },
|
|
);
|
|
// TTL: auto-prune completed items after 24h
|
|
this._collections.ap_inbox_queue.createIndex(
|
|
{ processedAt: 1 },
|
|
{ expireAfterSeconds: 86_400, background: true },
|
|
);
|
|
|
|
// Mastodon Client API indexes
|
|
this._collections.ap_oauth_apps.createIndex(
|
|
{ clientId: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
this._collections.ap_oauth_tokens.createIndex(
|
|
{ accessToken: 1 },
|
|
{ unique: true, sparse: true, background: true },
|
|
);
|
|
this._collections.ap_oauth_tokens.createIndex(
|
|
{ code: 1 },
|
|
{ unique: true, sparse: true, background: true },
|
|
);
|
|
this._collections.ap_markers.createIndex(
|
|
{ userId: 1, timeline: 1 },
|
|
{ unique: true, background: true },
|
|
);
|
|
} catch {
|
|
// Index creation failed — collections not yet available.
|
|
// Indexes already exist from previous startups; non-fatal.
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
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);
|
|
}
|
|
}
|