|
| 1 | +/** |
| 2 | + * ActivityPub Plugin for JSS |
| 3 | + * Adds federation support via the ActivityPub protocol |
| 4 | + */ |
| 5 | + |
| 6 | +import { webfinger } from 'microfed' |
| 7 | +import { loadOrCreateKeypair, getKeyId } from './keys.js' |
| 8 | +import { initStore } from './store.js' |
| 9 | +import { createInboxHandler } from './routes/inbox.js' |
| 10 | +import { createOutboxHandler } from './routes/outbox.js' |
| 11 | +import { createCollectionsHandler } from './routes/collections.js' |
| 12 | +import { createActorHandler } from './routes/actor.js' |
| 13 | + |
| 14 | +/** |
| 15 | + * ActivityPub Fastify plugin |
| 16 | + * @param {FastifyInstance} fastify |
| 17 | + * @param {object} options |
| 18 | + * @param {string} options.username - Default username for single-user mode |
| 19 | + * @param {string} options.displayName - Display name |
| 20 | + * @param {string} options.summary - Bio/description |
| 21 | + * @param {string} options.nostrPubkey - Nostr public key (hex) for identity linking |
| 22 | + */ |
| 23 | +export async function activityPubPlugin(fastify, options = {}) { |
| 24 | + // Initialize storage and keypair |
| 25 | + const keypair = loadOrCreateKeypair() |
| 26 | + initStore() |
| 27 | + |
| 28 | + // Store config for handlers |
| 29 | + const config = { |
| 30 | + keypair, |
| 31 | + username: options.username || 'me', |
| 32 | + displayName: options.displayName || options.username || 'Anonymous', |
| 33 | + summary: options.summary || '', |
| 34 | + nostrPubkey: options.nostrPubkey || null |
| 35 | + } |
| 36 | + |
| 37 | + // Decorate fastify with AP config |
| 38 | + fastify.decorate('apConfig', config) |
| 39 | + |
| 40 | + // Helper to build actor ID from request |
| 41 | + const getActorId = (request) => { |
| 42 | + const protocol = request.headers['x-forwarded-proto'] || request.protocol |
| 43 | + const host = request.headers['x-forwarded-host'] || request.hostname |
| 44 | + return `${protocol}://${host}/profile/card#me` |
| 45 | + } |
| 46 | + |
| 47 | + // Helper to get base URL |
| 48 | + const getBaseUrl = (request) => { |
| 49 | + const protocol = request.headers['x-forwarded-proto'] || request.protocol |
| 50 | + const host = request.headers['x-forwarded-host'] || request.hostname |
| 51 | + return `${protocol}://${host}` |
| 52 | + } |
| 53 | + |
| 54 | + // WebFinger endpoint |
| 55 | + fastify.get('/.well-known/webfinger', async (request, reply) => { |
| 56 | + const resource = request.query.resource |
| 57 | + if (!resource) { |
| 58 | + return reply.code(400).send({ error: 'Missing resource parameter' }) |
| 59 | + } |
| 60 | + |
| 61 | + const parsed = webfinger.parseResource(resource) |
| 62 | + if (!parsed) { |
| 63 | + return reply.code(400).send({ error: 'Invalid resource format' }) |
| 64 | + } |
| 65 | + |
| 66 | + // Check if this is our user |
| 67 | + const host = request.headers['x-forwarded-host'] || request.hostname |
| 68 | + if (parsed.domain !== host) { |
| 69 | + return reply.code(404).send({ error: 'Not found' }) |
| 70 | + } |
| 71 | + |
| 72 | + // For now, accept any username and map to /profile/card#me |
| 73 | + // In multi-user mode, we'd look up the user |
| 74 | + const baseUrl = getBaseUrl(request) |
| 75 | + const actorUrl = `${baseUrl}/profile/card#me` |
| 76 | + const profileUrl = `${baseUrl}/profile/card` |
| 77 | + |
| 78 | + const response = webfinger.createResponse( |
| 79 | + `${parsed.username}@${parsed.domain}`, |
| 80 | + actorUrl, |
| 81 | + { profileUrl } |
| 82 | + ) |
| 83 | + |
| 84 | + return reply |
| 85 | + .header('Content-Type', 'application/jrd+json') |
| 86 | + .header('Access-Control-Allow-Origin', '*') |
| 87 | + .send(response) |
| 88 | + }) |
| 89 | + |
| 90 | + // NodeInfo discovery (for Mastodon compatibility) |
| 91 | + fastify.get('/.well-known/nodeinfo', async (request, reply) => { |
| 92 | + const baseUrl = getBaseUrl(request) |
| 93 | + return reply |
| 94 | + .header('Content-Type', 'application/json') |
| 95 | + .send({ |
| 96 | + links: [ |
| 97 | + { |
| 98 | + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', |
| 99 | + href: `${baseUrl}/.well-known/nodeinfo/2.1` |
| 100 | + } |
| 101 | + ] |
| 102 | + }) |
| 103 | + }) |
| 104 | + |
| 105 | + fastify.get('/.well-known/nodeinfo/2.1', async (request, reply) => { |
| 106 | + const { getPostCount } = await import('./store.js') |
| 107 | + return reply |
| 108 | + .header('Content-Type', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"') |
| 109 | + .send({ |
| 110 | + version: '2.1', |
| 111 | + software: { |
| 112 | + name: 'jss', |
| 113 | + version: '0.0.61', |
| 114 | + repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer' |
| 115 | + }, |
| 116 | + protocols: ['activitypub', 'solid'], |
| 117 | + services: { inbound: [], outbound: [] }, |
| 118 | + usage: { |
| 119 | + users: { total: 1, activeMonth: 1, activeHalfyear: 1 }, |
| 120 | + localPosts: getPostCount() |
| 121 | + }, |
| 122 | + openRegistrations: true, |
| 123 | + metadata: { |
| 124 | + nodeName: config.displayName, |
| 125 | + nodeDescription: 'SAND Stack: Solid + ActivityPub + Nostr + DID' |
| 126 | + } |
| 127 | + }) |
| 128 | + }) |
| 129 | + |
| 130 | + // Actor endpoint - handle AP content negotiation for /profile/card |
| 131 | + const actorHandler = createActorHandler(config, keypair) |
| 132 | + |
| 133 | + // Decorate request to track AP handling |
| 134 | + fastify.decorateRequest('apHandled', false) |
| 135 | + |
| 136 | + // Register dedicated GET route for /profile/card with AP content negotiation |
| 137 | + // This needs to run BEFORE the wildcard LDP routes |
| 138 | + fastify.get('/profile/card', { |
| 139 | + // Run this handler first, before wildcard routes |
| 140 | + preHandler: async (request, reply) => { |
| 141 | + const accept = request.headers.accept || '' |
| 142 | + const wantsAP = accept.includes('activity+json') || |
| 143 | + accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"') |
| 144 | + |
| 145 | + if (wantsAP) { |
| 146 | + const actor = actorHandler(request) |
| 147 | + request.apHandled = true |
| 148 | + return reply |
| 149 | + .header('Content-Type', 'application/activity+json') |
| 150 | + .send(actor) |
| 151 | + } |
| 152 | + // If not AP, skip and let the request continue (but this route won't have a main handler) |
| 153 | + // We return early - the request will 404 on this route but get caught by wildcard |
| 154 | + } |
| 155 | + }, async (request, reply) => { |
| 156 | + // This handler won't be reached if AP was handled |
| 157 | + // For non-AP requests, we need to pass through to LDP |
| 158 | + // But we can't easily do that here, so we'll handle it differently |
| 159 | + reply.callNotFound() |
| 160 | + }) |
| 161 | + |
| 162 | + // Inbox endpoint |
| 163 | + const inboxHandler = createInboxHandler(config, keypair) |
| 164 | + fastify.post('/inbox', inboxHandler) |
| 165 | + fastify.post('/profile/card/inbox', inboxHandler) |
| 166 | + |
| 167 | + // Outbox endpoint |
| 168 | + const outboxHandler = createOutboxHandler(config, keypair) |
| 169 | + fastify.get('/profile/card/outbox', outboxHandler) |
| 170 | + |
| 171 | + // Followers/Following collections |
| 172 | + const collectionsHandler = createCollectionsHandler(config) |
| 173 | + fastify.get('/profile/card/followers', (req, reply) => collectionsHandler(req, reply, 'followers')) |
| 174 | + fastify.get('/profile/card/following', (req, reply) => collectionsHandler(req, reply, 'following')) |
| 175 | +} |
| 176 | + |
| 177 | +export default activityPubPlugin |
0 commit comments