mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
12
assets/icon.svg
Normal file
12
assets/icon.svg
Normal 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
376
index.js
Normal 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
75
lib/actor.js
Normal 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;
|
||||
}
|
||||
56
lib/controllers/activities.js
Normal file
56
lib/controllers/activities.js
Normal 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,
|
||||
};
|
||||
}
|
||||
39
lib/controllers/dashboard.js
Normal file
39
lib/controllers/dashboard.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
58
lib/controllers/followers.js
Normal file
58
lib/controllers/followers.js
Normal 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,
|
||||
};
|
||||
}
|
||||
58
lib/controllers/following.js
Normal file
58
lib/controllers/following.js
Normal 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
121
lib/controllers/migrate.js
Normal 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
410
lib/federation.js
Normal 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
291
lib/inbox.js
Normal 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
191
lib/jf2-to-as2.js
Normal 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
39
lib/keys.js
Normal 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
184
lib/migration.js
Normal 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
43
lib/webfinger.js
Normal 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
41
locales/en.json
Normal 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
51
package.json
Normal 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
29
views/activities.njk
Normal 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
45
views/dashboard.njk
Normal 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
26
views/followers.njk
Normal 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
27
views/following.njk
Normal 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
67
views/migrate.njk
Normal 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 %}
|
||||
Reference in New Issue
Block a user