mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
Fedify's acceptsJsonLd() returns false for Accept: */* or no Accept header because it only checks for explicit application/activity+json in the list. Remote servers fetching actor URLs for HTTP Signature verification (e.g. tags.pub) often omit Accept or use */*, getting HTML back instead of the actor JSON and causing "public key not found" failures. Add middleware to upgrade ambiguous Accept headers to application/activity+json for GET requests to /users/:id paths. Explicit text/html requests (browsers) are unaffected. Also fix followActor() storing inbox: "" for actors where Fedify uses remoteActor.inboxId?.href (not remoteActor.inbox?.id?.href). The inbox URL is stored correctly now for all actor types.
1883 lines
68 KiB
JavaScript
1883 lines
68 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 {
|
|
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 { resolveAuthor } from "./lib/resolve-author.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();
|
|
|
|
// Diagnostic: log inbox POSTs to detect federation stalls
|
|
if (req.method === "POST" && req.path.includes("inbox")) {
|
|
const ua = req.get("user-agent") || "unknown";
|
|
const bodyParsed = req.body !== undefined && Object.keys(req.body || {}).length > 0;
|
|
console.info(`[federation-diag] POST ${req.path} from=${ua.slice(0, 60)} bodyParsed=${bodyParsed} readable=${req.readable}`);
|
|
}
|
|
|
|
// 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",
|
|
});
|
|
});
|
|
|
|
// Public API: resolve a blog post URL → its Fedify-served AP object URL.
|
|
//
|
|
// GET /api/ap-url?post=https://blog.example.com/notes/foo/
|
|
// → { apUrl: "https://blog.example.com/activitypub/objects/note/notes/foo/" }
|
|
//
|
|
// Used by "Also on Fediverse" widgets so that the Mastodon authorize_interaction
|
|
// flow receives a URL that is always routed to Node.js (never intercepted by a
|
|
// static file server), ensuring reliable AP content negotiation.
|
|
//
|
|
// Special case — AP-likes: when like-of points to an ActivityPub object the
|
|
// widget should open the *original* post on the remote instance so the user
|
|
// can interact with it there. We return { apUrl: likeOf } in that case.
|
|
router.get("/api/ap-url", async (req, res) => {
|
|
try {
|
|
const postParam = req.query.post;
|
|
if (!postParam) {
|
|
return res.status(400).json({ error: "post parameter required" });
|
|
}
|
|
|
|
const { application } = req.app.locals;
|
|
const postsCollection = application.collections?.get("posts");
|
|
|
|
if (!postsCollection) {
|
|
return res.status(503).json({ error: "Database unavailable" });
|
|
}
|
|
|
|
const publicationUrl = (self._publicationUrl || application.url || "").replace(/\/$/, "");
|
|
|
|
// Match with or without trailing slash
|
|
const postUrl = postParam.replace(/\/$/, "");
|
|
const post = await postsCollection.findOne({
|
|
"properties.url": { $in: [postUrl, postUrl + "/"] },
|
|
});
|
|
|
|
if (!post) {
|
|
return res.status(404).json({ error: "Post not found" });
|
|
}
|
|
|
|
// Draft and unlisted posts are not federated
|
|
if (post?.properties?.["post-status"] === "draft") {
|
|
return res.status(404).json({ error: "Post not found" });
|
|
}
|
|
if (post?.properties?.visibility === "unlisted") {
|
|
return res.status(404).json({ error: "Post not found" });
|
|
}
|
|
|
|
const postType = post.properties?.["post-type"];
|
|
|
|
// For AP-likes: the widget should open the liked post on the remote instance
|
|
// so the user can interact with it there. We detect AP URLs the same way as
|
|
// jf2-to-as2.js: HEAD request with Accept: application/activity+json.
|
|
if (postType === "like") {
|
|
const likeOf = post.properties?.["like-of"] || "";
|
|
if (likeOf) {
|
|
let isAp = false;
|
|
try {
|
|
const ctrl = new AbortController();
|
|
const tid = setTimeout(() => ctrl.abort(), 3000);
|
|
const r = await fetch(likeOf, {
|
|
method: "HEAD",
|
|
headers: { Accept: "application/activity+json, application/ld+json" },
|
|
signal: ctrl.signal,
|
|
});
|
|
clearTimeout(tid);
|
|
const ct = r.headers.get("content-type") || "";
|
|
isAp = ct.includes("activity+json") || ct.includes("ld+json");
|
|
} catch { /* network error — treat as non-AP */ }
|
|
if (isAp) {
|
|
res.set("Cache-Control", "public, max-age=60");
|
|
return res.json({ apUrl: likeOf });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine the AP object type (mirrors jf2-to-as2.js logic)
|
|
const isArticle = postType === "article" && !!post.properties?.name;
|
|
const objectType = isArticle ? "article" : "note";
|
|
|
|
// Extract the path portion after the publication base URL
|
|
const resolvedUrl = (post.properties?.url || "").replace(/\/$/, "");
|
|
if (!resolvedUrl.startsWith(publicationUrl)) {
|
|
return res.status(500).json({ error: "Post URL does not match publication base" });
|
|
}
|
|
const postPath = resolvedUrl.slice(publicationUrl.length).replace(/^\//, "");
|
|
|
|
const mp = (self.options.mountPath || "").replace(/\/$/, "");
|
|
const apUrl = `${publicationUrl}${mp}/objects/${objectType}/${postPath}`;
|
|
|
|
res.set("Cache-Control", "public, max-age=300");
|
|
res.json({ apUrl });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
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;
|
|
|
|
// Intercept Micropub delete actions to broadcast Delete to fediverse.
|
|
// Wraps res.json to detect successful delete responses, then fires
|
|
// broadcastDelete asynchronously so remote servers remove the post.
|
|
router.use((req, res, next) => {
|
|
if (req.method !== "POST") return next();
|
|
if (!req.path.endsWith("/micropub")) return next();
|
|
|
|
const action = req.query?.action || req.body?.action;
|
|
if (action !== "delete") return next();
|
|
|
|
const postUrl = req.query?.url || req.body?.url;
|
|
if (!postUrl) return next();
|
|
|
|
const originalJson = res.json.bind(res);
|
|
res.json = function (body) {
|
|
// Fire broadcastDelete after successful delete (status 200)
|
|
if (res.statusCode === 200 && body?.success === "delete") {
|
|
console.info(
|
|
`[ActivityPub] Micropub delete detected for ${postUrl}, broadcasting Delete to followers`,
|
|
);
|
|
self.broadcastDelete(postUrl).catch((error) => {
|
|
console.warn(
|
|
`[ActivityPub] broadcastDelete after Micropub delete failed: ${error.message}`,
|
|
);
|
|
});
|
|
}
|
|
return originalJson(body);
|
|
};
|
|
|
|
return next();
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
|
|
const visibility = String(properties?.visibility || "").toLowerCase();
|
|
if (visibility === "unlisted") {
|
|
console.info(
|
|
"[ActivityPub] Skipping federation for unlisted post: " +
|
|
(properties?.url || "unknown"),
|
|
);
|
|
await logActivity(self._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: "Syndicate",
|
|
actorUrl: self._publicationUrl,
|
|
objectUrl: properties?.url,
|
|
summary: "Syndication skipped: post visibility is unlisted",
|
|
}).catch(() => {});
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const actorUrl = self._getActorUrl();
|
|
const handle = self.options.actor.handle;
|
|
|
|
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 = await 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}>}
|
|
*/
|
|
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
|
|
// 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) {
|
|
return { ok: false, error: "Could not resolve remote actor" };
|
|
}
|
|
|
|
// Send Follow activity
|
|
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 };
|
|
}
|
|
|
|
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 a native AP Like activity for a post URL (called programmatically,
|
|
* e.g. when a like is created via Micropub).
|
|
* @param {string} postUrl - URL of the post being liked
|
|
* @param {object} [collections] - MongoDB collections map (application.collections)
|
|
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
*/
|
|
async likePost(postUrl, collections) {
|
|
if (!this._federation) {
|
|
return { ok: false, error: "Federation not initialized" };
|
|
}
|
|
|
|
try {
|
|
const { Like } = await import("@fedify/fedify/vocab");
|
|
const handle = this.options.actor.handle;
|
|
const ctx = this._federation.createContext(
|
|
new URL(this._publicationUrl),
|
|
{ handle, publicationUrl: this._publicationUrl },
|
|
);
|
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
const cols = collections || this._collections;
|
|
|
|
const recipient = await resolveAuthor(postUrl, ctx, documentLoader, cols);
|
|
if (!recipient) {
|
|
return { ok: false, error: `Could not resolve post author for ${postUrl}` };
|
|
}
|
|
|
|
const uuid = crypto.randomUUID();
|
|
const activityId = `${this._publicationUrl.replace(/\/$/, "")}/activitypub/likes/${uuid}`;
|
|
|
|
const like = new Like({
|
|
id: new URL(activityId),
|
|
actor: ctx.getActorUri(handle),
|
|
object: new URL(postUrl),
|
|
});
|
|
|
|
await ctx.sendActivity({ identifier: handle }, recipient, like, {
|
|
orderingKey: postUrl,
|
|
});
|
|
|
|
const interactions = cols?.get?.("ap_interactions") || this._collections.ap_interactions;
|
|
if (interactions) {
|
|
await interactions.updateOne(
|
|
{ objectUrl: postUrl, type: "like" },
|
|
{ $set: { objectUrl: postUrl, type: "like", activityId, recipientUrl: recipient.id?.href || "", createdAt: new Date().toISOString() } },
|
|
{ upsert: true },
|
|
);
|
|
}
|
|
|
|
console.info(`[ActivityPub] Sent Like for ${postUrl}`);
|
|
return { ok: true };
|
|
} catch (error) {
|
|
console.error(`[ActivityPub] likePost failed for ${postUrl}:`, error.message);
|
|
return { ok: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a native AP Announce (boost) activity for a post URL (called
|
|
* programmatically, e.g. when a repost is created via Micropub).
|
|
* @param {string} postUrl - URL of the post being boosted
|
|
* @param {object} [collections] - MongoDB collections map (application.collections)
|
|
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
*/
|
|
async boostPost(postUrl, collections) {
|
|
if (!this._federation) {
|
|
return { ok: false, error: "Federation not initialized" };
|
|
}
|
|
|
|
try {
|
|
const { Announce } = await import("@fedify/fedify/vocab");
|
|
const handle = this.options.actor.handle;
|
|
const ctx = this._federation.createContext(
|
|
new URL(this._publicationUrl),
|
|
{ handle, publicationUrl: this._publicationUrl },
|
|
);
|
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
const cols = collections || this._collections;
|
|
|
|
const uuid = crypto.randomUUID();
|
|
const activityId = `${this._publicationUrl.replace(/\/$/, "")}/activitypub/boosts/${uuid}`;
|
|
const publicAddress = new URL("https://www.w3.org/ns/activitystreams#Public");
|
|
const followersUri = ctx.getFollowersUri(handle);
|
|
|
|
const announce = new Announce({
|
|
id: new URL(activityId),
|
|
actor: ctx.getActorUri(handle),
|
|
object: new URL(postUrl),
|
|
to: publicAddress,
|
|
cc: followersUri,
|
|
});
|
|
|
|
// Broadcast to followers
|
|
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
|
|
preferSharedInbox: true,
|
|
syncCollection: true,
|
|
orderingKey: postUrl,
|
|
});
|
|
|
|
// Also deliver directly to original post author
|
|
const recipient = await resolveAuthor(postUrl, ctx, documentLoader, cols);
|
|
if (recipient) {
|
|
await ctx.sendActivity({ identifier: handle }, recipient, announce, {
|
|
orderingKey: postUrl,
|
|
}).catch((err) => {
|
|
console.warn(`[ActivityPub] Direct boost delivery to author failed: ${err.message}`);
|
|
});
|
|
}
|
|
|
|
const interactions = cols?.get?.("ap_interactions") || this._collections.ap_interactions;
|
|
if (interactions) {
|
|
await interactions.updateOne(
|
|
{ objectUrl: postUrl, type: "boost" },
|
|
{ $set: { objectUrl: postUrl, type: "boost", activityId, createdAt: new Date().toISOString() } },
|
|
{ upsert: true },
|
|
);
|
|
}
|
|
|
|
console.info(`[ActivityPub] Sent Announce (boost) for ${postUrl}`);
|
|
return { ok: true };
|
|
} catch (error) {
|
|
console.error(`[ActivityPub] boostPost failed for ${postUrl}:`, error.message);
|
|
return { ok: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send an Update(Person) activity to all followers so remote servers
|
|
* re-fetch the actor object (picking up profile changes, new featured
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
},
|
|
});
|
|
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);
|
|
}
|
|
}
|