feat: ActivityPub federation endpoint for Indiekit

Implements full ActivityPub federation as an Indiekit plugin:
- Actor document (Person) with RSA key pair for HTTP Signatures
- WebFinger discovery (acct:rick@rmendes.net)
- Inbox: handles Follow, Undo, Like, Announce, Create, Delete, Move
- Outbox: serves published posts as ActivityStreams 2.0
- Content negotiation: AS2 JSON for AP clients, passthrough for browsers
- JF2-to-AS2 converter for all Indiekit post types
- Syndicator integration (pre-ticked checkbox for delivery to followers)
- Mastodon migration: alias config, CSV import for followers/following
- Admin UI: dashboard, followers, following, activity log, migration page
- Data retention: configurable TTL on activities, optional raw JSON storage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-02-18 22:13:51 +01:00
commit da625592fd
22 changed files with 2240 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

12
assets/icon.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="4.93" x2="6.76" y2="6.76"/>
<line x1="17.24" y1="17.24" x2="19.07" y2="19.07"/>
<line x1="4.93" y1="19.07" x2="6.76" y2="17.24"/>
<line x1="17.24" y1="6.76" x2="19.07" y2="4.93"/>
</svg>

After

Width:  |  Height:  |  Size: 607 B

376
index.js Normal file
View File

@@ -0,0 +1,376 @@
import path from "node:path";
import express from "express";
import { handleWebFinger } from "./lib/webfinger.js";
import { buildActorDocument } from "./lib/actor.js";
import { getOrCreateKeyPair } from "./lib/keys.js";
import { jf2ToActivityStreams, resolvePostUrl } from "./lib/jf2-to-as2.js";
import { createFederationHandler } from "./lib/federation.js";
import { dashboardController } from "./lib/controllers/dashboard.js";
import { followersController } from "./lib/controllers/followers.js";
import { followingController } from "./lib/controllers/following.js";
import { activitiesController } from "./lib/controllers/activities.js";
import { migrateGetController, migratePostController } from "./lib/controllers/migrate.js";
const defaults = {
mountPath: "/activitypub",
actor: {
handle: "rick",
name: "",
summary: "",
icon: "",
},
checked: true,
alsoKnownAs: "",
activityRetentionDays: 90, // Auto-delete activities older than this (0 = keep forever)
storeRawActivities: false, // Store full incoming JSON (enables debugging, costs storage)
};
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;
// Set at init time when we have access to Indiekit
this._publicationUrl = "";
this._actorUrl = "";
this._collections = {};
this._federationHandler = null;
}
get navigationItems() {
return {
href: this.options.mountPath,
text: "activitypub.title",
requiresDatabase: true,
};
}
get filePath() {
return path.dirname(new URL(import.meta.url).pathname);
}
/**
* WebFinger routes — mounted at /.well-known/
*/
get routesWellKnown() {
const router = express.Router(); // eslint-disable-line new-cap
const options = this.options;
const self = this;
router.get("/webfinger", (request, response) => {
const resource = request.query.resource;
if (!resource) {
return response.status(400).json({ error: "Missing resource parameter" });
}
const result = handleWebFinger(resource, {
handle: options.actor.handle,
hostname: new URL(self._publicationUrl).hostname,
actorUrl: self._actorUrl,
});
if (!result) {
return response.status(404).json({ error: "Resource not found" });
}
response.set("Content-Type", "application/jrd+json");
return response.json(result);
});
return router;
}
/**
* Public federation routes — mounted at mountPath, unauthenticated
*/
get routesPublic() {
const router = express.Router(); // eslint-disable-line new-cap
const self = this;
// Actor document (fallback — primary is content negotiation on /)
router.get("/actor", async (request, response) => {
const actor = await self._getActorDocument();
if (!actor) {
return response.status(500).json({ error: "Actor not configured" });
}
response.set("Content-Type", "application/activity+json");
return response.json(actor);
});
// Inbox — receive incoming activities
router.post("/inbox", express.raw({ type: ["application/activity+json", "application/ld+json", "application/json"] }), async (request, response, next) => {
try {
if (self._federationHandler) {
return await self._federationHandler.handleInbox(request, response);
}
return response.status(202).json({ status: "accepted" });
} catch (error) {
next(error);
}
});
// Outbox — serve published posts as ActivityStreams
router.get("/outbox", async (request, response, next) => {
try {
if (self._federationHandler) {
return await self._federationHandler.handleOutbox(request, response);
}
response.set("Content-Type", "application/activity+json");
return response.json({
"@context": "https://www.w3.org/ns/activitystreams",
type: "OrderedCollection",
totalItems: 0,
orderedItems: [],
});
} catch (error) {
next(error);
}
});
// Followers collection
router.get("/followers", async (request, response, next) => {
try {
if (self._federationHandler) {
return await self._federationHandler.handleFollowers(request, response);
}
response.set("Content-Type", "application/activity+json");
return response.json({
"@context": "https://www.w3.org/ns/activitystreams",
type: "OrderedCollection",
totalItems: 0,
orderedItems: [],
});
} catch (error) {
next(error);
}
});
// Following collection
router.get("/following", async (request, response, next) => {
try {
if (self._federationHandler) {
return await self._federationHandler.handleFollowing(request, response);
}
response.set("Content-Type", "application/activity+json");
return response.json({
"@context": "https://www.w3.org/ns/activitystreams",
type: "OrderedCollection",
totalItems: 0,
orderedItems: [],
});
} catch (error) {
next(error);
}
});
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/followers", followersController(mp));
router.get("/admin/following", followingController(mp));
router.get("/admin/activities", activitiesController(mp));
router.get("/admin/migrate", migrateGetController(mp));
router.post("/admin/migrate", migratePostController(mp, this.options));
return router;
}
/**
* Content negotiation handler — serves AS2 JSON for ActivityPub clients
* Registered as a separate endpoint with mountPath "/"
*/
get contentNegotiationRoutes() {
const router = express.Router(); // eslint-disable-line new-cap
const self = this;
router.get("*", async (request, response, next) => {
const accept = request.headers.accept || "";
const isActivityPub =
accept.includes("application/activity+json") ||
accept.includes("application/ld+json");
if (!isActivityPub) {
return next();
}
try {
// Root URL — serve actor document
if (request.path === "/") {
const actor = await self._getActorDocument();
if (!actor) {
return next();
}
response.set("Content-Type", "application/activity+json");
return response.json(actor);
}
// Post URLs — look up in database and convert to AS2
const { application } = request.app.locals;
const postsCollection = application?.collections?.get("posts");
if (!postsCollection) {
return next();
}
// Try to find a post matching this URL path
const requestUrl = `${self._publicationUrl}${request.path.slice(1)}`;
const post = await postsCollection.findOne({
"properties.url": requestUrl,
});
if (!post) {
return next();
}
const activity = jf2ToActivityStreams(
post.properties,
self._actorUrl,
self._publicationUrl,
);
// Return the object, not the wrapping Create activity
const object = activity.object || activity;
response.set("Content-Type", "application/activity+json");
return response.json({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
...object,
});
} catch {
return next();
}
});
return router;
}
/**
* Build and cache the actor document
*/
async _getActorDocument() {
const keysCollection = this._collections.ap_keys;
if (!keysCollection) {
return null;
}
const keyPair = await getOrCreateKeyPair(keysCollection, this._actorUrl);
return buildActorDocument({
actorUrl: this._actorUrl,
publicationUrl: this._publicationUrl,
mountPath: this.options.mountPath,
handle: this.options.actor.handle,
name: this.options.actor.name,
summary: this.options.actor.summary,
icon: this.options.actor.icon,
alsoKnownAs: this.options.alsoKnownAs,
publicKeyPem: keyPair.publicKeyPem,
});
}
/**
* Syndicator — delivers posts to ActivityPub followers
*/
get syndicator() {
const self = this;
return {
name: "ActivityPub syndicator",
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, publication) {
if (!self._federationHandler) {
return undefined;
}
return self._federationHandler.deliverToFollowers(
properties,
publication,
);
},
};
}
init(Indiekit) {
// Store publication URL for later use
this._publicationUrl = Indiekit.publication?.me
? Indiekit.publication.me.endsWith("/")
? Indiekit.publication.me
: `${Indiekit.publication.me}/`
: "";
this._actorUrl = this._publicationUrl;
// Register MongoDB collections
Indiekit.addCollection("ap_followers");
Indiekit.addCollection("ap_following");
Indiekit.addCollection("ap_activities");
Indiekit.addCollection("ap_keys");
// Store collection references for later use
this._collections = {
ap_followers: Indiekit.collections.get("ap_followers"),
ap_following: Indiekit.collections.get("ap_following"),
ap_activities: Indiekit.collections.get("ap_activities"),
ap_keys: Indiekit.collections.get("ap_keys"),
};
// Set up TTL index so ap_activities self-cleans (MongoDB handles expiry)
const retentionDays = this.options.activityRetentionDays;
if (retentionDays > 0) {
this._collections.ap_activities.createIndex(
{ receivedAt: 1 },
{ expireAfterSeconds: retentionDays * 86_400 },
);
}
// Initialize federation handler
this._federationHandler = createFederationHandler({
actorUrl: this._actorUrl,
publicationUrl: this._publicationUrl,
mountPath: this.options.mountPath,
actorConfig: this.options.actor,
alsoKnownAs: this.options.alsoKnownAs,
collections: this._collections,
storeRawActivities: this.options.storeRawActivities,
});
// Register as endpoint (adds routes)
Indiekit.addEndpoint(this);
// Register content negotiation handler as a virtual endpoint
Indiekit.addEndpoint({
name: "ActivityPub content negotiation",
mountPath: "/",
routesPublic: this.contentNegotiationRoutes,
});
// Register as syndicator (appears in post UI)
Indiekit.addSyndicator(this.syndicator);
}
}

