feat: migrate to Fedify for ActivityPub federation (v0.2.0)

Replace hand-rolled federation code with Fedify's battle-tested
implementation. This gives us proper HTTP Signatures, WebFinger,
NodeInfo, typed inbox listeners, and collection dispatchers out
of the box.

New modules:
- lib/federation-setup.js — Fedify Federation configuration
- lib/federation-bridge.js — Express↔Fedify middleware bridge
- lib/inbox-listeners.js — typed inbox handlers (Follow, Undo, etc.)
- lib/kv-store.js — MongoDB-backed KvStore adapter
- lib/controllers/profile.js — admin profile management
- views/activitypub-profile.njk — profile editing form

Removed hand-rolled modules:
- lib/actor.js, lib/federation.js, lib/inbox.js
- lib/keys.js, lib/webfinger.js

Key changes:
- Actor, inbox, outbox, followers, following all delegate to Fedify
- Syndication uses ctx.sendActivity() instead of manual delivery
- Profile managed via admin UI, stored in ap_profile collection
- Legacy PKCS#8 keys imported via Web Crypto API
- Custom bridge preserves Express mount path (req.originalUrl)
This commit is contained in:
Ricardo
2026-02-19 11:59:23 +01:00
parent c522989d38
commit eaf0f1d126
17 changed files with 1332 additions and 1114 deletions

329
index.js
View File

