diff --git a/index.js b/index.js index 8adf33b..a2e5331 100644 --- a/index.js +++ b/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); + } } diff --git a/lib/actor.js b/lib/actor.js deleted file mode 100644 index 6829521..0000000 --- a/lib/actor.js +++ /dev/null @@ -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; -} diff --git a/lib/controllers/migrate.js b/lib/controllers/migrate.js index 0d5bba3..b2cfa5f 100644 --- a/lib/controllers/migrate.js +++ b/lib/controllers/migrate.js @@ -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) { diff --git a/lib/controllers/profile.js b/lib/controllers/profile.js new file mode 100644 index 0000000..c08b68c --- /dev/null +++ b/lib/controllers/profile.js @@ -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); + } + }; +} diff --git a/lib/federation-bridge.js b/lib/federation-bridge.js new file mode 100644 index 0000000..26c17c6 --- /dev/null +++ b/lib/federation-bridge.js @@ -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); + } + }; +} diff --git a/lib/federation-setup.js b/lib/federation-setup.js new file mode 100644 index 0000000..cd79485 --- /dev/null +++ b/lib/federation-setup.js @@ -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"; +} diff --git a/lib/federation.js b/lib/federation.js deleted file mode 100644 index 308bbda..0000000 --- a/lib/federation.js +++ /dev/null @@ -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} true if delivery succeeded - */ -async function sendSignedActivity(activity, inboxUrl, privateKeyPem, keyId) { - const body = JSON.stringify(activity); - const bodyBuffer = Buffer.from(body); - const url = new URL(inboxUrl); - const date = new Date().toUTCString(); - const digest = computeDigest(bodyBuffer); - - // Build the signing string per HTTP Signatures spec - const signingString = [ - `(request-target): post ${url.pathname}`, - `host: ${url.host}`, - `date: ${date}`, - `digest: ${digest}`, - ].join("\n"); - - const signer = createSign("sha256"); - signer.update(signingString); - const signature = signer.sign(privateKeyPem, "base64"); - - const signatureHeader = [ - `keyId="${keyId}"`, - `algorithm="rsa-sha256"`, - `headers="(request-target) host date digest"`, - `signature="${signature}"`, - ].join(","); - - try { - const response = await fetch(inboxUrl, { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - Host: url.host, - Date: date, - Digest: digest, - Signature: signatureHeader, - }, - body, - signal: AbortSignal.timeout(15_000), - }); - return response.ok || response.status === 202; - } catch (error) { - console.error( - `[ActivityPub] Delivery to ${inboxUrl} failed:`, - error.message, - ); - return false; - } -} - -/** - * Verify the HTTP Signature on an incoming request. - * - * 1. Parse the Signature header - * 2. Fetch the remote actor's public key via keyId - * 3. Reconstruct the signing string - * 4. Verify with RSA-SHA256 - * - * @param {object} request - Express request object - * @param {Buffer} rawBody - Raw request body for digest verification - * @returns {Promise} true if signature is valid - */ -async function verifyHttpSignature(request, rawBody) { - const sigHeader = request.headers.signature; - if (!sigHeader) return false; - - // Parse signature header: keyId="...",algorithm="...",headers="...",signature="..." - const params = {}; - for (const part of sigHeader.split(",")) { - const eqIndex = part.indexOf("="); - if (eqIndex === -1) continue; - const key = part.slice(0, eqIndex).trim(); - const value = part.slice(eqIndex + 1).trim().replace(/^"|"$/g, ""); - params[key] = value; - } - - const { keyId: remoteKeyId, headers: headerNames, signature } = params; - if (!remoteKeyId || !headerNames || !signature) return false; - - // Verify Digest header matches body - if (request.headers.digest) { - const expectedDigest = computeDigest(rawBody); - if (request.headers.digest !== expectedDigest) return false; - } - - // Fetch the remote actor document to get their public key - const publicKeyPem = await fetchRemotePublicKey(remoteKeyId); - if (!publicKeyPem) return false; - - // Reconstruct signing string from the listed headers - const headerList = headerNames.split(" "); - const signingParts = headerList.map((h) => { - if (h === "(request-target)") { - const method = request.method.toLowerCase(); - const path = request.originalUrl || request.url; - return `(request-target): ${method} ${path}`; - } - if (h === "host") { - return `host: ${request.headers.host || request.hostname}`; - } - return `${h}: ${request.headers[h]}`; - }); - const signingString = signingParts.join("\n"); - - // Verify - try { - const verifier = createVerify("sha256"); - verifier.update(signingString); - return verifier.verify(publicKeyPem, signature, "base64"); - } catch { - return false; - } -} - -/** - * Fetch a remote actor's public key by key ID URL. - * The keyId is typically "https://remote.example/users/alice#main-key" - * — we fetch the actor document (without fragment) and extract publicKey. - */ -async function fetchRemotePublicKey(keyIdUrl) { - try { - // Remove fragment to get the actor document URL - const actorUrl = keyIdUrl.split("#")[0]; - - const response = await fetch(actorUrl, { - headers: { Accept: "application/activity+json" }, - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) return null; - - const doc = await response.json(); - - // Key may be at doc.publicKey.publicKeyPem or in a publicKey array - if (doc.publicKey) { - const key = Array.isArray(doc.publicKey) - ? doc.publicKey.find((k) => k.id === keyIdUrl) || doc.publicKey[0] - : doc.publicKey; - return key?.publicKeyPem || null; - } - - return null; - } catch { - return null; - } -} - -/** - * Build an empty OrderedCollection response. - */ -function emptyCollection(id) { - return { - "@context": "https://www.w3.org/ns/activitystreams", - type: "OrderedCollection", - id, - totalItems: 0, - orderedItems: [], - }; -} diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js new file mode 100644 index 0000000..e3dd477 --- /dev/null +++ b/lib/inbox-listeners.js @@ -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(), + }); +} diff --git a/lib/inbox.js b/lib/inbox.js deleted file mode 100644 index 86935d4..0000000 --- a/lib/inbox.js +++ /dev/null @@ -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(), - }); -} diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index 4bf694f..e8b4b6c 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -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}

\u{1F516} ${bookmarkUrl}` + : `\u{1F516} ${bookmarkUrl}`; + } 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 = { diff --git a/lib/keys.js b/lib/keys.js deleted file mode 100644 index 280fe45..0000000 --- a/lib/keys.js +++ /dev/null @@ -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 }; -} diff --git a/lib/kv-store.js b/lib/kv-store.js new file mode 100644 index 0000000..9b7d925 --- /dev/null +++ b/lib/kv-store.js @@ -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} + */ + 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) }); + } +} diff --git a/lib/webfinger.js b/lib/webfinger.js deleted file mode 100644 index e93f447..0000000 --- a/lib/webfinger.js +++ /dev/null @@ -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, - }, - ], - }; -} diff --git a/locales/en.json b/locales/en.json index b2ed946..fcaf014 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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.", diff --git a/package.json b/package.json index 94eae9b..cd12bb3 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/views/activitypub-dashboard.njk b/views/activitypub-dashboard.njk index 10513da..33f1a0f 100644 --- a/views/activitypub-dashboard.njk +++ b/views/activitypub-dashboard.njk @@ -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" diff --git a/views/activitypub-profile.njk b/views/activitypub-profile.njk new file mode 100644 index 0000000..58e0c26 --- /dev/null +++ b/views/activitypub-profile.njk @@ -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") }) }} + +
+ {{ 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") }) }} +
+{% endblock %}