75
lib/actor.js Normal file
View File

@@ -0,0 +1,75 @@
/**
* Build an ActivityPub Person actor document.
*
* This is the identity document that remote servers fetch to learn about
* this actor — it contains the profile, endpoints, and the public key
* used to verify HTTP Signatures on outbound activities.
*
* @param {object} options
* @param {string} options.actorUrl - Actor URL (also the Person id)
* @param {string} options.publicationUrl - Publication base URL (trailing slash)
* @param {string} options.mountPath - Plugin mount path (e.g. "/activitypub")
* @param {string} options.handle - Preferred username (e.g. "rick")
* @param {string} options.name - Display name
* @param {string} options.summary - Bio / profile summary
* @param {string} options.icon - Avatar URL or path
* @param {string} options.alsoKnownAs - Previous account URL (for Mastodon migration)
* @param {string} options.publicKeyPem - PEM-encoded RSA public key
* @returns {object} ActivityStreams Person document
*/
export function buildActorDocument(options) {
const {
actorUrl,
publicationUrl,
mountPath,
handle,
name,
summary,
icon,
alsoKnownAs,
publicKeyPem,
} = options;
const baseUrl = publicationUrl.replace(/\/$/, "");
const actor = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
type: "Person",
id: actorUrl,
preferredUsername: handle,
name: name || handle,
url: actorUrl,
inbox: `${baseUrl}${mountPath}/inbox`,
outbox: `${baseUrl}${mountPath}/outbox`,
followers: `${baseUrl}${mountPath}/followers`,
following: `${baseUrl}${mountPath}/following`,
publicKey: {
id: `${actorUrl}#main-key`,
owner: actorUrl,
publicKeyPem,
},
};
if (summary) {
actor.summary = summary;
}
if (icon) {
const iconUrl = icon.startsWith("http") ? icon : `${baseUrl}${icon.startsWith("/") ? "" : "/"}${icon}`;
actor.icon = {
type: "Image",
url: iconUrl,
};
}
if (alsoKnownAs) {
actor.alsoKnownAs = Array.isArray(alsoKnownAs)
? alsoKnownAs
: [alsoKnownAs];
}
return actor;
}

View File

@@ -0,0 +1,56 @@
/**
* Activity log controller — paginated list of inbound/outbound activities.
*/
const PAGE_SIZE = 20;
export function activitiesController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collection = application?.collections?.get("ap_activities");
if (!collection) {
return response.render("activities", {
title: response.locals.__("activitypub.activities"),
activities: [],
mountPath,
});
}
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
const totalCount = await collection.countDocuments();
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const activities = await collection
.find()
.sort({ receivedAt: -1 })
.skip((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
.toArray();
const cursor = buildCursor(page, totalPages, mountPath + "/admin/activities");
response.render("activities", {
title: response.locals.__("activitypub.activities"),
activities,
mountPath,
cursor,
});
} catch (error) {
next(error);
}
};
}
function buildCursor(page, totalPages, basePath) {
if (totalPages <= 1) return null;
return {
previous: page > 1
? { href: `${basePath}?page=${page - 1}` }
: undefined,
next: page < totalPages
? { href: `${basePath}?page=${page + 1}` }
: undefined,
};
}

View File

@@ -0,0 +1,39 @@
/**
* Dashboard controller — shows follower/following counts and recent activity.
*/
export function dashboardController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const followersCollection = application?.collections?.get("ap_followers");
const followingCollection = application?.collections?.get("ap_following");
const activitiesCollection =
application?.collections?.get("ap_activities");
const followerCount = followersCollection
? await followersCollection.countDocuments()
: 0;
const followingCount = followingCollection
? await followingCollection.countDocuments()
: 0;
const recentActivities = activitiesCollection
? await activitiesCollection
.find()
.sort({ receivedAt: -1 })
.limit(10)
.toArray()
: [];
response.render("dashboard", {
title: response.locals.__("activitypub.title"),
followerCount,
followingCount,
recentActivities,
mountPath,
});
} catch (error) {
next(error);
}
};
}

View File

@@ -0,0 +1,58 @@
/**
* Followers list controller — paginated list of accounts following this actor.
*/
const PAGE_SIZE = 20;
export function followersController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collection = application?.collections?.get("ap_followers");
if (!collection) {
return response.render("followers", {
title: response.locals.__("activitypub.followers"),
followers: [],
followerCount: 0,
mountPath,
});
}
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
const totalCount = await collection.countDocuments();
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const followers = await collection
.find()
.sort({ followedAt: -1 })
.skip((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
.toArray();
const cursor = buildCursor(page, totalPages, mountPath + "/admin/followers");
response.render("followers", {
title: response.locals.__("activitypub.followers"),
followers,
followerCount: totalCount,
mountPath,
cursor,
});
} catch (error) {
next(error);
}
};
}
function buildCursor(page, totalPages, basePath) {
if (totalPages <= 1) return null;
return {
previous: page > 1
? { href: `${basePath}?page=${page - 1}` }
: undefined,
next: page < totalPages
? { href: `${basePath}?page=${page + 1}` }
: undefined,
};
}