@@ -1,15 +1,26 @@
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 { setupFederation } from "./lib/federation-setup.js";
import {
createFedifyMiddleware,
} from "./lib/federation-bridge.js";
import {
jf2ToActivityStreams,
jf2ToAS2Activity,
} from "./lib/jf2-to-as2.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, migrateImportController } from "./lib/controllers/migrate.js";
import {
migrateGetController,
migratePostController,
migrateImportController,
} from "./lib/controllers/migrate.js";
import {
profileGetController,
profilePostController,
} from "./lib/controllers/profile.js";
const defaults = {
mountPath: "/activitypub",
@@ -21,8 +32,8 @@ const defaults = {
},
checked: true,
alsoKnownAs: "",
activityRetentionDays: 90, // Auto-delete activities older than this (0 = keep forever)
storeRawActivities: false, // Store full incoming JSON (enables debugging, costs storage)
activityRetentionDays: 90,
storeRawActivities: false,
};
export default class ActivityPubEndpoint {
@@ -33,11 +44,10 @@ export default class ActivityPubEndpoint {
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;
this._federation = null;
this._fedifyMiddleware = null;
}
get navigationItems() {
@@ -48,127 +58,40 @@ export default class ActivityPubEndpoint {
};
}
// filePath is set by Indiekit's plugin loader via require.resolve()
/**
* WebFinger routes — mounted at /.well-known/
* WebFinger + NodeInfo discovery — mounted at /.well-known/
* Fedify handles these automatically via federation.fetch().
*/
get routesWellKnown() {
const router = express.Router(); // eslint-disable-line new-cap
const 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);
router.use((req, res, next) => {
if (!self._fedifyMiddleware) return next();
return self._fedifyMiddleware(req, res, next);
});
return router;
}
/**
* Public federation routes — mounted at mountPath, unauthenticated
* Public federation routes — mounted at mountPath.
* Fedify handles actor, inbox, outbox, followers, following.
*/
get routesPublic() {
const router = express.Router(); // eslint-disable-line new-cap
const self = this;
// 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);
}
router.use((req, res, next) => {
if (!self._fedifyMiddleware) return next();
return self._fedifyMiddleware(req, res, next);
});
return router;
}
/**
* Authenticated admin routes — mounted at mountPath, behind IndieAuth
* Authenticated admin routes — mounted at mountPath, behind IndieAuth.
*/
get routes() {
const router = express.Router(); // eslint-disable-line new-cap
@@ -178,23 +101,36 @@ export default class ActivityPubEndpoint {
router.get("/admin/followers", followersController(mp));
router.get("/admin/following", followingController(mp));
router.get("/admin/activities", activitiesController(mp));
router.get("/admin/profile", profileGetController(mp));
router.post("/admin/profile", profilePostController(mp));
router.get("/admin/migrate", migrateGetController(mp, this.options));
router.post("/admin/migrate", migratePostController(mp, this.options));
router.post("/admin/migrate/import", migrateImportController(mp, this.options));
router.post(
"/admin/migrate/import",
migrateImportController(mp, this.options),
);
return router;
}
/**
* Content negotiation handler — serves AS2 JSON for ActivityPub clients
* Registered as a separate endpoint with mountPath "/"
* Content negotiation — serves AS2 JSON for ActivityPub clients
* requesting individual post URLs. Also handles NodeInfo data
* at /nodeinfo/2.1 (delegated to Fedify).
*/
get contentNegotiationRoutes() {
const router = express.Router(); // eslint-disable-line new-cap
const self = this;
router.get("{*path}", async (request, response, next) => {
const accept = request.headers.accept || "";
// Let Fedify handle NodeInfo data (/nodeinfo/2.1)
router.use((req, res, next) => {
if (!self._fedifyMiddleware) return next();
return self._fedifyMiddleware(req, res, next);
});
// Content negotiation for AP clients on regular URLs
router.get("{*path}", async (req, res, next) => {
const accept = req.headers.accept || "";
const isActivityPub =
accept.includes("application/activity+json") ||
accept.includes("application/ld+json");
@@ -204,25 +140,20 @@ export default class ActivityPubEndpoint {
}
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);
// Root URL — redirect to Fedify actor
if (req.path === "/") {
const actorPath = `${self.options.mountPath}/users/${self.options.actor.handle}`;
return res.redirect(actorPath);
}
// Post URLs — look up in database and convert to AS2
const { application } = request.app.locals;
const { application } = req.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 requestUrl = `${self._publicationUrl}${req.path.slice(1)}`;
const post = await postsCollection.findOne({
"properties.url": requestUrl,
});
@@ -231,16 +162,16 @@ export default class ActivityPubEndpoint {
return next();
}
const actorUrl = self._getActorUrl();
const activity = jf2ToActivityStreams(
post.properties,
self._actorUrl,
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({
res.set("Content-Type", "application/activity+json");
return res.json({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
@@ -256,30 +187,7 @@ export default class ActivityPubEndpoint {
}
/**
* 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
* Syndicator — delivers posts to ActivityPub followers via Fedify.
*/
get syndicator() {
const self = this;
@@ -303,15 +211,35 @@ export default class ActivityPubEndpoint {
};
},
async syndicate(properties, publication) {
if (!self._federationHandler) {
async syndicate(properties) {
if (!self._federation) {
return undefined;
}
try {
return await self._federationHandler.deliverToFollowers(
const actorUrl = self._getActorUrl();
const activity = jf2ToAS2Activity(
properties,
publication,
actorUrl,
self._publicationUrl,
);
if (!activity) {
return undefined;
}
const ctx = self._federation.createContext(
new URL(self._publicationUrl),
{},
);
await ctx.sendActivity(
{ identifier: self.options.actor.handle },
"followers",
activity,
);
return properties.url || undefined;
} catch (error) {
console.error("[ActivityPub] Syndication failed:", error.message);
return undefined;
@@ -320,6 +248,15 @@ export default class ActivityPubEndpoint {
};
}
/**
* Build the full actor URL from config.
* @returns {string}
*/
_getActorUrl() {
const base = this._publicationUrl.replace(/\/$/, "");
return `${base}${this.options.mountPath}/users/${this.options.actor.handle}`;
}
init(Indiekit) {
// Store publication URL for later use
this._publicationUrl = Indiekit.publication?.me
@@ -327,23 +264,31 @@ export default class ActivityPubEndpoint {
? 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");
Indiekit.addCollection("ap_kv");
Indiekit.addCollection("ap_profile");
// Store collection references for later use
// Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections;
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"),
ap_followers: indiekitCollections.get("ap_followers"),
ap_following: indiekitCollections.get("ap_following"),
ap_activities: indiekitCollections.get("ap_activities"),
ap_keys: indiekitCollections.get("ap_keys"),
ap_kv: indiekitCollections.get("ap_kv"),
ap_profile: indiekitCollections.get("ap_profile"),
get posts() {
return indiekitCollections.get("posts");
},
_publicationUrl: this._publicationUrl,
};
// Set up TTL index so ap_activities self-cleans (MongoDB handles expiry)
// TTL index for activity cleanup (MongoDB handles expiry automatically)
const retentionDays = this.options.activityRetentionDays;
if (retentionDays > 0) {
this._collections.ap_activities.createIndex(
@@ -352,28 +297,64 @@ export default class ActivityPubEndpoint {
);
}
// Initialize federation handler
this._federationHandler = createFederationHandler({
actorUrl: this._actorUrl,
publicationUrl: this._publicationUrl,
mountPath: this.options.mountPath,
actorConfig: this.options.actor,
alsoKnownAs: this.options.alsoKnownAs,
// Seed actor profile from config on first run
this._seedProfile().catch((error) => {
console.warn("[ActivityPub] Profile seed failed:", error.message);
});
// Set up Fedify Federation instance
const { federation } = setupFederation({
collections: this._collections,
mountPath: this.options.mountPath,
handle: this.options.actor.handle,
storeRawActivities: this.options.storeRawActivities,
});
// Register as endpoint (adds routes)
this._federation = federation;
this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}));
// Register as endpoint (mounts routesPublic, routesWellKnown, routes)
Indiekit.addEndpoint(this);
// Register content negotiation handler as a virtual endpoint
// Content negotiation + NodeInfo — virtual endpoint at root
Indiekit.addEndpoint({
name: "ActivityPub content negotiation",
mountPath: "/",
routesPublic: this.contentNegotiationRoutes,
});
// Register as syndicator (appears in post UI)
// Register syndicator (appears in post editing UI)
Indiekit.addSyndicator(this.syndicator);
}
/**
* Seed the ap_profile collection from config options on first run.
* Only creates a profile if none exists — preserves UI edits.
*/
async _seedProfile() {
const { ap_profile } = this._collections;
const existing = await ap_profile.findOne({});
if (existing) {
return;
}
const profile = {
name: this.options.actor.name || this.options.actor.handle,
summary: this.options.actor.summary || "",
url: this._publicationUrl,
icon: this.options.actor.icon || "",
manuallyApprovesFollowers: false,
createdAt: new Date().toISOString(),
};
// Only include alsoKnownAs if explicitly configured
if (this.options.alsoKnownAs) {
profile.alsoKnownAs = Array.isArray(this.options.alsoKnownAs)
? this.options.alsoKnownAs
: [this.options.alsoKnownAs];
}
await ap_profile.insertOne(profile);
}
}

View File

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

View File

@@ -14,10 +14,18 @@ import {
export function migrateGetController(mountPath, pluginOptions) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const profileCollection = application?.collections?.get("ap_profile");
const profile = profileCollection
? (await profileCollection.findOne({})) || {}
: {};
const currentAlias = profile.alsoKnownAs?.[0] || "";
response.render("activitypub-migrate", {
title: response.locals.__("activitypub.migrate.title"),
mountPath,
currentAlias: pluginOptions.alsoKnownAs || "",
currentAlias,
result: null,
});
} catch (error) {
@@ -29,22 +37,32 @@ export function migrateGetController(mountPath, pluginOptions) {
export function migratePostController(mountPath, pluginOptions) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const profileCollection = application?.collections?.get("ap_profile");
let result = null;
// Only handles alias updates (small payload, regular form POST)
const aliasUrl = request.body.aliasUrl?.trim();
if (aliasUrl) {
pluginOptions.alsoKnownAs = aliasUrl;
if (aliasUrl && profileCollection) {
await profileCollection.updateOne(
{},
{ $set: { alsoKnownAs: [aliasUrl] } },
{ upsert: true },
);
result = {
type: "success",
text: response.locals.__("activitypub.migrate.aliasSuccess"),
};
}
const profile = profileCollection
? (await profileCollection.findOne({})) || {}
: {};
const currentAlias = profile.alsoKnownAs?.[0] || "";
response.render("activitypub-migrate", {
title: response.locals.__("activitypub.migrate.title"),
mountPath,
currentAlias: pluginOptions.alsoKnownAs || "",
currentAlias,
result,
});
} catch (error) {

View File

@@ -0,0 +1,71 @@
/**
* Profile controller — edit the ActivityPub actor profile.
*
* GET: loads profile from ap_profile collection, renders form
* POST: saves updated profile fields back to ap_profile
*/
export function profileGetController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const profileCollection = application?.collections?.get("ap_profile");
const profile = profileCollection
? (await profileCollection.findOne({})) || {}
: {};
response.render("activitypub-profile", {
title: response.locals.__("activitypub.profile.title"),
mountPath,
profile,
result: null,
});
} catch (error) {
next(error);
}
};
}
export function profilePostController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const profileCollection = application?.collections?.get("ap_profile");
if (!profileCollection) {
return next(new Error("ap_profile collection not available"));
}
const { name, summary, url, icon, image, manuallyApprovesFollowers } =
request.body;
const update = {
$set: {
name: name?.trim() || "",
summary: summary?.trim() || "",
url: url?.trim() || "",
icon: icon?.trim() || "",
image: image?.trim() || "",
manuallyApprovesFollowers: manuallyApprovesFollowers === "true",
updatedAt: new Date().toISOString(),
},
};
await profileCollection.updateOne({}, update, { upsert: true });
const profile = await profileCollection.findOne({});
response.render("activitypub-profile", {
title: response.locals.__("activitypub.profile.title"),
mountPath,
profile,
result: {
type: "success",
text: response.locals.__("activitypub.profile.saved"),
},
});
} catch (error) {
next(error);
}
};
}

119
lib/federation-bridge.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* Express ↔ Fedify bridge.
*
* Converts Express requests to standard Request objects and delegates
* to federation.fetch(). We can't use @fedify/express's integrateFederation()
* because Indiekit plugins mount routes at a sub-path (e.g. /activitypub),
* which causes req.url to lose the mount prefix. Instead, we use
* req.originalUrl to preserve the full path that Fedify's URI templates expect.
*/
import { Readable } from "node:stream";
import { Buffer } from "node:buffer";
/**
* Convert an Express request to a standard Request with the full URL.
*
* @param {import("express").Request} req - Express request
* @returns {Request} Standard Request object
*/
export function fromExpressRequest(req) {
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const v of value) headers.append(key, v);
} else if (typeof value === "string") {
headers.append(key, value);
}
}
return new Request(url, {
method: req.method,
headers,
duplex: "half",
body:
req.method === "GET" || req.method === "HEAD"
? undefined
: Readable.toWeb(req),
});
}
/**
* Send a standard Response back through Express.
*
* @param {import("express").Response} res - Express response
* @param {Response} response - Standard Response from federation.fetch()
*/
async function sendFedifyResponse(res, response) {
res.status(response.status);
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
if (!response.body) {
res.end();
return;
}
const reader = response.body.getReader();
await new Promise((resolve) => {
function read({ done, value }) {
if (done) {
reader.releaseLock();
resolve();
return;
}
res.write(Buffer.from(value));
reader.read().then(read);
}
reader.read().then(read);
});
res.end();
}
/**
* Create Express middleware that delegates to Fedify's federation.fetch().
*
* On 404 (Fedify didn't match), calls next().
* On 406 (not acceptable), calls next() so Express can try other handlers.
* Otherwise, sends the Fedify response directly.
*
* @param {import("@fedify/fedify").Federation} federation
* @param {Function} contextDataFactory - (req) => contextData
* @returns {import("express").RequestHandler}
*/
export function createFedifyMiddleware(federation, contextDataFactory) {
return async (req, res, next) => {
try {
const request = fromExpressRequest(req);
const contextData = await Promise.resolve(contextDataFactory(req));
let notFound = false;
let notAcceptable = false;
const response = await federation.fetch(request, {
contextData,
onNotFound: () => {
notFound = true;
return new Response("Not found", { status: 404 });
},
onNotAcceptable: () => {
notAcceptable = true;
return new Response("Not acceptable", {
status: 406,
headers: { "Content-Type": "text/plain", Vary: "Accept" },
});
},
});
if (notFound || notAcceptable) {
return next();
}
await sendFedifyResponse(res, response);
} catch (error) {
next(error);
}
};
}

321
lib/federation-setup.js Normal file
View File

@@ -0,0 +1,321 @@
/**
* Fedify Federation setup — configures the Federation instance with all
* dispatchers, inbox listeners, and collection handlers.
*
* This replaces the hand-rolled federation.js, actor.js, keys.js, webfinger.js,
* and inbox.js with Fedify's battle-tested implementations.
*/
import { Temporal } from "@js-temporal/polyfill";
import {
Endpoints,
Image,
InProcessMessageQueue,
Person,
PropertyValue,
createFederation,
importSpki,
} from "@fedify/fedify";
import { MongoKvStore } from "./kv-store.js";
import { registerInboxListeners } from "./inbox-listeners.js";
/**
* Create and configure a Fedify Federation instance.
*
* @param {object} options
* @param {object} options.collections - MongoDB collections
* @param {string} options.mountPath - Plugin mount path (e.g. "/activitypub")
* @param {string} options.handle - Actor handle (e.g. "rick")
* @param {boolean} options.storeRawActivities - Whether to store full raw JSON
* @returns {{ federation: import("@fedify/fedify").Federation }}
*/
export function setupFederation(options) {
const {
collections,
mountPath,
handle,
storeRawActivities = false,
} = options;
const federation = createFederation({
kv: new MongoKvStore(collections.ap_kv),
queue: new InProcessMessageQueue(),
});
// --- Actor dispatcher ---
federation
.setActorDispatcher(
`${mountPath}/users/{identifier}`,
async (ctx, identifier) => {
if (identifier !== handle) return null;
const profile = await getProfile(collections);
const keyPairs = await ctx.getActorKeyPairs(identifier);
const personOptions = {
id: ctx.getActorUri(identifier),
preferredUsername: identifier,
name: profile.name || identifier,
url: profile.url ? new URL(profile.url) : null,
inbox: ctx.getInboxUri(identifier),
outbox: ctx.getOutboxUri(identifier),
followers: ctx.getFollowersUri(identifier),
following: ctx.getFollowingUri(identifier),
endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
manuallyApprovesFollowers:
profile.manuallyApprovesFollowers || false,
};
if (profile.summary) {
personOptions.summary = profile.summary;
}
if (profile.icon) {
personOptions.icon = new Image({
url: new URL(profile.icon),
mediaType: guessImageMediaType(profile.icon),
});
}
if (profile.image) {
personOptions.image = new Image({
url: new URL(profile.image),
mediaType: guessImageMediaType(profile.image),
});
}
if (keyPairs.length > 0) {
personOptions.publicKey = keyPairs[0].cryptographicKey;
personOptions.assertionMethod = keyPairs[0].multikey;
}
if (profile.attachments?.length > 0) {
personOptions.attachments = profile.attachments.map(
(att) => new PropertyValue({ name: att.name, value: att.value }),
);
}
if (profile.alsoKnownAs?.length > 0) {
personOptions.alsoKnownAs = profile.alsoKnownAs.map(
(u) => new URL(u),
);
}
if (profile.createdAt) {
personOptions.published = Temporal.Instant.from(profile.createdAt);
}
return new Person(personOptions);
},
)
.setKeyPairsDispatcher(async (ctx, identifier) => {
if (identifier !== handle) return [];
const legacyKey = await collections.ap_keys.findOne({});
if (legacyKey?.publicKeyPem && legacyKey?.privateKeyPem) {
try {
const publicKey = await importSpki(legacyKey.publicKeyPem, "RSA");
const privateKey = await importPkcs8Pem(legacyKey.privateKeyPem);
return [{ publicKey, privateKey }];
} catch {
console.warn(
"[ActivityPub] Could not import legacy RSA keys, generating new key pairs",
);
}
}
return [];
});
// --- Inbox listeners ---
const inboxChain = federation.setInboxListeners(
`${mountPath}/users/{identifier}/inbox`,
`${mountPath}/inbox`,
);
registerInboxListeners(inboxChain, {
collections,
handle,
storeRawActivities,
});
// --- Collection dispatchers ---
setupFollowers(federation, mountPath, handle, collections);
setupFollowing(federation, mountPath, handle, collections);
setupOutbox(federation, mountPath, handle, collections);
// --- NodeInfo ---
federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => {
const postsCount = collections.posts
? await collections.posts.countDocuments()
: 0;
return {
software: {
name: "indiekit",
version: { major: 1, minor: 0, patch: 0 },
},
protocols: ["activitypub"],
usage: {
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
localPosts: postsCount,
localComments: 0,
},
};
});
return { federation };
}
// --- Collection setup helpers ---
function setupFollowers(federation, mountPath, handle, collections) {
federation
.setFollowersDispatcher(
`${mountPath}/users/{identifier}/followers`,
async (ctx, identifier, cursor) => {
if (identifier !== handle) return null;
const pageSize = 20;
const skip = cursor ? Number.parseInt(cursor, 10) : 0;
const docs = await collections.ap_followers
.find()
.sort({ followedAt: -1 })
.skip(skip)
.limit(pageSize)
.toArray();
const total = await collections.ap_followers.countDocuments();
return {
items: docs.map((f) => new URL(f.actorUrl)),
nextCursor:
skip + pageSize < total ? String(skip + pageSize) : null,
};
},
)
.setCounter(async (ctx, identifier) => {
if (identifier !== handle) return 0;
return await collections.ap_followers.countDocuments();
})
.setFirstCursor(async () => "0");
}
function setupFollowing(federation, mountPath, handle, collections) {
federation
.setFollowingDispatcher(
`${mountPath}/users/{identifier}/following`,
async (ctx, identifier, cursor) => {
if (identifier !== handle) return null;
const pageSize = 20;
const skip = cursor ? Number.parseInt(cursor, 10) : 0;
const docs = await collections.ap_following
.find()
.sort({ followedAt: -1 })
.skip(skip)
.limit(pageSize)
.toArray();
const total = await collections.ap_following.countDocuments();
return {
items: docs.map((f) => new URL(f.actorUrl)),
nextCursor:
skip + pageSize < total ? String(skip + pageSize) : null,
};
},
)
.setCounter(async (ctx, identifier) => {
if (identifier !== handle) return 0;
return await collections.ap_following.countDocuments();
})
.setFirstCursor(async () => "0");
}
function setupOutbox(federation, mountPath, handle, collections) {
federation
.setOutboxDispatcher(
`${mountPath}/users/{identifier}/outbox`,
async (ctx, identifier, cursor) => {
if (identifier !== handle) return null;
const postsCollection = collections.posts;
if (!postsCollection) return { items: [] };
const pageSize = 20;
const skip = cursor ? Number.parseInt(cursor, 10) : 0;
const total = await postsCollection.countDocuments();
const posts = await postsCollection
.find()
.sort({ "properties.published": -1 })
.skip(skip)
.limit(pageSize)
.toArray();
const { jf2ToAS2Activity } = await import("./jf2-to-as2.js");
const items = posts
.map((post) => {
try {
return jf2ToAS2Activity(
post.properties,
ctx.getActorUri(identifier).href,
collections._publicationUrl,
);
} catch {
return null;
}
})
.filter(Boolean);
return {
items,
nextCursor:
skip + pageSize < total ? String(skip + pageSize) : null,
};
},
)
.setCounter(async (ctx, identifier) => {
if (identifier !== handle) return 0;
const postsCollection = collections.posts;
if (!postsCollection) return 0;
return await postsCollection.countDocuments();
})
.setFirstCursor(async () => "0");
}
// --- Helpers ---
async function getProfile(collections) {
const doc = await collections.ap_profile.findOne({});
return doc || {};
}
/**
* Import a PKCS#8 PEM private key using Web Crypto API.
* Fedify's importPem only handles PKCS#1, but Node.js crypto generates PKCS#8.
*/
async function importPkcs8Pem(pem) {
const lines = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(/\s/g, "");
const der = Uint8Array.from(atob(lines), (c) => c.charCodeAt(0));
return crypto.subtle.importKey(
"pkcs8",
der,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
true,
["sign"],
);
}
function guessImageMediaType(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";
}

View File

@@ -1,410 +0,0 @@
/**
* 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().toISOString(),
...(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: [],
};
}

215
lib/inbox-listeners.js Normal file
View File

@@ -0,0 +1,215 @@
/**
* Inbox listener registrations for the Fedify Federation instance.
*
* Each listener handles a specific ActivityPub activity type received
* in the actor's inbox (Follow, Undo, Like, Announce, Create, Delete, Move).
*/
import {
Accept,
Announce,
Create,
Delete,
Follow,
Like,
Move,
Note,
Undo,
} from "@fedify/fedify";
/**
* Register all inbox listeners on a federation's inbox chain.
*
* @param {object} inboxChain - Return value of federation.setInboxListeners()
* @param {object} options
* @param {object} options.collections - MongoDB collections
* @param {string} options.handle - Actor handle
* @param {boolean} options.storeRawActivities - Whether to store raw JSON
*/
export function registerInboxListeners(inboxChain, options) {
const { collections, handle, storeRawActivities } = options;
inboxChain
.on(Follow, async (ctx, follow) => {
const followerActor = await follow.getActor();
if (!followerActor?.id) return;
const followerUrl = followerActor.id.href;
const followerName =
followerActor.name?.toString() ||
followerActor.preferredUsername?.toString() ||
followerUrl;
await collections.ap_followers.updateOne(
{ actorUrl: followerUrl },
{
$set: {
actorUrl: followerUrl,
handle: followerActor.preferredUsername?.toString() || "",
name: followerName,
avatar: followerActor.icon
? (await followerActor.icon)?.url?.href || ""
: "",
inbox: followerActor.inbox?.id?.href || "",
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
followedAt: new Date().toISOString(),
},
},
{ upsert: true },
);
// Auto-accept: send Accept back
await ctx.sendActivity(
{ identifier: handle },
followerActor,
new Accept({
actor: ctx.getActorUri(handle),
object: follow,
}),
);
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Follow",
actorUrl: followerUrl,
actorName: followerName,
summary: `${followerName} followed you`,
});
})
.on(Undo, async (ctx, undo) => {
const actorObj = await undo.getActor();
const actorUrl = actorObj?.id?.href || "";
const inner = await undo.getObject();
if (inner instanceof Follow) {
await collections.ap_followers.deleteOne({ actorUrl });
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Undo(Follow)",
actorUrl,
summary: `${actorUrl} unfollowed you`,
});
} else if (inner instanceof Like) {
const objectId = (await inner.getObject())?.id?.href || "";
await collections.ap_activities.deleteOne({
type: "Like",
actorUrl,
objectUrl: objectId,
});
} else if (inner instanceof Announce) {
const objectId = (await inner.getObject())?.id?.href || "";
await collections.ap_activities.deleteOne({
type: "Announce",
actorUrl,
objectUrl: objectId,
});
} else {
const typeName = inner?.constructor?.name || "unknown";
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: `Undo(${typeName})`,
actorUrl,
summary: `${actorUrl} undid ${typeName}`,
});
}
})
.on(Like, async (ctx, like) => {
const actorObj = await like.getActor();
const actorUrl = actorObj?.id?.href || "";
const actorName =
actorObj?.name?.toString() ||
actorObj?.preferredUsername?.toString() ||
actorUrl;
const objectId = (await like.getObject())?.id?.href || "";
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Like",
actorUrl,
actorName,
objectUrl: objectId,
summary: `${actorName} liked ${objectId}`,
});
})
.on(Announce, async (ctx, announce) => {
const actorObj = await announce.getActor();
const actorUrl = actorObj?.id?.href || "";
const actorName =
actorObj?.name?.toString() ||
actorObj?.preferredUsername?.toString() ||
actorUrl;
const objectId = (await announce.getObject())?.id?.href || "";
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Announce",
actorUrl,
actorName,
objectUrl: objectId,
summary: `${actorName} boosted ${objectId}`,
});
})
.on(Create, async (ctx, create) => {
const object = await create.getObject();
if (!object) return;
const inReplyTo =
object instanceof Note
? (await object.getInReplyTo())?.id?.href
: null;
if (!inReplyTo) return;
const actorObj = await create.getActor();
const actorUrl = actorObj?.id?.href || "";
const actorName =
actorObj?.name?.toString() ||
actorObj?.preferredUsername?.toString() ||
actorUrl;
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Reply",
actorUrl,
actorName,
objectUrl: object.id?.href || "",
summary: `${actorName} replied to ${inReplyTo}`,
});
})
.on(Delete, async (ctx, del) => {
const objectId = (await del.getObject())?.id?.href || "";
if (objectId) {
await collections.ap_activities.deleteMany({ objectUrl: objectId });
}
})
.on(Move, async (ctx, move) => {
const oldActorObj = await move.getActor();
const oldActorUrl = oldActorObj?.id?.href || "";
const target = await move.getTarget();
const newActorUrl = target?.id?.href || "";
if (oldActorUrl && newActorUrl) {
await collections.ap_followers.updateOne(
{ actorUrl: oldActorUrl },
{ $set: { actorUrl: newActorUrl, movedFrom: oldActorUrl } },
);
}
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Move",
actorUrl: oldActorUrl,
objectUrl: newActorUrl,
summary: `${oldActorUrl} moved to ${newActorUrl}`,
});
});
}
/**
* Log an activity to the ap_activities collection.
*/
async function logActivity(collections, storeRaw, record) {
await collections.ap_activities.insertOne({
...record,
receivedAt: new Date().toISOString(),
});
}

