mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
1810 lines
64 KiB
JavaScript
1810 lines
64 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;
|
|
}
|
|
},
|
|
|
|
delete: async (url) => this.delete(url),
|
|
update: async (properties) => this.update(properties),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 { 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 },
|
|
);
|
|
|
|
// Build the Note/Article object by calling jf2ToAS2Activity() and
|
|
// extracting the wrapped object from the returned Create activity.
|
|
// For post edits, Fediverse servers expect an Update activity wrapping
|
|
// the updated object — NOT a second Create activity.
|
|
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;
|
|
}
|
|
|
|
// Extract the Note/Article object from the Create wrapper, then build
|
|
// an Update activity around it — matching broadcastActorUpdate() pattern.
|
|
const noteObject = await createActivity.getObject();
|
|
const activity = new Update({
|
|
actor: ctx.getActorUri(handle),
|
|
object: noteObject,
|
|
});
|
|
|
|
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)
|
|
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);
|
|
}
|
|
}
|