View File

@@ -0,0 +1,58 @@
/**
* Following list controller — paginated list of accounts this actor follows.
*/
const PAGE_SIZE = 20;
export function followingController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collection = application?.collections?.get("ap_following");
if (!collection) {
return response.render("following", {
title: response.locals.__("activitypub.following"),
following: [],
followingCount: 0,
mountPath,
});
}
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
const totalCount = await collection.countDocuments();
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const following = await collection
.find()
.sort({ followedAt: -1 })
.skip((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
.toArray();
const cursor = buildCursor(page, totalPages, mountPath + "/admin/following");
response.render("following", {
title: response.locals.__("activitypub.following"),
following,
followingCount: totalCount,
mountPath,
cursor,
});
} catch (error) {
next(error);
}
};
}
function buildCursor(page, totalPages, basePath) {
if (totalPages <= 1) return null;
return {
previous: page > 1
? { href: `${basePath}?page=${page - 1}` }
: undefined,
next: page < totalPages
? { href: `${basePath}?page=${page + 1}` }
: undefined,
};
}

121
lib/controllers/migrate.js Normal file
View File

@@ -0,0 +1,121 @@
/**
* Migration controller — handles Mastodon account migration UI.
*
* GET: shows the 3-step migration page
* POST: processes alias update or CSV file import
*/
import {
parseMastodonFollowingCsv,
parseMastodonFollowersList,
bulkImportFollowing,
bulkImportFollowers,
} from "../migration.js";
export function migrateGetController(mountPath) {
return async (request, response, next) => {
try {
response.render("migrate", {
title: response.locals.__("activitypub.migrate"),
mountPath,
result: null,
});
} catch (error) {
next(error);
}
};
}
export function migratePostController(mountPath, pluginOptions) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const action = request.body.action;
let result = null;
if (action === "alias") {
// Update alsoKnownAs on the actor config
const aliasUrl = request.body.aliasUrl?.trim();
if (aliasUrl) {
pluginOptions.alsoKnownAs = aliasUrl;
result = {
type: "success",
text: response.locals.__("activitypub.migrate.aliasSuccess"),
};
}
}
if (action === "import") {
const followingCollection =
application?.collections?.get("ap_following");
const followersCollection =
application?.collections?.get("ap_followers");
const importFollowing = request.body.importTypes?.includes("following");
const importFollowers = request.body.importTypes?.includes("followers");
// Read uploaded file — express-fileupload or raw body
const fileContent = extractFileContent(request);
if (!fileContent) {
result = { type: "error", text: "No file uploaded" };
} else {
let followingResult = { imported: 0, failed: 0 };
let followersResult = { imported: 0, failed: 0 };
if (importFollowing && followingCollection) {
const handles = parseMastodonFollowingCsv(fileContent);
followingResult = await bulkImportFollowing(
handles,
followingCollection,
);
}
if (importFollowers && followersCollection) {
const entries = parseMastodonFollowersList(fileContent);
followersResult = await bulkImportFollowers(
entries,
followersCollection,
);
}
const totalFailed =
followingResult.failed + followersResult.failed;
result = {
type: "success",
text: response.locals
.__("activitypub.migrate.success")
.replace("%d", followingResult.imported)
.replace("%d", followersResult.imported)
.replace("%d", totalFailed),
};
}
}
response.render("migrate", {
title: response.locals.__("activitypub.migrate"),
mountPath,
result,
});
} catch (error) {
next(error);
}
};
}
/**
* Extract file content from the request.
* Supports express-fileupload (request.files) and raw text body.
*/
function extractFileContent(request) {
// express-fileupload attaches to request.files
if (request.files?.csvFile) {
return request.files.csvFile.data.toString("utf-8");
}
// Fallback: file content submitted as text in a textarea
if (request.body.csvContent) {
return request.body.csvContent;
}
return null;
}

410
lib/federation.js Normal file
View File

