diff --git a/src/ap/index.js b/src/ap/index.js index d868691..9cc51ec 100644 --- a/src/ap/index.js +++ b/src/ap/index.js @@ -4,13 +4,15 @@ */ import { webfinger } from 'microfed' -import { loadOrCreateKeypair, getKeyId } from './keys.js' +import websocket from '@fastify/websocket' +import { getWebIdFromRequestAsync } from '../auth/token.js' +import { loadOrCreateKeypair, getKeyId, getDefaultKeyPath } from './keys.js' import { initStore } from './store.js' import { createInboxHandler } from './routes/inbox.js' -import { createOutboxHandler, createOutboxPostHandler } from './routes/outbox.js' +import { createOutboxHandler, createOutboxPostHandler, createPostObjectHandler } from './routes/outbox.js' import { createCollectionsHandler } from './routes/collections.js' import { createActorHandler } from './routes/actor.js' -import { createAppsHandler, createVerifyCredentialsHandler, createInstanceHandler } from './routes/mastodon.js' +import { createAppsHandler, createVerifyCredentialsHandler, createUpdateCredentialsHandler, createAccountLookupHandler, createAccountsSearchHandler, createPreferencesHandler, createListsHandler, createAccountListsHandler, createRelationshipsHandler, createInstanceHandler, createInstanceV2Handler, createSearchHandler, createTimelinesHomeHandler, createPostStatusHandler, createGetStatusHandler, createFavouriteStatusHandler, createUpdateStatusHandler, createGetAccountHandler, createGetAccountStatusesHandler, createFollowAccountHandler, createGetNotificationsHandler, getProfileMediaBuffer } from './routes/mastodon.js' import { createAuthorizeHandler, createAuthorizePostHandler, createTokenHandler } from './routes/oauth.js' // Shared state for actor handler (accessed by server.js) @@ -27,21 +29,104 @@ export function getActorHandler() { return sharedActorHandler } * @param {string} options.nostrPubkey - Nostr public key (hex) for identity linking */ export async function activityPubPlugin(fastify, options = {}) { - // Initialize storage and keypair - const keypair = loadOrCreateKeypair() - await initStore() - - // Store config for handlers - const config = { - keypair, - username: options.username || 'me', - displayName: options.displayName || options.username || 'Anonymous', + const defaultUsername = options.username || 'me' + await initStore(undefined, defaultUsername) + + // Register WebSocket support for Mastodon streaming API if not already present. + if (!fastify.websocketServer) { + await fastify.register(websocket) + } + + const subdomains = options.subdomains || false + const baseDomain = options.baseDomain || null + + // Single-user fallback config (used when not in subdomain mode) + const defaultConfig = { + username: defaultUsername, + displayName: options.displayName || defaultUsername, summary: options.summary || '', - nostrPubkey: options.nostrPubkey || null + nostrPubkey: options.nostrPubkey || null, + subdomains, + baseDomain + } + + // Keypair cache: username → keypair (loaded on demand) + const keypairCache = new Map() + + const getKeypairForUser = (username) => { + if (!keypairCache.has(username)) { + keypairCache.set(username, loadOrCreateKeypair(getDefaultKeyPath(username))) + } + return keypairCache.get(username) + } + + // In single-user mode (no subdomains) pre-load the keypair now. + // In subdomain mode, keypairs are created on first request per user — no eager load. + if (!subdomains) { + defaultConfig.keypair = getKeypairForUser(defaultUsername) + } + + const getRequestHost = (request) => request.headers['x-forwarded-host'] || request.hostname + + const getUsernameFromWebId = (webId) => { + if (!webId) return null + try { + const url = new URL(webId) + const hostname = url.hostname + if (hostname === 'localhost' || /^(127\.|192\.168\.|10\.|::1)/.test(hostname)) { + const seg = url.pathname.split('/').filter(Boolean)[0] + return seg || null + } + const parts = hostname.split('.') + return parts.length >= 2 ? parts[0] : (parts[0] || null) + } catch { + return null + } + } + + /** + * In subdomain mode, derive the active user from the request subdomain. + * e.g. alice.pivot-test.local → username "alice" + * Any subdomain user is automatically a valid AP actor. + * Falls back to the default (single-user) config. + */ + const getUserConfig = (request) => { + if (subdomains && baseDomain) { + const host = getRequestHost(request) + const baseDomainHost = baseDomain.includes(':') ? baseDomain.split(':')[0] : baseDomain + const hostNoPort = host.includes(':') ? host.split(':')[0] : host + const subdomain = hostNoPort.endsWith('.' + baseDomainHost) + ? hostNoPort.slice(0, -(baseDomainHost.length + 1)) + : null + if (subdomain) { + const keypair = getKeypairForUser(subdomain) + return { + keypair, + username: subdomain, + displayName: subdomain, + summary: '', + nostrPubkey: null, + subdomains, + baseDomain + } + } + } + return defaultConfig } - // Decorate fastify with AP config - fastify.decorate('apConfig', config) + // Decorate fastify with AP config (default user, for compat) + fastify.decorate('apConfig', defaultConfig) + + // In subdomain mode, the canonical AP actor host is the AP username subdomain. + // Example: --ap-username alice + --base-domain pivot-test.local:4443 + // -> actor/profile URLs use alice.pivot-test.local:4443. + const getActorHost = (request, userConfig) => { + const uc = userConfig || getUserConfig(request) + if (uc.subdomains && uc.baseDomain) { + return `${uc.username}.${uc.baseDomain}` + } + return getRequestHost(request) + } // Helper to detect protocol from proxy headers const getProtocol = (request) => { @@ -58,26 +143,74 @@ export async function activityPubPlugin(fastify, options = {}) { } } // If still no protocol and hostname looks like a public domain, assume https - if (!protocol && request.hostname && !request.hostname.match(/^(localhost|127\.|192\.168\.|10\.)/)) { + const host = getRequestHost(request) + const hostNoPort = host.includes(':') ? host.split(':')[0] : host + if (!protocol && hostNoPort && !hostNoPort.match(/^(localhost|127\.|192\.168\.|10\.)/)) { protocol = 'https' } return protocol || request.protocol } - // Helper to build actor ID from request - const getActorId = (request) => { + // Helper to build actor ID from request (optionally for a specific user config) + const getActorId = (request, userConfig) => { const protocol = getProtocol(request) - const host = request.headers['x-forwarded-host'] || request.hostname + const host = getActorHost(request, userConfig) return `${protocol}://${host}/profile/card.jsonld#me` } // Helper to get base URL - const getBaseUrl = (request) => { + const getBaseUrl = (request, actor = false, userConfig) => { const protocol = getProtocol(request) - const host = request.headers['x-forwarded-host'] || request.hostname + const host = actor ? getActorHost(request, userConfig) : getRequestHost(request) return `${protocol}://${host}` } + // host-meta discovery (used by Mastodon clients like Phanpy) + fastify.get('/.well-known/host-meta', async (request, reply) => { + const baseUrl = getBaseUrl(request) + const xml = `\n\n \n` + + return reply + .header('Content-Type', 'application/xrd+xml; charset=utf-8') + .header('Access-Control-Allow-Origin', '*') + .send(xml) + }) + + fastify.get('/.well-known/host-meta.json', async (request, reply) => { + const baseUrl = getBaseUrl(request) + return reply + .header('Content-Type', 'application/json') + .header('Access-Control-Allow-Origin', '*') + .send({ + links: [ + { + rel: 'lrdd', + type: 'application/jrd+json', + template: `${baseUrl}/.well-known/webfinger?resource={uri}` + } + ] + }) + }) + + // OAuth 2.0 Authorization Server Metadata (RFC 8414) + // Used by Mastodon-compatible clients during discovery. + fastify.get('/.well-known/oauth-authorization-server', async (request, reply) => { + const baseUrl = getBaseUrl(request) + return reply + .header('Content-Type', 'application/json') + .header('Access-Control-Allow-Origin', '*') + .send({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/oauth/authorize`, + token_endpoint: `${baseUrl}/oauth/token`, + registration_endpoint: `${baseUrl}/api/v1/apps`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_post'], + code_challenge_methods_supported: ['S256'] + }) + }) + // WebFinger endpoint fastify.get('/.well-known/webfinger', async (request, reply) => { const resource = request.query.resource @@ -90,20 +223,31 @@ export async function activityPubPlugin(fastify, options = {}) { return reply.code(400).send({ error: 'Invalid resource format' }) } - // Check if this is our user - const host = request.headers['x-forwarded-host'] || request.hostname + // Check if this is our domain + const host = getRequestHost(request) if (parsed.domain !== host) { return reply.code(404).send({ error: 'Not found' }) } - // For now, accept any username and map to /profile/card.jsonld#me - // In multi-user mode, we'd look up the user + // In subdomain mode, any username on this domain is a valid AP actor. + // In single-user mode, only the configured username is accepted. + const username = parsed.username + if (!subdomains && username !== defaultConfig.username) { + return reply.code(404).send({ error: 'User not found' }) + } + + // Build a minimal config for this user to resolve their actor host + const matchedUser = subdomains + ? { username, subdomains, baseDomain } + : defaultConfig + const baseUrl = getBaseUrl(request) - const actorUrl = `${baseUrl}/profile/card.jsonld#me` - const profileUrl = `${baseUrl}/profile/card.jsonld` + const actorBaseUrl = getBaseUrl(request, true, matchedUser) + const actorUrl = `${actorBaseUrl}/profile/card.jsonld#me` + const profileUrl = `${actorBaseUrl}/profile/card.jsonld` const response = webfinger.createResponse( - `${parsed.username}@${parsed.domain}`, + `${username}@${parsed.domain}`, actorUrl, { profileUrl } ) @@ -111,7 +255,7 @@ export async function activityPubPlugin(fastify, options = {}) { // Add remoteStorage link relation response.links.push({ rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage', - href: `${baseUrl}/storage/${config.username}/`, + href: `${baseUrl}/storage/${username}/`, properties: { 'http://remotestorage.io/spec/version': 'draft-dejong-remotestorage-22', 'http://tools.ietf.org/html/rfc6749#section-4.2': `${baseUrl}/oauth/authorize`, @@ -155,7 +299,7 @@ export async function activityPubPlugin(fastify, options = {}) { services: { inbound: [], outbound: [] }, usage: { users: { total: 1, activeMonth: 1, activeHalfyear: 1 }, - localPosts: getPostCount() + localPosts: getPostCount(defaultConfig.username) }, openRegistrations: true, metadata: { @@ -165,32 +309,124 @@ export async function activityPubPlugin(fastify, options = {}) { }) }) - // Actor endpoint - expose handler for profile/card AP requests - const actorHandler = createActorHandler(config, keypair) + // Actor endpoint - build a dispatcher that resolves the right user config per request + // In subdomain mode each user's subdomain routes here; in single-host mode use primary. + const actorHandlerDispatch = (request, reply) => { + const uc = getUserConfig(request) + return createActorHandler(uc, uc.keypair)(request, reply) + } - // Store actorHandler in shared state for use by server-level hook - sharedActorHandler = actorHandler + // Store actorHandler dispatcher in shared state for use by server-level hook + sharedActorHandler = actorHandlerDispatch - // Inbox endpoint - const inboxHandler = createInboxHandler(config, keypair) - fastify.post('/inbox', inboxHandler) - fastify.post('/profile/card.jsonld/inbox', inboxHandler) + // Shared inbox (base domain) — use default config + fastify.post('/inbox', createInboxHandler(defaultConfig, getKeypairForUser(defaultUsername))) - // Outbox endpoint - const outboxHandler = createOutboxHandler(config, keypair) - const outboxPostHandler = createOutboxPostHandler(config, keypair) - fastify.get('/profile/card.jsonld/outbox', outboxHandler) - fastify.post('/profile/card.jsonld/outbox', outboxPostHandler) + // Per-user inbox/outbox/collections — same route path, resolved by subdomain + const inboxDispatch = (request, reply) => { + const uc = getUserConfig(request) + return createInboxHandler(uc, uc.keypair)(request, reply) + } + const outboxDispatch = (request, reply) => { + const uc = getUserConfig(request) + return createOutboxHandler(uc, uc.keypair)(request, reply) + } + const outboxPostDispatch = (request, reply) => { + const uc = getUserConfig(request) + return createOutboxPostHandler(uc, uc.keypair)(request, reply) + } + const postObjectDispatch = (request, reply) => { + const uc = getUserConfig(request) + return createPostObjectHandler(uc)(request, reply) + } + const collectionsDispatch = (request, reply, type) => { + const uc = getUserConfig(request) + return createCollectionsHandler(uc)(request, reply, type) + } - // Followers/Following collections - const collectionsHandler = createCollectionsHandler(config) - fastify.get('/profile/card.jsonld/followers', (req, reply) => collectionsHandler(req, reply, 'followers')) - fastify.get('/profile/card.jsonld/following', (req, reply) => collectionsHandler(req, reply, 'following')) + fastify.post('/profile/card.jsonld/inbox', inboxDispatch) + fastify.get('/profile/card.jsonld/outbox', outboxDispatch) + fastify.post('/profile/card.jsonld/outbox', outboxPostDispatch) + fastify.get('/posts/:id', postObjectDispatch) + fastify.get('/profile/avatar.png', async (request, reply) => { + const uc = getUserConfig(request) + let mediaUsername = uc.username + if (!mediaUsername || mediaUsername === 'me') { + const auth = await getWebIdFromRequestAsync(request) + mediaUsername = getUsernameFromWebId(auth.webId) || mediaUsername + } + const media = getProfileMediaBuffer(mediaUsername, 'avatar') + if (media) { + return reply.header('Content-Type', media.contentType).send(media.buffer) + } + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBgNfQh8EAAAAASUVORK5CYII=', 'base64') + return reply.header('Content-Type', 'image/png').send(png) + }) + fastify.get('/profile/header.png', async (request, reply) => { + const uc = getUserConfig(request) + let mediaUsername = uc.username + if (!mediaUsername || mediaUsername === 'me') { + const auth = await getWebIdFromRequestAsync(request) + mediaUsername = getUsernameFromWebId(auth.webId) || mediaUsername + } + const media = getProfileMediaBuffer(mediaUsername, 'header') + if (media) { + return reply.header('Content-Type', media.contentType).send(media.buffer) + } + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBgNfQh8EAAAAASUVORK5CYII=', 'base64') + return reply.header('Content-Type', 'image/png').send(png) + }) + fastify.get('/profile/card.jsonld/followers', (req, reply) => collectionsDispatch(req, reply, 'followers')) + fastify.get('/profile/card.jsonld/following', (req, reply) => collectionsDispatch(req, reply, 'following')) // Mastodon-compatible API endpoints + const streamingDispatch = (connection, request) => { + // Minimal compatibility endpoint for Mastodon clients (Phanpy/Elk): + // accept connection and keep it alive even when no events are emitted yet. + const ping = setInterval(() => { + if (connection.socket.readyState === 1) { + try { + connection.socket.ping() + } catch { + clearInterval(ping) + } + } + }, 30000) + + connection.socket.on('close', () => clearInterval(ping)) + connection.socket.on('error', () => clearInterval(ping)) + } + + fastify.get('/api/v1/streaming', { websocket: true }, streamingDispatch) + fastify.get('/api/v1/streaming/', { websocket: true }, streamingDispatch) + fastify.post('/api/v1/apps', createAppsHandler()) - fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(config)) - fastify.get('/api/v1/instance', createInstanceHandler(config)) + fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(getUserConfig)) + fastify.patch('/api/v1/accounts/update_credentials', createUpdateCredentialsHandler()) + fastify.post('/api/v1/accounts/update_credentials', createUpdateCredentialsHandler()) + fastify.get('/api/v1/accounts/lookup', createAccountLookupHandler()) + fastify.get('/api/v1/accounts/search', createAccountsSearchHandler()) + fastify.get('/api/v1/preferences', createPreferencesHandler()) + fastify.get('/api/v1/lists', createListsHandler()) + fastify.get('/api/v1/accounts/relationships', createRelationshipsHandler()) + fastify.get('/api/v1/instance', createInstanceHandler()) + fastify.get('/api/v2/instance', createInstanceV2Handler()) + fastify.get('/api/v2/search', createSearchHandler()) + fastify.get('/api/v1/timelines/home', createTimelinesHomeHandler()) + fastify.post('/api/v1/statuses', createPostStatusHandler()) + fastify.get('/api/v1/statuses/:id', createGetStatusHandler()) + fastify.get('/api/v1/statuses/:id/source', createGetStatusHandler()) + fastify.get('/api/v1/statuses/:id/history', createGetStatusHandler()) + fastify.get('/api/v1/statuses/*', createGetStatusHandler()) + fastify.post('/api/v1/statuses/:id/favourite', createFavouriteStatusHandler(getUserConfig)) + fastify.post('/api/v1/statuses/*', createFavouriteStatusHandler(getUserConfig)) + fastify.put('/api/v1/statuses/:id', createUpdateStatusHandler()) + fastify.put('/api/v1/statuses/*', createUpdateStatusHandler()) + fastify.get('/api/v1/accounts/:id', createGetAccountHandler()) + fastify.get('/api/v1/accounts/:id/lists', createAccountListsHandler()) + fastify.get('/api/v1/accounts/:id/statuses', createGetAccountStatusesHandler()) + fastify.post('/api/v1/accounts/:id/follow', createFollowAccountHandler(getUserConfig)) + fastify.get('/api/v1/notifications', createGetNotificationsHandler(getUserConfig)) // OAuth 2.0 authorize/token flow (Mastodon clients, remoteStorage, third-party panes) fastify.get('/oauth/authorize', createAuthorizeHandler()) diff --git a/src/ap/keys.js b/src/ap/keys.js index 2a71d6c..6242f53 100644 --- a/src/ap/keys.js +++ b/src/ap/keys.js @@ -7,7 +7,14 @@ import { generateKeyPairSync } from 'crypto' import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs' import { dirname, join } from 'path' -const DEFAULT_KEY_PATH = 'data/ap-keys.json' +/** + * Get default key path under DATA_ROOT/.idp/ap/ + * @param {string} username + */ +export function getDefaultKeyPath(username = 'me') { + const dataRoot = process.env.DATA_ROOT || './data' + return join(dataRoot, '.idp', 'ap', 'keys.json') +} /** * Generate RSA keypair @@ -25,27 +32,27 @@ export function generateKeypair(modulusLength = 2048) { /** * Load keypair from disk, generate if not exists - * @param {string} path - Path to keys file + * @param {string} [path] - Path to keys file (defaults to DATA_ROOT/.idp/ap/keys.json) * @returns {{ publicKey: string, privateKey: string }} */ -export function loadOrCreateKeypair(path = DEFAULT_KEY_PATH) { - if (existsSync(path)) { - const data = JSON.parse(readFileSync(path, 'utf8')) - return data +export function loadOrCreateKeypair(path) { + const resolvedPath = path || getDefaultKeyPath('me') + if (existsSync(resolvedPath)) { + return JSON.parse(readFileSync(resolvedPath, 'utf8')) } // Generate new keypair const keypair = generateKeypair() // Ensure directory exists - const dir = dirname(path) + const dir = dirname(resolvedPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } // Save to disk - writeFileSync(path, JSON.stringify(keypair, null, 2)) - console.log(`Generated new ActivityPub keypair: ${path}`) + writeFileSync(resolvedPath, JSON.stringify(keypair, null, 2)) + console.log(`Generated new ActivityPub keypair: ${resolvedPath}`) return keypair } diff --git a/src/ap/routes/actor.js b/src/ap/routes/actor.js index be6b7b2..a71a425 100644 --- a/src/ap/routes/actor.js +++ b/src/ap/routes/actor.js @@ -23,9 +23,12 @@ export function createActorHandler(config, keypair) { } catch { /* ignore */ } } } - // If still no protocol and hostname looks like a public domain, assume https - const host = request.headers['x-forwarded-host'] || request.hostname - if (!protocol && host && !host.match(/^(localhost|127\.|192\.168\.|10\.)/)) { + const requestHost = request.headers['x-forwarded-host'] || request.hostname + const host = (config.subdomains && config.baseDomain) + ? `${config.username}.${config.baseDomain}` + : requestHost + const hostNoPort = host.includes(':') ? host.split(':')[0] : host + if (!protocol && hostNoPort && !hostNoPort.match(/^(localhost|127\.|192\.168\.|10\.)/)) { protocol = 'https' } protocol = protocol || request.protocol diff --git a/src/ap/routes/collections.js b/src/ap/routes/collections.js index c51bf63..2d8cc43 100644 --- a/src/ap/routes/collections.js +++ b/src/ap/routes/collections.js @@ -20,13 +20,13 @@ export function createCollectionsHandler(config) { let items, totalItems if (collectionType === 'followers') { - const followers = getFollowers() + const followers = getFollowers(config.username) items = followers.map(f => f.actor) - totalItems = getFollowerCount() + totalItems = getFollowerCount(config.username) } else { - const following = getFollowing() + const following = getFollowing(config.username) items = following.map(f => f.actor) - totalItems = getFollowingCount() + totalItems = getFollowingCount(config.username) } const collection = { diff --git a/src/ap/routes/inbox.js b/src/ap/routes/inbox.js index 9a27660..1c2352f 100644 --- a/src/ap/routes/inbox.js +++ b/src/ap/routes/inbox.js @@ -4,6 +4,8 @@ */ import { auth, outbox } from 'microfed' +import { Agent } from 'undici' +import { createSign } from 'crypto' import { saveActivity, addFollower, @@ -14,6 +16,9 @@ import { } from '../store.js' import { getKeyId } from '../keys.js' +// Allow self-signed certs when fetching local actors (e.g. server fetching itself) +const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } }) + /** * Fetch remote actor (with caching) * @param {string} id - Actor URL @@ -28,6 +33,7 @@ async function fetchActor(id, log) { try { const response = await fetch(fetchUrl, { + dispatcher: insecureAgent, headers: { 'Accept': 'application/activity+json', 'User-Agent': 'JSS/1.0 (+https://github.com/JavaScriptSolidServer/JavaScriptSolidServer)' @@ -143,7 +149,7 @@ export function createInboxHandler(config, keypair) { // Save activity if (activity.id) { - saveActivity(activity) + saveActivity(config.username, activity) } // Handle activity by type @@ -157,15 +163,15 @@ export function createInboxHandler(config, keypair) { switch (activity.type) { case 'Follow': - await handleFollow(activity, actorId, profileUrl, keypair, request.log) + await handleFollow(config.username, activity, actorId, profileUrl, keypair, request.log) break case 'Undo': - await handleUndo(activity, request.log) + await handleUndo(config.username, activity, request.log) break case 'Accept': - handleAccept(activity, request.log) + handleAccept(config.username, activity, request.log) break case 'Create': @@ -192,7 +198,7 @@ export function createInboxHandler(config, keypair) { /** * Handle Follow activity */ -async function handleFollow(activity, actorId, profileUrl, keypair, log) { +async function handleFollow(username, activity, actorId, profileUrl, keypair, log) { const followerActor = await fetchActor(activity.actor, log) if (!followerActor) { log.warn('Could not fetch follower actor') @@ -200,20 +206,38 @@ async function handleFollow(activity, actorId, profileUrl, keypair, log) { } // Add to followers - addFollower(activity.actor, followerActor.inbox) + addFollower(username, activity.actor, followerActor.inbox) log.info(`New follower: ${followerActor.preferredUsername || activity.actor}`) - // Send Accept + // Send Accept — use direct signed fetch to support self-signed certs const accept = outbox.createAccept(actorId, activity) + const body = JSON.stringify(accept) + const inboxUrl = new URL(followerActor.inbox) + const date = new Date().toUTCString() + const digest = `SHA-256=${Buffer.from( + (await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body))) + ).toString('base64')}` + const signingString = `(request-target): post ${inboxUrl.pathname}\nhost: ${inboxUrl.host}\ndate: ${date}\ndigest: ${digest}` + const signer = createSign('RSA-SHA256') + signer.update(signingString) + const signature = signer.sign(keypair.privateKey, 'base64') + const keyId = `${profileUrl}#main-key` + const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"` try { - await outbox.send({ - activity: accept, - inbox: followerActor.inbox, - privateKey: keypair.privateKey, - keyId: `${profileUrl}#main-key` + const res = await fetch(followerActor.inbox, { + method: 'POST', + dispatcher: insecureAgent, + headers: { + 'Content-Type': 'application/activity+json', + 'Accept': 'application/activity+json', + 'Date': date, + 'Digest': digest, + 'Signature': signatureHeader + }, + body }) - log.info(`Sent Accept to ${followerActor.inbox}`) + log.info(`Sent Accept to ${followerActor.inbox} — ${res.status}`) } catch (err) { log.error(`Failed to send Accept: ${err.message}`) } @@ -222,9 +246,9 @@ async function handleFollow(activity, actorId, profileUrl, keypair, log) { /** * Handle Undo activity */ -async function handleUndo(activity, log) { +async function handleUndo(username, activity, log) { if (activity.object?.type === 'Follow') { - removeFollower(activity.actor) + removeFollower(username, activity.actor) log.info(`Unfollowed by ${activity.actor}`) } } @@ -232,13 +256,13 @@ async function handleUndo(activity, log) { /** * Handle Accept activity (our follow was accepted) */ -function handleAccept(activity, log) { +function handleAccept(username, activity, log) { if (activity.object?.type === 'Follow') { const target = typeof activity.object.object === 'string' ? activity.object.object : activity.object.object?.id if (target) { - acceptFollowing(target) + acceptFollowing(username, target) log.info('Follow accepted!') } } diff --git a/src/ap/routes/mastodon.js b/src/ap/routes/mastodon.js index 1141c2b..67408d5 100644 --- a/src/ap/routes/mastodon.js +++ b/src/ap/routes/mastodon.js @@ -7,8 +7,368 @@ * https://docs.joinmastodon.org/methods/accounts/#verify_credentials */ +import { createSign, randomUUID } from 'crypto' +import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' +import { Agent } from 'undici' +import { getWebIdFromRequestAsync } from '../../auth/token.js' +import { safeFetch } from '../../utils/ssrf.js' +import { + getPosts, + getPost, + getPostById, + updatePost, + getFollowers, + getFollowing, + getFollowerCount, + getFollowingCount, + addFollowing, + addFollower, + acceptFollowing, + cacheActor, + getCachedActor +} from '../store.js' + +// Allow self-signed certs when delivering activities to local/dev servers +const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } }) + +/** + * Send a signed ActivityPub activity to a remote inbox + */ +async function sendSignedActivity (activity, inboxUrl, actorId, keypair, log) { + const body = JSON.stringify(activity) + const url = new URL(inboxUrl) + const date = new Date().toUTCString() + const digest = `SHA-256=${Buffer.from( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)) + ).toString('base64')}` + const signingString = `(request-target): post ${url.pathname}\nhost: ${url.host}\ndate: ${date}\ndigest: ${digest}` + const signer = createSign('RSA-SHA256') + signer.update(signingString) + const signature = signer.sign(keypair.privateKey, 'base64') + const keyId = `${actorId}#main-key` + const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"` + + try { + const res = await fetch(inboxUrl, { + method: 'POST', + dispatcher: insecureAgent, + headers: { + 'Content-Type': 'application/activity+json', + 'Accept': 'application/activity+json', + 'Date': date, + 'Digest': digest, + 'Signature': signatureHeader + }, + body + }) + if (log) log.info(`Sent ${activity.type} to ${inboxUrl} — ${res.status}`) + return res.status + } catch (err) { + if (log) log.error(`Failed to send ${activity.type} to ${inboxUrl}: ${err.message}`) + return null + } +} + // In-memory client store (replace with persistent storage later) const clients = new Map() +let clientsLoaded = false + +// Per-account profile overrides (display_name, note) persisted to disk +const profileOverrides = new Map() +const loadedProfiles = new Set() + +function getClientsFilePath () { + const root = process.env.DATA_ROOT || './data' + return join(root, '.idp', 'ap', 'oauth-clients.json') +} + +function ensureClientsLoaded () { + if (clientsLoaded) return + clientsLoaded = true + + const filePath = getClientsFilePath() + try { + if (!existsSync(filePath)) return + const raw = readFileSync(filePath, 'utf8') + const list = JSON.parse(raw) + if (Array.isArray(list)) { + for (const c of list) { + if (c?.client_id) clients.set(c.client_id, c) + } + } + } catch { + // Ignore malformed/missing file and continue with empty registry + } +} + +function persistClients () { + const filePath = getClientsFilePath() + try { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(Array.from(clients.values()), null, 2), 'utf8') + } catch { + // If persistence fails, keep in-memory behavior rather than failing requests + } +} + +function getPodApDir (username) { + const root = process.env.DATA_ROOT || './data' + return join(root, username, '.ap') +} + +function getProfilesFilePath (username) { + return join(getPodApDir(username), 'profile-overrides.json') +} + +function ensureProfileLoaded (username) { + if (loadedProfiles.has(username)) return + loadedProfiles.add(username) + + const filePath = getProfilesFilePath(username) + try { + if (!existsSync(filePath)) return + const raw = readFileSync(filePath, 'utf8') + const data = JSON.parse(raw) + if (data && typeof data === 'object') profileOverrides.set(username, data) + } catch { + // Ignore malformed/missing file and continue with defaults + } +} + +function persistProfile (username) { + const filePath = getProfilesFilePath(username) + try { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(profileOverrides.get(username) || {}, null, 2), 'utf8') + } catch { + // Keep server functional even if persistence fails + } +} + +function getProfileOverride (username) { + ensureProfileLoaded(username) + return profileOverrides.get(username) || null +} + +function getProfileMediaDir (username) { + return join(getPodApDir(username), 'profile-media') +} + +function getProfileMediaPath (username, kind, ext) { + return join(getProfileMediaDir(username), `${kind}.${ext}`) +} + +function cleanupProfileMedia (username, kind) { + const dir = getProfileMediaDir(username) + if (!existsSync(dir)) return + for (const file of readdirSync(dir)) { + if (file.startsWith(`${kind}.`)) { + try { unlinkSync(join(dir, file)) } catch {} + } + } +} + +function getProfileMediaPart (request, fieldName) { + const ct = request.headers['content-type'] || '' + const boundaryMatch = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i) + const boundary = boundaryMatch?.[1] || boundaryMatch?.[2] + if (!boundary) return null + + const bodyBuffer = Buffer.isBuffer(request.body) + ? request.body + : Buffer.from(String(request.body || ''), 'utf8') + + // latin1 keeps 1-byte index mapping between string and buffer offsets. + const raw = bodyBuffer.toString('latin1') + const boundaryToken = `--${boundary}` + let pos = raw.indexOf(boundaryToken) + + while (pos !== -1) { + let partStart = pos + boundaryToken.length + if (raw.slice(partStart, partStart + 2) === '--') break + if (raw.slice(partStart, partStart + 2) === '\r\n') partStart += 2 + + const headerEnd = raw.indexOf('\r\n\r\n', partStart) + if (headerEnd === -1) break + + const headers = raw.slice(partStart, headerEnd) + const contentStart = headerEnd + 4 + const nextBoundary = raw.indexOf(`\r\n${boundaryToken}`, contentStart) + if (nextBoundary === -1) break + + const disposition = headers.split('\r\n').find(h => /^content-disposition:/i.test(h)) || '' + const nameMatch = disposition.match(/name="([^"]+)"/i) + const filenameMatch = disposition.match(/filename="([^"]*)"/i) + const contentTypeMatch = headers.match(/content-type:\s*([^\r\n]+)/i) + + const name = nameMatch?.[1] + const filename = filenameMatch?.[1] || '' + const contentType = (contentTypeMatch?.[1] || '').trim().toLowerCase() + + if (name === fieldName && filename) { + const buffer = bodyBuffer.subarray(contentStart, nextBoundary) + return { filename, contentType, buffer } + } + + pos = nextBoundary + 2 + } + + // Fallback parser path for client variations in multipart layout. + try { + const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const re = new RegExp( + `name="${fieldName}"; filename="([^"]*)"[\\s\\S]*?Content-Type:\\s*([^\\r\\n]+)\\r\\n\\r\\n([\\s\\S]*?)\\r\\n--${escapedBoundary}`, + 'i' + ) + const match = raw.match(re) + if (match && match[1]) { + return { + filename: match[1], + contentType: (match[2] || '').trim().toLowerCase(), + buffer: Buffer.from(match[3] || '', 'latin1') + } + } + } catch { + // Ignore fallback parse errors + } + + return null +} + +function getAnyProfileMediaPart (request, preferredFieldNames = []) { + for (const name of preferredFieldNames) { + const part = getProfileMediaPart(request, name) + if (part) return part + } + + // Last-resort: pick the first file-like multipart section. + const ct = request.headers['content-type'] || '' + const boundaryMatch = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i) + const boundary = boundaryMatch?.[1] || boundaryMatch?.[2] + if (!boundary) return null + + const bodyBuffer = Buffer.isBuffer(request.body) + ? request.body + : Buffer.from(String(request.body || ''), 'utf8') + const raw = bodyBuffer.toString('latin1') + const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + try { + const re = new RegExp( + `name="([^"]+)"; filename="([^"]*)"[\\s\\S]*?Content-Type:\\s*([^\\r\\n]+)\\r\\n\\r\\n([\\s\\S]*?)\\r\\n--${escapedBoundary}`, + 'i' + ) + const match = raw.match(re) + if (match && match[2]) { + return { + filename: match[2], + contentType: (match[3] || '').trim().toLowerCase(), + buffer: Buffer.from(match[4] || '', 'latin1') + } + } + } catch { + // ignore parse fallback errors + } + + return null +} + +function saveProfileMedia (username, kind, filePart) { + if (!filePart || !filePart.buffer || filePart.buffer.length === 0) return null + + const typeToExt = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif' + } + + let ext = typeToExt[filePart.contentType] + if (!ext && filePart.filename.includes('.')) { + ext = filePart.filename.split('.').pop().toLowerCase() + } + + if (!ext || !['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) { + return null + } + if (ext === 'jpeg') ext = 'jpg' + + const mediaDir = getProfileMediaDir(username) + mkdirSync(mediaDir, { recursive: true }) + cleanupProfileMedia(username, kind) + + const filePath = getProfileMediaPath(username, kind, ext) + writeFileSync(filePath, filePart.buffer) + return `${kind}.${ext}` +} + +function findProfileMediaPath (username, kind) { + const dir = getProfileMediaDir(username) + if (!existsSync(dir)) return null + for (const file of readdirSync(dir)) { + if (file.startsWith(`${kind}.`)) { + return join(dir, file) + } + } + + return null +} + +export function getProfileMediaBuffer (username, kind) { + const path = findProfileMediaPath(username, kind) + if (!path || !existsSync(path)) return null + + const ext = path.split('.').pop().toLowerCase() + const contentType = ext === 'jpg' || ext === 'jpeg' + ? 'image/jpeg' + : ext === 'png' + ? 'image/png' + : ext === 'webp' + ? 'image/webp' + : ext === 'gif' + ? 'image/gif' + : 'application/octet-stream' + + return { buffer: readFileSync(path), contentType } +} + +function parseMultipartTextFields (request) { + const ct = request.headers['content-type'] || '' + const boundaryMatch = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i) + const boundary = boundaryMatch?.[1] || boundaryMatch?.[2] + if (!boundary) return {} + + const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '') + const parts = raw.split(`--${boundary}`) + const out = {} + + for (const part of parts) { + if (!part || part === '--' || part.trim() === '') continue + const splitAt = part.indexOf('\r\n\r\n') + if (splitAt === -1) continue + + const headerBlock = part.slice(0, splitAt) + const contentDisposition = headerBlock + .split('\r\n') + .find(h => /^content-disposition:/i.test(h)) + if (!contentDisposition) continue + + const nameMatch = contentDisposition.match(/name="([^"]+)"/i) + if (!nameMatch) continue + const field = nameMatch[1] + + // Ignore binary file parts for now. + if (/filename="/i.test(contentDisposition)) continue + + let value = part.slice(splitAt + 4) + value = value.replace(/\r\n--$/, '').replace(/\r\n$/, '') + out[field] = value + } + + return out +} // Stable instance start time (used for created_at) const startedAt = new Date().toISOString() @@ -23,6 +383,9 @@ function parseBody (request) { } const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '') const ct = request.headers['content-type'] || '' + if (ct.includes('multipart/form-data')) { + return parseMultipartTextFields(request) + } if (ct.includes('application/json')) { try { return JSON.parse(raw) } catch { return {} } } @@ -36,6 +399,7 @@ function parseBody (request) { */ export function createAppsHandler () { return async (request, reply) => { + ensureClientsLoaded() const body = parseBody(request) const { client_name, redirect_uris, scopes, website } = body @@ -43,8 +407,8 @@ export function createAppsHandler () { return reply.code(422).send({ error: 'client_name and redirect_uris are required' }) } - const clientId = crypto.randomUUID() - const clientSecret = crypto.randomUUID() + const clientId = randomUUID() + const clientSecret = randomUUID() const client = { id: clientId, @@ -57,6 +421,7 @@ export function createAppsHandler () { } clients.set(clientId, client) + persistClients() return reply.send(client) } @@ -66,33 +431,100 @@ export function createAppsHandler () { * GET /api/v1/accounts/verify_credentials — Who am I? * Returns the authenticated user's profile as a Mastodon Account object */ -export function createVerifyCredentialsHandler (config) { +export function createVerifyCredentialsHandler (getUserConfig) { return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + const protocol = request.headers['x-forwarded-proto'] || request.protocol const host = request.headers['x-forwarded-host'] || request.hostname const baseUrl = `${protocol}://${host}` + const uc = typeof getUserConfig === 'function' ? getUserConfig(request) : getUserConfig + // In single-host mode uc.username may be the default 'me' — override with token webId + const username = (uc.username && uc.username !== 'me') + ? uc.username + : (getUsernameFromWebId(auth.webId) || uc.username) + + const profile = getProfileOverride(username) + const displayName = profile?.display_name || uc.displayName || username + const notePlain = profile?.note || uc.summary || '' const account = { - id: '1', - username: config.username, - acct: config.username, - display_name: config.displayName, - note: config.summary ? `

