From da625592fd9a864a0000c0eac0e0c3c7dc7f7cbe Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 18 Feb 2026 22:13:51 +0100 Subject: [PATCH] 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 --- .gitignore | 1 + assets/icon.svg | 12 + index.js | 376 +++++++++++++++++++++++++++++++ lib/actor.js | 75 +++++++ lib/controllers/activities.js | 56 +++++ lib/controllers/dashboard.js | 39 ++++ lib/controllers/followers.js | 58 +++++ lib/controllers/following.js | 58 +++++ lib/controllers/migrate.js | 121 ++++++++++ lib/federation.js | 410 ++++++++++++++++++++++++++++++++++ lib/inbox.js | 291 ++++++++++++++++++++++++ lib/jf2-to-as2.js | 191 ++++++++++++++++ lib/keys.js | 39 ++++ lib/migration.js | 184 +++++++++++++++ lib/webfinger.js | 43 ++++ locales/en.json | 41 ++++ package.json | 51 +++++ views/activities.njk | 29 +++ views/dashboard.njk | 45 ++++ views/followers.njk | 26 +++ views/following.njk | 27 +++ views/migrate.njk | 67 ++++++ 22 files changed, 2240 insertions(+) create mode 100644 .gitignore create mode 100644 assets/icon.svg create mode 100644 index.js create mode 100644 lib/actor.js create mode 100644 lib/controllers/activities.js create mode 100644 lib/controllers/dashboard.js create mode 100644 lib/controllers/followers.js create mode 100644 lib/controllers/following.js create mode 100644 lib/controllers/migrate.js create mode 100644 lib/federation.js create mode 100644 lib/inbox.js create mode 100644 lib/jf2-to-as2.js create mode 100644 lib/keys.js create mode 100644 lib/migration.js create mode 100644 lib/webfinger.js create mode 100644 locales/en.json create mode 100644 package.json create mode 100644 views/activities.njk create mode 100644 views/dashboard.njk create mode 100644 views/followers.njk create mode 100644 views/following.njk create mode 100644 views/migrate.njk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..7a0be18 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..3f458a4 --- /dev/null +++ b/index.js @@ -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); + } +} diff --git a/lib/actor.js b/lib/actor.js new file mode 100644 index 0000000..6829521 --- /dev/null +++ b/lib/actor.js @@ -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; +} diff --git a/lib/controllers/activities.js b/lib/controllers/activities.js new file mode 100644 index 0000000..2408b27 --- /dev/null +++ b/lib/controllers/activities.js @@ -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, + }; +} diff --git a/lib/controllers/dashboard.js b/lib/controllers/dashboard.js new file mode 100644 index 0000000..defef97 --- /dev/null +++ b/lib/controllers/dashboard.js @@ -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); + } + }; +} diff --git a/lib/controllers/followers.js b/lib/controllers/followers.js new file mode 100644 index 0000000..bc262fd --- /dev/null +++ b/lib/controllers/followers.js @@ -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, + }; +} diff --git a/lib/controllers/following.js b/lib/controllers/following.js new file mode 100644 index 0000000..911c6bf --- /dev/null +++ b/lib/controllers/following.js @@ -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, + }; +} diff --git a/lib/controllers/migrate.js b/lib/controllers/migrate.js new file mode 100644 index 0000000..a1aa866 --- /dev/null +++ b/lib/controllers/migrate.js @@ -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; +} diff --git a/lib/federation.js b/lib/federation.js new file mode 100644 index 0000000..b196c06 --- /dev/null +++ b/lib/federation.js @@ -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} 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} 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: [], + }; +} diff --git a/lib/inbox.js b/lib/inbox.js new file mode 100644 index 0000000..dbbaeb8 --- /dev/null +++ b/lib/inbox.js @@ -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(), + }); +} diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js new file mode 100644 index 0000000..4bf694f --- /dev/null +++ b/lib/jf2-to-as2.js @@ -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}

\u{1F516} ${bookmarkUrl}` + : `\u{1F516} ${bookmarkUrl}`; + 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"; +} diff --git a/lib/keys.js b/lib/keys.js new file mode 100644 index 0000000..c5c9f93 --- /dev/null +++ b/lib/keys.js @@ -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 }; +} diff --git a/lib/migration.js b/lib/migration.js new file mode 100644 index 0000000..fcd67fc --- /dev/null +++ b/lib/migration.js @@ -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 }; +} diff --git a/lib/webfinger.js b/lib/webfinger.js new file mode 100644 index 0000000..e93f447 --- /dev/null +++ b/lib/webfinger.js @@ -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, + }, + ], + }; +} diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..9453cb8 --- /dev/null +++ b/locales/en.json @@ -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." + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d9d6682 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/views/activities.njk b/views/activities.njk new file mode 100644 index 0000000..f916c7d --- /dev/null +++ b/views/activities.njk @@ -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 %} diff --git a/views/dashboard.njk b/views/dashboard.njk new file mode 100644 index 0000000..dcd304e --- /dev/null +++ b/views/dashboard.njk @@ -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 %} diff --git a/views/followers.njk b/views/followers.njk new file mode 100644 index 0000000..9512943 --- /dev/null +++ b/views/followers.njk @@ -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 %} diff --git a/views/following.njk b/views/following.njk new file mode 100644 index 0000000..5285a95 --- /dev/null +++ b/views/following.njk @@ -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 %} diff --git a/views/migrate.njk b/views/migrate.njk new file mode 100644 index 0000000..2cc7c8e --- /dev/null +++ b/views/migrate.njk @@ -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") }) }} + +
+ + {{ input({ + name: "aliasUrl", + label: __("activitypub.migrate.aliasLabel"), + hint: __("activitypub.migrate.aliasHint"), + type: "url" + }) }} + {{ button({ text: __("activitypub.migrate.aliasSave") }) }} +
+ +
+ + {# Step 2 — Import CSV #} + {{ heading({ text: __("activitypub.migrate.step2Title"), level: 2 }) }} + {{ prose({ text: __("activitypub.migrate.step2Desc") }) }} + +
+ + + {{ 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") }) }} +
+ +
+ + {# Step 3 — Instructions #} + {{ heading({ text: __("activitypub.migrate.step3Title"), level: 2 }) }} + {{ prose({ text: __("activitypub.migrate.step3Desc") }) }} +{% endblock %}