View File

@@ -1,291 +0,0 @@
/**
* 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().toISOString(),
},
},
{ 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().toISOString(),
});
}

View File

@@ -1,18 +1,39 @@
/**
* 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.
* Two export flavors:
* - jf2ToActivityStreams() — returns plain JSON-LD objects (for content negotiation)
* - jf2ToAS2Activity() — returns Fedify vocab instances (for outbox + syndicator)
*/
import { Temporal } from "@js-temporal/polyfill";
import {
Announce,
Article,
Audio,
Create,
Hashtag,
Image,
Like,
Note,
Video,
} from "@fedify/fedify";
// ---------------------------------------------------------------------------
// Plain JSON-LD (content negotiation on individual post URLs)
// ---------------------------------------------------------------------------
/**
* Convert JF2 properties to a plain ActivityStreams JSON-LD object.
*
* @param {object} properties - JF2 post properties from Indiekit's posts collection
* @param {string} actorUrl - This actor's URL (e.g. "https://rmendes.net/")
* @param {object} properties - JF2 post properties
* @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
* @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",
@@ -22,7 +43,6 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
};
}
// Repost/boost — Announce activity
if (postType === "repost") {
return {
"@context": "https://www.w3.org/ns/activitystreams",
@@ -32,7 +52,6 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
};
}
// Everything else is wrapped in a Create activity
const isArticle = postType === "article" && properties.name;
const postUrl = resolvePostUrl(properties.url, publicationUrl);
@@ -43,10 +62,9 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
published: properties.published,
url: postUrl,
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [`${actorUrl.replace(/\/$/, "")}/activitypub/followers`],
cc: [`${actorUrl.replace(/\/$/, "")}/followers`],
};
// Content — bookmarks get special treatment
if (postType === "bookmark") {
const bookmarkUrl = properties["bookmark-of"];
const commentary = properties.content?.html || properties.content || "";
@@ -71,75 +89,18 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
}
}
// 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: "",
});
}
}
const attachments = buildPlainAttachments(properties, publicationUrl);
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)}`,
})),
];
const tags = buildPlainTags(properties, publicationUrl, object.tag);
if (tags.length > 0) {
object.tag = tags;
}
return {
@@ -150,6 +111,112 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
};
}
// ---------------------------------------------------------------------------
// Fedify vocab objects (outbox dispatcher + syndicator delivery)
// ---------------------------------------------------------------------------
/**
* Convert JF2 properties to a Fedify Activity object.
*
* @param {object} properties - JF2 post properties
* @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
* @param {string} publicationUrl - Publication base URL with trailing slash
* @returns {import("@fedify/fedify").Activity | null}
*/
export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
const postType = properties["post-type"];
const actorUri = new URL(actorUrl);
if (postType === "like") {
const likeOf = properties["like-of"];
if (!likeOf) return null;
return new Like({
actor: actorUri,
object: new URL(likeOf),
});
}
if (postType === "repost") {
const repostOf = properties["repost-of"];
if (!repostOf) return null;
return new Announce({
actor: actorUri,
object: new URL(repostOf),
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
});
}
const isArticle = postType === "article" && properties.name;
const postUrl = resolvePostUrl(properties.url, publicationUrl);
const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`;
const noteOptions = {
attributedTo: actorUri,
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
cc: new URL(followersUrl),
};
if (postUrl) {
noteOptions.id = new URL(postUrl);
noteOptions.url = new URL(postUrl);
}
if (properties.published) {
try {
noteOptions.published = Temporal.Instant.from(properties.published);
} catch {
// Invalid date format — skip
}
}
// Content
if (postType === "bookmark") {
const bookmarkUrl = properties["bookmark-of"];
const commentary = properties.content?.html || properties.content || "";
noteOptions.content = commentary
? `${commentary}<br><br>\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`
: `\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`;
} else {
noteOptions.content = properties.content?.html || properties.content || "";
}
if (isArticle) {
noteOptions.name = properties.name;
if (properties.summary) {
noteOptions.summary = properties.summary;
}
}
if (properties["in-reply-to"]) {
noteOptions.inReplyTo = new URL(properties["in-reply-to"]);
}
// Attachments
const fedifyAttachments = buildFedifyAttachments(properties, publicationUrl);
if (fedifyAttachments.length > 0) {
noteOptions.attachments = fedifyAttachments;
}
// Hashtags
const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
if (fedifyTags.length > 0) {
noteOptions.tags = fedifyTags;
}
const object = isArticle
? new Article(noteOptions)
: new Note(noteOptions);
return new Create({
actor: actorUri,
object,
});
}
// ---------------------------------------------------------------------------
// URL resolution helpers
// ---------------------------------------------------------------------------
/**
* Resolve a post URL, ensuring it's absolute.
* @param {string} url - Post URL (may be relative or absolute)
@@ -163,9 +230,6 @@ export function resolvePostUrl(url, publicationUrl) {
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;
@@ -173,9 +237,144 @@ function resolveMediaUrl(url, publicationUrl) {
return `${base}/${url.replace(/^\//, "")}`;
}
/**
* Guess MIME type from file extension.
*/
// ---------------------------------------------------------------------------
// Attachment builders
// ---------------------------------------------------------------------------
function buildPlainAttachments(properties, publicationUrl) {
const attachments = [];
if (properties.photo) {
for (const photo of asArray(properties.photo)) {
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) {
for (const video of asArray(properties.video)) {
const url = typeof video === "string" ? video : video.url;
attachments.push({
type: "Video",
url: resolveMediaUrl(url, publicationUrl),
name: "",
});
}
}
if (properties.audio) {
for (const audio of asArray(properties.audio)) {
const url = typeof audio === "string" ? audio : audio.url;
attachments.push({
type: "Audio",
url: resolveMediaUrl(url, publicationUrl),
name: "",
});
}
}
return attachments;
}
function buildFedifyAttachments(properties, publicationUrl) {
const attachments = [];
if (properties.photo) {
for (const photo of asArray(properties.photo)) {
const url = typeof photo === "string" ? photo : photo.url;
const alt = typeof photo === "string" ? "" : photo.alt || "";
attachments.push(
new Image({
url: new URL(resolveMediaUrl(url, publicationUrl)),
mediaType: guessMediaType(url),
name: alt,
}),
);
}
}
if (properties.video) {
for (const video of asArray(properties.video)) {
const url = typeof video === "string" ? video : video.url;
attachments.push(
new Video({
url: new URL(resolveMediaUrl(url, publicationUrl)),
}),
);
}
}
if (properties.audio) {
for (const audio of asArray(properties.audio)) {
const url = typeof audio === "string" ? audio : audio.url;
attachments.push(
new Audio({
url: new URL(resolveMediaUrl(url, publicationUrl)),
}),
);
}
}
return attachments;
}
// ---------------------------------------------------------------------------
// Tag builders
// ---------------------------------------------------------------------------
function buildPlainTags(properties, publicationUrl, existing) {
const tags = [...(existing || [])];
if (properties.category) {
for (const cat of asArray(properties.category)) {
tags.push({
type: "Hashtag",
name: `#${cat.replace(/\s+/g, "")}`,
href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
});
}
}
return tags;
}
function buildFedifyTags(properties, publicationUrl, postType) {
const tags = [];
if (postType === "bookmark") {
tags.push(
new Hashtag({
name: "#bookmark",
href: new URL(`${publicationUrl}categories/bookmark`),
}),
);
}
if (properties.category) {
for (const cat of asArray(properties.category)) {
tags.push(
new Hashtag({
name: `#${cat.replace(/\s+/g, "")}`,
href: new URL(
`${publicationUrl}categories/${encodeURIComponent(cat)}`,
),
}),
);
}
}
return tags;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function asArray(value) {
return Array.isArray(value) ? value : [value];
}
function guessMediaType(url) {
const ext = url.split(".").pop()?.toLowerCase();
const types = {

View File

@@ -1,39 +0,0 @@
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().toISOString(),
});
return { publicKeyPem: publicKey, privateKeyPem: privateKey };
}

55
lib/kv-store.js Normal file
View File

@@ -0,0 +1,55 @@
/**
* MongoDB-backed KvStore adapter for Fedify.
*
* Implements Fedify's KvStore interface using a MongoDB collection.
* Keys are string arrays (e.g. ["keypair", "rsa", "rick"]) — we serialize
* them as a joined path string for MongoDB's _id field.
*/
/**
* @implements {import("@fedify/fedify").KvStore}
*/
export class MongoKvStore {
/** @param {import("mongodb").Collection} collection */
constructor(collection) {
this.collection = collection;
}
/**
* Serialize a Fedify key (string[]) to a MongoDB document _id.
* @param {string[]} key
* @returns {string}
*/
_serializeKey(key) {
return key.join("/");
}
/**
* @param {string[]} key
* @returns {Promise<unknown>}
*/
async get(key) {
const doc = await this.collection.findOne({ _id: this._serializeKey(key) });
return doc ? doc.value : undefined;
}
/**
* @param {string[]} key
* @param {unknown} value
*/
async set(key, value) {
const id = this._serializeKey(key);
await this.collection.updateOne(
{ _id: id },
{ $set: { _id: id, value, updatedAt: new Date().toISOString() } },
{ upsert: true },
);
}
/**
* @param {string[]} key
*/
async delete(key) {
await this.collection.deleteOne({ _id: this._serializeKey(key) });
}
}

View File

@@ -1,43 +0,0 @@
/**
* 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,
},
],
};
}

View File

@@ -19,6 +19,24 @@
"direction": "Direction",
"directionInbound": "Received",
"directionOutbound": "Sent",
"profile": {
"title": "Profile",
"intro": "Edit how your actor appears to other fediverse users. Changes take effect immediately.",
"nameLabel": "Display name",
"nameHint": "Your name as shown on your fediverse profile",
"summaryLabel": "Bio",
"summaryHint": "A short description of yourself. HTML is allowed.",
"urlLabel": "Website URL",
"urlHint": "Your website address, shown as a link on your profile",
"iconLabel": "Avatar URL",
"iconHint": "URL to your profile picture (square, at least 400x400px recommended)",
"imageLabel": "Header image URL",
"imageHint": "URL to a banner image shown at the top of your profile",
"manualApprovalLabel": "Manually approve followers",
"manualApprovalHint": "When enabled, follow requests require your approval before they take effect",
"save": "Save profile",
"saved": "Profile saved. Changes are now visible to the fediverse."
},
"migrate": {
"title": "Mastodon migration",
"intro": "This guide walks you through moving your Mastodon identity to your IndieWeb site. Complete each step in order — your existing followers will be notified and can re-follow you automatically.",

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "0.1.10",
"version": "0.2.0",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
@@ -39,6 +39,7 @@
"dependencies": {
"@fedify/fedify": "^1.10.0",
"@fedify/express": "^1.9.0",
"@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0"
},
"peerDependencies": {

View File

@@ -22,6 +22,10 @@
title: __("activitypub.activities"),
url: mountPath + "/admin/activities"
},
{
title: __("activitypub.profile.title"),
url: mountPath + "/admin/profile"
},
{
title: __("activitypub.migrate.title"),
url: mountPath + "/admin/migrate"

View File

@@ -0,0 +1,74 @@
{% extends "document.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "input/macro.njk" import input with context %}
{% from "textarea/macro.njk" import textarea with context %}
{% from "checkboxes/macro.njk" import checkboxes with context %}
{% from "button/macro.njk" import button 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 %}
{{ prose({ text: __("activitypub.profile.intro") }) }}
<form method="post" novalidate>
{{ input({
name: "name",
label: __("activitypub.profile.nameLabel"),
hint: __("activitypub.profile.nameHint"),
value: profile.name
}) }}
{{ textarea({
name: "summary",
label: __("activitypub.profile.summaryLabel"),
hint: __("activitypub.profile.summaryHint"),
value: profile.summary,
rows: 4
}) }}
{{ input({
name: "url",
label: __("activitypub.profile.urlLabel"),
hint: __("activitypub.profile.urlHint"),
value: profile.url,
type: "url"
}) }}
{{ input({
name: "icon",
label: __("activitypub.profile.iconLabel"),
hint: __("activitypub.profile.iconHint"),
value: profile.icon,
type: "url"
}) }}
{{ input({
name: "image",
label: __("activitypub.profile.imageLabel"),
hint: __("activitypub.profile.imageHint"),
value: profile.image,
type: "url"
}) }}
{{ checkboxes({
name: "manuallyApprovesFollowers",
items: [
{
label: __("activitypub.profile.manualApprovalLabel"),
value: "true",
hint: __("activitypub.profile.manualApprovalHint")
}
],
values: ["true"] if profile.manuallyApprovesFollowers else []
}) }}
{{ button({ text: __("activitypub.profile.save") }) }}
</form>
{% endblock %}