${escapeHtml(config.summary)}

` : '', - url: `${baseUrl}/profile/card.jsonld`, - uri: `${baseUrl}/profile/card.jsonld#me`, + ...buildAccount(username, baseUrl), + display_name: displayName, + note: notePlain ? `

${escapeHtml(notePlain)}

` : '', avatar: `${baseUrl}/profile/avatar.png`, - header: '', - locked: false, - bot: false, - created_at: startedAt, - followers_count: 0, - following_count: 0, - statuses_count: 0, + avatar_static: `${baseUrl}/profile/avatar.png`, + header: `${baseUrl}/profile/header.png`, + header_static: `${baseUrl}/profile/header.png`, source: { privacy: 'public', sensitive: false, language: 'en', - note: config.summary || '' + note: notePlain, + fields: [] + } + } + + return reply.send(account) + } +} + +/** + * PATCH /api/v1/accounts/update_credentials + * Update current user's profile fields for Mastodon clients. + */ +export function createUpdateCredentialsHandler () { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + const username = getUsernameFromWebId(auth.webId) + if (!username) { + return reply.code(400).send({ error: 'Invalid WebID' }) + } + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + + const body = parseBody(request) + const avatarPart = getAnyProfileMediaPart(request, ['avatar', 'avatar[]', 'avatar_file']) + const headerPart = getAnyProfileMediaPart(request, ['header', 'header[]', 'header_file']) + const avatarFile = saveProfileMedia(username, 'avatar', avatarPart) + const headerFile = saveProfileMedia(username, 'header', headerPart) + + const prev = getProfileOverride(username) || {} + const next = { + ...prev, + ...(typeof body.display_name === 'string' ? { display_name: body.display_name } : {}), + ...(typeof body.note === 'string' ? { note: body.note } : {}), + ...(avatarFile ? { avatar_file: avatarFile } : {}), + ...(headerFile ? { header_file: headerFile } : {}), + updated_at: new Date().toISOString() + } + + profileOverrides.set(username, next) + persistProfile(username) + + const account = { + ...buildAccount(username, baseUrl), + display_name: next.display_name || username, + note: next.note ? `

${escapeHtml(next.note)}