@@ -0,0 +1,410 @@
/**
* Federation handler — the core glue for ActivityPub protocol operations.
*
* Handles HTTP Signature signing/verification, inbox dispatch, outbox
* serving, collection endpoints, and outbound activity delivery.
*
* Uses Node's crypto for HTTP Signatures rather than Fedify's middleware,
* because the plugin owns its own Express routes and Fedify's
* integrateFederation() expects to own the request lifecycle.
* Fedify is used for utility functions (e.g. lookupWebFinger in migration).
*/
import { createHash, createSign, createVerify } from "node:crypto";
import { getOrCreateKeyPair } from "./keys.js";
import { jf2ToActivityStreams, resolvePostUrl } from "./jf2-to-as2.js";
import { processInboxActivity } from "./inbox.js";
/**
* Create the federation handler used by all AP route handlers in index.js.
*
* @param {object} options
* @param {string} options.actorUrl - Actor URL (e.g. "https://rmendes.net/")
* @param {string} options.publicationUrl - Publication base URL with trailing slash
* @param {string} options.mountPath - Plugin mount path (e.g. "/activitypub")
* @param {object} options.actorConfig - { handle, name, summary, icon }
* @param {string} options.alsoKnownAs - Previous account URL for migration
* @param {object} options.collections - MongoDB collections
* @returns {object} Handler with handleInbox, handleOutbox, handleFollowers, handleFollowing, deliverToFollowers
*/
export function createFederationHandler(options) {
const {
actorUrl,
publicationUrl,
mountPath,
collections,
storeRawActivities = false,
} = options;
const baseUrl = publicationUrl.replace(/\/$/, "");
const keyId = `${actorUrl}#main-key`;
// Lazy-loaded key pair — fetched from MongoDB on first use
let _keyPair = null;
async function getKeyPair() {
if (!_keyPair) {
_keyPair = await getOrCreateKeyPair(collections.ap_keys, actorUrl);
}
return _keyPair;
}
return {
/**
* POST /inbox — receive and process incoming activities.
*/
async handleInbox(request, response) {
let body;
try {
const raw =
request.body instanceof Buffer
? request.body
: Buffer.from(request.body || "");
body = JSON.parse(raw.toString("utf-8"));
} catch {
return response.status(400).json({ error: "Invalid JSON" });
}
// Verify HTTP Signature
const rawBuffer =
request.body instanceof Buffer
? request.body
: Buffer.from(request.body || "");
const signatureValid = await verifyHttpSignature(request, rawBuffer);
if (!signatureValid) {
return response.status(401).json({ error: "Invalid HTTP signature" });
}
// Dispatch to inbox handlers
try {
await processInboxActivity(body, collections, {
actorUrl,
storeRawActivities,
async deliverActivity(activity, inboxUrl) {
const keyPair = await getKeyPair();
return sendSignedActivity(
activity,
inboxUrl,
keyPair.privateKeyPem,
keyId,
);
},
});
return response.status(202).json({ status: "accepted" });
} catch (error) {
console.error("[ActivityPub] Inbox processing error:", error);
return response
.status(500)
.json({ error: "Failed to process activity" });
}
},
/**
* GET /outbox — serve published posts as an OrderedCollection.
*/
async handleOutbox(request, response) {
const { application } = request.app.locals;
const postsCollection = application?.collections?.get("posts");
if (!postsCollection) {
response.set("Content-Type", "application/activity+json");
return response.json(emptyCollection(`${baseUrl}${mountPath}/outbox`));
}
const page = Number.parseInt(request.query.page, 10) || 0;
const pageSize = 20;
const totalItems = await postsCollection.countDocuments();
const posts = await postsCollection
.find()
.sort({ "properties.published": -1 })
.skip(page * pageSize)
.limit(pageSize)
.toArray();
const orderedItems = posts.map((post) =>
jf2ToActivityStreams(post.properties, actorUrl, publicationUrl),
);
response.set("Content-Type", "application/activity+json");
return response.json({
"@context": "https://www.w3.org/ns/activitystreams",
type: "OrderedCollection",
id: `${baseUrl}${mountPath}/outbox`,
totalItems,
orderedItems,
});
},
/**
* GET /followers — serve followers as an OrderedCollection.
*/
async handleFollowers(request, response) {
const docs = await collections.ap_followers
.find()
.sort({ followedAt: -1 })
.toArray();
response.set("Content-Type", "application/activity+json");
return response.json({
"@context": "https://www.w3.org/ns/activitystreams",
type: "OrderedCollection",
id: `${baseUrl}${mountPath}/followers`,
totalItems: docs.length,
orderedItems: docs.map((f) => f.actorUrl),
});
},
/**
* GET /following — serve following as an OrderedCollection.
*/
async handleFollowing(request, response) {
const docs = await collections.ap_following
.find()
.sort({ followedAt: -1 })
.toArray();
response.set("Content-Type", "application/activity+json");
return response.json({
"@context": "https://www.w3.org/ns/activitystreams",
type: "OrderedCollection",
id: `${baseUrl}${mountPath}/following`,
totalItems: docs.length,
orderedItems: docs.map((f) => f.actorUrl),
});
},
/**
* Deliver a post to all followers' inboxes.
* Called by the syndicator when a post is published with AP ticked.
*
* @param {object} properties - JF2 post properties
* @param {object} publication - Indiekit publication object
* @returns {string} The ActivityPub object URL (stored as syndication URL)
*/
async deliverToFollowers(properties) {
const keyPair = await getKeyPair();
const activity = jf2ToActivityStreams(
properties,
actorUrl,
publicationUrl,
);
// Set an explicit activity ID
const postUrl = resolvePostUrl(properties.url, publicationUrl);
activity.id = `${postUrl}#activity`;
// Gather unique inbox URLs (prefer sharedInbox for efficiency)
const followers = await collections.ap_followers.find().toArray();
const inboxes = new Set();
for (const follower of followers) {
inboxes.add(follower.sharedInbox || follower.inbox);
}
// Deliver to each unique inbox
let delivered = 0;
for (const inboxUrl of inboxes) {
if (!inboxUrl) continue;
const ok = await sendSignedActivity(
activity,
inboxUrl,
keyPair.privateKeyPem,
keyId,
);
if (ok) delivered++;
}
// Log outbound activity
await collections.ap_activities.insertOne({
direction: "outbound",
type: activity.type,
actorUrl,
objectUrl: activity.object?.id || activity.object,
summary: `Delivered ${activity.type} to ${delivered}/${inboxes.size} inboxes`,
receivedAt: new Date(),
...(storeRawActivities ? { raw: activity } : {}),
});
// Return the object URL — Indiekit stores this in the post's syndication array
return activity.object?.id || activity.object?.url || postUrl;
},
};
}
// --- HTTP Signature implementation ---
/**
* Compute SHA-256 digest of a body buffer for the Digest header.
*/
function computeDigest(body) {
const hash = createHash("sha256").update(body).digest("base64");
return `SHA-256=${hash}`;
}
/**
* Sign and send an activity to a remote inbox.
*
* @param {object} activity - ActivityStreams activity object
* @param {string} inboxUrl - Target inbox URL
* @param {string} privateKeyPem - PEM-encoded RSA private key
* @param {string} keyId - Key ID URL (e.g. "https://rmendes.net/#main-key")
* @returns {Promise<boolean>} true if delivery succeeded
*/
async function sendSignedActivity(activity, inboxUrl, privateKeyPem, keyId) {
const body = JSON.stringify(activity);
const bodyBuffer = Buffer.from(body);
const url = new URL(inboxUrl);
const date = new Date().toUTCString();
const digest = computeDigest(bodyBuffer);
// Build the signing string per HTTP Signatures spec
const signingString = [
`(request-target): post ${url.pathname}`,
`host: ${url.host}`,
`date: ${date}`,
`digest: ${digest}`,
].join("\n");
const signer = createSign("sha256");
signer.update(signingString);
const signature = signer.sign(privateKeyPem, "base64");
const signatureHeader = [
`keyId="${keyId}"`,
`algorithm="rsa-sha256"`,
`headers="(request-target) host date digest"`,
`signature="${signature}"`,
].join(",");
try {
const response = await fetch(inboxUrl, {
method: "POST",
headers: {
"Content-Type": "application/activity+json",
Host: url.host,
Date: date,
Digest: digest,
Signature: signatureHeader,
},
body,
signal: AbortSignal.timeout(15_000),
});
return response.ok || response.status === 202;
} catch (error) {
console.error(
`[ActivityPub] Delivery to ${inboxUrl} failed:`,
error.message,
);
return false;
}
}
/**
* Verify the HTTP Signature on an incoming request.
*
* 1. Parse the Signature header
* 2. Fetch the remote actor's public key via keyId
* 3. Reconstruct the signing string
* 4. Verify with RSA-SHA256
*
* @param {object} request - Express request object
* @param {Buffer} rawBody - Raw request body for digest verification
* @returns {Promise<boolean>} true if signature is valid
*/
async function verifyHttpSignature(request, rawBody) {
const sigHeader = request.headers.signature;
if (!sigHeader) return false;
// Parse signature header: keyId="...",algorithm="...",headers="...",signature="..."
const params = {};
for (const part of sigHeader.split(",")) {
const eqIndex = part.indexOf("=");
if (eqIndex === -1) continue;
const key = part.slice(0, eqIndex).trim();
const value = part.slice(eqIndex + 1).trim().replace(/^"|"$/g, "");
params[key] = value;
}
const { keyId: remoteKeyId, headers: headerNames, signature } = params;
if (!remoteKeyId || !headerNames || !signature) return false;
// Verify Digest header matches body
if (request.headers.digest) {
const expectedDigest = computeDigest(rawBody);
if (request.headers.digest !== expectedDigest) return false;
}
// Fetch the remote actor document to get their public key
const publicKeyPem = await fetchRemotePublicKey(remoteKeyId);
if (!publicKeyPem) return false;
// Reconstruct signing string from the listed headers
const headerList = headerNames.split(" ");
const signingParts = headerList.map((h) => {
if (h === "(request-target)") {
const method = request.method.toLowerCase();
const path = request.originalUrl || request.url;
return `(request-target): ${method} ${path}`;
}
if (h === "host") {
return `host: ${request.headers.host || request.hostname}`;
}
return `${h}: ${request.headers[h]}`;
});
const signingString = signingParts.join("\n");
// Verify
try {
const verifier = createVerify("sha256");
verifier.update(signingString);
return verifier.verify(publicKeyPem, signature, "base64");
} catch {
return false;
}
}
/**
* Fetch a remote actor's public key by key ID URL.
* The keyId is typically "https://remote.example/users/alice#main-key"
* — we fetch the actor document (without fragment) and extract publicKey.
*/
async function fetchRemotePublicKey(keyIdUrl) {
try {
// Remove fragment to get the actor document URL
const actorUrl = keyIdUrl.split("#")[0];
const response = await fetch(actorUrl, {
headers: { Accept: "application/activity+json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return null;
const doc = await response.json();
// Key may be at doc.publicKey.publicKeyPem or in a publicKey array
if (doc.publicKey) {
const key = Array.isArray(doc.publicKey)
? doc.publicKey.find((k) => k.id === keyIdUrl) || doc.publicKey[0]
: doc.publicKey;
return key?.publicKeyPem || null;
}
return null;
} catch {
return null;
}
}
/**
* Build an empty OrderedCollection response.
*/
function emptyCollection(id) {
return {
"@context": "https://www.w3.org/ns/activitystreams",
type: "OrderedCollection",
id,
totalItems: 0,
orderedItems: [],
};
}

291
lib/inbox.js Normal file
View File

@@ -0,0 +1,291 @@
/**
* Inbox activity processors.
*
* Each handler receives a parsed ActivityStreams activity, the MongoDB
* collections, and a context object with delivery capabilities.
* Activities are auto-accepted (Follow) and logged for the admin UI.
*/
/**
* Dispatch an incoming activity to the appropriate handler.
*
* @param {object} activity - Parsed ActivityStreams activity
* @param {object} collections - MongoDB collections (ap_followers, ap_following, ap_activities)
* @param {object} context - { actorUrl, deliverActivity(activity, inboxUrl), storeRawActivities }
*/
export async function processInboxActivity(activity, collections, context) {
const type = activity.type;
switch (type) {
case "Follow":
return handleFollow(activity, collections, context);
case "Undo":
return handleUndo(activity, collections, context);
case "Like":
return handleLike(activity, collections, context);
case "Announce":
return handleAnnounce(activity, collections, context);
case "Create":
return handleCreate(activity, collections, context);
case "Delete":
return handleDelete(activity, collections);
case "Move":
return handleMove(activity, collections, context);
default:
await logActivity(collections, context, {
direction: "inbound",
type,
actorUrl: resolveActorUrl(activity.actor),
summary: `Received unhandled activity: ${type}`,
raw: activity,
});
}
}
/**
* Handle Follow — store follower, send Accept back.
*/
async function handleFollow(activity, collections, context) {
const followerActorUrl = resolveActorUrl(activity.actor);
// Fetch remote actor profile for display info
const profile = await fetchActorProfile(followerActorUrl);
// Upsert follower record
await collections.ap_followers.updateOne(
{ actorUrl: followerActorUrl },
{
$set: {
actorUrl: followerActorUrl,
handle: profile.preferredUsername || "",
name:
profile.name || profile.preferredUsername || followerActorUrl,
avatar: profile.icon?.url || "",
inbox: profile.inbox || "",
sharedInbox: profile.endpoints?.sharedInbox || "",
followedAt: new Date(),
},
},
{ upsert: true },
);
// Send Accept(Follow) back to the follower's inbox
const acceptActivity = {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Accept",
actor: context.actorUrl,
object: activity,
};
const targetInbox = profile.inbox || `${followerActorUrl}inbox`;
await context.deliverActivity(acceptActivity, targetInbox);
await logActivity(collections, context, {
direction: "inbound",
type: "Follow",
actorUrl: followerActorUrl,
actorName:
profile.name || profile.preferredUsername || followerActorUrl,
summary: `${profile.name || followerActorUrl} followed you`,
raw: activity,
});
}
/**
* Handle Undo — dispatch based on the inner activity type.
*/
async function handleUndo(activity, collections, context) {
const inner =
typeof activity.object === "string" ? { type: "unknown" } : activity.object;
const actorUrl = resolveActorUrl(activity.actor);
switch (inner.type) {
case "Follow":
await collections.ap_followers.deleteOne({ actorUrl });
await logActivity(collections, context, {
direction: "inbound",
type: "Undo(Follow)",
actorUrl,
summary: `${actorUrl} unfollowed you`,
raw: activity,
});
break;
case "Like":
await collections.ap_activities.deleteOne({
type: "Like",
actorUrl,
objectUrl: resolveObjectUrl(inner.object),
});
break;
case "Announce":
await collections.ap_activities.deleteOne({
type: "Announce",
actorUrl,
objectUrl: resolveObjectUrl(inner.object),
});
break;
default:
await logActivity(collections, context, {
direction: "inbound",
type: `Undo(${inner.type})`,
actorUrl,
summary: `${actorUrl} undid ${inner.type}`,
raw: activity,
});
}
}
/**
* Handle Like — log for admin display.
*/
async function handleLike(activity, collections, context) {
const actorUrl = resolveActorUrl(activity.actor);
const objectUrl = resolveObjectUrl(activity.object);
const profile = await fetchActorProfile(actorUrl);
await logActivity(collections, context, {
direction: "inbound",
type: "Like",
actorUrl,
actorName: profile.name || profile.preferredUsername || actorUrl,
objectUrl,
summary: `${profile.name || actorUrl} liked ${objectUrl}`,
raw: activity,
});
}
/**
* Handle Announce (boost) — log for admin display.
*/
async function handleAnnounce(activity, collections, context) {
const actorUrl = resolveActorUrl(activity.actor);
const objectUrl = resolveObjectUrl(activity.object);
const profile = await fetchActorProfile(actorUrl);
await logActivity(collections, context, {
direction: "inbound",
type: "Announce",
actorUrl,
actorName: profile.name || profile.preferredUsername || actorUrl,
objectUrl,
summary: `${profile.name || actorUrl} boosted ${objectUrl}`,
raw: activity,
});
}
/**
* Handle Create — if it's a reply to one of our posts, log it.
*/
async function handleCreate(activity, collections, context) {
const object =
typeof activity.object === "string" ? { id: activity.object } : activity.object;
const inReplyTo = object.inReplyTo;
// Only log replies to our posts (inReplyTo is set)
if (!inReplyTo) return;
const actorUrl = resolveActorUrl(activity.actor);
const profile = await fetchActorProfile(actorUrl);
await logActivity(collections, context, {
direction: "inbound",
type: "Reply",
actorUrl,
actorName: profile.name || profile.preferredUsername || actorUrl,
objectUrl: object.id || object.url || "",
summary: `${profile.name || actorUrl} replied to ${inReplyTo}`,
raw: activity,
});
}
/**
* Handle Delete — remove activity records for deleted objects.
*/
async function handleDelete(activity, collections) {
const objectUrl = resolveObjectUrl(activity.object);
if (objectUrl) {
await collections.ap_activities.deleteMany({ objectUrl });
}
}
/**
* Handle Move — update follower record if actor moved to a new account.
* This is part of the Mastodon migration flow: after a Move, followers
* are expected to re-follow the new account.
*/
async function handleMove(activity, collections, context) {
const oldActorUrl = resolveActorUrl(activity.actor);
const newActorUrl = resolveObjectUrl(activity.target || activity.object);
if (oldActorUrl && newActorUrl) {
await collections.ap_followers.updateOne(
{ actorUrl: oldActorUrl },
{ $set: { actorUrl: newActorUrl, movedFrom: oldActorUrl } },
);
}
await logActivity(collections, context, {
direction: "inbound",
type: "Move",
actorUrl: oldActorUrl,
objectUrl: newActorUrl,
summary: `${oldActorUrl} moved to ${newActorUrl}`,
raw: activity,
});
}
// --- Helpers ---
/**
* Extract actor URL from an activity's actor field.
* The actor can be a string URL or an object with an id field.
*/
function resolveActorUrl(actor) {
if (typeof actor === "string") return actor;
return actor?.id || "";
}
/**
* Extract object URL from an activity's object field.
*/
function resolveObjectUrl(object) {
if (typeof object === "string") return object;
return object?.id || object?.url || "";
}
/**
* Fetch a remote actor's profile document for display info.
* Returns an empty object on failure — federation should be resilient
* to unreachable remote servers.
*/
async function fetchActorProfile(actorUrl) {
try {
const response = await fetch(actorUrl, {
headers: { Accept: "application/activity+json" },
signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
return await response.json();
}
} catch {
// Remote server unreachable — proceed without profile
}
return {};
}
/**
* Write an activity record to the ap_activities collection.
* Strips the raw JSON field unless storeRawActivities is enabled,
* keeping the activity log lightweight for backups.
*/
async function logActivity(collections, context, record) {
const { raw, ...rest } = record;
await collections.ap_activities.insertOne({
...rest,
...(context.storeRawActivities ? { raw } : {}),
receivedAt: new Date(),
});
}

191
lib/jf2-to-as2.js Normal file
View File

@@ -0,0 +1,191 @@
/**
* Convert Indiekit JF2 post properties to ActivityStreams 2.0 objects.
*
* JF2 is the simplified Microformats2 JSON format used by Indiekit internally.
* ActivityStreams 2.0 (AS2) is the JSON-LD format used by ActivityPub for federation.
*
* @param {object} properties - JF2 post properties from Indiekit's posts collection
* @param {string} actorUrl - This actor's URL (e.g. "https://rmendes.net/")
* @param {string} publicationUrl - Publication base URL with trailing slash
* @returns {object} ActivityStreams activity (Create, Like, or Announce)
*/
export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
const postType = properties["post-type"];
// Like — not wrapped in Create, stands alone
if (postType === "like") {
return {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Like",
actor: actorUrl,
object: properties["like-of"],
};
}
// Repost/boost — Announce activity
if (postType === "repost") {
return {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Announce",
actor: actorUrl,
object: properties["repost-of"],
};
}
// Everything else is wrapped in a Create activity
const isArticle = postType === "article" && properties.name;
const postUrl = resolvePostUrl(properties.url, publicationUrl);
const object = {
type: isArticle ? "Article" : "Note",
id: postUrl,
attributedTo: actorUrl,
published: properties.published,
url: postUrl,
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [`${actorUrl.replace(/\/$/, "")}/activitypub/followers`],
};
// Content — bookmarks get special treatment
if (postType === "bookmark") {
const bookmarkUrl = properties["bookmark-of"];
const commentary = properties.content?.html || properties.content || "";
object.content = commentary
? `${commentary}<br><br>\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`
: `\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`;
object.tag = [
{
type: "Hashtag",
name: "#bookmark",
href: `${publicationUrl}categories/bookmark`,
},
];
} else {
object.content = properties.content?.html || properties.content || "";
}
if (isArticle) {
object.name = properties.name;
if (properties.summary) {
object.summary = properties.summary;
}
}
// Reply
if (properties["in-reply-to"]) {
object.inReplyTo = properties["in-reply-to"];
}
// Media attachments
const attachments = [];
if (properties.photo) {
const photos = Array.isArray(properties.photo)
? properties.photo
: [properties.photo];
for (const photo of photos) {
const url = typeof photo === "string" ? photo : photo.url;
const alt = typeof photo === "string" ? "" : photo.alt || "";
attachments.push({
type: "Image",
mediaType: guessMediaType(url),
url: resolveMediaUrl(url, publicationUrl),
name: alt,
});
}
}
if (properties.video) {
const videos = Array.isArray(properties.video)
? properties.video
: [properties.video];
for (const video of videos) {
const url = typeof video === "string" ? video : video.url;
attachments.push({
type: "Video",
url: resolveMediaUrl(url, publicationUrl),
name: "",
});
}
}
if (properties.audio) {
const audios = Array.isArray(properties.audio)
? properties.audio
: [properties.audio];
for (const audio of audios) {
const url = typeof audio === "string" ? audio : audio.url;
attachments.push({
type: "Audio",
url: resolveMediaUrl(url, publicationUrl),
name: "",
});
}
}
if (attachments.length > 0) {
object.attachment = attachments;
}
// Categories → hashtags
if (properties.category) {
const categories = Array.isArray(properties.category)
? properties.category
: [properties.category];
object.tag = [
...(object.tag || []),
...categories.map((cat) => ({
type: "Hashtag",
name: `#${cat.replace(/\s+/g, "")}`,
href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
})),
];
}
return {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
actor: actorUrl,
object,
};
}
/**
* Resolve a post URL, ensuring it's absolute.
* @param {string} url - Post URL (may be relative or absolute)
* @param {string} publicationUrl - Base publication URL
* @returns {string} Absolute URL
*/
export function resolvePostUrl(url, publicationUrl) {
if (!url) return "";
if (url.startsWith("http")) return url;
const base = publicationUrl.replace(/\/$/, "");
return `${base}/${url.replace(/^\//, "")}`;
}
/**
* Resolve a media URL, ensuring it's absolute.
*/
function resolveMediaUrl(url, publicationUrl) {
if (!url) return "";
if (url.startsWith("http")) return url;
const base = publicationUrl.replace(/\/$/, "");
return `${base}/${url.replace(/^\//, "")}`;
}
/**
* Guess MIME type from file extension.
*/
function guessMediaType(url) {
const ext = url.split(".").pop()?.toLowerCase();
const types = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
avif: "image/avif",
};
return types[ext] || "image/jpeg";
}

39
lib/keys.js Normal file
View File

@@ -0,0 +1,39 @@
import { generateKeyPair } from "node:crypto";
import { promisify } from "node:util";
const generateKeyPairAsync = promisify(generateKeyPair);
/**
* Get or create an RSA 2048-bit key pair for the ActivityPub actor.
* Keys are stored in the ap_keys MongoDB collection so they persist
* across server restarts — a stable key pair is essential for federation
* since remote servers cache the public key for signature verification.
*
* @param {Collection} collection - MongoDB ap_keys collection
* @param {string} actorUrl - Actor URL (used as the key document identifier)
* @returns {Promise<{publicKeyPem: string, privateKeyPem: string}>}
*/
export async function getOrCreateKeyPair(collection, actorUrl) {
const existing = await collection.findOne({ actorUrl });
if (existing) {
return {
publicKeyPem: existing.publicKeyPem,
privateKeyPem: existing.privateKeyPem,
};
}
const { publicKey, privateKey } = await generateKeyPairAsync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
await collection.insertOne({
actorUrl,
publicKeyPem: publicKey,
privateKeyPem: privateKey,
createdAt: new Date(),
});
return { publicKeyPem: publicKey, privateKeyPem: privateKey };
}

184
lib/migration.js Normal file
View File

@@ -0,0 +1,184 @@
/**
* Mastodon migration utilities.
*
* Parses Mastodon data export CSVs and resolves handles via WebFinger
* to import followers/following into the ActivityPub collections.
*/
/**
* Parse Mastodon's following_accounts.csv export.
* Format: "Account address,Show boosts,Notify on new posts,Languages"
* First row is the header.
*
* @param {string} csvText - Raw CSV text
* @returns {string[]} Array of handles (e.g. ["user@instance.social"])
*/
export function parseMastodonFollowingCsv(csvText) {
const lines = csvText.trim().split("\n");
// Skip header row
return lines
.slice(1)
.map((line) => line.split(",")[0].trim())
.filter((handle) => handle.length > 0 && handle.includes("@"));
}
/**
* Parse Mastodon's followers CSV or JSON export.
* Accepts the same CSV format as following, or a JSON array of actor URLs.
*
* @param {string} text - Raw CSV or JSON text
* @returns {string[]} Array of handles or actor URLs
*/
export function parseMastodonFollowersList(text) {
const trimmed = text.trim();
// Try JSON first (array of actor URLs)
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
} catch {
// Fall through to CSV parsing
}
}
// CSV format — same as following
return parseMastodonFollowingCsv(trimmed);
}
/**
* Resolve a fediverse handle (user@instance) to an actor URL via WebFinger.
*
* @param {string} handle - Handle like "user@instance.social"
* @returns {Promise<{actorUrl: string, inbox: string, sharedInbox: string, name: string, handle: string} | null>}
*/
export async function resolveHandleViaWebFinger(handle) {
const [user, domain] = handle.split("@");
if (!user || !domain) return null;
try {
// WebFinger lookup
const wfUrl = `https://${domain}/.well-known/webfinger?resource=acct:${encodeURIComponent(handle)}`;
const wfResponse = await fetch(wfUrl, {
headers: { Accept: "application/jrd+json" },
signal: AbortSignal.timeout(10_000),
});
if (!wfResponse.ok) return null;
const jrd = await wfResponse.json();
const selfLink = jrd.links?.find(
(l) => l.rel === "self" && l.type === "application/activity+json",
);
if (!selfLink?.href) return null;
// Fetch actor document for inbox and profile
const actorResponse = await fetch(selfLink.href, {
headers: { Accept: "application/activity+json" },
signal: AbortSignal.timeout(10_000),
});
if (!actorResponse.ok) return null;
const actor = await actorResponse.json();
return {
actorUrl: actor.id || selfLink.href,
inbox: actor.inbox || "",
sharedInbox: actor.endpoints?.sharedInbox || "",
name: actor.name || actor.preferredUsername || handle,
handle: actor.preferredUsername || user,
};
} catch {
return null;
}
}
/**
* Import a list of handles into the ap_following collection.
*
* @param {string[]} handles - Array of handles to import
* @param {Collection} collection - MongoDB ap_following collection
* @returns {Promise<{imported: number, failed: number}>}
*/
export async function bulkImportFollowing(handles, collection) {
let imported = 0;
let failed = 0;
for (const handle of handles) {
const resolved = await resolveHandleViaWebFinger(handle);
if (!resolved) {
failed++;
continue;
}
await collection.updateOne(
{ actorUrl: resolved.actorUrl },
{
$set: {
actorUrl: resolved.actorUrl,
handle: resolved.handle,
name: resolved.name,
inbox: resolved.inbox,
sharedInbox: resolved.sharedInbox,
followedAt: new Date(),
source: "import",
},
},
{ upsert: true },
);
imported++;
}
return { imported, failed };
}
/**
* Import a list of handles/URLs into the ap_followers collection.
* These are "pending" followers — they'll become real when they
* re-follow after the Mastodon Move activity.
*
* @param {string[]} entries - Array of handles or actor URLs
* @param {Collection} collection - MongoDB ap_followers collection
* @returns {Promise<{imported: number, failed: number}>}
*/
export async function bulkImportFollowers(entries, collection) {
let imported = 0;
let failed = 0;
for (const entry of entries) {
// If it's a URL, store directly; if it's a handle, resolve via WebFinger
const isUrl = entry.startsWith("http");
let actorData;
if (isUrl) {
actorData = { actorUrl: entry, handle: "", name: entry, inbox: "", sharedInbox: "" };
} else {
actorData = await resolveHandleViaWebFinger(entry);
}
if (!actorData) {
failed++;
continue;
}
await collection.updateOne(
{ actorUrl: actorData.actorUrl },
{
$set: {
actorUrl: actorData.actorUrl,
handle: actorData.handle,
name: actorData.name,
inbox: actorData.inbox,
sharedInbox: actorData.sharedInbox,
followedAt: new Date(),
pending: true, // Will be confirmed when they re-follow after Move
},
},
{ upsert: true },
);
imported++;
}
return { imported, failed };
}

