mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
329
index.js
329
index.js
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
75
lib/actor.js
75
lib/actor.js
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
71
lib/controllers/profile.js
Normal file
71
lib/controllers/profile.js
Normal 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
119
lib/federation-bridge.js
Normal 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
321
lib/federation-setup.js
Normal 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";
|
||||
}
|
||||
@@ -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
215
lib/inbox-listeners.js
Normal 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(),
|
||||
});
|
||||
}
|
||||
291
lib/inbox.js
291
lib/inbox.js
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
39
lib/keys.js
39
lib/keys.js
@@ -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
55
lib/kv-store.js
Normal 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) });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
74
views/activitypub-profile.njk
Normal file
74
views/activitypub-profile.njk
Normal 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 %}
|
||||
Reference in New Issue
Block a user