` : '', + avatar: `${baseUrl}/profile/avatar.png`, + avatar_static: `${baseUrl}/profile/avatar.png`, + header: `${baseUrl}/profile/header.png`, + header_static: `${baseUrl}/profile/header.png`, + source: { + privacy: 'public', + sensitive: false, + language: body.language || 'en', + note: next.note || '', + fields: [] } } @@ -100,38 +532,1117 @@ export function createVerifyCredentialsHandler (config) { } } +/** + * GET /api/v1/accounts/lookup?acct=alice or alice@example.org + */ +export function createAccountLookupHandler () { + return async (request, reply) => { + const acct = (request.query?.acct || '').trim() + if (!acct) { + return reply.code(422).send({ error: 'acct is required' }) + } + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const parsed = parseAccountIdentifier(acct) + if (!parsed) { + return reply.code(404).send({ error: 'Account not found' }) + } + + if (parsed.domain && !isLocalAccountDomain(parsed.domain, host)) { + const remote = await resolveRemoteAccount(parsed) + if (!remote) { + return reply.code(404).send({ error: 'Remote account not found' }) + } + return reply.send(remote) + } + + const baseUrl = `${protocol}://${host}` + + return reply.send(buildAccount(parsed.username, baseUrl)) + } +} + +/** + * Shared instance data builder + */ +function buildInstanceData (host, wsProtocol) { + return { + uri: host, + domain: host, + title: 'JSS', + description: 'SAND Stack: Solid + ActivityPub + Nostr + DID', + short_description: 'Solid pod with Mastodon-compatible API', + version: '4.0.0 (compatible; JSS 0.0.99)', + urls: { + streaming_api: `${wsProtocol}://${host}` + }, + stats: { + user_count: 1, + status_count: 0, + domain_count: 1 + }, + languages: ['en'], + registrations: false, + approval_required: false, + configuration: { + statuses: { max_characters: 5000 }, + media_attachments: { supported_mime_types: [] }, + polls: { max_options: 4, max_characters_per_option: 50, min_expiration: 300, max_expiration: 2629746 } + }, + contact: { email: 'admin@example.com', account: null }, + rules: [] + } +} + /** * GET /api/v1/instance — Instance information * Required by most Mastodon clients before login */ -export function createInstanceHandler (config) { +export function createInstanceHandler () { + return async (request, reply) => { + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const wsProtocol = protocol === 'https' ? 'wss' : 'ws' + return reply.send(buildInstanceData(host, wsProtocol)) + } +} + +/** + * GET /api/v2/instance — Instance information (v2 format for Elk/Phanpy) + */ +export function createInstanceV2Handler () { return async (request, reply) => { const protocol = request.headers['x-forwarded-proto'] || request.protocol const host = request.headers['x-forwarded-host'] || request.hostname const wsProtocol = protocol === 'https' ? 'wss' : 'ws' + const data = buildInstanceData(host, wsProtocol) + // v2 moves stats → usage, adds thumbnail + const v2 = { + ...data, + usage: { users: { active_month: data.stats.user_count } }, + thumbnail: { url: null }, + registrations: { enabled: false, approval_required: false, message: null } + } + delete v2.stats + return reply.send(v2) + } +} - return reply.send({ - uri: host, - title: config.displayName || 'JSS', - description: 'SAND Stack: Solid + ActivityPub + Nostr + DID', - short_description: 'Solid pod with Mastodon-compatible API', - version: '4.0.0 (compatible; JSS 0.0.99)', - urls: { - streaming_api: `${wsProtocol}://${host}` - }, - stats: { - user_count: 1, - status_count: 0, - domain_count: 1 - }, - languages: ['en'], - registrations: false, - approval_required: false, - configuration: { - statuses: { max_characters: 5000 }, - media_attachments: { supported_mime_types: [] } +/** + * Extract username from webId + * Subdomain mode: https://alice.pivot-test.local:4443/profile/card#me → alice + * Path mode (test/single-host): http://127.0.0.1:PORT/alice/profile/card#me → alice + */ +function getUsernameFromWebId (webId) { + if (!webId) return null + try { + const url = new URL(webId) + const hostname = url.hostname + // IP address or localhost — extract from first path segment + if (hostname === 'localhost' || /^(127\.|192\.168\.|10\.|::1)/.test(hostname)) { + const seg = url.pathname.split('/').filter(Boolean)[0] + return seg || null + } + // Subdomain mode — first hostname segment + const parts = hostname.split('.') + if (parts.length >= 2) { + return parts[0] !== 'www' ? parts[0] : null + } + return parts[0] || null + } catch { + return null + } +} + +/** + * Build a Mastodon-compatible Account object + * baseUrl is used to derive the domain/port; username replaces the subdomain. + */ +function buildAccount (username, baseUrl) { + // Build the account's own base URL using its subdomain, not the requester's + let accountBaseUrl = baseUrl + let accountHost = null + try { + const u = new URL(baseUrl) + const hostParts = u.hostname.split('.') + // Replace first segment (subdomain) with this account's username + if (hostParts.length >= 2) { + hostParts[0] = username + u.hostname = hostParts.join('.') + accountBaseUrl = u.origin + } + accountHost = u.host + } catch { /* keep baseUrl as-is */ } + + if (!accountHost) { + try { + accountHost = new URL(accountBaseUrl).host + } catch { + accountHost = null + } + } + + const postCount = getPosts(username, 1000).length + const account = { + id: username, + username, + acct: accountHost ? `${username}@${accountHost}` : username, + display_name: username, + locked: false, + bot: false, + discoverable: true, + group: false, + created_at: startedAt, + note: '', + url: `${accountBaseUrl}/profile/card.jsonld`, + avatar: `${accountBaseUrl}/profile/avatar.png`, + avatar_static: `${accountBaseUrl}/profile/avatar.png`, + header: `${accountBaseUrl}/profile/header.png`, + header_static: `${accountBaseUrl}/profile/header.png`, + followers_count: getFollowerCount(username), + following_count: getFollowingCount(username), + statuses_count: postCount, + last_status_at: postCount > 0 ? new Date().toISOString().split('T')[0] : null, + emojis: [], + fields: [] + } + + const profile = getProfileOverride(username) + if (profile) { + if (typeof profile.display_name === 'string') account.display_name = profile.display_name + if (typeof profile.note === 'string') account.note = profile.note + } + + return account +} + +/** + * Build a Mastodon-compatible Status object + */ +function buildStatus (post, username, baseUrl) { + return { + id: post.id, + created_at: post.published, + in_reply_to_id: post.in_reply_to || null, + in_reply_to_account_id: null, + sensitive: false, + spoiler_text: '', + visibility: 'public', + language: 'en', + uri: post.id, + url: post.id, + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + edited_at: null, + content: post.content, + reblog: null, + application: null, + account: buildAccount(username, baseUrl), + media_attachments: [], + mentions: [], + tags: [], + emojis: [], + card: null, + poll: null + } +} + +function parseAccountIdentifier (idOrAcct) { + if (!idOrAcct) return null + + const raw = String(idOrAcct).trim() + if (!raw) return null + + if (raw.startsWith('http://') || raw.startsWith('https://')) { + try { + const url = new URL(raw) + const username = getUsernameFromWebId(raw) + if (!username) return null + return { username, domain: url.host } + } catch { + return null + } + } + + let value = raw + if (value.startsWith('@')) value = value.slice(1) + + if (!value) return null + + const at = value.indexOf('@') + if (at === -1) { + return { username: value, domain: null } + } + + const username = value.slice(0, at) + const domain = value.slice(at + 1) + if (!username || !domain) return null + return { username, domain } +} + +function isLocalAccountDomain (domain, requestHost) { + if (!domain) return true + + const norm = String(domain).toLowerCase() + const host = String(requestHost || '').toLowerCase() + if (!host) return false + + const hostNoPort = host.includes(':') ? host.split(':')[0] : host + if (hostNoPort === 'localhost' || /^(127\.|10\.|192\.168\.|::1)/.test(hostNoPort)) { + // In local/dev mode (IP or localhost host headers), allow acct domains. + return true + } + + if (norm === host) return true + + // In subdomain mode allow sibling pod hosts under the same base domain. + const firstDot = host.indexOf('.') + if (firstDot === -1 || firstDot === host.length - 1) return false + const base = host.slice(firstDot + 1) + return norm.endsWith(`.${base}`) +} + +function normalizeAccountIdentifier (idOrAcct) { + const parsed = parseAccountIdentifier(idOrAcct) + return parsed?.username || null +} + +function getActorUrlFromWebfinger (resource) { + const links = Array.isArray(resource?.links) ? resource.links : [] + const self = links.find((l) => { + if (l?.rel !== 'self' || !l?.href) return false + const t = String(l.type || '').toLowerCase() + return t.includes('activity+json') || t.includes('application/ld+json') + }) + return self?.href || null +} + +function buildRemoteAccountFromActor (actor, parsed) { + const actorId = actor?.id || actor?.url || '' + const profileUrl = typeof actor?.url === 'string' + ? actor.url + : String(actorId || '').replace(/#.*$/, '') + + const username = actor?.preferredUsername || parsed.username + const acct = `${username}@${parsed.domain}` + const iconUrl = actor?.icon?.url || null + const imageUrl = actor?.image?.url || null + + return { + id: acct, + username, + acct, + display_name: actor?.name || username, + locked: false, + bot: false, + discoverable: true, + group: false, + created_at: startedAt, + note: actor?.summary || '', + url: profileUrl || actorId || null, + avatar: iconUrl, + avatar_static: iconUrl, + header: imageUrl, + header_static: imageUrl, + followers_count: 0, + following_count: 0, + statuses_count: 0, + last_status_at: null, + emojis: [], + fields: [], + _remote: { + actorId, + inbox: actor?.inbox || actor?.endpoints?.sharedInbox || null + } + } +} + +async function resolveRemoteAccount (parsed) { + const resource = `acct:${parsed.username}@${parsed.domain}` + const wfUrl = `https://${parsed.domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}` + + let wfJson = null + try { + const wf = await safeFetch(wfUrl, { + headers: { Accept: 'application/jrd+json, application/json' } + }, { requireHttps: true, blockPrivateIPs: true, resolveDNS: true }) + if (!wf.ok) return null + wfJson = await wf.json() + } catch { + return null + } + + const actorUrl = getActorUrlFromWebfinger(wfJson) + if (!actorUrl) return null + + let actor = getCachedActor(actorUrl) + if (!actor) { + try { + const actorRes = await safeFetch(actorUrl, { + headers: { + Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/json' + } + }, { requireHttps: true, blockPrivateIPs: true, resolveDNS: true }) + if (!actorRes.ok) return null + actor = await actorRes.json() + if (actor?.id) cacheActor(actor) + } catch { + return null + } + } + + if (!actor || typeof actor !== 'object') return null + return buildRemoteAccountFromActor(actor, parsed) +} + +/** + * GET /api/v1/timelines/home + * Return authenticated user's federated timeline + */ +export function createTimelinesHomeHandler () { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + const username = getUsernameFromWebId(auth.webId) + if (!username) { + return reply.code(400).send({ error: 'Invalid WebID' }) + } + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + + // Get posts from accounts this user follows + const following = getFollowing(username) + const statuses = [] + + for (const follow of following) { + const actor = follow.actor + // Extract username from actor URL + try { + const actorUrl = new URL(actor) + const followedUsername = actorUrl.hostname.split('.')[0] + const posts = getPosts(followedUsername, 50) + statuses.push(...posts.map(p => buildStatus(p, followedUsername, baseUrl))) + } catch { + // Skip invalid actor URLs } - }) + } + + // Also include own posts + const ownPosts = getPosts(username, 50) + statuses.push(...ownPosts.map(p => buildStatus(p, username, baseUrl))) + + // Sort by date descending + statuses.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + + // Limit to 20 latest + return reply.send(statuses.slice(0, 20)) + } +} + +/** + * POST /api/v1/statuses + * Create a new status (post/note) + */ +export function createPostStatusHandler () { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + const username = getUsernameFromWebId(auth.webId) + if (!username) { + return reply.code(400).send({ error: 'Invalid WebID' }) + } + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + + const body = parseBody(request) + const { status, in_reply_to_id } = body + if (!status) { + return reply.code(422).send({ error: 'status is required' }) + } + + const postId = `${baseUrl}/posts/${randomUUID()}` + const now = new Date().toISOString() + + // Save to AP store + const { savePost } = await import('../store.js') + savePost(username, postId, status, in_reply_to_id || null) + + // Return Mastodon status object + const post = { + id: postId, + content: status, + published: now, + in_reply_to: in_reply_to_id || null + } + + return reply.code(200).send(buildStatus(post, username, baseUrl)) + } +} + +/** + * Resolve status identifier from request params. + * Supports both /api/v1/statuses/:id and wildcard /api/v1/statuses/* forms. + */ +function getStatusIdParam (request) { + return request.params?.id || request.params?.['*'] || null +} + +function resolvePostByStatusId (rawId, baseUrl) { + if (!rawId) return null + + // /source and /history suffixes are endpoint modifiers + let normalized = rawId + if (normalized.endsWith('/source')) normalized = normalized.slice(0, -('/source'.length)) + if (normalized.endsWith('/history')) normalized = normalized.slice(0, -('/history'.length)) + + // Full URL IDs are canonical in our store + if (normalized.startsWith('http://') || normalized.startsWith('https://')) { + return getPostById(normalized) + } + + // Bare ID fallback + const canonical = `${baseUrl}/posts/${normalized}` + return getPostById(canonical) || getPostById(normalized) +} + +function deriveActorFromPostUrl (statusId) { + try { + const parsed = new URL(statusId) + const pathParts = parsed.pathname.split('/').filter(Boolean) + if (pathParts.length < 2 || pathParts[0] !== 'posts') return null + + // In subdomain mode: bob.example.org/posts/uuid -> actor bob.example.org/profile/card.jsonld#me + const hostParts = parsed.hostname.split('.') + if (hostParts.length < 2) return null + const actorBase = `${parsed.protocol}//${parsed.host}` + return { + actorId: `${actorBase}/profile/card.jsonld#me`, + inbox: `${actorBase}/profile/card.jsonld/inbox` + } + } catch { + return null + } +} + +/** + * GET /api/v1/statuses/:id and /api/v1/statuses/:id/source + * Returns a status object, or source payload for editing. + */ +export function createGetStatusHandler () { + return async (request, reply) => { + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + + const rawId = getStatusIdParam(request) + if (!rawId) { + return reply.code(400).send({ error: 'Status ID is required' }) + } + + const sourceMode = rawId.endsWith('/source') + const historyMode = rawId.endsWith('/history') + const post = resolvePostByStatusId(rawId, baseUrl) + if (!post) { + return reply.code(404).send({ error: 'Status not found' }) + } + + if (historyMode) { + return reply.send([ + { + content: post.content, + spoiler_text: '', + sensitive: false, + created_at: post.published || new Date().toISOString(), + account: buildAccount(post.username, baseUrl) + } + ]) + } + + if (!sourceMode) { + return reply.send(buildStatus(post, post.username, baseUrl)) + } + + // /source is for editing own posts: require auth and ownership. + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + const username = getUsernameFromWebId(auth.webId) + if (!username || username !== post.username) { + return reply.code(404).send({ error: 'Status not found' }) + } + + return reply.send({ + id: post.id, + text: post.content, + spoiler_text: '', + sensitive: false + }) + } +} + + +/** + * PUT /api/v1/statuses/:id + * Edit a status content. + */ +export function createUpdateStatusHandler () { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + const username = getUsernameFromWebId(auth.webId) + if (!username) { + return reply.code(400).send({ error: 'Invalid WebID' }) + } + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + + const rawId = getStatusIdParam(request) + if (!rawId) { + return reply.code(400).send({ error: 'Status ID is required' }) + } + + const body = parseBody(request) + const { status } = body + if (!status) { + return reply.code(422).send({ error: 'status is required' }) + } + + // Accept both canonical URL IDs and bare IDs. + let postId = rawId + if (!postId.startsWith('http://') && !postId.startsWith('https://')) { + postId = `${baseUrl}/posts/${postId}` + } + + let post = getPost(username, postId) + if (!post) { + // Fallback for edge cases where host/protocol differs from request host. + post = getPostById(rawId) || getPostById(postId) + if (!post || post.username !== username) { + return reply.code(404).send({ error: 'Status not found' }) + } + postId = post.id + } + + updatePost(username, postId, status) + const updated = getPost(username, postId) + + return reply.send(buildStatus(updated || { ...post, content: status }, username, baseUrl)) + } +} + +/** + * POST /api/v1/statuses/:id/favourite + * Favourite (Like) a status. + */ +export function createFavouriteStatusHandler (getUserConfig) { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + const username = getUsernameFromWebId(auth.webId) + if (!username) { + return reply.code(400).send({ error: 'Invalid WebID' }) + } + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + + let rawId = getStatusIdParam(request) + if (!rawId) { + return reply.code(400).send({ error: 'Status ID is required' }) + } + if (rawId.endsWith('/favourite')) { + rawId = rawId.slice(0, -('/favourite'.length)) + } + + const targetStatusId = (rawId.startsWith('http://') || rawId.startsWith('https://')) + ? rawId + : `${baseUrl}/posts/${rawId}` + + const localPost = resolvePostByStatusId(rawId, baseUrl) + + // Best-effort AP Like delivery for remote posts. + const requesterActorId = `${baseUrl}/profile/card.jsonld#me` + const requesterConfig = typeof getUserConfig === 'function' ? getUserConfig(request) : {} + const requesterKeypair = requesterConfig?.keypair + const remoteTarget = deriveActorFromPostUrl(targetStatusId) + if (requesterKeypair && remoteTarget?.inbox && !targetStatusId.startsWith(`${baseUrl}/`)) { + const likeActivity = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${requesterActorId}/activities/${randomUUID()}`, + type: 'Like', + actor: requesterActorId, + object: targetStatusId + } + sendSignedActivity(likeActivity, remoteTarget.inbox, requesterActorId, requesterKeypair, request.log) + } + + if (localPost) { + const status = buildStatus(localPost, localPost.username, baseUrl) + return reply.send({ + ...status, + favourited: true, + favourites_count: Number(status.favourites_count || 0) + 1 + }) + } + + const accountId = targetStatusId.startsWith('http') + ? (() => { + try { + return new URL(targetStatusId).hostname.split('.')[0] || 'unknown' + } catch { + return 'unknown' + } + })() + : 'unknown' + + return reply.send({ + id: targetStatusId, + uri: targetStatusId, + url: targetStatusId, + created_at: new Date().toISOString(), + account: buildAccount(accountId, baseUrl), + content: '', + visibility: 'public', + reblogs_count: 0, + favourites_count: 1, + replies_count: 0, + favourited: true, + reblogged: false, + muted: false, + bookmarked: false, + pinned: false, + language: 'en', + text: '', + edited_at: null, + poll: null, + card: null + }) + } +} + +/** + * GET /api/v1/accounts/:id + * Get account info by username or ID + */ +export function createGetAccountHandler () { + return async (request, reply) => { + const { id } = request.params + const accountId = normalizeAccountIdentifier(id) + if (!accountId) { + return reply.code(400).send({ error: 'ID is required' }) + } + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + + return reply.send(buildAccount(accountId, baseUrl)) + } +} + +/** + * GET /api/v1/accounts/:id/statuses + * Get statuses for a user + */ +export function createGetAccountStatusesHandler () { + return async (request, reply) => { + const { id } = request.params + const accountId = normalizeAccountIdentifier(id) + if (!accountId) { + return reply.code(400).send({ error: 'ID is required' }) + } + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + + const posts = getPosts(accountId, 20) + const statuses = posts.map(p => buildStatus(p, accountId, baseUrl)) + + return reply.send(statuses) + } +} + +/** + * GET /api/v1/preferences + * Minimal preference payload for Mastodon clients. + */ +export function createPreferencesHandler () { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + return reply.send({ + 'posting:default:visibility': 'public', + 'posting:default:sensitive': false, + 'posting:default:language': 'en', + 'reading:expand:media': 'default', + 'reading:expand:spoilers': false + }) + } +} + +/** + * GET /api/v1/accounts/relationships?id[]=... + */ +export function createRelationshipsHandler () { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + const username = getUsernameFromWebId(auth.webId) + if (!username) { + return reply.code(400).send({ error: 'Invalid WebID' }) + } + + const raw = request.query?.id ?? request.query?.['id[]'] + const ids = Array.isArray(raw) ? raw : (raw ? [raw] : []) + + const following = new Set( + getFollowing(username) + .map(f => normalizeAccountIdentifier(f.actor)) + .filter(Boolean) + ) + + const followers = new Set( + getFollowers(username) + .map(f => normalizeAccountIdentifier(f.actor)) + .filter(Boolean) + ) + + const relationships = ids.map((id) => { + const normalized = normalizeAccountIdentifier(id) + return { + id, + following: normalized ? following.has(normalized) : false, + showing_reblogs: true, + notifying: false, + followed_by: normalized ? followers.has(normalized) : false, + blocking: false, + blocked_by: false, + muting: false, + muting_notifications: false, + requested: false, + domain_blocking: false, + endorsed: false, + note: '' + } + }) + + return reply.send(relationships) + } +} + +/** + * GET /api/v1/lists + * Minimal compatibility: return no lists. + */ +export function createListsHandler () { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + return reply.send([]) + } +} + +/** + * GET /api/v1/accounts/:id/lists + * Minimal compatibility: account is not in any lists. + */ +export function createAccountListsHandler () { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + return reply.send([]) + } +} + +/** + * Extract a canonical URL from Mastodon search query text. + * Supports raw URLs and Phanpy-style wrapped links like: + * alice.host/s/https://alice.host/posts/ + */ +function extractSearchUrl (query) { + if (!query || typeof query !== 'string') return null + + // If query embeds "https://" in the middle (Phanpy /s/ form), keep the URL part. + const embeddedHttps = query.indexOf('https://') + if (embeddedHttps > 0) { + return query.slice(embeddedHttps) + } + + // Regular URL query + if (query.startsWith('http://') || query.startsWith('https://')) { + return query + } + + return null +} + +/** + * GET /api/v2/search + * Minimal Mastodon search implementation for clients resolving status URLs and accounts. + */ +export function createSearchHandler () { + return async (request, reply) => { + const q = request.query?.q || '' + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + + const result = { + accounts: [], + statuses: [], + hashtags: [] + } + + // Account lookup fallback for plain handles/acct strings (bob, @bob, bob@example.org) + const parsed = parseAccountIdentifier(String(q).trim()) + if (parsed) { + if (isLocalAccountDomain(parsed.domain, host)) { + result.accounts.push(buildAccount(parsed.username, baseUrl)) + } else { + const remote = await resolveRemoteAccount(parsed) + if (remote) result.accounts.push(remote) + } + } + + const targetUrl = extractSearchUrl(q) + if (!targetUrl) { + return reply.send(result) + } + + // Try direct ID match first (posts are stored as full URL IDs) + let post = getPostById(targetUrl) + + // Fallback: if URL path is /posts/:id, reconstruct canonical ID with origin + if (!post) { + try { + const u = new URL(targetUrl) + const match = u.pathname.match(/^\/posts\/([^/?#]+)$/) + if (match) { + const canonical = `${u.origin}/posts/${match[1]}` + post = getPostById(canonical) + } + } catch { + // Ignore invalid URLs and return empty result below + } + } + + if (!post) { + return reply.send(result) + } + + result.statuses.push(buildStatus(post, post.username, baseUrl)) + return reply.send(result) + } +} + +/** + * GET /api/v1/accounts/search + * Minimal account search used by some Mastodon clients. + */ +export function createAccountsSearchHandler () { + return async (request, reply) => { + const q = String(request.query?.q || '').trim() + if (!q) return reply.send([]) + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const parsed = parseAccountIdentifier(q) + if (!parsed) return reply.send([]) + + const baseUrl = `${protocol}://${host}` + if (!isLocalAccountDomain(parsed.domain, host)) { + const remote = await resolveRemoteAccount(parsed) + return reply.send(remote ? [remote] : []) + } + + return reply.send([buildAccount(parsed.username, baseUrl)]) + } +} + +/** + * POST /api/v1/accounts/:id/follow + * Follow a user + */ +export function createFollowAccountHandler (getUserConfig) { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + const { id: rawTargetId } = request.params + if (!rawTargetId) { + return reply.code(400).send({ error: 'Target ID is required' }) + } + + // Derive requester identity from request subdomain (reliable in subdomain mode) + // This is more reliable than extracting from the token webId which may vary + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const uc = typeof getUserConfig === 'function' ? getUserConfig(request) : {} + // In single-host mode uc.username may be 'me' — override with token webId + const username = (uc.username && uc.username !== 'me') + ? uc.username + : (getUsernameFromWebId(auth.webId) || uc.username) + if (!username) { + return reply.code(400).send({ error: 'Cannot determine requester identity' }) + } + const baseDomain = uc.baseDomain || request.headers['x-forwarded-host'] || request.hostname + const subdomains = uc.subdomains || false + const host = request.headers['x-forwarded-host'] || request.hostname + + const targetParsed = parseAccountIdentifier(rawTargetId) + if (!targetParsed) { + return reply.code(400).send({ error: 'Invalid target account identifier' }) + } + + const isLocalTarget = isLocalAccountDomain(targetParsed.domain, host) + + // Build actor URLs — in subdomain mode use subdomain-style, otherwise path-based + let targetActorId, requesterActorId, requesterInbox + if (subdomains && baseDomain) { + requesterActorId = `${protocol}://${username}.${baseDomain}/profile/card.jsonld#me` + requesterInbox = `${protocol}://${username}.${baseDomain}/profile/card.jsonld/inbox` + } else { + requesterActorId = `${protocol}://${host}/${username}/profile/card.jsonld#me` + requesterInbox = `${protocol}://${host}/${username}/profile/card.jsonld/inbox` + } + + let remoteInbox = null + if (isLocalTarget) { + const targetUsername = targetParsed.username + if (subdomains && baseDomain) { + targetActorId = `${protocol}://${targetUsername}.${baseDomain}/profile/card.jsonld#me` + } else { + targetActorId = `${protocol}://${host}/${targetUsername}/profile/card.jsonld#me` + } + } else { + const remote = await resolveRemoteAccount(targetParsed) + if (!remote?._remote?.actorId) { + return reply.code(404).send({ error: 'Remote account not found' }) + } + targetActorId = remote._remote.actorId + remoteInbox = remote._remote.inbox || null + } + + // Add to requester's following list (pending until Accept received for remote) + addFollowing(username, targetActorId, isLocalTarget) + + // Add to target's followers list only for local targets. + if (isLocalTarget) { + addFollower(targetParsed.username, requesterActorId, requesterInbox) + } else if (remoteInbox) { + // Deliver outbound Follow activity to remote actor's inbox + const uc = typeof getUserConfig === 'function' ? getUserConfig(request) : {} + const keypair = uc.keypair + if (keypair) { + const followActivity = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${requesterActorId}/activities/${randomUUID()}`, + type: 'Follow', + actor: requesterActorId, + object: targetActorId + } + sendSignedActivity(followActivity, remoteInbox, requesterActorId, keypair, request.log) + } + } + + // Return relationship + return reply.send({ + id: rawTargetId, + following: true, + showing_reblogs: true, + notifying: false, + languages: ['en'], + blocked: false, + blocking: false, + muting: false, + muting_notifications: false, + requested: false, + domain_blocking: false + }) + } +} + +/** + * GET /api/v1/notifications + * Get notifications (follows, likes, etc) + * For now, return empty - would need extended AP model + */ +export function createGetNotificationsHandler (getUserConfig) { + return async (request, reply) => { + const auth = await getWebIdFromRequestAsync(request) + if (!auth.webId) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + const uc = typeof getUserConfig === 'function' ? getUserConfig(request) : getUserConfig + const currentUsername = (uc.username && uc.username !== 'me') + ? uc.username + : (getUsernameFromWebId(auth.webId) || uc.username) + + // Build follow notifications from the followers table + const followers = getFollowers(currentUsername) + const notifications = followers + .map((f, i) => { + // actor may be "https://bob.host/profile/card.jsonld#me" — strip fragment first + let actorUrl = f.actor || '' + try { actorUrl = actorUrl.split('#')[0] } catch { /* keep */ } + // Use getUsernameFromWebId which handles both subdomain and path-based WebIDs + const followerUsername = getUsernameFromWebId(actorUrl + '#me') || getUsernameFromWebId(actorUrl) + // Skip if we can't determine follower or it's the user themselves + if (!followerUsername || followerUsername === currentUsername) return null + const followerBase = (() => { + try { + const u = new URL(actorUrl) + return `${u.protocol}//${u.host}` + } catch { return baseUrl } + })() + return { + id: String(i + 1), + type: 'follow', + created_at: f.created_at ? new Date(f.created_at).toISOString() : new Date().toISOString(), + account: buildAccount(followerUsername, followerBase) + } + }) + .filter(Boolean) + + return reply.send(notifications) } } @@ -139,6 +1650,7 @@ export function createInstanceHandler (config) { * Look up a registered client */ export function getClient (clientId) { + ensureClientsLoaded() return clients.get(clientId) || null } @@ -149,6 +1661,16 @@ function escapeHtml (str) { export default { createAppsHandler, createVerifyCredentialsHandler, + createUpdateCredentialsHandler, + createAccountLookupHandler, + createAccountsSearchHandler, + createPreferencesHandler, + createListsHandler, + createAccountListsHandler, + createRelationshipsHandler, createInstanceHandler, + createSearchHandler, + createFavouriteStatusHandler, + createUpdateStatusHandler, getClient } diff --git a/src/ap/routes/outbox.js b/src/ap/routes/outbox.js index f063465..069877c 100644 --- a/src/ap/routes/outbox.js +++ b/src/ap/routes/outbox.js @@ -5,8 +5,42 @@ */ import { outbox } from 'microfed' -import { getPosts, savePost, getFollowerInboxes } from '../store.js' -import { randomUUID } from 'crypto' +import { Agent } from 'undici' +import { createSign, randomUUID } from 'crypto' +import { getPosts, getPost, getPostById, savePost, getFollowerInboxes } from '../store.js' + +// Allow self-signed certs when delivering to local inboxes +const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } }) + +/** + * Send a signed ActivityPub POST to an inbox, tolerating self-signed TLS + */ +async function sendSigned(inbox, activity, privateKey, keyId) { + const body = JSON.stringify(activity) + const inboxUrl = new URL(inbox) + const date = new Date().toUTCString() + const digest = `SHA-256=${Buffer.from( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)) + ).toString('base64')}` + const signingString = `(request-target): post ${inboxUrl.pathname}\nhost: ${inboxUrl.host}\ndate: ${date}\ndigest: ${digest}` + const signer = createSign('RSA-SHA256') + signer.update(signingString) + const signature = signer.sign(privateKey, 'base64') + const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"` + + return fetch(inbox, { + method: 'POST', + dispatcher: insecureAgent, + headers: { + 'Content-Type': 'application/activity+json', + 'Accept': 'application/activity+json', + 'Date': date, + 'Digest': digest, + 'Signature': signatureHeader + }, + body + }) +} /** * Create outbox handler @@ -22,7 +56,7 @@ export function createOutboxHandler(config, keypair) { const profileUrl = `${baseUrl}/profile/card.jsonld` const actorId = `${profileUrl}#me` - const posts = getPosts(20) + const posts = getPosts(config.username, 20) const collection = { '@context': 'https://www.w3.org/ns/activitystreams', @@ -69,9 +103,10 @@ export function createOutboxPostHandler(config, keypair) { // Parse body let activity try { - activity = typeof request.body === 'string' - ? JSON.parse(request.body) - : request.body + const raw = typeof request.body === 'string' + ? request.body + : request.body.toString() + activity = JSON.parse(raw) } catch { return reply.code(400).send({ error: 'Invalid JSON' }) } @@ -108,6 +143,7 @@ export function createOutboxPostHandler(config, keypair) { // Save post if (activity.type === 'Create' && activity.object?.type === 'Note') { savePost( + config.username, activity.object.id, activity.object.content, activity.object.inReplyTo || null @@ -115,19 +151,12 @@ export function createOutboxPostHandler(config, keypair) { } // Deliver to followers - const inboxes = getFollowerInboxes() + const inboxes = getFollowerInboxes(config.username) request.log.info(`Delivering to ${inboxes.length} follower(s)`) const keyId = `${profileUrl}#main-key` const deliveryResults = await Promise.allSettled( - inboxes.map(inbox => - outbox.send({ - activity, - inbox, - privateKey: keypair.privateKey, - keyId - }) - ) + inboxes.map(inbox => sendSigned(inbox, activity, keypair.privateKey, keyId)) ) const succeeded = deliveryResults.filter(r => r.status === 'fulfilled').length @@ -146,4 +175,49 @@ export function createOutboxPostHandler(config, keypair) { } } -export default { createOutboxHandler, createOutboxPostHandler } +/** + * Create post object handler + * GET /posts/:id returns ActivityStreams Note object for permalink fetches. + * + * @param {object} config - AP configuration + * @returns {Function} Fastify handler + */ +export function createPostObjectHandler(config) { + return async (request, reply) => { + const postId = `${request.params.id}` + const protocol = request.headers['x-forwarded-proto'] || request.protocol + const host = request.headers['x-forwarded-host'] || request.hostname + const baseUrl = `${protocol}://${host}` + const profileUrl = `${baseUrl}/profile/card.jsonld` + const actorId = `${profileUrl}#me` + + // Stored post IDs are full URLs. Try direct URL match first, then fallback. + const fullId = `${baseUrl}/posts/${postId}` + let post = getPost(config.username, fullId) + if (!post) post = getPost(config.username, postId) + if (!post) post = getPostById(fullId) + if (!post) post = getPostById(postId) + + if (!post) { + return reply.code(404).send({ error: 'Post not found' }) + } + + const note = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Note', + id: post.id, + content: post.content, + published: post.published, + attributedTo: actorId, + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: [`${profileUrl}/followers`], + ...(post.in_reply_to ? { inReplyTo: post.in_reply_to } : {}) + } + + return reply + .header('Content-Type', 'application/activity+json') + .send(note) + } +} + +export default { createOutboxHandler, createOutboxPostHandler, createPostObjectHandler } diff --git a/src/ap/store.js b/src/ap/store.js index 7116c03..c58b6f8 100644 --- a/src/ap/store.js +++ b/src/ap/store.js @@ -7,48 +7,60 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { dirname } from 'path' +import { dirname, join } from 'path' let db = null let dbPath = null -// SQL schema +// SQL schema — username column on all user-scoped tables const SCHEMA = ` -- Followers (people following us) CREATE TABLE IF NOT EXISTS followers ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL, + username TEXT NOT NULL, actor TEXT NOT NULL, inbox TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, username) ); + CREATE INDEX IF NOT EXISTS idx_followers_username ON followers(username); -- Following (people we follow) CREATE TABLE IF NOT EXISTS following ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL, + username TEXT NOT NULL, actor TEXT NOT NULL, accepted INTEGER DEFAULT 0, - created_at TEXT DEFAULT CURRENT_TIMESTAMP + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, username) ); + CREATE INDEX IF NOT EXISTS idx_following_username ON following(username); -- Activities (inbox) CREATE TABLE IF NOT EXISTS activities ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL, + username TEXT NOT NULL, type TEXT NOT NULL, actor TEXT, object TEXT, raw TEXT NOT NULL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, username) ); + CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username); -- Posts (our outbox) CREATE TABLE IF NOT EXISTS posts ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL, + username TEXT NOT NULL, content TEXT NOT NULL, in_reply_to TEXT, - published TEXT DEFAULT CURRENT_TIMESTAMP + published TEXT DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, username) ); + CREATE INDEX IF NOT EXISTS idx_posts_username ON posts(username); - -- Known actors (cache) + -- Known actors (cache — global, not per-user) CREATE TABLE IF NOT EXISTS actors ( id TEXT PRIMARY KEY, data TEXT NOT NULL, @@ -56,27 +68,37 @@ const SCHEMA = ` ); ` +/** + * Get the default DB path under DATA_ROOT/{username}/.ap/ + */ +function getDefaultDbPath(username = 'me') { + const dataRoot = process.env.DATA_ROOT || './data' + return join(dataRoot, username, '.ap', 'activitypub.db') +} + /** * Initialize the database - * Uses sql.js (WASM) for cross-platform compatibility - * @param {string} path - Path to SQLite file + * @param {string} [path] - Path to SQLite file (defaults to {DATA_ROOT}/{username}/.ap/activitypub.db) + * @param {string} [username] - Username used when defaulting path (default: me) */ -export async function initStore(path = 'data/activitypub.db') { +export async function initStore(path, username = 'me') { + const resolvedPath = path || getDefaultDbPath(username) + // Ensure directory exists - const dir = dirname(path) + const dir = dirname(resolvedPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } - dbPath = path + dbPath = resolvedPath // Use sql.js (WASM, works everywhere) const initSqlJs = (await import('sql.js')).default const SQL = await initSqlJs() // Load existing database if it exists - if (existsSync(path)) { - const buffer = readFileSync(path) + if (existsSync(resolvedPath)) { + const buffer = readFileSync(resolvedPath) db = new SQL.Database(buffer) } else { db = new SQL.Database() @@ -142,64 +164,72 @@ function getAll(sql, params = []) { // Followers -export function addFollower(actorId, inbox) { +export function addFollower(username, actorId, inbox) { runStmt( - 'INSERT OR REPLACE INTO followers (id, actor, inbox) VALUES (?, ?, ?)', - [actorId, actorId, inbox] + 'INSERT OR REPLACE INTO followers (id, username, actor, inbox) VALUES (?, ?, ?, ?)', + [actorId, username, actorId, inbox] ) } -export function removeFollower(actorId) { - runStmt('DELETE FROM followers WHERE id = ?', [actorId]) +export function removeFollower(username, actorId) { + runStmt('DELETE FROM followers WHERE id = ? AND username = ?', [actorId, username]) } -export function getFollowers() { - return getAll('SELECT * FROM followers ORDER BY created_at DESC') +export function getFollowers(username) { + return getAll('SELECT * FROM followers WHERE username = ? ORDER BY created_at DESC', [username]) } -export function getFollowerCount() { - const row = getOne('SELECT COUNT(*) as count FROM followers') +export function getFollowerCount(username) { + const row = getOne('SELECT COUNT(*) as count FROM followers WHERE username = ?', [username]) return row ? row.count : 0 } -export function getFollowerInboxes() { - return getAll('SELECT DISTINCT inbox FROM followers WHERE inbox IS NOT NULL') +export function getFollowerInboxes(username) { + return getAll('SELECT DISTINCT inbox FROM followers WHERE username = ? AND inbox IS NOT NULL', [username]) .map(row => row.inbox) } // Following -export function addFollowing(actorId, accepted = false) { +export function addFollowing(username, actorId, accepted = false) { runStmt( - 'INSERT OR REPLACE INTO following (id, actor, accepted) VALUES (?, ?, ?)', - [actorId, actorId, accepted ? 1 : 0] + 'INSERT OR REPLACE INTO following (id, username, actor, accepted) VALUES (?, ?, ?, ?)', + [actorId, username, actorId, accepted ? 1 : 0] ) } -export function acceptFollowing(actorId) { - runStmt('UPDATE following SET accepted = 1 WHERE id = ?', [actorId]) +export function acceptFollowing(username, actorId) { + runStmt('UPDATE following SET accepted = 1 WHERE actor = ? AND username = ?', [actorId, username]) + const row = getOne('SELECT id FROM following WHERE actor = ? AND username = ?', [actorId, username]) + if (!row) { + runStmt( + 'INSERT INTO following (id, username, actor, accepted) VALUES (?, ?, ?, 1)', + [actorId, username, actorId] + ) + } } -export function removeFollowing(actorId) { - runStmt('DELETE FROM following WHERE id = ?', [actorId]) +export function removeFollowing(username, actorId) { + runStmt('DELETE FROM following WHERE id = ? AND username = ?', [actorId, username]) } -export function getFollowing() { - return getAll('SELECT * FROM following WHERE accepted = 1 ORDER BY created_at DESC') +export function getFollowing(username) { + return getAll('SELECT * FROM following WHERE username = ? AND accepted = 1 ORDER BY created_at DESC', [username]) } -export function getFollowingCount() { - const row = getOne('SELECT COUNT(*) as count FROM following WHERE accepted = 1') +export function getFollowingCount(username) { + const row = getOne('SELECT COUNT(*) as count FROM following WHERE username = ? AND accepted = 1', [username]) return row ? row.count : 0 } // Activities -export function saveActivity(activity) { +export function saveActivity(username, activity) { runStmt( - 'INSERT OR REPLACE INTO activities (id, type, actor, object, raw) VALUES (?, ?, ?, ?, ?)', + 'INSERT OR REPLACE INTO activities (id, username, type, actor, object, raw) VALUES (?, ?, ?, ?, ?, ?)', [ activity.id, + username, activity.type, typeof activity.actor === 'string' ? activity.actor : activity.actor?.id, typeof activity.object === 'string' ? activity.object : JSON.stringify(activity.object), @@ -208,8 +238,8 @@ export function saveActivity(activity) { ) } -export function getActivities(limit = 20) { - return getAll('SELECT * FROM activities ORDER BY created_at DESC LIMIT ?', [limit]) +export function getActivities(username, limit = 20) { + return getAll('SELECT * FROM activities WHERE username = ? ORDER BY created_at DESC LIMIT ?', [username, limit]) .map(row => ({ ...row, raw: JSON.parse(row.raw) @@ -218,27 +248,35 @@ export function getActivities(limit = 20) { // Posts -export function savePost(id, content, inReplyTo = null) { +export function savePost(username, id, content, inReplyTo = null) { runStmt( - 'INSERT INTO posts (id, content, in_reply_to) VALUES (?, ?, ?)', - [id, content, inReplyTo] + 'INSERT INTO posts (id, username, content, in_reply_to) VALUES (?, ?, ?, ?)', + [id, username, content, inReplyTo] ) } -export function getPosts(limit = 20) { - return getAll('SELECT * FROM posts ORDER BY published DESC LIMIT ?', [limit]) +export function getPosts(username, limit = 20) { + return getAll('SELECT * FROM posts WHERE username = ? ORDER BY published DESC LIMIT ?', [username, limit]) } -export function getPost(id) { +export function getPost(username, id) { + return getOne('SELECT * FROM posts WHERE id = ? AND username = ?', [id, username]) +} + +export function getPostById(id) { return getOne('SELECT * FROM posts WHERE id = ?', [id]) } -export function getPostCount() { - const row = getOne('SELECT COUNT(*) as count FROM posts') +export function updatePost(username, id, content) { + runStmt('UPDATE posts SET content = ? WHERE id = ? AND username = ?', [content, id, username]) +} + +export function getPostCount(username) { + const row = getOne('SELECT COUNT(*) as count FROM posts WHERE username = ?', [username]) return row ? row.count : 0 } -// Actor cache +// Actor cache (global — not per-user) export function cacheActor(actor) { runStmt( @@ -270,6 +308,8 @@ export default { savePost, getPosts, getPost, + getPostById, + updatePost, getPostCount, cacheActor, getCachedActor diff --git a/src/auth/middleware.js b/src/auth/middleware.js index 88007dd..611e067 100644 --- a/src/auth/middleware.js +++ b/src/auth/middleware.js @@ -7,8 +7,10 @@ import { getWebIdFromRequestAsync } from './token.js'; import { checkAccess, getRequiredMode } from '../wac/checker.js'; import { AccessMode } from '../wac/parser.js'; +import { parseN3Patch } from '../patch/n3-patch.js'; +import { parseSparqlUpdate } from '../patch/sparql-update.js'; import * as storage from '../storage/filesystem.js'; -import { getEffectiveUrlPath } from '../utils/url.js'; +import { getEffectiveUrlPath, getBaseDomainHost } from '../utils/url.js'; import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashlib/index.js'; /** @@ -26,8 +28,10 @@ import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashl export function buildResourceUrl(request, urlPath) { // Use request.headers.host (includes port) instead of request.hostname (strips port) const host = request.headers.host || request.hostname; + // request.hostname may include port — strip it for comparison + const hostnameOnly = request.hostname.includes(':') ? request.hostname.split(':')[0] : request.hostname; if (request.subdomainsEnabled && request.baseDomain && - request.hostname === request.baseDomain && !request.podName) { + hostnameOnly === getBaseDomainHost(request.baseDomain) && !request.podName) { const pathMatch = urlPath.match(/^\/([^/]+)(\/.*)?$/); // Treat a path segment as a pod name only if it looks like one: // - not a dotfile (.well-known, .acl, .meta, ...) @@ -91,7 +95,13 @@ export async function authorize(request, reply, options = {}) { const resourceUrl = buildResourceUrl(request, urlPath); // Get required access mode - use override if provided, otherwise derive from method - const requiredMode = options.requiredMode || getRequiredMode(method); + let requiredMode = options.requiredMode || getRequiredMode(method); + + // PATCH can be authorized as Append when it is insert-only. + // Any delete operation (or parse ambiguity) stays Write. + if (!options.requiredMode && method === 'PATCH') { + requiredMode = getPatchRequiredMode(request, resourceUrl); + } // For write operations on non-existent resources, check parent container let checkPath = storagePath; @@ -120,6 +130,36 @@ export async function authorize(request, reply, options = {}) { return { authorized: allowed, webId, wacAllow, authError, paymentRequired, paid, balance, currency }; } +/** + * Determine PATCH required mode from patch payload semantics. + * Insert-only patches require Append; delete-capable patches require Write. + */ +function getPatchRequiredMode(request, baseUri) { + const contentType = (request.headers['content-type'] || '').toLowerCase(); + const rawBody = Buffer.isBuffer(request.body) ? request.body.toString() : request.body; + + if (typeof rawBody !== 'string') { + return AccessMode.WRITE; + } + + try { + if (contentType.includes('application/sparql-update')) { + const update = parseSparqlUpdate(rawBody, baseUri); + return update.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE; + } + + if (contentType.includes('text/n3') || contentType.includes('application/n3')) { + const patch = parseN3Patch(rawBody, baseUri); + return patch.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE; + } + } catch { + // Fail closed to Write when patch parsing is invalid/ambiguous. + return AccessMode.WRITE; + } + + return AccessMode.WRITE; +} + /** * Get parent container path */ @@ -155,10 +195,16 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au // If mashlib is enabled, serve mashlib instead of static error page // Mashlib has built-in login functionality via panes.runDataBrowser() if (request.mashlibEnabled) { + // OIDC code-flow callbacks often land back on protected resources with + // ?code=...&state=...; return 200 so the browser shell can process the + // callback instead of getting stuck on an HTTP 401 page. + const isOidcCallback = request.method === 'GET' && + typeof request.query?.code === 'string' && + typeof request.query?.state === 'string'; const html = request.mashlibModule ? generateModuleDatabrowserHtml(request.mashlibModule) : generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null); - return reply.code(statusCode).type('text/html').send(html); + return reply.code(isOidcCallback ? 200 : statusCode).type('text/html').send(html); } return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request)); } @@ -426,5 +472,60 @@ async function authorizeAclAccess(request, urlPath, method, webId, authError) { requiredMode: AccessMode.CONTROL }); + // Owner fallback: allow ACL read/edit even if acl:Control is missing + // or the ACL document is invalid/unparseable. + if (!allowed && isAclOwnerAclMethod(method) && isAclOwner(request, protectedUrl, webId)) { + return { + authorized: true, + webId, + wacAllow: 'user="read write append control", public=""', + authError + }; + } + return { authorized: allowed, webId, wacAllow, authError }; } + +function isAclMutationMethod(method) { + const m = (method || '').toUpperCase(); + return m === 'PUT' || m === 'PATCH' || m === 'DELETE' || m === 'POST'; +} + +function isAclOwnerAclMethod(method) { + const m = (method || '').toUpperCase(); + return m === 'GET' || m === 'HEAD' || isAclMutationMethod(m); +} + +function isAclOwner(request, protectedUrl, webId) { + if (!webId) return false; + + const candidates = getOwnerWebIdCandidates(request, protectedUrl); + return candidates.includes(webId); +} + +function getOwnerWebIdCandidates(request, protectedUrl) { + let parsed; + try { + parsed = new URL(protectedUrl); + } catch { + return []; + } + + const origin = parsed.origin; + const pathSegments = parsed.pathname.split('/').filter(Boolean); + + // Path-based multi-user mode: first path segment is pod name. + if (!request.subdomainsEnabled && !request.singleUser && pathSegments.length > 0 && !pathSegments[0].startsWith('.')) { + const podName = pathSegments[0]; + return [ + `${origin}/${podName}/profile/card.jsonld#me`, + `${origin}/${podName}/profile/card#me` + ]; + } + + // Subdomain mode and single-user mode use an origin-scoped profile. + return [ + `${origin}/profile/card.jsonld#me`, + `${origin}/profile/card#me` + ]; +} diff --git a/src/auth/solid-oidc.js b/src/auth/solid-oidc.js index ec7c4a4..18919e8 100644 --- a/src/auth/solid-oidc.js +++ b/src/auth/solid-oidc.js @@ -12,6 +12,7 @@ import * as jose from 'jose'; import { validateExternalUrl } from '../utils/ssrf.js'; +import { getPublicJwks } from '../idp/keys.js'; // Cache for OIDC configurations and JWKS const oidcConfigCache = new Map(); @@ -285,6 +286,26 @@ async function getOidcConfig(issuer) { } } + // For the server's own trusted issuer, avoid an outbound HTTPS fetch. + // This prevents local/self-signed certificate problems during resource-token + // verification and guarantees consistency with our served discovery doc. + if (isTrusted) { + const baseUrl = issuer.replace(/\/$/, ''); + const config = { + issuer: baseUrl + '/', + authorization_endpoint: `${baseUrl}/idp/auth`, + token_endpoint: `${baseUrl}/idp/token`, + userinfo_endpoint: `${baseUrl}/idp/me`, + jwks_uri: `${baseUrl}/.well-known/jwks.json`, + registration_endpoint: `${baseUrl}/idp/reg`, + introspection_endpoint: `${baseUrl}/idp/token/introspection`, + revocation_endpoint: `${baseUrl}/idp/token/revocation`, + end_session_endpoint: `${baseUrl}/idp/session/end`, + }; + oidcConfigCache.set(issuer, { config, timestamp: Date.now() }); + return config; + } + const configUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`; try { @@ -312,6 +333,15 @@ async function getJwks(issuer) { return cached.jwks; } + const normalizedIssuer = issuer.replace(/\/$/, ''); + const isTrusted = trustedIssuers.has(normalizedIssuer) || trustedIssuers.has(normalizedIssuer + '/'); + + if (isTrusted) { + const localJwks = jose.createLocalJWKSet(await getPublicJwks()); + jwksCache.set(issuer, { jwks: localJwks, timestamp: Date.now() }); + return localJwks; + } + // Get OIDC config to find JWKS URI const config = await getOidcConfig(issuer); const jwksUri = config.jwks_uri; diff --git a/src/handlers/container.js b/src/handlers/container.js index c143e01..cb2f24b 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -287,7 +287,8 @@ export async function handleCreatePod(request, reply) { let baseUri, podUri, webId; if (subdomainsEnabled && baseDomain) { - // Subdomain mode: alice.example.com/profile/card.jsonld#me + // Subdomain mode: alice.example.com:port/profile/card.jsonld#me + // baseDomain may include port (e.g. "example.com:3100") const podHost = `${name}.${baseDomain}`; baseUri = `${request.protocol}://${baseDomain}`; podUri = `${request.protocol}://${podHost}/`; diff --git a/src/handlers/resource.js b/src/handlers/resource.js index 1ec3b1b..6dccb09 100644 --- a/src/handlers/resource.js +++ b/src/handlers/resource.js @@ -14,7 +14,7 @@ import { } from '../rdf/conneg.js'; import { emitChange } from '../notifications/events.js'; import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js'; -import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMashlib, DATA_ISLAND_MAX_BYTES } from '../mashlib/index.js'; +import { generateDatabrowserHtml, generateModuleDatabrowserHtml, getMashlibDecision, DATA_ISLAND_MAX_BYTES } from '../mashlib/index.js'; import { turtleToJsonLd } from '../rdf/turtle.js'; /** @@ -126,12 +126,26 @@ export async function handleGet(request, reply) { return reply.code(404).send({ error: 'Not Found' }); } - // Check If-None-Match for conditional GET (304 Not Modified) + // Check If-None-Match for conditional GET (304 Not Modified). + // Important: don't short-circuit likely mashlib navigation requests, + // otherwise a top-level navigation can reuse a previously cached RDF + // variant (e.g., Turtle from mashlib XHR) and display raw text. const ifNoneMatch = request.headers['if-none-match']; if (ifNoneMatch) { - const check = checkIfNoneMatchForGet(ifNoneMatch, stats.etag); - if (!check.ok && check.notModified) { - return reply.code(304).send(); + const decisionContentType = stats.isDirectory + ? 'application/ld+json' + : getContentType(storagePath); + const mashlibDecision = getMashlibDecision( + request, + request.mashlibEnabled, + decisionContentType + ); + + if (!mashlibDecision.serve) { + const check = checkIfNoneMatchForGet(ifNoneMatch, stats.etag); + if (!check.ok && check.notModified) { + return reply.code(304).send(); + } } } @@ -238,7 +252,8 @@ export async function handleGet(request, reply) { const jsonLd = generateContainerJsonLd(resourceUrl, entries || []); // Check if we should serve Mashlib data browser for containers - if (shouldServeMashlib(request, request.mashlibEnabled, 'application/ld+json')) { + const containerMashlibDecision = getMashlibDecision(request, request.mashlibEnabled, 'application/ld+json'); + if (containerMashlibDecision.serve) { // Phase 1 of #7: also embed the container's JSON-LD listing as a // data island so consumers that look for ``; } // Local mode - use defer (reliable when served locally) - return `SolidOS Web App${island}${reader}
`; + return `SolidOS Web App${island}${reader}
`; } /** @@ -324,35 +525,69 @@ export function generateModuleDatabrowserHtml(moduleUrl, resourceUrl = '', opts * @returns {boolean} */ export function shouldServeMashlib(request, mashlibEnabled, contentType) { - const accept = request.headers.accept || ''; - const secFetchDest = request.headers['sec-fetch-dest'] || ''; + return getMashlibDecision(request, mashlibEnabled, contentType).serve; +} + +/** + * Explain whether mashlib should serve this request. + * Returns both decision and a stable reason code. + * + * @param {object} request - Fastify request + * @param {boolean} mashlibEnabled - Whether mashlib is enabled + * @param {string} contentType - Content type of the resource + * @returns {{serve: boolean, reason: string}} + */ +export function getMashlibDecision(request, mashlibEnabled, contentType) { + const accept = String(request.headers.accept || '').toLowerCase(); + const secFetchDest = String(request.headers['sec-fetch-dest'] || '').toLowerCase(); + const secFetchMode = String(request.headers['sec-fetch-mode'] || '').toLowerCase(); + const secFetchUser = String(request.headers['sec-fetch-user'] || '').toLowerCase(); if (!mashlibEnabled) { - return false; + return { serve: false, reason: 'disabled' }; } - // Only serve mashlib for top-level document navigation - // sec-fetch-dest: 'document' = browser navigation (serve mashlib) - // sec-fetch-dest: 'empty' = JavaScript fetch/XHR (serve RDF data) - if (secFetchDest && secFetchDest !== 'document') { - return false; + // Block non-navigation sub-resource fetches (XHR, fetch API, scripts, etc.) + // sec-fetch-dest values that indicate non-document fetches are blocked. + // We do NOT require 'document' because on Android Chrome back navigation + // the header may be absent or differ from a fresh forward navigation. + // The Accept: text/html check below is the primary discriminator since + // mashlib XHR never includes text/html in its Accept header. + const nonDocumentDests = new Set([ + 'empty', 'script', 'worker', 'sharedworker', 'serviceworker', + 'style', 'image', 'font', 'media', 'manifest', 'object', 'embed', + 'report', 'xslt', 'audioworklet', 'paintworklet', 'track', 'video', + 'audio', 'fetch' + ]); + if (secFetchDest && nonDocumentDests.has(secFetchDest)) { + return { serve: false, reason: `non-document-dest:${secFetchDest}` }; } - // Must explicitly accept HTML as a primary type (not via */*) - // Browser navigation: "text/html,application/xhtml+xml,..." - // Mashlib fetch: "application/rdf+xml;q=0.9, */*;q=0.1,..." - if (!accept.includes('text/html')) { - return false; + // Prefer explicit Accept: text/html, but tolerate navigation requests + // where intermediaries strip/normalize Accept on back/forward or reload. + // Mashlib/XHR fetches still get blocked by nonDocumentDests above. + const acceptsHtml = accept.includes('text/html'); + const isLikelyNavigation = + secFetchMode === 'navigate' || + secFetchUser === '?1' || + secFetchDest === 'document' || + secFetchDest === 'iframe' || + secFetchDest === 'frame'; + if (!acceptsHtml && !isLikelyNavigation) { + return { serve: false, reason: 'not-html-and-not-navigation' }; } - // Don't serve mashlib if RDF types appear BEFORE text/html in Accept header - // This handles cases like "application/rdf+xml, text/html" where RDF is preferred - const htmlPos = accept.indexOf('text/html'); - const acceptRdfTypes = ['application/rdf+xml', 'text/turtle', 'application/ld+json', 'text/n3', 'application/n-triples']; - for (const rdfType of acceptRdfTypes) { - const rdfPos = accept.indexOf(rdfType); - if (rdfPos !== -1 && rdfPos < htmlPos) { - return false; // RDF type is preferred over HTML + // If HTML is explicitly present, honor RDF preference ordering. + // (When HTML is absent but request looks like navigation, we still + // serve mashlib to avoid plain RDF on back/reload through proxies.) + if (acceptsHtml) { + const htmlPos = accept.indexOf('text/html'); + const acceptRdfTypes = ['application/rdf+xml', 'text/turtle', 'application/ld+json', 'text/n3', 'application/n-triples']; + for (const rdfType of acceptRdfTypes) { + const rdfPos = accept.indexOf(rdfType); + if (rdfPos !== -1 && rdfPos < htmlPos) { + return { serve: false, reason: `rdf-preferred:${rdfType}` }; // RDF type is preferred over HTML + } } } @@ -371,7 +606,10 @@ export function shouldServeMashlib(request, mashlibEnabled, contentType) { ]; const baseType = contentType.split(';')[0].trim().toLowerCase(); - return rdfTypes.includes(baseType); + if (!rdfTypes.includes(baseType)) { + return { serve: false, reason: `non-rdf-content:${baseType || 'unknown'}` }; + } + return { serve: true, reason: 'serve' }; } /** diff --git a/src/rdf/turtle.js b/src/rdf/turtle.js index e0b81e3..730a503 100644 --- a/src/rdf/turtle.js +++ b/src/rdf/turtle.js @@ -181,8 +181,13 @@ function jsonLdToQuads(jsonLd, baseUri) { if (doc['@context']) { mergedContext = { ...mergedContext, ...doc['@context'] }; } - // Each document with @id is a node (no @graph needed) - if (doc['@id']) { + if (doc['@graph']) { + // JSON-LD @graph container (e.g. ACL files produced by serializeAcl) + // The @context is already merged above so prefix expansion will work. + for (const node of doc['@graph']) { + if (node['@id']) nodes.push(node); + } + } else if (doc['@id']) { nodes.push(doc); } } @@ -331,7 +336,7 @@ function valueToTerm(value, baseUri, context, isIdType = false) { if (typeof value === 'string') { // If context says this should be a URI, treat it as a named node if (isIdType) { - const uri = resolveUri(value, baseUri); + const uri = resolveUri(expandUri(value, context), baseUri); return namedNode(uri); } return literal(value); @@ -348,9 +353,10 @@ function valueToTerm(value, baseUri, context, isIdType = false) { // Object values if (typeof value === 'object') { - // @id reference + // @id reference — expand CURIEs (e.g. "acl:Read") before resolving if (value['@id']) { - const uri = resolveUri(value['@id'], baseUri); + const expanded = expandUri(value['@id'], context); + const uri = resolveUri(expanded, baseUri); return uri.startsWith('_:') ? blankNode(uri.slice(2)) : namedNode(uri); diff --git a/src/server.js b/src/server.js index 696dfad..93cf2f3 100644 --- a/src/server.js +++ b/src/server.js @@ -22,6 +22,7 @@ import { webrtcPlugin } from './webrtc/index.js'; import { tunnelPlugin } from './tunnel/index.js'; import { terminalPlugin } from './terminal/index.js'; import { registerErrorHandler } from './utils/error-handler.js'; +import { getBaseDomainHost } from './utils/url.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -220,11 +221,14 @@ export function createServer(options = {}) { // Extract pod name from subdomain if enabled if (subdomainsEnabled && baseDomain) { - const host = request.hostname; - // Check if host is a subdomain of baseDomain - if (host !== baseDomain && host.endsWith('.' + baseDomain)) { + // request.hostname may include port in some Fastify versions — strip it + const rawHost = request.hostname; + const host = rawHost.includes(':') ? rawHost.split(':')[0] : rawHost; + const baseDomainHost = getBaseDomainHost(baseDomain); + // Check if host is a subdomain of baseDomain (hostname part only) + if (host !== baseDomainHost && host.endsWith('.' + baseDomainHost)) { // Extract subdomain (e.g., "alice.example.com" -> "alice") - const subdomain = host.slice(0, -(baseDomain.length + 1)); + const subdomain = host.slice(0, -(baseDomainHost.length + 1)); // Only single-level subdomains (no dots) if (!subdomain.includes('.')) { request.podName = subdomain; @@ -289,7 +293,9 @@ export function createServer(options = {}) { username: apUsername, displayName: apDisplayName, summary: apSummary, - nostrPubkey: apNostrPubkey + nostrPubkey: apNostrPubkey, + subdomains: subdomainsEnabled, + baseDomain }); } @@ -444,9 +450,15 @@ export function createServer(options = {}) { fastify.addHook('preHandler', async (request, reply) => { // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js']; - const apPaths = ['/inbox', '/profile/card.jsonld/inbox', '/profile/card.jsonld/outbox', '/profile/card.jsonld/followers', '/profile/card.jsonld/following', + const apPaths = ['/inbox', '/posts/', '/profile/avatar.png', '/profile/header.png', '/profile/card.jsonld/inbox', '/profile/card.jsonld/outbox', '/profile/card.jsonld/followers', '/profile/card.jsonld/following', '/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials', + '/api/v1/timelines/', '/api/v1/statuses', '/api/v1/accounts/', '/api/v1/notifications', '/oauth/authorize', '/oauth/token']; + const isApPublicPath = apPaths.some(p => + request.url === p || + request.url.startsWith(p + '?') || + (p.endsWith('/') && request.url.startsWith(p)) + ); // Check if request wants ActivityPub content for profile const accept = request.headers.accept || ''; const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"'); @@ -460,7 +472,7 @@ export function createServer(options = {}) { request.url.startsWith('/.well-known/') || (nostrEnabled && request.url.startsWith(nostrPath)) || (gitEnabled && isGitRequest(request.url)) || - (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) || + (activitypubEnabled && (request.url.startsWith('/api/v1/') || request.url.startsWith('/api/v2/') || isApPublicPath)) || isProfileAP || request.url.startsWith('/storage/') || (payEnabled && isPayRequest(request.url)) || diff --git a/src/utils/url.js b/src/utils/url.js index 227a2c9..ebc2438 100644 --- a/src/utils/url.js +++ b/src/utils/url.js @@ -151,6 +151,50 @@ export function getResourceName(urlPath) { return parts[parts.length - 1]; } +/** + * Extract the hostname-only part of a baseDomain that may include a port. + * Used for routing comparisons against request.hostname (which never has port). + * + * Examples: + * 'example.com' → 'example.com' + * 'example.com:3100' → 'example.com' + * '[::1]:3100' → '[::1]' + * + * @param {string} baseDomain - The configured baseDomain (may include :port) + * @returns {string} + */ +export function getBaseDomainHost(baseDomain) { + if (!baseDomain) return baseDomain; + + let value = String(baseDomain).trim(); + if (!value) return value; + + // Accept defensive forms like "https://example.com:3100/". + if (!value.includes('://')) { + value = `http://${value}`; + } + + try { + const parsed = new URL(value); + return parsed.hostname; + } catch { + // Fallback for malformed values: best-effort host extraction. + const candidate = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/\/.*$/, ''); + + // Bracketed IPv6 + if (candidate.startsWith('[')) { + const end = candidate.indexOf(']'); + return end === -1 ? candidate : candidate.slice(0, end + 1); + } + + const colon = candidate.lastIndexOf(':'); + if (colon === -1) return candidate; + const maybePort = candidate.slice(colon + 1); + return /^\d+$/.test(maybePort) ? candidate.slice(0, colon) : candidate; + } +} + + /** * Extract pod name from URL path or request * diff --git a/test/adapter.test.js b/test/adapter.test.js new file mode 100644 index 0000000..ea3caa8 --- /dev/null +++ b/test/adapter.test.js @@ -0,0 +1,57 @@ +import { beforeEach, afterEach, describe, it } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; + +import { createAdapter } from '../src/idp/adapter.js'; + +describe('FilesystemAdapter', () => { + let tmpRoot; + let originalDataRoot; + + beforeEach(async () => { + originalDataRoot = process.env.DATA_ROOT; + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'jss-adapter-')); + process.env.DATA_ROOT = tmpRoot; + }); + + afterEach(async () => { + if (originalDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = originalDataRoot; + await fs.remove(tmpRoot); + }); + + it('findByUid returns a valid session whose storage id starts with underscore', async () => { + const adapter = createAdapter('Session'); + const id = '_leadingUnderscoreSessionId'; + const uid = 'session-uid-123'; + + await adapter.upsert(id, { + uid, + accountId: 'acct-1', + kind: 'Session', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }, 3600); + + const found = await adapter.findByUid(uid); + + assert.ok(found, 'expected findByUid to find underscore-prefixed session file'); + assert.strictEqual(found._id, id); + assert.strictEqual(found.uid, uid); + }); + + it('findByUid still ignores index files', async () => { + const adapter = createAdapter('Session'); + await fs.ensureDir(adapter.dir); + await fs.writeJson(path.join(adapter.dir, '_session_index.json'), { + uid: 'wrong-uid', + _id: '_session_index', + }); + + const found = await adapter.findByUid('wrong-uid'); + + assert.strictEqual(found, undefined); + }); +}); \ No newline at end of file diff --git a/test/auth.test.js b/test/auth.test.js index d34e760..622b7f4 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -4,6 +4,8 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; +import fs from 'fs-extra'; +import path from 'path'; import { startTestServer, stopTestServer, @@ -142,6 +144,148 @@ describe('Authentication', () => { assertStatus(res, 201); }); + it('should allow append-only PATCH for insert-only patch', async () => { + await createTestPod('appendpatch1'); + await createTestPod('appendwriter1'); + + const baseUrl = getBaseUrl(); + + // Create target resource and container first. + await request('/appendpatch1/public/item.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': { ex: 'http://example.org/' }, + '@id': '#it', + 'ex:name': 'initial' + }), + auth: 'appendpatch1' + }); + + // Set container ACL: owner full control + authenticated append only. + const acl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/appendpatch1/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' }, + { '@id': 'acl:Control' } + ] + }, + { + '@id': '#authenticated-append', + '@type': 'acl:Authorization', + 'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Append' } + ] + } + ] + }; + + await request('/appendpatch1/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(acl), + auth: 'appendpatch1' + }); + + const insertOnlyPatch = ` + @prefix solid: . + @prefix ex: . + _:patch a solid:InsertDeletePatch; + solid:inserts { <#it> ex:added "yes" }. + `; + + const res = await request('/appendpatch1/public/item.json', { + method: 'PATCH', + headers: { 'Content-Type': 'text/n3' }, + body: insertOnlyPatch, + auth: 'appendwriter1' + }); + + assertStatus(res, 204); + }); + + it('should deny append-only PATCH when patch includes deletes', async () => { + await createTestPod('appendpatch2'); + await createTestPod('appendwriter2'); + + const baseUrl = getBaseUrl(); + + await request('/appendpatch2/public/item.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': { ex: 'http://example.org/' }, + '@id': '#it', + 'ex:name': 'initial' + }), + auth: 'appendpatch2' + }); + + const acl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/appendpatch2/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' }, + { '@id': 'acl:Control' } + ] + }, + { + '@id': '#authenticated-append', + '@type': 'acl:Authorization', + 'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' }, + 'acl:accessTo': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:default': { '@id': `${baseUrl}/appendpatch2/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Append' } + ] + } + ] + }; + + await request('/appendpatch2/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(acl), + auth: 'appendpatch2' + }); + + const deletePatch = ` + @prefix solid: . + @prefix ex: . + _:patch a solid:InsertDeletePatch; + solid:deletes { <#it> ex:name "initial" }. + `; + + const res = await request('/appendpatch2/public/item.json', { + method: 'PATCH', + headers: { 'Content-Type': 'text/n3' }, + body: deletePatch, + auth: 'appendwriter2' + }); + + assertStatus(res, 403); + }); + it('should deny public read on inbox', async () => { await createTestPod('inboxread'); @@ -212,6 +356,106 @@ describe('Authentication', () => { const res3 = await request('/authuser1/authenticated-only/test.txt', { auth: 'authuser2' }); assertStatus(res3, 200); }); + + it('should allow owner to edit ACL even without acl:Control', async () => { + await createTestPod('aclowner1'); + const baseUrl = getBaseUrl(); + + // Initial ACL update while owner still has Control via inherited defaults. + const noControlAcl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner-no-control', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/aclowner1/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/aclowner1/public/` }, + 'acl:default': { '@id': `${baseUrl}/aclowner1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' } + ] + } + ] + }; + + const setNoControl = await request('/aclowner1/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(noControlAcl), + auth: 'aclowner1' + }); + assert.ok(setNoControl.status < 300, `Initial ACL write failed: ${setNoControl.status}`); + + // Second edit would normally fail (no acl:Control), but owner fallback should allow it. + const updatedAcl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner-updated', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/aclowner1/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/aclowner1/public/` }, + 'acl:default': { '@id': `${baseUrl}/aclowner1/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' } + ] + } + ] + }; + + const secondEdit = await request('/aclowner1/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(updatedAcl), + auth: 'aclowner1' + }); + assert.ok(secondEdit.status < 300, `Owner should edit ACL without Control, got ${secondEdit.status}`); + + // Owner should also be able to read ACL even without Control. + const readAcl = await request('/aclowner1/public/.acl', { + method: 'GET', + auth: 'aclowner1' + }); + assert.ok(readAcl.status < 300, `Owner should read ACL without Control, got ${readAcl.status}`); + }); + + it('should allow owner to repair a broken ACL document', async () => { + await createTestPod('aclowner2'); + const baseUrl = getBaseUrl(); + + // Corrupt the ACL on disk to simulate an invalid/unparseable ACL document. + const aclPath = path.join('data', 'aclowner2', 'public', '.acl'); + await fs.writeFile(aclPath, 'this is not valid ACL content', 'utf8'); + + // Owner must still be able to repair the ACL afterwards. + const repairedAcl = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': `${baseUrl}/aclowner2/profile/card.jsonld#me` }, + 'acl:accessTo': { '@id': `${baseUrl}/aclowner2/public/` }, + 'acl:default': { '@id': `${baseUrl}/aclowner2/public/` }, + 'acl:mode': [ + { '@id': 'acl:Read' }, + { '@id': 'acl:Write' }, + { '@id': 'acl:Control' } + ] + } + ] + }; + + const repair = await request('/aclowner2/public/.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(repairedAcl), + auth: 'aclowner2' + }); + assert.ok(repair.status < 300, `Owner should repair broken ACL, got ${repair.status}`); + }); }); describe('WAC-Allow Header', () => { diff --git a/test/conneg.test.js b/test/conneg.test.js index dc80c0a..87df271 100644 --- a/test/conneg.test.js +++ b/test/conneg.test.js @@ -1,680 +1,686 @@ -/** - * Content Negotiation Tests - * - * Tests Turtle <-> JSON-LD conversion with conneg enabled. - * Note: Content negotiation is OFF by default (JSON-LD native server). - */ - -import { describe, it, before, after } from 'node:test'; -import assert from 'node:assert'; -import { - startTestServer, - stopTestServer, - request, - createTestPod, - assertStatus, - assertHeader, - assertHeaderContains -} from './helpers.js'; - -describe('Content Negotiation (conneg enabled)', () => { - before(async () => { - // Start server with conneg ENABLED - await startTestServer({ conneg: true }); - await createTestPod('connegtest'); - }); - - after(async () => { - await stopTestServer(); - }); - - describe('GET with Accept header', () => { - it('should return JSON-LD when Accept: application/ld+json', async () => { - // Create a JSON-LD resource - const data = { - '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' }, - '@id': '#me', - 'foaf:name': 'Alice' - }; - - await request('/connegtest/public/alice.json', { - method: 'PUT', - headers: { 'Content-Type': 'application/ld+json' }, - body: JSON.stringify(data), - auth: 'connegtest' - }); - - const res = await request('/connegtest/public/alice.json', { - headers: { 'Accept': 'application/ld+json' } - }); - - assertStatus(res, 200); - assertHeaderContains(res, 'Content-Type', 'application/ld+json'); - - const body = await res.json(); - assert.strictEqual(body['foaf:name'], 'Alice'); - }); - - it('should return Turtle when Accept: text/turtle', async () => { - // Create a JSON-LD resource - const data = { - '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' }, - '@id': '#me', - 'foaf:name': 'Bob' - }; - - await request('/connegtest/public/bob.json', { - method: 'PUT', - headers: { 'Content-Type': 'application/ld+json' }, - body: JSON.stringify(data), - auth: 'connegtest' - }); - - const res = await request('/connegtest/public/bob.json', { - headers: { 'Accept': 'text/turtle' } - }); - - assertStatus(res, 200); - assertHeaderContains(res, 'Content-Type', 'text/turtle'); - - const turtle = await res.text(); - // Should contain foaf prefix and name - assert.ok(turtle.includes('foaf:') || turtle.includes('http://xmlns.com/foaf/0.1/'), - 'Turtle should contain foaf prefix or URI'); - assert.ok(turtle.includes('Bob'), 'Turtle should contain the name'); - }); - - it('should default to JSON-LD for */* Accept', async () => { - const res = await request('/connegtest/public/alice.json', { - headers: { 'Accept': '*/*' } - }); - - assertStatus(res, 200); - assertHeaderContains(res, 'Content-Type', 'application/ld+json'); - }); - - it('should include Vary header with Accept', async () => { - const res = await request('/connegtest/public/alice.json'); - const vary = res.headers.get('Vary'); - assert.ok(vary && vary.includes('Accept'), 'Should have Vary: Accept'); - }); - }); - - describe('PUT with Content-Type', () => { - it('should accept Turtle input and store as JSON-LD', async () => { - const turtle = ` - @prefix foaf: . - <#me> foaf:name "Charlie". - `; - - const res = await request('/connegtest/public/charlie.json', { - method: 'PUT', - headers: { 'Content-Type': 'text/turtle' }, - body: turtle, - auth: 'connegtest' - }); - - assertStatus(res, 201); - - // Verify it's stored as JSON-LD - const getRes = await request('/connegtest/public/charlie.json', { - headers: { 'Accept': 'application/ld+json' } - }); - - assertStatus(getRes, 200); - const data = await getRes.json(); - assert.ok(data['@context'], 'Should have @context'); - }); - - it('should accept N3 input', async () => { - const n3 = ` - @prefix schema: . - <#item> schema:name "Widget". - `; - - const res = await request('/connegtest/public/widget.json', { - method: 'PUT', - headers: { 'Content-Type': 'text/n3' }, - body: n3, - auth: 'connegtest' - }); - - assertStatus(res, 201); - }); - - it('should return 400 for invalid Turtle', async () => { - const invalidTurtle = 'this is not valid turtle {{{'; - - const res = await request('/connegtest/public/invalid.json', { - method: 'PUT', - headers: { 'Content-Type': 'text/turtle' }, - body: invalidTurtle, - auth: 'connegtest' - }); - - assertStatus(res, 400); - }); - }); - - describe('POST with Content-Type', () => { - it('should accept Turtle input in POST', async () => { - const turtle = ` - @prefix dc: . - <#doc> dc:title "My Document". - `; - - const res = await request('/connegtest/public/', { - method: 'POST', - headers: { - 'Content-Type': 'text/turtle', - 'Slug': 'turtle-doc.json' - }, - body: turtle, - auth: 'connegtest' - }); - - assertStatus(res, 201); - const location = res.headers.get('Location'); - assert.ok(location, 'Should have Location header'); - }); - }); - - describe('Accept-* Headers', () => { - it('should advertise Turtle support in Accept-Put', async () => { - const res = await request('/connegtest/public/alice.json'); - const acceptPut = res.headers.get('Accept-Put'); - assert.ok(acceptPut && acceptPut.includes('text/turtle'), - 'Accept-Put should include text/turtle'); - }); - - it('should advertise Turtle support in Accept-Post for containers', async () => { - const res = await request('/connegtest/public/'); - const acceptPost = res.headers.get('Accept-Post'); - assert.ok(acceptPost && acceptPost.includes('text/turtle'), - 'Accept-Post should include text/turtle'); - }); - - it('should advertise N3 support in Accept-Put when conneg enabled', async () => { - const res = await request('/connegtest/public/alice.json'); - const acceptPut = res.headers.get('Accept-Put'); - assert.ok(acceptPut && acceptPut.includes('text/n3'), - 'Accept-Put should include text/n3 (canAcceptInput accepts it under conneg)'); - }); - - it('should advertise N3 support in Accept-Post for containers when conneg enabled', async () => { - const res = await request('/connegtest/public/'); - const acceptPost = res.headers.get('Accept-Post'); - assert.ok(acceptPost && acceptPost.includes('text/n3'), - 'Accept-Post should include text/n3 (canAcceptInput accepts it under conneg)'); - }); - - it('should advertise application/json in Accept-Put', async () => { - const res = await request('/connegtest/public/alice.json'); - const acceptPut = res.headers.get('Accept-Put'); - assert.ok(acceptPut && acceptPut.includes('application/json'), - 'Accept-Put should include application/json (canAcceptInput treats it as a JSON-LD alias)'); - }); - - it('should advertise application/json in Accept-Post for containers', async () => { - const res = await request('/connegtest/public/'); - const acceptPost = res.headers.get('Accept-Post'); - assert.ok(acceptPost && acceptPost.includes('application/json'), - 'Accept-Post should include application/json (canAcceptInput treats it as a JSON-LD alias)'); - }); - }); - - // Regression coverage for #294 — Solid convention dotfiles (.acl, .meta) - // were excluded from conneg because getContentType() returned - // application/octet-stream for them. Turtle-native clients (umai etc.) - // fetching /.meta got JSON-LD back and errored on parse. - describe('Solid convention dotfiles (#294)', () => { - const metaData = { - '@context': { 'ldp': 'http://www.w3.org/ns/ldp#' }, - '@id': '', - '@type': 'ldp:BasicContainer' - }; - - before(async () => { - // Write a JSON-LD .meta file (the format JSS writes internally). - await request('/connegtest/public/.meta', { - method: 'PUT', - headers: { 'Content-Type': 'application/ld+json' }, - body: JSON.stringify(metaData), - auth: 'connegtest' - }); - }); - - it('serves .meta as JSON-LD by default', async () => { - const res = await request('/connegtest/public/.meta', { auth: 'connegtest' }); - assertStatus(res, 200); - assertHeaderContains(res, 'Content-Type', 'application/ld+json'); - }); - - it('serves .meta as Turtle when Accept: text/turtle (the umai case)', async () => { - const res = await request('/connegtest/public/.meta', { - headers: { 'Accept': 'text/turtle' }, - auth: 'connegtest' - }); - assertStatus(res, 200); - assertHeaderContains(res, 'Content-Type', 'text/turtle'); - const turtle = await res.text(); - // First byte after the `@prefix` block must parse as Turtle, - // not '{' (the bug signature umai hit). - assert.ok(!turtle.trimStart().startsWith('{'), - `response looks like JSON, not Turtle: ${turtle.slice(0, 60)}`); - }); - - it('accepts Turtle PUT to .meta and round-trips to JSON-LD', async () => { - const turtle = ` - @prefix ldp: . - <> a ldp:BasicContainer. - `; - const putRes = await request('/connegtest/public/.meta', { - method: 'PUT', - headers: { 'Content-Type': 'text/turtle' }, - body: turtle, - auth: 'connegtest' - }); - assert.ok(putRes.status < 300, `PUT turtle should succeed, got ${putRes.status}`); - - // Default GET now serves the converted-and-stored JSON-LD. - const getRes = await request('/connegtest/public/.meta', { - headers: { 'Accept': 'application/ld+json' }, - auth: 'connegtest' - }); - assertStatus(getRes, 200); - assertHeaderContains(getRes, 'Content-Type', 'application/ld+json'); - const body = await getRes.json(); - assert.ok(body['@context'] || body['@graph'] || body['@type'] || body['@id'], - 'round-tripped JSON-LD should have at least one @-keyword'); - }); - }); - - // ACL resources require a JSON-LD payload (application/ld+json or - // application/json) on PUT regardless of conneg setting: round-trip - // serialization between JSON-LD and Turtle has known limitations - // that can cause data loss. See #295. - describe('ACL content-type guard (#295)', () => { - const aclJsonLd = { - '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, - '@graph': [ - { - '@id': '#owner', - '@type': 'acl:Authorization', - 'acl:agent': { '@id': '#me' }, - 'acl:accessTo': { '@id': './' }, - 'acl:mode': [{ '@id': 'acl:Read' }, { '@id': 'acl:Write' }, { '@id': 'acl:Control' }] - } - ] - }; - - it('rejects text/turtle PUT to .acl with 415', async () => { - const turtle = ` - @prefix acl: . - <#owner> a acl:Authorization; - acl:mode acl:Read. - `; - const res = await request('/connegtest/public/turtle-reject.acl', { - method: 'PUT', - headers: { 'Content-Type': 'text/turtle' }, - body: turtle, - auth: 'connegtest' - }); - assertStatus(res, 415); - assertHeaderContains(res, 'Accept', 'application/ld+json'); - assertHeaderContains(res, 'Accept', 'application/json'); - assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); - assertHeaderContains(res, 'Accept-Put', 'application/json'); - }); - - it('rejects text/n3 PUT to .acl with 415', async () => { - const res = await request('/connegtest/public/n3-reject.acl', { - method: 'PUT', - headers: { 'Content-Type': 'text/n3' }, - body: '@prefix acl: . <#x> a acl:Authorization.', - auth: 'connegtest' - }); - assertStatus(res, 415); - assertHeaderContains(res, 'Accept', 'application/ld+json'); - assertHeaderContains(res, 'Accept', 'application/json'); - assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); - assertHeaderContains(res, 'Accept-Put', 'application/json'); - }); - - it('rejects text/plain PUT to .acl with 415 (URL-extension protection)', async () => { - const res = await request('/connegtest/public/plain-reject.acl', { - method: 'PUT', - headers: { 'Content-Type': 'text/plain' }, - body: 'arbitrary text', - auth: 'connegtest' - }); - assertStatus(res, 415); - assertHeaderContains(res, 'Accept', 'application/ld+json'); - assertHeaderContains(res, 'Accept', 'application/json'); - assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); - assertHeaderContains(res, 'Accept-Put', 'application/json'); - }); - - it('rejects PUT to .acl with no Content-Type with 415', async () => { - // Use Uint8Array body so fetch() doesn't auto-set Content-Type - // (which it does for string bodies: text/plain;charset=UTF-8). - const res = await request('/connegtest/public/no-ct-reject.acl', { - method: 'PUT', - body: new Uint8Array([1, 2, 3, 4]), - auth: 'connegtest' - }); - assertStatus(res, 415); - assertHeaderContains(res, 'Accept', 'application/ld+json'); - assertHeaderContains(res, 'Accept', 'application/json'); - assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); - assertHeaderContains(res, 'Accept-Put', 'application/json'); - }); - - it('accepts application/ld+json PUT to .acl', async () => { - const res = await request('/connegtest/public/jsonld-accept.acl', { - method: 'PUT', - headers: { 'Content-Type': 'application/ld+json' }, - body: JSON.stringify(aclJsonLd), - auth: 'connegtest' - }); - assert.ok(res.status < 300, `JSON-LD PUT to .acl should succeed, got ${res.status}`); - }); - - it('accepts application/json PUT to .acl', async () => { - const res = await request('/connegtest/public/json-accept.acl', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(aclJsonLd), - auth: 'connegtest' - }); - assert.ok(res.status < 300, `application/json PUT to .acl should succeed, got ${res.status}`); - }); - }); -}); - -describe('Content Negotiation (conneg disabled - default)', () => { - before(async () => { - // Start server with conneg DISABLED (default) - await startTestServer({ conneg: false }); - await createTestPod('noconneg'); - }); - - after(async () => { - await stopTestServer(); - }); - - describe('Default JSON-LD behavior', () => { - it('should always return JSON-LD regardless of Accept header', async () => { - // Create resource - const data = { - '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' }, - '@id': '#me', - 'foaf:name': 'DefaultUser' - }; - - await request('/noconneg/public/user.json', { - method: 'PUT', - headers: { 'Content-Type': 'application/ld+json' }, - body: JSON.stringify(data), - auth: 'noconneg' - }); - - // Request Turtle - const res = await request('/noconneg/public/user.json', { - headers: { 'Accept': 'text/turtle' } - }); - - assertStatus(res, 200); - // Should still return JSON-LD when conneg disabled - const body = await res.json(); - assert.strictEqual(body['foaf:name'], 'DefaultUser'); - }); - - it('should accept JSON-LD input', async () => { - const data = { '@id': '#test', 'http://example.org/p': 'value' }; - - const res = await request('/noconneg/public/test.json', { - method: 'PUT', - headers: { 'Content-Type': 'application/ld+json' }, - body: JSON.stringify(data), - auth: 'noconneg' - }); - - assertStatus(res, 201); - }); - - it('should accept plain JSON input', async () => { - const data = { foo: 'bar' }; - - const res = await request('/noconneg/public/plain.json', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - auth: 'noconneg' - }); - - assertStatus(res, 201); - }); - - it('should accept non-RDF content types', async () => { - const res = await request('/noconneg/public/readme.txt', { - method: 'PUT', - headers: { 'Content-Type': 'text/plain' }, - body: 'Hello World', - auth: 'noconneg' - }); - - assertStatus(res, 201); - - const getRes = await request('/noconneg/public/readme.txt'); - assertStatus(getRes, 200); - const text = await getRes.text(); - assert.strictEqual(text, 'Hello World'); - }); - - it('should not advertise Turtle in Accept-Put when conneg disabled', async () => { - const res = await request('/noconneg/public/'); - const acceptPut = res.headers.get('Accept-Put'); - // Should only advertise JSON-LD, not Turtle - assert.ok(acceptPut && acceptPut.includes('application/ld+json'), - 'Accept-Put should include application/ld+json'); - assert.ok(!acceptPut || !acceptPut.includes('text/turtle'), - 'Accept-Put should NOT include text/turtle when conneg disabled'); - }); - - it('should advertise application/json in Accept-Put when conneg disabled', async () => { - const res = await request('/noconneg/public/'); - const acceptPut = res.headers.get('Accept-Put'); - assert.ok(acceptPut && acceptPut.includes('application/json'), - 'Accept-Put should include application/json (canAcceptInput treats it as a JSON-LD alias)'); - }); - - it('should advertise application/json in Accept-Post when conneg disabled', async () => { - const res = await request('/noconneg/public/'); - const acceptPost = res.headers.get('Accept-Post'); - assert.ok(acceptPost && acceptPost.includes('application/json'), - 'Accept-Post should include application/json (canAcceptInput treats it as a JSON-LD alias)'); - }); - - it('should not advertise text/n3 in Accept-Put when conneg disabled', async () => { - const res = await request('/noconneg/public/'); - const acceptPut = res.headers.get('Accept-Put'); - assert.ok(!acceptPut || !acceptPut.includes('text/n3'), - 'Accept-Put should NOT include text/n3 when conneg disabled'); - }); - }); - - // The .acl content-type guard applies regardless of conneg setting (#295). - // The default deployment configuration is conneg disabled, so ensure the - // guard fires there too. - describe('ACL content-type guard (#295) — conneg disabled', () => { - const aclJsonLd = { - '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, - '@graph': [ - { - '@id': '#owner', - '@type': 'acl:Authorization', - 'acl:agent': { '@id': '#me' }, - 'acl:accessTo': { '@id': './' }, - 'acl:mode': [{ '@id': 'acl:Read' }, { '@id': 'acl:Write' }, { '@id': 'acl:Control' }] - } - ] - }; - - it('rejects text/turtle PUT to .acl with 415', async () => { - const turtle = `@prefix acl: . <#x> a acl:Authorization.`; - const res = await request('/noconneg/public/turtle-reject.acl', { - method: 'PUT', - headers: { 'Content-Type': 'text/turtle' }, - body: turtle, - auth: 'noconneg' - }); - assertStatus(res, 415); - assertHeaderContains(res, 'Accept', 'application/ld+json'); - assertHeaderContains(res, 'Accept', 'application/json'); - assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); - assertHeaderContains(res, 'Accept-Put', 'application/json'); - }); - - it('rejects text/plain PUT to .acl with 415', async () => { - const res = await request('/noconneg/public/plain-reject.acl', { - method: 'PUT', - headers: { 'Content-Type': 'text/plain' }, - body: 'arbitrary text', - auth: 'noconneg' - }); - assertStatus(res, 415); - assertHeaderContains(res, 'Accept', 'application/ld+json'); - assertHeaderContains(res, 'Accept', 'application/json'); - assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); - assertHeaderContains(res, 'Accept-Put', 'application/json'); - }); - - it('rejects PUT to .acl with no Content-Type with 415', async () => { - // Use Uint8Array body so fetch() doesn't auto-set Content-Type - // (which it does for string bodies: text/plain;charset=UTF-8). - const res = await request('/noconneg/public/no-ct-reject.acl', { - method: 'PUT', - body: new Uint8Array([1, 2, 3, 4]), - auth: 'noconneg' - }); - assertStatus(res, 415); - assertHeaderContains(res, 'Accept', 'application/ld+json'); - assertHeaderContains(res, 'Accept', 'application/json'); - assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); - assertHeaderContains(res, 'Accept-Put', 'application/json'); - }); - - it('accepts application/ld+json PUT to .acl', async () => { - const res = await request('/noconneg/public/jsonld-accept.acl', { - method: 'PUT', - headers: { 'Content-Type': 'application/ld+json' }, - body: JSON.stringify(aclJsonLd), - auth: 'noconneg' - }); - assert.ok(res.status < 300, `JSON-LD PUT to .acl should succeed, got ${res.status}`); - }); - - it('accepts application/json PUT to .acl', async () => { - const res = await request('/noconneg/public/json-accept.acl', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(aclJsonLd), - auth: 'noconneg' - }); - assert.ok(res.status < 300, `application/json PUT to .acl should succeed, got ${res.status}`); - }); - }); -}); - -// Regression coverage for #325 — q-weighted Accept and HEAD/GET parity. -// Previously the conneg dispatcher used naive substring matching on the -// Accept header, so any Accept that mentioned text/turtle (even at q=0.1 -// alongside q=1.0 application/ld+json) returned Turtle. Separately, HEAD -// on a container without an index.html hard-coded application/ld+json, -// so HEAD and GET disagreed on content-type for the same URL. -describe('Content Negotiation — q-weights and HEAD/GET parity (#325)', () => { - before(async () => { - await startTestServer({ conneg: true }); - await createTestPod('qwtest'); - }); - after(async () => { await stopTestServer(); }); - - function ct(res) { - return (res.headers.get('content-type') || '').split(';')[0].trim(); - } - - describe('container — q-weight respected', () => { - it('Accept: jsonld q=1.0, turtle q=0.1 → JSON-LD', async () => { - const res = await request('/qwtest/', { - headers: { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' } - }); - assertStatus(res, 200); - assert.strictEqual(ct(res), 'application/ld+json'); - const body = await res.text(); - assert.ok(body.trimStart().startsWith('{'), - `body should be JSON, got: ${body.slice(0, 80)}`); - }); - - it('Accept: jsonld, turtle;q=0.5 → JSON-LD wins (downstream repro)', async () => { - const res = await request('/qwtest/', { - headers: { Accept: 'application/ld+json, text/turtle;q=0.5' } - }); - assert.strictEqual(ct(res), 'application/ld+json'); - const body = await res.text(); - assert.ok(body.trimStart().startsWith('{'), - `body should be JSON, got: ${body.slice(0, 80)}`); - }); - - it('Accept: turtle (explicit) → Turtle', async () => { - const res = await request('/qwtest/', { headers: { Accept: 'text/turtle' } }); - assert.strictEqual(ct(res), 'text/turtle'); - const body = await res.text(); - assert.ok(body.trimStart().startsWith('@prefix'), - `body should be Turtle, got: ${body.slice(0, 80)}`); - }); - - it('no Accept → JSON-LD (native default)', async () => { - const res = await request('/qwtest/'); - assert.strictEqual(ct(res), 'application/ld+json'); - }); - }); - - describe('container — HEAD content-type matches GET', () => { - const cases = [ - ['no Accept', {}], - ['jsonld preferred', { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }], - ['turtle preferred', { Accept: 'text/turtle' }], - ['mixed (q=0.5)', { Accept: 'application/ld+json, text/turtle;q=0.5' }] - ]; - for (const [label, headers] of cases) { - it(`HEAD === GET content-type — ${label}`, async () => { - const get = await request('/qwtest/', { headers }); - const head = await request('/qwtest/', { method: 'HEAD', headers }); - assert.strictEqual(get.status, 200); - assert.strictEqual(head.status, 200); - assert.strictEqual(ct(head), ct(get), - `HEAD ct (${ct(head)}) must equal GET ct (${ct(get)}) for ${label}`); - }); - } - }); - - describe('container — auth path matches anonymous', () => { - it('GET with auth returns same content-type as without auth (turtle case)', async () => { - const headers = { Accept: 'text/turtle' }; - const anon = await request('/qwtest/', { headers }); - const authed = await request('/qwtest/', { headers, auth: 'qwtest' }); - assert.strictEqual(ct(anon), 'text/turtle'); - assert.strictEqual(ct(authed), ct(anon), - 'authenticated GET must report the same content-type as anonymous'); - }); - - it('GET with auth returns same content-type as without auth (jsonld case)', async () => { - const headers = { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }; - const anon = await request('/qwtest/', { headers }); - const authed = await request('/qwtest/', { headers, auth: 'qwtest' }); - assert.strictEqual(ct(anon), 'application/ld+json'); - assert.strictEqual(ct(authed), ct(anon)); - }); - }); -}); +/** + * Content Negotiation Tests + * + * Tests Turtle <-> JSON-LD conversion with conneg enabled. + * Note: Content negotiation is OFF by default (JSON-LD native server). + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { + startTestServer, + stopTestServer, + request, + createTestPod, + assertStatus, + assertHeader, + assertHeaderContains +} from './helpers.js'; + +describe('Content Negotiation (conneg enabled)', () => { + before(async () => { + // Start server with conneg ENABLED + await startTestServer({ conneg: true }); + await createTestPod('connegtest'); + }); + + after(async () => { + await stopTestServer(); + }); + + describe('GET with Accept header', () => { + it('should return JSON-LD when Accept: application/ld+json', async () => { + // Create a JSON-LD resource + const data = { + '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' }, + '@id': '#me', + 'foaf:name': 'Alice' + }; + + await request('/connegtest/public/alice.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(data), + auth: 'connegtest' + }); + + const res = await request('/connegtest/public/alice.json', { + headers: { 'Accept': 'application/ld+json' } + }); + + assertStatus(res, 200); + assertHeaderContains(res, 'Content-Type', 'application/ld+json'); + + const body = await res.json(); + assert.strictEqual(body['foaf:name'], 'Alice'); + }); + + it('should return Turtle when Accept: text/turtle', async () => { + // Create a JSON-LD resource + const data = { + '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' }, + '@id': '#me', + 'foaf:name': 'Bob' + }; + + await request('/connegtest/public/bob.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(data), + auth: 'connegtest' + }); + + const res = await request('/connegtest/public/bob.json', { + headers: { 'Accept': 'text/turtle' } + }); + + assertStatus(res, 200); + assertHeaderContains(res, 'Content-Type', 'text/turtle'); + + const turtle = await res.text(); + // Should contain foaf prefix and name + assert.ok(turtle.includes('foaf:') || turtle.includes('http://xmlns.com/foaf/0.1/'), + 'Turtle should contain foaf prefix or URI'); + assert.ok(turtle.includes('Bob'), 'Turtle should contain the name'); + }); + + it('should default to JSON-LD for */* Accept', async () => { + const res = await request('/connegtest/public/alice.json', { + headers: { 'Accept': '*/*' } + }); + + assertStatus(res, 200); + assertHeaderContains(res, 'Content-Type', 'application/ld+json'); + }); + + it('should include Vary header with Accept', async () => { + const res = await request('/connegtest/public/alice.json'); + const vary = res.headers.get('Vary'); + assert.ok(vary && vary.includes('Accept'), 'Should have Vary: Accept'); + }); + }); + + describe('PUT with Content-Type', () => { + it('should accept Turtle input and store as JSON-LD', async () => { + const turtle = ` + @prefix foaf: . + <#me> foaf:name "Charlie". + `; + + const res = await request('/connegtest/public/charlie.json', { + method: 'PUT', + headers: { 'Content-Type': 'text/turtle' }, + body: turtle, + auth: 'connegtest' + }); + + assertStatus(res, 201); + + // Verify it's stored as JSON-LD + const getRes = await request('/connegtest/public/charlie.json', { + headers: { 'Accept': 'application/ld+json' } + }); + + assertStatus(getRes, 200); + const data = await getRes.json(); + assert.ok(data['@context'], 'Should have @context'); + }); + + it('should accept N3 input', async () => { + const n3 = ` + @prefix schema: . + <#item> schema:name "Widget". + `; + + const res = await request('/connegtest/public/widget.json', { + method: 'PUT', + headers: { 'Content-Type': 'text/n3' }, + body: n3, + auth: 'connegtest' + }); + + assertStatus(res, 201); + }); + + it('should return 400 for invalid Turtle', async () => { + const invalidTurtle = 'this is not valid turtle {{{'; + + const res = await request('/connegtest/public/invalid.json', { + method: 'PUT', + headers: { 'Content-Type': 'text/turtle' }, + body: invalidTurtle, + auth: 'connegtest' + }); + + assertStatus(res, 400); + }); + }); + + describe('POST with Content-Type', () => { + it('should accept Turtle input in POST', async () => { + const turtle = ` + @prefix dc: . + <#doc> dc:title "My Document". + `; + + const res = await request('/connegtest/public/', { + method: 'POST', + headers: { + 'Content-Type': 'text/turtle', + 'Slug': 'turtle-doc.json' + }, + body: turtle, + auth: 'connegtest' + }); + + assertStatus(res, 201); + const location = res.headers.get('Location'); + assert.ok(location, 'Should have Location header'); + }); + }); + + describe('Accept-* Headers', () => { + it('should advertise Turtle support in Accept-Put', async () => { + const res = await request('/connegtest/public/alice.json'); + const acceptPut = res.headers.get('Accept-Put'); + assert.ok(acceptPut && acceptPut.includes('text/turtle'), + 'Accept-Put should include text/turtle'); + }); + + it('should advertise Turtle support in Accept-Post for containers', async () => { + const res = await request('/connegtest/public/'); + const acceptPost = res.headers.get('Accept-Post'); + assert.ok(acceptPost && acceptPost.includes('text/turtle'), + 'Accept-Post should include text/turtle'); + }); + + it('should advertise N3 support in Accept-Put when conneg enabled', async () => { + const res = await request('/connegtest/public/alice.json'); + const acceptPut = res.headers.get('Accept-Put'); + assert.ok(acceptPut && acceptPut.includes('text/n3'), + 'Accept-Put should include text/n3 (canAcceptInput accepts it under conneg)'); + }); + + it('should advertise N3 support in Accept-Post for containers when conneg enabled', async () => { + const res = await request('/connegtest/public/'); + const acceptPost = res.headers.get('Accept-Post'); + assert.ok(acceptPost && acceptPost.includes('text/n3'), + 'Accept-Post should include text/n3 (canAcceptInput accepts it under conneg)'); + }); + + it('should advertise application/json in Accept-Put', async () => { + const res = await request('/connegtest/public/alice.json'); + const acceptPut = res.headers.get('Accept-Put'); + assert.ok(acceptPut && acceptPut.includes('application/json'), + 'Accept-Put should include application/json (canAcceptInput treats it as a JSON-LD alias)'); + }); + + it('should advertise application/json in Accept-Post for containers', async () => { + const res = await request('/connegtest/public/'); + const acceptPost = res.headers.get('Accept-Post'); + assert.ok(acceptPost && acceptPost.includes('application/json'), + 'Accept-Post should include application/json (canAcceptInput treats it as a JSON-LD alias)'); + }); + }); + + // Regression coverage for #294 — Solid convention dotfiles (.acl, .meta) + // were excluded from conneg because getContentType() returned + // application/octet-stream for them. Turtle-native clients (umai etc.) + // fetching /.meta got JSON-LD back and errored on parse. + describe('Solid convention dotfiles (#294)', () => { + const metaData = { + '@context': { 'ldp': 'http://www.w3.org/ns/ldp#' }, + '@id': '', + '@type': 'ldp:BasicContainer' + }; + + before(async () => { + // Write a JSON-LD .meta file (the format JSS writes internally). + await request('/connegtest/public/.meta', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(metaData), + auth: 'connegtest' + }); + }); + + it('serves .meta as JSON-LD by default', async () => { + const res = await request('/connegtest/public/.meta', { auth: 'connegtest' }); + assertStatus(res, 200); + assertHeaderContains(res, 'Content-Type', 'application/ld+json'); + }); + + it('serves .meta as Turtle when Accept: text/turtle (the umai case)', async () => { + const res = await request('/connegtest/public/.meta', { + headers: { 'Accept': 'text/turtle' }, + auth: 'connegtest' + }); + assertStatus(res, 200); + assertHeaderContains(res, 'Content-Type', 'text/turtle'); + const turtle = await res.text(); + // First byte after the `@prefix` block must parse as Turtle, + // not '{' (the bug signature umai hit). + assert.ok(!turtle.trimStart().startsWith('{'), + `response looks like JSON, not Turtle: ${turtle.slice(0, 60)}`); + }); + + it('accepts Turtle PUT to .meta and round-trips to JSON-LD', async () => { + const turtle = ` + @prefix ldp: . + <> a ldp:BasicContainer. + `; + const putRes = await request('/connegtest/public/.meta', { + method: 'PUT', + headers: { 'Content-Type': 'text/turtle' }, + body: turtle, + auth: 'connegtest' + }); + assert.ok(putRes.status < 300, `PUT turtle should succeed, got ${putRes.status}`); + + // Default GET now serves the converted-and-stored JSON-LD. + const getRes = await request('/connegtest/public/.meta', { + headers: { 'Accept': 'application/ld+json' }, + auth: 'connegtest' + }); + assertStatus(getRes, 200); + assertHeaderContains(getRes, 'Content-Type', 'application/ld+json'); + const body = await getRes.json(); + assert.ok(body['@context'] || body['@graph'] || body['@type'] || body['@id'], + 'round-tripped JSON-LD should have at least one @-keyword'); + }); + }); + + // ACL resources follow conneg write rules: + // - conneg enabled: accept JSON-LD/JSON/Turtle/N3 and convert Turtle/N3 to JSON-LD + // - conneg disabled: accept JSON-LD/JSON only + describe('ACL content-type guard', () => { + const aclJsonLd = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': '#me' }, + 'acl:accessTo': { '@id': './' }, + 'acl:mode': [{ '@id': 'acl:Read' }, { '@id': 'acl:Write' }, { '@id': 'acl:Control' }] + } + ] + }; + + it('accepts text/turtle PUT to .acl when conneg is enabled', async () => { + const turtle = ` + @prefix acl: . + <#owner> a acl:Authorization; + acl:mode acl:Read. + `; + const res = await request('/connegtest/public/turtle-accept.acl', { + method: 'PUT', + headers: { 'Content-Type': 'text/turtle' }, + body: turtle, + auth: 'connegtest' + }); + assert.ok(res.status < 300, `text/turtle PUT to .acl should succeed with conneg, got ${res.status}`); + + const getRes = await request('/connegtest/public/turtle-accept.acl', { + headers: { 'Accept': 'application/ld+json' }, + auth: 'connegtest' + }); + assertStatus(getRes, 200); + assertHeaderContains(getRes, 'Content-Type', 'application/ld+json'); + }); + + it('accepts text/n3 PUT to .acl when conneg is enabled', async () => { + const res = await request('/connegtest/public/n3-accept.acl', { + method: 'PUT', + headers: { 'Content-Type': 'text/n3' }, + body: '@prefix acl: . <#x> a acl:Authorization.', + auth: 'connegtest' + }); + assert.ok(res.status < 300, `text/n3 PUT to .acl should succeed with conneg, got ${res.status}`); + }); + + it('rejects text/plain PUT to .acl with 415 (URL-extension protection)', async () => { + const res = await request('/connegtest/public/plain-reject.acl', { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: 'arbitrary text', + auth: 'connegtest' + }); + assertStatus(res, 415); + assertHeaderContains(res, 'Accept', 'application/ld+json'); + assertHeaderContains(res, 'Accept', 'application/json'); + assertHeaderContains(res, 'Accept', 'text/turtle'); + assertHeaderContains(res, 'Accept', 'text/n3'); + assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); + assertHeaderContains(res, 'Accept-Put', 'application/json'); + assertHeaderContains(res, 'Accept-Put', 'text/turtle'); + assertHeaderContains(res, 'Accept-Put', 'text/n3'); + }); + + it('rejects PUT to .acl with no Content-Type with 415', async () => { + // Use Uint8Array body so fetch() doesn't auto-set Content-Type + // (which it does for string bodies: text/plain;charset=UTF-8). + const res = await request('/connegtest/public/no-ct-reject.acl', { + method: 'PUT', + body: new Uint8Array([1, 2, 3, 4]), + auth: 'connegtest' + }); + assertStatus(res, 415); + assertHeaderContains(res, 'Accept', 'application/ld+json'); + assertHeaderContains(res, 'Accept', 'application/json'); + assertHeaderContains(res, 'Accept', 'text/turtle'); + assertHeaderContains(res, 'Accept', 'text/n3'); + assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); + assertHeaderContains(res, 'Accept-Put', 'application/json'); + assertHeaderContains(res, 'Accept-Put', 'text/turtle'); + assertHeaderContains(res, 'Accept-Put', 'text/n3'); + }); + + it('accepts application/ld+json PUT to .acl', async () => { + const res = await request('/connegtest/public/jsonld-accept.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(aclJsonLd), + auth: 'connegtest' + }); + assert.ok(res.status < 300, `JSON-LD PUT to .acl should succeed, got ${res.status}`); + }); + + it('accepts application/json PUT to .acl', async () => { + const res = await request('/connegtest/public/json-accept.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(aclJsonLd), + auth: 'connegtest' + }); + assert.ok(res.status < 300, `application/json PUT to .acl should succeed, got ${res.status}`); + }); + }); +}); + +describe('Content Negotiation (conneg disabled - default)', () => { + before(async () => { + // Start server with conneg DISABLED (default) + await startTestServer({ conneg: false }); + await createTestPod('noconneg'); + }); + + after(async () => { + await stopTestServer(); + }); + + describe('Default JSON-LD behavior', () => { + it('should always return JSON-LD regardless of Accept header', async () => { + // Create resource + const data = { + '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' }, + '@id': '#me', + 'foaf:name': 'DefaultUser' + }; + + await request('/noconneg/public/user.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(data), + auth: 'noconneg' + }); + + // Request Turtle + const res = await request('/noconneg/public/user.json', { + headers: { 'Accept': 'text/turtle' } + }); + + assertStatus(res, 200); + // Should still return JSON-LD when conneg disabled + const body = await res.json(); + assert.strictEqual(body['foaf:name'], 'DefaultUser'); + }); + + it('should accept JSON-LD input', async () => { + const data = { '@id': '#test', 'http://example.org/p': 'value' }; + + const res = await request('/noconneg/public/test.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(data), + auth: 'noconneg' + }); + + assertStatus(res, 201); + }); + + it('should accept plain JSON input', async () => { + const data = { foo: 'bar' }; + + const res = await request('/noconneg/public/plain.json', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + auth: 'noconneg' + }); + + assertStatus(res, 201); + }); + + it('should accept non-RDF content types', async () => { + const res = await request('/noconneg/public/readme.txt', { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: 'Hello World', + auth: 'noconneg' + }); + + assertStatus(res, 201); + + const getRes = await request('/noconneg/public/readme.txt'); + assertStatus(getRes, 200); + const text = await getRes.text(); + assert.strictEqual(text, 'Hello World'); + }); + + it('should not advertise Turtle in Accept-Put when conneg disabled', async () => { + const res = await request('/noconneg/public/'); + const acceptPut = res.headers.get('Accept-Put'); + // Should only advertise JSON-LD, not Turtle + assert.ok(acceptPut && acceptPut.includes('application/ld+json'), + 'Accept-Put should include application/ld+json'); + assert.ok(!acceptPut || !acceptPut.includes('text/turtle'), + 'Accept-Put should NOT include text/turtle when conneg disabled'); + }); + + it('should advertise application/json in Accept-Put when conneg disabled', async () => { + const res = await request('/noconneg/public/'); + const acceptPut = res.headers.get('Accept-Put'); + assert.ok(acceptPut && acceptPut.includes('application/json'), + 'Accept-Put should include application/json (canAcceptInput treats it as a JSON-LD alias)'); + }); + + it('should advertise application/json in Accept-Post when conneg disabled', async () => { + const res = await request('/noconneg/public/'); + const acceptPost = res.headers.get('Accept-Post'); + assert.ok(acceptPost && acceptPost.includes('application/json'), + 'Accept-Post should include application/json (canAcceptInput treats it as a JSON-LD alias)'); + }); + + it('should not advertise text/n3 in Accept-Put when conneg disabled', async () => { + const res = await request('/noconneg/public/'); + const acceptPut = res.headers.get('Accept-Put'); + assert.ok(!acceptPut || !acceptPut.includes('text/n3'), + 'Accept-Put should NOT include text/n3 when conneg disabled'); + }); + }); + + // The .acl content-type guard applies regardless of conneg setting (#295). + // The default deployment configuration is conneg disabled, so ensure the + // guard fires there too. + describe('ACL content-type guard (#295) — conneg disabled', () => { + const aclJsonLd = { + '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, + '@graph': [ + { + '@id': '#owner', + '@type': 'acl:Authorization', + 'acl:agent': { '@id': '#me' }, + 'acl:accessTo': { '@id': './' }, + 'acl:mode': [{ '@id': 'acl:Read' }, { '@id': 'acl:Write' }, { '@id': 'acl:Control' }] + } + ] + }; + + it('rejects text/turtle PUT to .acl with 415', async () => { + const turtle = `@prefix acl: . <#x> a acl:Authorization.`; + const res = await request('/noconneg/public/turtle-reject.acl', { + method: 'PUT', + headers: { 'Content-Type': 'text/turtle' }, + body: turtle, + auth: 'noconneg' + }); + assertStatus(res, 415); + assertHeaderContains(res, 'Accept', 'application/ld+json'); + assertHeaderContains(res, 'Accept', 'application/json'); + assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); + assertHeaderContains(res, 'Accept-Put', 'application/json'); + }); + + it('rejects text/plain PUT to .acl with 415', async () => { + const res = await request('/noconneg/public/plain-reject.acl', { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: 'arbitrary text', + auth: 'noconneg' + }); + assertStatus(res, 415); + assertHeaderContains(res, 'Accept', 'application/ld+json'); + assertHeaderContains(res, 'Accept', 'application/json'); + assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); + assertHeaderContains(res, 'Accept-Put', 'application/json'); + }); + + it('rejects PUT to .acl with no Content-Type with 415', async () => { + // Use Uint8Array body so fetch() doesn't auto-set Content-Type + // (which it does for string bodies: text/plain;charset=UTF-8). + const res = await request('/noconneg/public/no-ct-reject.acl', { + method: 'PUT', + body: new Uint8Array([1, 2, 3, 4]), + auth: 'noconneg' + }); + assertStatus(res, 415); + assertHeaderContains(res, 'Accept', 'application/ld+json'); + assertHeaderContains(res, 'Accept', 'application/json'); + assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); + assertHeaderContains(res, 'Accept-Put', 'application/json'); + }); + + it('accepts application/ld+json PUT to .acl', async () => { + const res = await request('/noconneg/public/jsonld-accept.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify(aclJsonLd), + auth: 'noconneg' + }); + assert.ok(res.status < 300, `JSON-LD PUT to .acl should succeed, got ${res.status}`); + }); + + it('accepts application/json PUT to .acl', async () => { + const res = await request('/noconneg/public/json-accept.acl', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(aclJsonLd), + auth: 'noconneg' + }); + assert.ok(res.status < 300, `application/json PUT to .acl should succeed, got ${res.status}`); + }); + }); +}); + +// Regression coverage for #325 — q-weighted Accept and HEAD/GET parity. +// Previously the conneg dispatcher used naive substring matching on the +// Accept header, so any Accept that mentioned text/turtle (even at q=0.1 +// alongside q=1.0 application/ld+json) returned Turtle. Separately, HEAD +// on a container without an index.html hard-coded application/ld+json, +// so HEAD and GET disagreed on content-type for the same URL. +describe('Content Negotiation — q-weights and HEAD/GET parity (#325)', () => { + before(async () => { + await startTestServer({ conneg: true }); + await createTestPod('qwtest'); + }); + after(async () => { await stopTestServer(); }); + + function ct(res) { + return (res.headers.get('content-type') || '').split(';')[0].trim(); + } + + describe('container — q-weight respected', () => { + it('Accept: jsonld q=1.0, turtle q=0.1 → JSON-LD', async () => { + const res = await request('/qwtest/', { + headers: { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' } + }); + assertStatus(res, 200); + assert.strictEqual(ct(res), 'application/ld+json'); + const body = await res.text(); + assert.ok(body.trimStart().startsWith('{'), + `body should be JSON, got: ${body.slice(0, 80)}`); + }); + + it('Accept: jsonld, turtle;q=0.5 → JSON-LD wins (downstream repro)', async () => { + const res = await request('/qwtest/', { + headers: { Accept: 'application/ld+json, text/turtle;q=0.5' } + }); + assert.strictEqual(ct(res), 'application/ld+json'); + const body = await res.text(); + assert.ok(body.trimStart().startsWith('{'), + `body should be JSON, got: ${body.slice(0, 80)}`); + }); + + it('Accept: turtle (explicit) → Turtle', async () => { + const res = await request('/qwtest/', { headers: { Accept: 'text/turtle' } }); + assert.strictEqual(ct(res), 'text/turtle'); + const body = await res.text(); + assert.ok(body.trimStart().startsWith('@prefix'), + `body should be Turtle, got: ${body.slice(0, 80)}`); + }); + + it('no Accept → JSON-LD (native default)', async () => { + const res = await request('/qwtest/'); + assert.strictEqual(ct(res), 'application/ld+json'); + }); + }); + + describe('container — HEAD content-type matches GET', () => { + const cases = [ + ['no Accept', {}], + ['jsonld preferred', { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }], + ['turtle preferred', { Accept: 'text/turtle' }], + ['mixed (q=0.5)', { Accept: 'application/ld+json, text/turtle;q=0.5' }] + ]; + for (const [label, headers] of cases) { + it(`HEAD === GET content-type — ${label}`, async () => { + const get = await request('/qwtest/', { headers }); + const head = await request('/qwtest/', { method: 'HEAD', headers }); + assert.strictEqual(get.status, 200); + assert.strictEqual(head.status, 200); + assert.strictEqual(ct(head), ct(get), + `HEAD ct (${ct(head)}) must equal GET ct (${ct(get)}) for ${label}`); + }); + } + }); + + describe('container — auth path matches anonymous', () => { + it('GET with auth returns same content-type as without auth (turtle case)', async () => { + const headers = { Accept: 'text/turtle' }; + const anon = await request('/qwtest/', { headers }); + const authed = await request('/qwtest/', { headers, auth: 'qwtest' }); + assert.strictEqual(ct(anon), 'text/turtle'); + assert.strictEqual(ct(authed), ct(anon), + 'authenticated GET must report the same content-type as anonymous'); + }); + + it('GET with auth returns same content-type as without auth (jsonld case)', async () => { + const headers = { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }; + const anon = await request('/qwtest/', { headers }); + const authed = await request('/qwtest/', { headers, auth: 'qwtest' }); + assert.strictEqual(ct(anon), 'application/ld+json'); + assert.strictEqual(ct(authed), ct(anon)); + }); + }); +}); diff --git a/test/mastodon-api.test.js b/test/mastodon-api.test.js new file mode 100644 index 0000000..cd1a711 --- /dev/null +++ b/test/mastodon-api.test.js @@ -0,0 +1,482 @@ +/** + * Mastodon-compatible API Tests + * + * Tests the Mastodon API endpoints exposed by the ActivityPub plugin. + * Covers: verify_credentials, statuses, follow, notifications. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { + startTestServer, + stopTestServer, + request, + createTestPod, + getPodToken, + assertStatus +} from './helpers.js'; + +describe('Mastodon API (activitypub enabled)', () => { + let alicePostUrl = null; + + before(async () => { + await startTestServer({ activitypub: true, subdomains: false }); + await createTestPod('alice'); + await createTestPod('bob'); + }); + + after(async () => { + await stopTestServer(); + }); + + // ── verify_credentials ────────────────────────────────────────────────── + + describe('GET /api/v1/accounts/verify_credentials', () => { + it('should return 401 without token', async () => { + const res = await request('/api/v1/accounts/verify_credentials'); + assertStatus(res, 401); + }); + + it('should return authenticated user profile', async () => { + const res = await request('/api/v1/accounts/verify_credentials', { auth: 'alice' }); + assertStatus(res, 200); + const body = await res.json(); + assert.strictEqual(body.username, 'alice', 'username should be alice'); + assert.strictEqual(body.id, 'alice', 'id should be alice not "1"'); + assert.ok(typeof body.followers_count === 'number', 'followers_count should be a number'); + assert.ok(typeof body.following_count === 'number', 'following_count should be a number'); + assert.ok(typeof body.statuses_count === 'number', 'statuses_count should be a number'); + assert.ok(body.source, 'should have source field'); + }); + }); + + // ── instance ───────────────────────────────────────────────────────────── + + describe('GET /api/v2/instance', () => { + it('should return instance metadata', async () => { + const res = await request('/api/v2/instance'); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(body.domain, 'should have domain'); + assert.ok(body.title, 'should have title'); + assert.ok(body.version, 'should have version'); + assert.ok(body.configuration?.statuses?.max_characters, 'should have statuses config'); + }); + }); + + // ── account lookup ─────────────────────────────────────────────────────── + + describe('GET /api/v1/accounts/lookup', () => { + it('should resolve account by acct handle', async () => { + const res = await request('/api/v1/accounts/lookup?acct=alice'); + assertStatus(res, 200); + const body = await res.json(); + assert.strictEqual(body.username, 'alice', 'lookup should resolve alice'); + }); + + it('should resolve account by full acct handle with domain', async () => { + const res = await request('/api/v1/accounts/lookup?acct=alice@alice.pivot-test.local:4443'); + assertStatus(res, 200); + const body = await res.json(); + assert.strictEqual(body.username, 'alice', 'lookup should normalize alice@domain to alice'); + }); + }); + + // ── update credentials ─────────────────────────────────────────────────── + + describe('PATCH /api/v1/accounts/update_credentials', () => { + it('should return 401 without token', async () => { + const res = await request('/api/v1/accounts/update_credentials', { + method: 'PATCH', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ display_name: 'Alice New' }).toString() + }); + assertStatus(res, 401); + }); + + it('should update profile fields and return updated account', async () => { + const res = await request('/api/v1/accounts/update_credentials', { + method: 'PATCH', + auth: 'alice', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ display_name: 'Alice New', note: 'Bio from test' }).toString() + }); + assertStatus(res, 200); + const body = await res.json(); + assert.strictEqual(body.display_name, 'Alice New', 'display_name should update'); + assert.ok(body.note.includes('Bio from test'), 'note should update'); + + const verifyRes = await request('/api/v1/accounts/verify_credentials', { auth: 'alice' }); + assertStatus(verifyRes, 200); + const verified = await verifyRes.json(); + assert.strictEqual(verified.display_name, 'Alice New', 'verify_credentials should reflect updated display_name'); + assert.strictEqual(verified.source.note, 'Bio from test', 'verify_credentials source.note should reflect updated note'); + }); + + it('should accept jpg avatar upload and serve it from profile/avatar.png', async () => { + const boundary = '----jss-test-boundary'; + const jpegHeader = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]); + const parts = [ + Buffer.from(`--${boundary}\r\n`), + Buffer.from('Content-Disposition: form-data; name="display_name"\r\n\r\n'), + Buffer.from('Alice Avatar\r\n'), + Buffer.from(`--${boundary}\r\n`), + Buffer.from('Content-Disposition: form-data; name="avatar"; filename="avatar.jpg"\r\n'), + Buffer.from('Content-Type: image/jpeg\r\n\r\n'), + jpegHeader, + Buffer.from('\r\n'), + Buffer.from(`--${boundary}--\r\n`) + ]; + const body = Buffer.concat(parts); + + const updateRes = await request('/api/v1/accounts/update_credentials', { + method: 'PATCH', + auth: 'alice', + headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, + body + }); + assertStatus(updateRes, 200); + + const avatarRes = await request('/profile/avatar.png', { auth: 'alice' }); + assertStatus(avatarRes, 200); + const ct = avatarRes.headers.get('content-type') || ''; + assert.ok(ct.startsWith('image/'), 'avatar route should serve an image'); + }); + }); + + // ── preferences / relationships ────────────────────────────────────────── + + describe('GET /api/v1/preferences', () => { + it('should return 401 without token', async () => { + const res = await request('/api/v1/preferences'); + assertStatus(res, 401); + }); + + it('should return Mastodon preference payload', async () => { + const res = await request('/api/v1/preferences', { auth: 'alice' }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Object.prototype.hasOwnProperty.call(body, 'posting:default:visibility'), 'preferences should include visibility key'); + }); + }); + + describe('GET /api/v1/lists', () => { + it('should return 401 without token', async () => { + const res = await request('/api/v1/lists'); + assertStatus(res, 401); + }); + + it('should return an empty array when authenticated', async () => { + const res = await request('/api/v1/lists', { auth: 'alice' }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Array.isArray(body), 'lists should be an array'); + assert.strictEqual(body.length, 0, 'lists should default to empty'); + }); + }); + + describe('GET /api/v1/accounts/relationships', () => { + it('should return relationship entries for requested IDs', async () => { + const res = await request('/api/v1/accounts/relationships?id[]=alice', { auth: 'bob' }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Array.isArray(body), 'relationships should be an array'); + assert.strictEqual(body.length, 1, 'should return one relationship object'); + assert.strictEqual(body[0].id, 'alice', 'relationship id should match query'); + assert.ok(Object.prototype.hasOwnProperty.call(body[0], 'following'), 'relationship should include following field'); + }); + }); + + // ── search ─────────────────────────────────────────────────────────────── + + describe('GET /api/v2/search', () => { + it('should resolve a status URL to a status result', async () => { + // Create a post first so it can be found by URL search + const createRes = await request('/api/v1/statuses', { + method: 'POST', + auth: 'alice', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ status: 'searchable post' }).toString() + }); + assertStatus(createRes, 200); + const created = await createRes.json(); + + const q = encodeURIComponent(created.uri); + const res = await request(`/api/v2/search?q=${q}&limit=1&resolve=true`); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.accounts), 'accounts should be an array'); + assert.ok(Array.isArray(body.statuses), 'statuses should be an array'); + assert.ok(Array.isArray(body.hashtags), 'hashtags should be an array'); + assert.ok(body.statuses.length >= 1, 'should return at least one status'); + assert.strictEqual(body.statuses[0].uri, created.uri, 'resolved status URI should match'); + }); + }); + + // ── statuses ───────────────────────────────────────────────────────────── + + describe('POST /api/v1/statuses', () => { + it('should return 401 without token', async () => { + const res = await request('/api/v1/statuses', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'status=test' + }); + assertStatus(res, 401); + }); + + it('should create a status and return Mastodon status object', async () => { + const res = await request('/api/v1/statuses', { + method: 'POST', + auth: 'alice', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ status: 'hello from alice test' }).toString() + }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(body.id, 'should have id'); + alicePostUrl = body.uri; + assert.ok(alicePostUrl, 'should return canonical uri'); + assert.strictEqual(body.content, 'hello from alice test', 'content should match'); + assert.strictEqual(body.account.username, 'alice', 'account username should be alice'); + assert.strictEqual(body.visibility, 'public'); + }); + + it('should update statuses_count after posting', async () => { + // Post as bob + await request('/api/v1/statuses', { + method: 'POST', + auth: 'bob', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ status: 'hello from bob test' }).toString() + }); + + const res = await request('/api/v1/accounts/verify_credentials', { auth: 'bob' }); + const body = await res.json(); + assert.ok(body.statuses_count >= 1, 'statuses_count should be at least 1 after posting'); + }); + + it('should edit a status using full URL ID path', async () => { + const createRes = await request('/api/v1/statuses', { + method: 'POST', + auth: 'alice', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ status: 'edit me' }).toString() + }); + assertStatus(createRes, 200); + const created = await createRes.json(); + + const editRes = await request(`/api/v1/statuses/${created.id}`, { + method: 'PUT', + auth: 'alice', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ status: 'edited text', language: 'fr', sensitive: 'false' }).toString() + }); + assertStatus(editRes, 200); + const edited = await editRes.json(); + assert.strictEqual(edited.content, 'edited text', 'status content should be updated'); + + const postUrl = new URL(created.uri); + const postId = postUrl.pathname.split('/').filter(Boolean).pop(); + const permalinkRes = await request(`/posts/${postId}`); + assertStatus(permalinkRes, 200); + const permalink = await permalinkRes.json(); + assert.strictEqual(permalink.content, 'edited text', 'permalink should reflect edited content'); + }); + + it('should fetch status source using full URL ID path', async () => { + const createRes = await request('/api/v1/statuses', { + method: 'POST', + auth: 'alice', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ status: 'source me' }).toString() + }); + assertStatus(createRes, 200); + const created = await createRes.json(); + + const sourceRes = await request(`/api/v1/statuses/${created.id}/source`, { + method: 'GET', + auth: 'alice' + }); + assertStatus(sourceRes, 200); + const source = await sourceRes.json(); + assert.strictEqual(source.text, 'source me', 'source endpoint should return editable text'); + assert.ok(Object.prototype.hasOwnProperty.call(source, 'spoiler_text'), 'source should include spoiler_text'); + assert.ok(Object.prototype.hasOwnProperty.call(source, 'sensitive'), 'source should include sensitive'); + }); + + it('should fetch status by full URL ID path', async () => { + const createRes = await request('/api/v1/statuses', { + method: 'POST', + auth: 'alice', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ status: 'status endpoint me' }).toString() + }); + assertStatus(createRes, 200); + const created = await createRes.json(); + + const statusRes = await request(`/api/v1/statuses/${created.id}`); + assertStatus(statusRes, 200); + const fetched = await statusRes.json(); + assert.strictEqual(fetched.uri, created.uri, 'status URI should match'); + assert.strictEqual(fetched.content, 'status endpoint me', 'status content should match'); + }); + + it('should fetch status history by full URL ID path', async () => { + const createRes = await request('/api/v1/statuses', { + method: 'POST', + auth: 'alice', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ status: 'history endpoint me' }).toString() + }); + assertStatus(createRes, 200); + const created = await createRes.json(); + + const historyRes = await request(`/api/v1/statuses/${created.id}/history`); + assertStatus(historyRes, 200); + const history = await historyRes.json(); + assert.ok(Array.isArray(history), 'history should be an array'); + assert.ok(history.length >= 1, 'history should have at least one entry'); + assert.strictEqual(history[0].content, 'history endpoint me', 'history should include current content'); + }); + + it('should favourite a status by full URL ID path', async () => { + const createRes = await request('/api/v1/statuses', { + method: 'POST', + auth: 'bob', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ status: 'like me' }).toString() + }); + assertStatus(createRes, 200); + const created = await createRes.json(); + + const favRes = await request(`/api/v1/statuses/${created.id}/favourite`, { + method: 'POST', + auth: 'alice' + }); + assertStatus(favRes, 200); + const liked = await favRes.json(); + assert.strictEqual(liked.uri, created.uri, 'favourited status should preserve URI'); + assert.strictEqual(liked.favourited, true, 'favourited should be true'); + assert.ok(liked.favourites_count >= 1, 'favourites_count should be incremented'); + }); + }); + + // ── permalink post object ──────────────────────────────────────────────── + + describe('GET /posts/:id', () => { + it('should return Note object for a created status permalink', async () => { + assert.ok(alicePostUrl, 'alice post URL should exist from previous status test'); + const res = await request(alicePostUrl); + assertStatus(res, 200); + const body = await res.json(); + assert.strictEqual(body.type, 'Note', 'should return ActivityStreams Note'); + assert.strictEqual(body.id, alicePostUrl, 'note id should match canonical post URL'); + assert.strictEqual(body.content, 'hello from alice test', 'note content should match created status'); + }); + }); + + // ── follow ──────────────────────────────────────────────────────────────── + + describe('POST /api/v1/accounts/:id/follow', () => { + it('should return 401 without token', async () => { + const res = await request('/api/v1/accounts/alice/follow', { method: 'POST' }); + assertStatus(res, 401); + }); + + it('should follow a user and return relationship with requested: false', async () => { + const res = await request('/api/v1/accounts/alice/follow', { + method: 'POST', + auth: 'bob' + }); + assertStatus(res, 200); + const body = await res.json(); + assert.strictEqual(body.id, 'alice'); + assert.strictEqual(body.following, true); + assert.strictEqual(body.requested, false, 'should be immediately accepted'); + }); + + it('should increment following_count for bob', async () => { + const res = await request('/api/v1/accounts/verify_credentials', { auth: 'bob' }); + const body = await res.json(); + assert.ok(body.following_count >= 1, 'bob following_count should be at least 1'); + }); + + it('should increment followers_count for alice', async () => { + const res = await request('/api/v1/accounts/verify_credentials', { auth: 'alice' }); + const body = await res.json(); + assert.ok(body.followers_count >= 1, 'alice followers_count should be at least 1'); + }); + }); + + // ── notifications ───────────────────────────────────────────────────────── + + describe('GET /api/v1/notifications', () => { + it('should return 401 without token', async () => { + const res = await request('/api/v1/notifications'); + assertStatus(res, 401); + }); + + it('should return follow notification for alice with bob as follower', async () => { + const res = await request('/api/v1/notifications', { auth: 'alice' }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Array.isArray(body), 'should be an array'); + const followNotif = body.find(n => n.type === 'follow') + assert.ok(followNotif, 'should have a follow notification'); + assert.strictEqual(followNotif.account.username, 'bob', 'follower should be bob not alice'); + assert.ok(followNotif.created_at, 'should have created_at'); + }); + + it('should return empty notifications for bob (nobody follows bob)', async () => { + const res = await request('/api/v1/notifications', { auth: 'bob' }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Array.isArray(body), 'should be an array'); + assert.strictEqual(body.length, 0, 'bob should have no notifications'); + }); + }); + + // ── home timeline ───────────────────────────────────────────────────────── + + describe('GET /api/v1/timelines/home', () => { + it('should return 401 without token', async () => { + const res = await request('/api/v1/timelines/home'); + assertStatus(res, 401); + }); + + it('should return posts from followed users and self', async () => { + const res = await request('/api/v1/timelines/home', { auth: 'alice' }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Array.isArray(body), 'should be an array'); + assert.ok(body.length >= 1, 'should have at least one post'); + // All posts should have required fields + for (const post of body) { + assert.ok(post.id, 'post should have id'); + assert.ok(post.content, 'post should have content'); + assert.ok(post.account?.username, 'post should have account.username'); + assert.ok(post.created_at, 'post should have created_at'); + } + }); + }); + + describe('GET /api/v1/accounts/:id/statuses', () => { + it('should accept handle-like account identifiers', async () => { + const res = await request('/api/v1/accounts/alice@alice.pivot-test.local:4443/statuses'); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Array.isArray(body), 'statuses should be an array'); + }); + }); + + describe('GET /api/v1/accounts/:id/lists', () => { + it('should return empty list memberships for account identifiers', async () => { + const res = await request('/api/v1/accounts/bob@bob.pivot-test.local:4443/lists', { auth: 'alice' }); + assertStatus(res, 200); + const body = await res.json(); + assert.ok(Array.isArray(body), 'account lists should be an array'); + assert.strictEqual(body.length, 0, 'account lists should default to empty'); + }); + }); +}); diff --git a/test/solid-oidc.test.js b/test/solid-oidc.test.js index e29c35e..c83afd6 100644 --- a/test/solid-oidc.test.js +++ b/test/solid-oidc.test.js @@ -6,6 +6,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import * as jose from 'jose'; +import crypto from 'crypto'; import { startTestServer, stopTestServer, @@ -14,6 +15,8 @@ import { getBaseUrl, assertStatus } from './helpers.js'; +import { addTrustedIssuer, clearCaches, verifySolidOidc } from '../src/auth/solid-oidc.js'; +import { getJwks } from '../src/idp/keys.js'; describe('Solid-OIDC', () => { let keyPair; @@ -178,6 +181,67 @@ describe('Solid-OIDC', () => { assertStatus(res, 401); }); + + it('verifies tokens from a trusted self issuer without remote discovery fetch', async () => { + clearCaches(); + + const issuer = 'https://pivot-test.local:4443'; + addTrustedIssuer(issuer); + + const dpopKeyPair = await jose.generateKeyPair('ES256'); + const dpopPublicJwk = await jose.exportJWK(dpopKeyPair.publicKey); + dpopPublicJwk.alg = 'ES256'; + const thumbprint = await jose.calculateJwkThumbprint(dpopPublicJwk, 'sha256'); + + const serverJwks = await getJwks(); + const signingKey = serverJwks.keys.find((key) => key.alg === 'RS256') || serverJwks.keys[0]; + const privateKey = await jose.importJWK(signingKey, signingKey.alg); + + const accessToken = await new jose.SignJWT({ + webid: 'https://alice.pivot-test.local:4443/profile/card#me', + sub: 'https://alice.pivot-test.local:4443/profile/card#me', + iss: issuer, + aud: 'solid', + cnf: { jkt: thumbprint }, + }) + .setProtectedHeader({ alg: signingKey.alg, kid: signingKey.kid }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); + + const dpopProof = await new jose.SignJWT({ + htm: 'GET', + htu: 'https://alice.pivot-test.local:4443/private/', + iat: Math.floor(Date.now() / 1000), + jti: crypto.randomUUID(), + }) + .setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: dpopPublicJwk }) + .sign(dpopKeyPair.privateKey); + + const originalFetch = global.fetch; + global.fetch = async () => { + throw new Error('trusted self issuer should not use fetch'); + }; + + try { + const result = await verifySolidOidc({ + method: 'GET', + protocol: 'https', + hostname: 'alice.pivot-test.local:4443', + url: '/private/', + headers: { + authorization: `DPoP ${accessToken}`, + dpop: dpopProof, + }, + }); + + assert.strictEqual(result.error, null); + assert.strictEqual(result.webId, 'https://alice.pivot-test.local:4443/profile/card#me'); + } finally { + global.fetch = originalFetch; + clearCaches(); + } + }); }); describe('Bearer Token Fallback', () => { diff --git a/test/subdomain-base-files.test.js b/test/subdomain-base-files.test.js index a0fa55a..85b3b9f 100644 --- a/test/subdomain-base-files.test.js +++ b/test/subdomain-base-files.test.js @@ -107,3 +107,56 @@ describe('buildResourceUrl — subdomain mode disabled', () => { assert.strictEqual(buildResourceUrl(req, '/alice/'), 'https://example.com/alice/'); }); }); + +// Regression: Fastify sets request.hostname to host:port when a non-default +// port is in use. Previously getBaseDomainHost was not called, so +// 'alice.pivot-test.local:4443' never matched '.pivot-test.local', making +// request.podName stay null and subdomain routing break entirely. +describe('buildResourceUrl — port-bearing hostname (subdomain mode)', () => { + const baseDomain = 'pivot-test.local:4443'; + + it('subdomain request with port — uses headers.host verbatim', () => { + // Simulate Fastify: hostname includes port, headers.host also has port + const req = makeRequest({ + hostname: 'alice.pivot-test.local:4443', + baseDomain, + podName: 'alice', + }); + // Already on subdomain — buildResourceUrl uses headers.host directly + assert.strictEqual( + buildResourceUrl(req, '/'), + 'https://alice.pivot-test.local:4443/' + ); + }); + + it('base-domain with port — rewrites pod path to subdomain URL', () => { + // hostname matches baseDomain host after stripping port + const req = { + protocol: 'https', + hostname: 'pivot-test.local:4443', + headers: { host: 'pivot-test.local:4443' }, + subdomainsEnabled: true, + baseDomain, + podName: null, + }; + assert.strictEqual( + buildResourceUrl(req, '/alice/'), + 'https://alice.pivot-test.local:4443/' + ); + }); + + it('base-domain with port — no rewrite for file with extension', () => { + const req = { + protocol: 'https', + hostname: 'pivot-test.local:4443', + headers: { host: 'pivot-test.local:4443' }, + subdomainsEnabled: true, + baseDomain, + podName: null, + }; + assert.strictEqual( + buildResourceUrl(req, '/mashlib.js'), + 'https://pivot-test.local:4443/mashlib.js' + ); + }); +}); diff --git a/test/url.test.js b/test/url.test.js index 78a8fcf..31a4fa7 100644 --- a/test/url.test.js +++ b/test/url.test.js @@ -8,7 +8,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import path from 'path'; -import { getPodName, getContentType, urlToPath, urlToPathWithPod } from '../src/utils/url.js'; +import { getPodName, getContentType, getBaseDomainHost, urlToPath, urlToPathWithPod } from '../src/utils/url.js'; describe('getPodName', () => { describe('subdomain mode', () => { @@ -83,8 +83,41 @@ describe('getPodName', () => { }); }); +// Regression coverage for getBaseDomainHost — must strip port from baseDomain +// before comparing against request.hostname. +// Fastify sets request.hostname to the full host:port string, so the subdomain +// detection in server.js was failing for non-default ports (e.g. :4443). +describe('getBaseDomainHost', () => { + it('plain hostname — returned as-is', () => { + assert.strictEqual(getBaseDomainHost('example.com'), 'example.com'); + }); + + it('hostname:port — strips the port', () => { + assert.strictEqual(getBaseDomainHost('example.com:4443'), 'example.com'); + }); + + it('hostname:80 — strips even well-known port', () => { + assert.strictEqual(getBaseDomainHost('example.com:80'), 'example.com'); + }); + + it('full https URL form — extracts hostname only', () => { + assert.strictEqual(getBaseDomainHost('https://example.com:3100/'), 'example.com'); + }); + + it('full http URL without port — extracts hostname', () => { + assert.strictEqual(getBaseDomainHost('http://example.com/'), 'example.com'); + }); + + it('localhost:4443 — strips port', () => { + assert.strictEqual(getBaseDomainHost('localhost:4443'), 'localhost'); + }); + + it('pivot-test.local:4443 — strips port (real regression case)', () => { + assert.strictEqual(getBaseDomainHost('pivot-test.local:4443'), 'pivot-test.local'); + }); +}); + // Regression coverage for #294 — .acl and .meta must be recognised as RDF -// resources so content negotiation kicks in for Turtle-native clients. describe('getContentType', () => { describe('extension-based mapping (existing)', () => { it('maps .jsonld → application/ld+json', () => {