43
lib/webfinger.js Normal file
View File

@@ -0,0 +1,43 @@
/**
* Handle WebFinger resource resolution.
*
* WebFinger is the discovery mechanism for ActivityPub — when someone
* searches for @rick@rmendes.net, their server queries:
* GET /.well-known/webfinger?resource=acct:rick@rmendes.net
*
* We return a JRD (JSON Resource Descriptor) pointing to the actor URL
* so the remote server can then fetch the full actor document.
*
* @param {string} resource - The resource query (e.g. "acct:rick@rmendes.net")
* @param {object} options
* @param {string} options.handle - Actor handle (e.g. "rick")
* @param {string} options.hostname - Publication hostname (e.g. "rmendes.net")
* @param {string} options.actorUrl - Full actor URL (e.g. "https://rmendes.net/")
* @returns {object|null} JRD response object, or null if resource doesn't match
*/
export function handleWebFinger(resource, options) {
const { handle, hostname, actorUrl } = options;
const expectedAcct = `acct:${handle}@${hostname}`;
// Match both "acct:rick@rmendes.net" and the actor URL itself
if (resource !== expectedAcct && resource !== actorUrl) {
return null;
}
return {
subject: expectedAcct,
aliases: [actorUrl],
links: [
{
rel: "self",
type: "application/activity+json",
href: actorUrl,
},
{
rel: "http://webfinger.net/rel/profile-page",
type: "text/html",
href: actorUrl,
},
],
};
}

