mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
- Add outbox permanent failure handling with smart cleanup: - 410 Gone: immediate full cleanup (follower + timeline + notifications) - 404: strike system (3 failures over 7+ days triggers cleanup) - Strike reset on inbound activity (proves actor is alive) - Add recursive reply chain fetching (depth 5) with isContext flag - Add reply forwarding to followers for public replies to our posts - Add write-time visibility classification (public/unlisted/private/direct) Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
872 lines
29 KiB
JavaScript
872 lines
29 KiB
JavaScript
/**
|
|
* 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 { AsyncLocalStorage } from "node:async_hooks";
|
|
import { createRequire } from "node:module";
|
|
import { Temporal } from "@js-temporal/polyfill";
|
|
import {
|
|
createFederation,
|
|
InProcessMessageQueue,
|
|
ParallelMessageQueue,
|
|
} from "@fedify/fedify";
|
|
import {
|
|
exportJwk,
|
|
generateCryptoKeyPair,
|
|
importJwk,
|
|
} from "@fedify/fedify/sig";
|
|
import {
|
|
Application,
|
|
Article,
|
|
Create,
|
|
Endpoints,
|
|
Group,
|
|
Hashtag,
|
|
Image,
|
|
Note,
|
|
Organization,
|
|
Person,
|
|
PropertyValue,
|
|
Service,
|
|
} from "@fedify/fedify/vocab";
|
|
import { configure, getConsoleSink } from "@logtape/logtape";
|
|
import { RedisMessageQueue, RedisKvStore } from "@fedify/redis";
|
|
import { createFederationDebugger } from "@fedify/debugger";
|
|
import Redis from "ioredis";
|
|
import { MongoKvStore } from "./kv-store.js";
|
|
import { registerInboxListeners } from "./inbox-listeners.js";
|
|
import { jf2ToAS2Activity, resolvePostUrl } from "./jf2-to-as2.js";
|
|
import { cachedQuery } from "./redis-cache.js";
|
|
import { onOutboxPermanentFailure } from "./outbox-failure.js";
|
|
|
|
const COLLECTION_CACHE_TTL = 300; // 5 minutes
|
|
|
|
/**
|
|
* 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 }}
|
|
*/
|
|
// Track whether LogTape has been configured (can only call configure() once)
|
|
let _logtapeConfigured = false;
|
|
|
|
export function setupFederation(options) {
|
|
const {
|
|
collections,
|
|
mountPath,
|
|
handle,
|
|
storeRawActivities = false,
|
|
redisUrl = "",
|
|
publicationUrl = "",
|
|
parallelWorkers = 5,
|
|
actorType = "Person",
|
|
logLevel = "warning",
|
|
debugDashboard = false,
|
|
debugPassword = "",
|
|
} = options;
|
|
|
|
// Map config string to Fedify actor class
|
|
const actorTypeMap = { Person, Service, Application, Organization, Group };
|
|
const ActorClass = actorTypeMap[actorType] || Person;
|
|
|
|
// Configure LogTape for Fedify delivery logging (once per process).
|
|
// When the debug dashboard is enabled, skip this — the debugger
|
|
// auto-configures LogTape with per-trace log collection + OpenTelemetry.
|
|
// Valid levels: "debug" | "info" | "warning" | "error" | "fatal"
|
|
const validLevels = ["debug", "info", "warning", "error", "fatal"];
|
|
const resolvedLevel = validLevels.includes(logLevel) ? logLevel : "warning";
|
|
if (!debugDashboard && !_logtapeConfigured) {
|
|
_logtapeConfigured = true;
|
|
configure({
|
|
contextLocalStorage: new AsyncLocalStorage(),
|
|
sinks: {
|
|
console: getConsoleSink(),
|
|
},
|
|
loggers: [
|
|
{
|
|
// All Fedify logs — federation, vocab, delivery, HTTP signatures
|
|
category: ["fedify"],
|
|
sinks: ["console"],
|
|
lowestLevel: resolvedLevel,
|
|
},
|
|
],
|
|
}).catch((error) => {
|
|
console.warn("[ActivityPub] LogTape configure failed:", error.message);
|
|
});
|
|
}
|
|
|
|
let queue;
|
|
let kv;
|
|
if (redisUrl) {
|
|
const redisQueue = new RedisMessageQueue(() => new Redis(redisUrl));
|
|
if (parallelWorkers > 1) {
|
|
queue = new ParallelMessageQueue(redisQueue, parallelWorkers);
|
|
console.info(
|
|
`[ActivityPub] Using Redis message queue with ${parallelWorkers} parallel workers`,
|
|
);
|
|
} else {
|
|
queue = redisQueue;
|
|
console.info("[ActivityPub] Using Redis message queue (single worker)");
|
|
}
|
|
// Use Redis for Fedify KV store — idempotence records, public key cache,
|
|
// remote document cache. Redis handles TTL natively so entries auto-expire
|
|
// instead of growing unbounded in MongoDB.
|
|
kv = new RedisKvStore(new Redis(redisUrl));
|
|
console.info("[ActivityPub] Using Redis KV store for Fedify");
|
|
} else {
|
|
queue = new InProcessMessageQueue();
|
|
kv = new MongoKvStore(collections.ap_kv);
|
|
console.warn(
|
|
"[ActivityPub] Using in-process message queue + MongoDB KV store (not recommended for production)",
|
|
);
|
|
}
|
|
|
|
const federation = createFederation({
|
|
kv,
|
|
queue,
|
|
});
|
|
|
|
// --- Actor dispatcher ---
|
|
federation
|
|
.setActorDispatcher(
|
|
`${mountPath}/users/{identifier}`,
|
|
async (ctx, identifier) => {
|
|
// Instance actor: Application-type actor for the domain itself
|
|
// Required for authorized fetch to avoid infinite loops
|
|
const hostname = ctx.url?.hostname || "";
|
|
if (identifier === hostname) {
|
|
const keyPairs = await ctx.getActorKeyPairs(identifier);
|
|
const appOptions = {
|
|
id: ctx.getActorUri(identifier),
|
|
preferredUsername: hostname,
|
|
name: hostname,
|
|
inbox: ctx.getInboxUri(identifier),
|
|
outbox: ctx.getOutboxUri(identifier),
|
|
endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
|
|
};
|
|
if (keyPairs.length > 0) {
|
|
appOptions.publicKey = keyPairs[0].cryptographicKey;
|
|
appOptions.assertionMethods = keyPairs.map((k) => k.multikey);
|
|
}
|
|
return new Application(appOptions);
|
|
}
|
|
|
|
if (identifier !== handle) return null;
|
|
|
|
return buildPersonActor(ctx, identifier, collections, actorType);
|
|
},
|
|
)
|
|
.mapHandle((_ctx, username) => {
|
|
if (username === handle) return handle;
|
|
// Accept hostname as valid identifier for instance actor
|
|
if (publicationUrl) {
|
|
try {
|
|
const hostname = new URL(publicationUrl).hostname;
|
|
if (username === hostname) return hostname;
|
|
} catch { /* ignore */ }
|
|
}
|
|
return null;
|
|
})
|
|
.mapAlias((_ctx, alias) => {
|
|
// Resolve profile URL and /@handle patterns via WebFinger.
|
|
// Must return { identifier } or { username }, not a bare string.
|
|
if (!publicationUrl) return null;
|
|
try {
|
|
const pub = new URL(publicationUrl);
|
|
if (alias.hostname !== pub.hostname) return null;
|
|
const path = alias.pathname.replace(/\/$/, "");
|
|
if (path === "" || path === `/@${handle}`) return { identifier: handle };
|
|
} catch { /* ignore */ }
|
|
return null;
|
|
})
|
|
.setKeyPairsDispatcher(async (ctx, identifier) => {
|
|
// Allow key pairs for both the main actor and instance actor
|
|
const hostname = ctx.url?.hostname || "";
|
|
if (identifier !== handle && identifier !== hostname) return [];
|
|
|
|
const keyPairs = [];
|
|
|
|
// --- Legacy RSA key pair (HTTP Signatures) ---
|
|
const legacyKey = await collections.ap_keys.findOne({ type: "rsa" });
|
|
// Fall back to old schema (no type field) for backward compat
|
|
const rsaDoc =
|
|
legacyKey ||
|
|
(await collections.ap_keys.findOne({
|
|
publicKeyPem: { $exists: true },
|
|
}));
|
|
|
|
if (rsaDoc?.publicKeyPem && rsaDoc?.privateKeyPem) {
|
|
try {
|
|
const publicKey = await importSpkiPem(rsaDoc.publicKeyPem);
|
|
const privateKey = await importPkcs8Pem(rsaDoc.privateKeyPem);
|
|
keyPairs.push({ publicKey, privateKey });
|
|
} catch {
|
|
console.warn("[ActivityPub] Could not import legacy RSA keys");
|
|
}
|
|
}
|
|
|
|
// --- Ed25519 key pair (Object Integrity Proofs) ---
|
|
// Load from DB or generate + persist on first use
|
|
let ed25519Doc = await collections.ap_keys.findOne({
|
|
type: "ed25519",
|
|
});
|
|
|
|
if (ed25519Doc?.publicKeyJwk && ed25519Doc?.privateKeyJwk) {
|
|
try {
|
|
const publicKey = await importJwk(
|
|
ed25519Doc.publicKeyJwk,
|
|
"public",
|
|
);
|
|
const privateKey = await importJwk(
|
|
ed25519Doc.privateKeyJwk,
|
|
"private",
|
|
);
|
|
keyPairs.push({ publicKey, privateKey });
|
|
} catch (error) {
|
|
console.warn(
|
|
"[ActivityPub] Could not import Ed25519 keys, regenerating:",
|
|
error.message,
|
|
);
|
|
ed25519Doc = null; // Force regeneration below
|
|
}
|
|
}
|
|
|
|
if (!ed25519Doc) {
|
|
try {
|
|
const ed25519 = await generateCryptoKeyPair("Ed25519");
|
|
await collections.ap_keys.insertOne({
|
|
type: "ed25519",
|
|
publicKeyJwk: await exportJwk(ed25519.publicKey),
|
|
privateKeyJwk: await exportJwk(ed25519.privateKey),
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
keyPairs.push(ed25519);
|
|
console.info(
|
|
"[ActivityPub] Generated and persisted Ed25519 key pair",
|
|
);
|
|
} catch (error) {
|
|
console.warn(
|
|
"[ActivityPub] Could not generate Ed25519 key pair:",
|
|
error.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
return keyPairs;
|
|
});
|
|
// NOTE: .authorize() is intentionally NOT chained here.
|
|
// Fedify's authorize predicate triggers HTTP Signature verification on
|
|
// every GET to the actor endpoint. When a remote server that requires
|
|
// authorized fetch (e.g. kobolds.online, void.ello.tech) requests our
|
|
// actor, Fedify tries to fetch THEIR public key to verify the signature.
|
|
// Those instances return 401, causing a FetchError that Fedify doesn't
|
|
// catch — resulting in 500s for those servers and error log spam.
|
|
// Authorized fetch requires authenticated document loading (using the
|
|
// instance actor's keys for outgoing fetches), which Fedify doesn't yet
|
|
// support out of the box. Re-enable once Fedify adds this capability.
|
|
|
|
// --- WebFinger custom links ---
|
|
// Add OStatus subscribe template so remote servers (WordPress AP, Misskey, etc.)
|
|
// can redirect users to our authorize_interaction page for remote follow.
|
|
federation.setWebFingerLinksDispatcher((_ctx, _resource) => {
|
|
return [
|
|
{
|
|
rel: "http://ostatus.org/schema/1.0/subscribe",
|
|
template: `${publicationUrl}${mountPath.replace(/^\//, "")}/authorize_interaction?uri={uri}`,
|
|
},
|
|
];
|
|
});
|
|
|
|
// --- Inbox listeners ---
|
|
const inboxChain = federation.setInboxListeners(
|
|
`${mountPath}/users/{identifier}/inbox`,
|
|
`${mountPath}/inbox`,
|
|
);
|
|
registerInboxListeners(inboxChain, {
|
|
collections,
|
|
handle,
|
|
storeRawActivities,
|
|
});
|
|
|
|
// Enable authenticated fetches for the shared inbox.
|
|
// Without this, Fedify can't verify incoming HTTP Signatures from servers
|
|
// that require authorized fetch (e.g. hachyderm.io returns 401 on unsigned GETs).
|
|
// This tells Fedify to use our actor's key pair when fetching remote actor
|
|
// documents during signature verification on the shared inbox.
|
|
inboxChain.setSharedKeyDispatcher((_ctx) => ({ identifier: handle }));
|
|
|
|
// --- Collection dispatchers ---
|
|
setupFollowers(federation, mountPath, handle, collections);
|
|
setupFollowing(federation, mountPath, handle, collections);
|
|
setupOutbox(federation, mountPath, handle, collections);
|
|
setupLiked(federation, mountPath, handle, collections);
|
|
setupFeatured(federation, mountPath, handle, collections, publicationUrl);
|
|
setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl);
|
|
|
|
// --- Object dispatchers (make posts dereferenceable) ---
|
|
setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl);
|
|
|
|
// --- NodeInfo ---
|
|
// Fedify 2.0: software.version is now a plain string (was SemVer object)
|
|
let softwareVersion = "1.0.0";
|
|
try {
|
|
const require = createRequire(import.meta.url);
|
|
const pkg = require("@indiekit/indiekit/package.json");
|
|
if (pkg.version) softwareVersion = pkg.version;
|
|
} catch { /* fallback to "1.0.0" */ }
|
|
|
|
federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => {
|
|
const postsCount = collections.posts
|
|
? await collections.posts.countDocuments()
|
|
: 0;
|
|
|
|
return {
|
|
software: {
|
|
name: "indiekit",
|
|
version: softwareVersion,
|
|
},
|
|
protocols: ["activitypub"],
|
|
usage: {
|
|
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
|
|
localPosts: postsCount,
|
|
localComments: 0,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Handle permanent delivery failures (Fedify 2.0).
|
|
// Fires when a remote inbox returns 404/410.
|
|
// 410: immediate full cleanup. 404: strike system (3 strikes over 7 days).
|
|
federation.setOutboxPermanentFailureHandler(async (_ctx, values) => {
|
|
await onOutboxPermanentFailure(
|
|
values.statusCode,
|
|
values.actorIds,
|
|
values.inbox,
|
|
collections,
|
|
);
|
|
});
|
|
|
|
// Wrap with debug dashboard if enabled. The debugger proxies the
|
|
// Federation object and intercepts requests at {mountPath}/__debug__/,
|
|
// serving a live dashboard showing traces, activities, signature
|
|
// verification, and correlated logs. It auto-configures OpenTelemetry
|
|
// tracing and LogTape per-trace log collection.
|
|
let activeFederation = federation;
|
|
if (debugDashboard) {
|
|
const debugOptions = {
|
|
path: `${mountPath}/__debug__`,
|
|
};
|
|
if (debugPassword) {
|
|
debugOptions.auth = { type: "password", password: debugPassword };
|
|
}
|
|
activeFederation = createFederationDebugger(federation, debugOptions);
|
|
console.info(
|
|
`[ActivityPub] Debug dashboard enabled at ${mountPath}/__debug__/` +
|
|
(debugPassword ? " (password-protected)" : " (WARNING: no password set)"),
|
|
);
|
|
}
|
|
|
|
// Start the message queue for outbound activity delivery.
|
|
// Without this, ctx.sendActivity() enqueues delivery tasks but the
|
|
// InProcessMessageQueue never processes them — activities are never
|
|
// actually POSTed to follower inboxes.
|
|
activeFederation.startQueue().catch((error) => {
|
|
console.error("[ActivityPub] Failed to start delivery queue:", error.message);
|
|
});
|
|
|
|
return { federation: activeFederation };
|
|
}
|
|
|
|
// --- Collection setup helpers ---
|
|
|
|
function setupFollowers(federation, mountPath, handle, collections) {
|
|
federation
|
|
.setFollowersDispatcher(
|
|
`${mountPath}/users/{identifier}/followers`,
|
|
async (ctx, identifier, cursor) => {
|
|
if (identifier !== handle) return null;
|
|
|
|
// One-shot collection: when cursor is null, return ALL followers
|
|
// as Recipient objects so sendActivity("followers") can deliver.
|
|
// See: https://fedify.dev/manual/collections#one-shot-followers-collection-for-gathering-recipients
|
|
if (cursor == null) {
|
|
const docs = await cachedQuery("col:followers:recipients", COLLECTION_CACHE_TTL, async () => {
|
|
return await collections.ap_followers
|
|
.find()
|
|
.sort({ followedAt: -1 })
|
|
.toArray();
|
|
});
|
|
return {
|
|
items: docs.map((f) => ({
|
|
id: new URL(f.actorUrl),
|
|
inboxId: f.inbox ? new URL(f.inbox) : null,
|
|
endpoints: f.sharedInbox
|
|
? { sharedInbox: new URL(f.sharedInbox) }
|
|
: null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// Paginated collection: for remote browsing of /followers endpoint
|
|
const pageSize = 20;
|
|
const skip = Number.parseInt(cursor, 10);
|
|
const [docs, total] = await cachedQuery(`col:followers:page:${cursor}`, COLLECTION_CACHE_TTL, async () => {
|
|
const d = await collections.ap_followers
|
|
.find()
|
|
.sort({ followedAt: -1 })
|
|
.skip(skip)
|
|
.limit(pageSize)
|
|
.toArray();
|
|
const t = await collections.ap_followers.countDocuments();
|
|
return [d, t];
|
|
});
|
|
|
|
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 cachedQuery("col:followers:count", COLLECTION_CACHE_TTL, async () => {
|
|
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, total] = await cachedQuery(`col:following:page:${cursor}`, COLLECTION_CACHE_TTL, async () => {
|
|
const d = await collections.ap_following
|
|
.find()
|
|
.sort({ followedAt: -1 })
|
|
.skip(skip)
|
|
.limit(pageSize)
|
|
.toArray();
|
|
const t = await collections.ap_following.countDocuments();
|
|
return [d, t];
|
|
});
|
|
|
|
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 cachedQuery("col:following:count", COLLECTION_CACHE_TTL, async () => {
|
|
return await collections.ap_following.countDocuments();
|
|
});
|
|
})
|
|
.setFirstCursor(async () => "0");
|
|
}
|
|
|
|
function setupLiked(federation, mountPath, handle, collections) {
|
|
federation
|
|
.setLikedDispatcher(
|
|
`${mountPath}/users/{identifier}/liked`,
|
|
async (ctx, identifier, cursor) => {
|
|
if (identifier !== handle) return null;
|
|
if (!collections.posts) return { items: [] };
|
|
|
|
const pageSize = 20;
|
|
const skip = cursor ? Number.parseInt(cursor, 10) : 0;
|
|
const query = { "properties.post-type": "like" };
|
|
const [docs, total] = await cachedQuery(`col:liked:page:${cursor}`, COLLECTION_CACHE_TTL, async () => {
|
|
const d = await collections.posts
|
|
.find(query)
|
|
.sort({ "properties.published": -1 })
|
|
.skip(skip)
|
|
.limit(pageSize)
|
|
.toArray();
|
|
const t = await collections.posts.countDocuments(query);
|
|
return [d, t];
|
|
});
|
|
|
|
const items = docs
|
|
.map((d) => {
|
|
const likeOf = d.properties?.["like-of"];
|
|
return likeOf ? new URL(likeOf) : null;
|
|
})
|
|
.filter(Boolean);
|
|
|
|
return {
|
|
items,
|
|
nextCursor:
|
|
skip + pageSize < total ? String(skip + pageSize) : null,
|
|
};
|
|
},
|
|
)
|
|
.setCounter(async (ctx, identifier) => {
|
|
if (identifier !== handle) return 0;
|
|
if (!collections.posts) return 0;
|
|
return await cachedQuery("col:liked:count", COLLECTION_CACHE_TTL, async () => {
|
|
return await collections.posts.countDocuments({
|
|
"properties.post-type": "like",
|
|
});
|
|
});
|
|
})
|
|
.setFirstCursor(async () => "0");
|
|
}
|
|
|
|
function setupFeatured(federation, mountPath, handle, collections, publicationUrl) {
|
|
federation.setFeaturedDispatcher(
|
|
`${mountPath}/users/{identifier}/featured`,
|
|
async (ctx, identifier) => {
|
|
if (identifier !== handle) return null;
|
|
if (!collections.ap_featured) return { items: [] };
|
|
|
|
const docs = await collections.ap_featured
|
|
.find()
|
|
.sort({ pinnedAt: -1 })
|
|
.toArray();
|
|
|
|
// Convert pinned post URLs to Fedify Note/Article objects
|
|
const items = [];
|
|
for (const doc of docs) {
|
|
if (!collections.posts) continue;
|
|
const post = await collections.posts.findOne({
|
|
"properties.url": doc.postUrl,
|
|
});
|
|
if (!post) continue;
|
|
const actorUrl = ctx.getActorUri(identifier).href;
|
|
const activity = jf2ToAS2Activity(
|
|
post.properties,
|
|
actorUrl,
|
|
publicationUrl,
|
|
);
|
|
if (activity instanceof Create) {
|
|
const obj = await activity.getObject();
|
|
if (obj) items.push(obj);
|
|
}
|
|
}
|
|
|
|
return { items };
|
|
},
|
|
);
|
|
}
|
|
|
|
function setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl) {
|
|
federation.setFeaturedTagsDispatcher(
|
|
`${mountPath}/users/{identifier}/tags`,
|
|
async (ctx, identifier) => {
|
|
if (identifier !== handle) return null;
|
|
if (!collections.ap_featured_tags) return { items: [] };
|
|
|
|
const docs = await collections.ap_featured_tags
|
|
.find()
|
|
.sort({ addedAt: -1 })
|
|
.toArray();
|
|
|
|
const baseUrl = publicationUrl
|
|
? publicationUrl.replace(/\/$/, "")
|
|
: ctx.url.origin;
|
|
|
|
const items = docs.map(
|
|
(doc) =>
|
|
new Hashtag({
|
|
name: `#${doc.tag}`,
|
|
href: new URL(
|
|
`/categories/${encodeURIComponent(doc.tag)}`,
|
|
baseUrl,
|
|
),
|
|
}),
|
|
);
|
|
|
|
return { items };
|
|
},
|
|
);
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
function setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl) {
|
|
// Shared lookup: find post by URL path, convert to Fedify Note/Article
|
|
async function resolvePost(ctx, id) {
|
|
if (!collections.posts || !publicationUrl) return null;
|
|
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
|
|
const post = await collections.posts.findOne({ "properties.url": postUrl });
|
|
if (!post) return null;
|
|
const actorUrl = ctx.getActorUri(handle).href;
|
|
const activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
|
|
// Only Create activities wrap Note/Article objects
|
|
if (!(activity instanceof Create)) return null;
|
|
return await activity.getObject();
|
|
}
|
|
|
|
// Note dispatcher — handles note, reply, bookmark, jam, rsvp, checkin
|
|
federation.setObjectDispatcher(
|
|
Note,
|
|
`${mountPath}/objects/note/{+id}`,
|
|
async (ctx, { id }) => {
|
|
const obj = await resolvePost(ctx, id);
|
|
return obj instanceof Note ? obj : null;
|
|
},
|
|
);
|
|
|
|
// Article dispatcher
|
|
federation.setObjectDispatcher(
|
|
Article,
|
|
`${mountPath}/objects/article/{+id}`,
|
|
async (ctx, { id }) => {
|
|
const obj = await resolvePost(ctx, id);
|
|
return obj instanceof Article ? obj : null;
|
|
},
|
|
);
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
async function getProfile(collections) {
|
|
const doc = await collections.ap_profile.findOne({});
|
|
return doc || {};
|
|
}
|
|
|
|
/**
|
|
* Build the Person/Service/Organization actor object from the stored profile.
|
|
* Used by both the actor dispatcher (for serving the actor to federation
|
|
* requests) and broadcastActorUpdate() (for sending Update activities).
|
|
*
|
|
* @param {object} ctx - Fedify context (base Context or RequestContext)
|
|
* @param {string} identifier - Actor handle (e.g. "rick")
|
|
* @param {object} collections - MongoDB collections
|
|
* @param {string} [defaultActorType="Person"] - Fallback actor type
|
|
* @returns {Promise<import("@fedify/fedify").Actor|null>}
|
|
*/
|
|
export async function buildPersonActor(
|
|
ctx,
|
|
identifier,
|
|
collections,
|
|
defaultActorType = "Person",
|
|
) {
|
|
const actorTypeMap = { Person, Service, Application, Organization, Group };
|
|
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),
|
|
liked: ctx.getLikedUri(identifier),
|
|
featured: ctx.getFeaturedUri(identifier),
|
|
featuredTags: ctx.getFeaturedTagsUri(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.assertionMethods = keyPairs.map((k) => k.multikey);
|
|
}
|
|
|
|
// Build profile field attachments (PropertyValue).
|
|
// Always include a "Fediverse" field with the actor's handle — this serves
|
|
// two purposes: (1) shows the canonical fediverse address on the profile,
|
|
// and (2) ensures 2+ attachments when combined with user-defined fields,
|
|
// preventing Fedify's JSON-LD compaction from collapsing single-element
|
|
// arrays to plain objects (which Mastodon's update_account_fields rejects).
|
|
const actorUrl = ctx.getActorUri(identifier)?.href;
|
|
const fediverseField = actorUrl
|
|
? new PropertyValue({
|
|
name: "Fediverse",
|
|
value: `<a href="${actorUrl}" rel="me">${actorUrl}</a>`,
|
|
})
|
|
: null;
|
|
|
|
if (profile.attachments?.length > 0) {
|
|
personOptions.attachments = profile.attachments.map(
|
|
(att) =>
|
|
new PropertyValue({
|
|
name: att.name,
|
|
value: formatAttachmentValue(att.value),
|
|
}),
|
|
);
|
|
// Append fediverse field if not already present in user-defined fields
|
|
if (fediverseField && !profile.attachments.some((a) => a.name === "Fediverse")) {
|
|
personOptions.attachments.push(fediverseField);
|
|
}
|
|
} else if (fediverseField) {
|
|
personOptions.attachments = [fediverseField];
|
|
}
|
|
|
|
if (profile.alsoKnownAs?.length > 0) {
|
|
personOptions.alsoKnownAs = profile.alsoKnownAs.map((u) => new URL(u));
|
|
}
|
|
|
|
if (profile.createdAt) {
|
|
personOptions.published = Temporal.Instant.from(profile.createdAt);
|
|
}
|
|
|
|
const profileActorType = profile.actorType || defaultActorType;
|
|
const ResolvedActorClass = actorTypeMap[profileActorType] || Person;
|
|
|
|
return new ResolvedActorClass(personOptions);
|
|
}
|
|
|
|
/**
|
|
* Import an SPKI PEM public key using Web Crypto API.
|
|
* Replaces Fedify 1.x's importSpki() which was removed in 2.0.
|
|
*/
|
|
async function importSpkiPem(pem) {
|
|
const lines = pem
|
|
.replace("-----BEGIN PUBLIC KEY-----", "")
|
|
.replace("-----END PUBLIC KEY-----", "")
|
|
.replace(/\s/g, "");
|
|
const der = Uint8Array.from(atob(lines), (c) => c.charCodeAt(0));
|
|
return crypto.subtle.importKey(
|
|
"spki",
|
|
der,
|
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
true,
|
|
["verify"],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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"],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Format an attachment value for ActivityPub PropertyValue.
|
|
* If the value looks like a URL, wrap it in an HTML anchor tag with rel="me"
|
|
* so Mastodon can verify profile link ownership. Plain text values pass through.
|
|
*/
|
|
function formatAttachmentValue(value) {
|
|
if (!value) return "";
|
|
const trimmed = value.trim();
|
|
// Already contains HTML — pass through
|
|
if (trimmed.startsWith("<")) return trimmed;
|
|
// URL — wrap in anchor with rel="me"
|
|
if (/^https?:\/\//i.test(trimmed)) {
|
|
const escaped = trimmed
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
return `<a href="${escaped}" rel="me">${escaped}</a>`;
|
|
}
|
|
// Plain text (e.g. pronouns) — return as-is
|
|
return trimmed;
|
|
}
|
|
|
|
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";
|
|
}
|