41
locales/en.json Normal file
View File

@@ -0,0 +1,41 @@
{
"activitypub": {
"title": "ActivityPub",
"followers": "Followers",
"following": "Following",
"activities": "Activity log",
"migrate": "Mastodon migration",
"recentActivity": "Recent activity",
"noActivity": "No activity yet. Once your actor is federated, interactions will appear here.",
"noFollowers": "No followers yet.",
"noFollowing": "Not following anyone yet.",
"followerCount": "%d follower",
"followerCount_plural": "%d followers",
"followingCount": "%d following",
"followedAt": "Followed",
"source": "Source",
"sourceImport": "Mastodon import",
"sourceManual": "Manual",
"sourceFederation": "Federation",
"direction": "Direction",
"directionInbound": "Received",
"directionOutbound": "Sent",
"migrate.aliasLabel": "Your old Mastodon account URL",
"migrate.aliasHint": "e.g. https://mstdn.social/users/rmdes — sets alsoKnownAs on your actor",
"migrate.aliasSave": "Save alias",
"migrate.importLabel": "Import followers and following",
"migrate.fileLabel": "Mastodon export CSV",
"migrate.fileHint": "Upload following_accounts.csv from your Mastodon data export",
"migrate.importButton": "Import",
"migrate.importFollowing": "Import following list",
"migrate.importFollowers": "Import followers list (pending until they re-follow after Move)",
"migrate.step1Title": "Step 1 — Configure actor alias",
"migrate.step1Desc": "Link your old Mastodon account to this actor so the fediverse knows they are the same person.",
"migrate.step2Title": "Step 2 — Import followers/following",
"migrate.step2Desc": "Upload your Mastodon data export CSV to import your social graph.",
"migrate.step3Title": "Step 3 — Trigger Move on Mastodon",
"migrate.step3Desc": "Go to your Mastodon instance → Preferences → Account → Move to a different account. Enter your new handle and confirm. After the Move, followers will automatically re-follow you here.",
"migrate.success": "Imported %d following, %d followers (%d failed).",
"migrate.aliasSuccess": "Actor alias updated."
}
}

51
package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "0.1.0",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
"indiekit-plugin",
"indieweb",
"activitypub",
"fediverse",
"federation",
"fedify"
],
"author": {
"name": "Ricardo Mendes",
"url": "https://rmendes.net"
},
"license": "MIT",
"engines": {
"node": ">=22"
},
"type": "module",
"main": "index.js",
"exports": "./index.js",
"files": [
"assets",
"lib",
"locales",
"views",
"index.js"
],
"repository": {
"type": "git",
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub"
},
"bugs": {
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
},
"dependencies": {
"@fedify/fedify": "^1.10.0",
"@fedify/express": "^1.9.0",
"express": "^5.0.0"
},
"peerDependencies": {
"@indiekit/error": "^1.0.0-beta.25",
"@indiekit/frontend": "^1.0.0-beta.25"
},
"publishConfig": {
"access": "public"
}
}

29
views/activities.njk Normal file
View File

@@ -0,0 +1,29 @@
{% extends "document.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "card/macro.njk" import card with context %}
{% from "badge/macro.njk" import badge with context %}
{% from "prose/macro.njk" import prose with context %}
{% from "pagination/macro.njk" import pagination with context %}
{% block content %}
{{ heading({ text: __("activitypub.activities"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
{% if activities.length > 0 %}
{% for activity in activities %}
{{ card({
title: activity.actorName or activity.actorUrl,
description: { text: activity.summary },
published: activity.receivedAt,
badges: [
{ text: activity.type },
{ text: __("activitypub.directionInbound") if activity.direction === "inbound" else __("activitypub.directionOutbound") }
]
}) }}
{% endfor %}
{{ pagination(cursor) if cursor }}
{% else %}
{{ prose({ text: __("activitypub.noActivity") }) }}
{% endif %}
{% endblock %}

45
views/dashboard.njk Normal file
View File

@@ -0,0 +1,45 @@
{% extends "document.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "card/macro.njk" import card with context %}
{% from "card-grid/macro.njk" import cardGrid with context %}
{% from "prose/macro.njk" import prose with context %}
{% from "badge/macro.njk" import badge with context %}
{% block content %}
{{ heading({ text: title, level: 1 }) }}
{{ cardGrid({ cardSize: "16rem", items: [
{
title: followerCount + " " + __("activitypub.followers"),
url: mountPath + "/admin/followers"
},
{
title: followingCount + " " + __("activitypub.following"),
url: mountPath + "/admin/following"
},
{
title: __("activitypub.activities"),
url: mountPath + "/admin/activities"
},
{
title: __("activitypub.migrate"),
url: mountPath + "/admin/migrate"
}
]}) }}
{{ heading({ text: __("activitypub.recentActivity"), level: 2 }) }}
{% if recentActivities.length > 0 %}
{% for activity in recentActivities %}
{{ card({
title: activity.actorName or activity.actorUrl,
description: { text: activity.summary },
published: activity.receivedAt,
badges: [{ text: activity.type }]
}) }}
{% endfor %}
{% else %}
{{ prose({ text: __("activitypub.noActivity") }) }}
{% endif %}
{% endblock %}

26
views/followers.njk Normal file
View File

@@ -0,0 +1,26 @@
{% extends "document.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "card/macro.njk" import card with context %}
{% from "prose/macro.njk" import prose with context %}
{% from "pagination/macro.njk" import pagination with context %}
{% block content %}
{{ heading({ text: followerCount + " " + __("activitypub.followers"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
{% if followers.length > 0 %}
{% for follower in followers %}
{{ card({
title: follower.name or follower.handle or follower.actorUrl,
url: follower.actorUrl,
photo: { src: follower.avatar, alt: follower.name } if follower.avatar,
description: { text: "@" + follower.handle if follower.handle },
published: follower.followedAt
}) }}
{% endfor %}
{{ pagination(cursor) if cursor }}
{% else %}
{{ prose({ text: __("activitypub.noFollowers") }) }}
{% endif %}
{% endblock %}

27
views/following.njk Normal file
View File

@@ -0,0 +1,27 @@
{% extends "document.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "card/macro.njk" import card with context %}
{% from "prose/macro.njk" import prose with context %}
{% from "badge/macro.njk" import badge with context %}
{% from "pagination/macro.njk" import pagination with context %}
{% block content %}
{{ heading({ text: followingCount + " " + __("activitypub.following"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
{% if following.length > 0 %}
{% for account in following %}
{{ card({
title: account.name or account.handle or account.actorUrl,
url: account.actorUrl,
description: { text: "@" + account.handle if account.handle },
published: account.followedAt,
badges: [{ text: __("activitypub.sourceImport") if account.source === "import" else __("activitypub.sourceFederation") }]
}) }}
{% endfor %}
{{ pagination(cursor) if cursor }}
{% else %}
{{ prose({ text: __("activitypub.noFollowing") }) }}
{% endif %}
{% endblock %}

67
views/migrate.njk Normal file
View File

@@ -0,0 +1,67 @@
{% extends "document.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "input/macro.njk" import input with context %}
{% from "button/macro.njk" import button with context %}
{% from "checkboxes/macro.njk" import checkboxes with context %}
{% from "file-input/macro.njk" import fileInput with context %}
{% from "details/macro.njk" import details with context %}
{% from "notification-banner/macro.njk" import notificationBanner with context %}
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
{% if result %}
{{ notificationBanner({ type: result.type, text: result.text }) }}
{% endif %}
{# Step 1 — Actor alias #}
{{ heading({ text: __("activitypub.migrate.step1Title"), level: 2 }) }}
{{ prose({ text: __("activitypub.migrate.step1Desc") }) }}
<form method="post" novalidate>
<input type="hidden" name="action" value="alias">
{{ input({
name: "aliasUrl",
label: __("activitypub.migrate.aliasLabel"),
hint: __("activitypub.migrate.aliasHint"),
type: "url"
}) }}
{{ button({ text: __("activitypub.migrate.aliasSave") }) }}
</form>
<hr>
{# Step 2 — Import CSV #}
{{ heading({ text: __("activitypub.migrate.step2Title"), level: 2 }) }}
{{ prose({ text: __("activitypub.migrate.step2Desc") }) }}
<form method="post" enctype="multipart/form-data" novalidate>
<input type="hidden" name="action" value="import">
{{ checkboxes({
name: "importTypes",
items: [
{ value: "following", text: __("activitypub.migrate.importFollowing") },
{ value: "followers", text: __("activitypub.migrate.importFollowers") }
],
values: ["following"]
}) }}
{{ fileInput({
name: "csvFile",
label: __("activitypub.migrate.fileLabel"),
hint: __("activitypub.migrate.fileHint"),
accept: ".csv,.txt"
}) }}
{{ button({ text: __("activitypub.migrate.importButton") }) }}
</form>
<hr>
{# Step 3 — Instructions #}
{{ heading({ text: __("activitypub.migrate.step3Title"), level: 2 }) }}
{{ prose({ text: __("activitypub.migrate.step3Desc") }) }}
{% endblock %}