FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

GitHub Viewer

import Fastify from 'fastify'; import rateLimit from '@fastify/rate-limit'; import { readFile } from 'fs/promises'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { handleGet, handleHead, handlePut, handleDelete, handleOptions, handlePatch } from './handlers/resource.js'; import { handlePost, handleCreatePod, createPodStructure } from './handlers/container.js'; import * as storage from './storage/filesystem.js'; import { getCorsHeaders } from './ldp/headers.js'; import { authorize, handleUnauthorized } from './auth/middleware.js'; import { notificationsPlugin } from './notifications/index.js'; import { startFileWatcher } from './notifications/events.js'; import { idpPlugin } from './idp/index.js'; import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js'; import { AccessMode } from './wac/parser.js'; import { registerNostrRelay } from './nostr/relay.js'; import { createPayHandler, isPayRequest } from './handlers/pay.js'; import { activityPubPlugin, getActorHandler } from './ap/index.js'; import { remoteStoragePlugin } from './remotestorage.js'; import { dbPlugin } from './db/index.js'; 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'; const __dirname = dirname(fileURLToPath(import.meta.url)); /** * Create and configure Fastify server * @param {object} options - Server options * @param {boolean} options.logger - Enable logging (default true) * @param {boolean} options.conneg - Enable content negotiation for RDF (default false) * @param {boolean} options.notifications - Enable WebSocket notifications (default false) * @param {boolean} options.idp - Enable built-in Identity Provider (default false) * @param {string} options.idpIssuer - IdP issuer URL (default: server URL) * @param {object} options.ssl - SSL configuration { key, cert } (default null) * @param {string} options.root - Data directory path (default from env or ./data) * @param {boolean} options.subdomains - Enable subdomain-based pods for XSS protection (default false) * @param {string} options.baseDomain - Base domain for subdomain pods (e.g., "example.com") * @param {boolean} options.git - Enable Git HTTP backend for clone/push (default false) * @param {boolean} options.nostr - Enable Nostr relay (default false) * @param {string} options.nostrPath - Nostr relay WebSocket path (default '/relay') * @param {number} options.nostrMaxEvents - Max events in relay memory (default 1000) * @param {boolean} options.activitypub - Enable ActivityPub federation (default false) * @param {string} options.apUsername - ActivityPub username (default 'me') * @param {string} options.apDisplayName - ActivityPub display name * @param {string} options.apSummary - ActivityPub bio/summary * @param {string} options.apNostrPubkey - Nostr pubkey for identity linking * @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false) * @param {boolean} options.pay - Enable HTTP 402 paid /pay/* routes (default false) * @param {number} options.payCost - Cost per request in satoshis (default 1) * @param {string} options.payMempoolUrl - Mempool API base URL (default testnet4) * @param {string} options.payAddress - Pod's MRC20 address for receiving token transfers */ export function createServer(options = {}) { // Content negotiation is OFF by default - we're a JSON-LD native server const connegEnabled = options.conneg ?? false; // WebSocket notifications are OFF by default const notificationsEnabled = options.notifications ?? false; // Identity Provider is OFF by default const idpEnabled = options.idp ?? false; const idpIssuer = options.idpIssuer; // Subdomain mode is OFF by default - use path-based pods const subdomainsEnabled = options.subdomains ?? false; const baseDomain = options.baseDomain || null; // Mashlib data browser is OFF by default // mashlibCdn: load from CDN; mashlibModule: URL to ES module entry point const mashlibModule = options.mashlibModule ?? false; const mashlibCdn = options.mashlibCdn ?? false; const mashlibEnabled = mashlibCdn || !!mashlibModule; const mashlibVersion = options.mashlibVersion ?? '2.0.0'; // Git HTTP backend is OFF by default - enables clone/push via git protocol const gitEnabled = options.git ?? false; // Nostr relay is OFF by default const nostrEnabled = options.nostr ?? false; const nostrPath = options.nostrPath ?? '/relay'; const nostrMaxEvents = options.nostrMaxEvents ?? 1000; // WebRTC signaling is OFF by default const webrtcEnabled = options.webrtc ?? false; const webrtcPath = options.webrtcPath ?? '/.webrtc'; // Terminal (WebSocket shell) is OFF by default const terminalEnabled = options.terminal ?? false; // Tunnel proxy is OFF by default const tunnelEnabled = options.tunnel ?? false; const tunnelPath = options.tunnelPath ?? '/.tunnel'; // ActivityPub federation is OFF by default const activitypubEnabled = options.activitypub ?? false; const apUsername = options.apUsername ?? 'me'; const apDisplayName = options.apDisplayName ?? options.apUsername ?? 'Anonymous'; const apSummary = options.apSummary ?? ''; const apNostrPubkey = options.apNostrPubkey ?? null; // Invite-only registration is OFF by default - open registration const inviteOnly = options.inviteOnly ?? false; // Single-user mode - creates pod on startup, disables registration const singleUser = options.singleUser ?? false; const singleUserName = options.singleUserName ?? 'me'; const singleUserPassword = options.singleUserPassword ?? null; // Default storage quota per pod (50MB default, 0 = unlimited) const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024; // WebID-TLS client certificate authentication is OFF by default const webidTlsEnabled = options.webidTls ?? false; // Live reload - injects script to auto-refresh browser on file changes const liveReloadEnabled = options.liveReload ?? false; // MongoDB-backed /db/ route is OFF by default const mongoEnabled = options.mongo ?? false; const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017'; const mongoDatabase = options.mongoDatabase ?? 'solid'; // HTTP 402 paid /pay/ routes are OFF by default const payEnabled = options.pay ?? false; const payCost = options.payCost ?? 1; const payMempoolUrl = options.payMempoolUrl ?? 'https://mempool.space/testnet4'; const payAddress = options.payAddress ?? null; // Pod's MRC20 address for token deposits const payToken = options.payToken ?? null; // Token ticker for primary market const payRate = options.payRate ?? 1; // Sats per token const payChains = options.payChains ?? null; // Multi-chain IDs (e.g. "tbtc3,tbtc4") // Set data root via environment variable if provided if (options.root) { process.env.DATA_ROOT = options.root; } // Fastify options const loggerEnabled = options.logger ?? true; const fastifyOptions = { logger: loggerEnabled ? { level: options.logLevel || 'info' } : false, disableRequestLogging: true, trustProxy: true, // Force close connections on server.close() (useful for tests with WebSockets) forceCloseConnections: options.forceCloseConnections ?? false, // Handle raw body for non-JSON content bodyLimit: 10 * 1024 * 1024, // 10MB // Gracefully handle client TCP errors (ECONNRESET, EPIPE, etc.) clientErrorHandler: (err, socket) => { if (err.code === 'ECONNRESET' || err.code === 'EPIPE' || err.code === 'ECONNABORTED') { socket.destroy(); return; } // Default Fastify behavior for other client errors socket.destroy(err); } }; // Add HTTPS support if SSL config provided if (options.ssl && options.ssl.key && options.ssl.cert) { fastifyOptions.https = { key: options.ssl.key, cert: options.ssl.cert, }; // Enable client certificate request for WebID-TLS if (webidTlsEnabled) { fastifyOptions.https.requestCert = true; // Don't reject unauthorized - we verify via WebID profile, not CA chain fastifyOptions.https.rejectUnauthorized = false; } } const fastify = Fastify(fastifyOptions); registerErrorHandler(fastify); // Add raw body parser for all content types fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => { done(null, body); }); // Git content types need explicit handling (binary data) fastify.addContentTypeParser('application/x-git-receive-pack-request', { parseAs: 'buffer' }, (req, body, done) => { done(null, body); }); fastify.addContentTypeParser('application/x-git-upload-pack-request', { parseAs: 'buffer' }, (req, body, done) => { done(null, body); }); // Attach server config to requests fastify.decorateRequest('connegEnabled', null); fastify.decorateRequest('notificationsEnabled', null); fastify.decorateRequest('idpEnabled', null); fastify.decorateRequest('subdomainsEnabled', null); fastify.decorateRequest('baseDomain', null); fastify.decorateRequest('podName', null); fastify.decorateRequest('mashlibEnabled', null); fastify.decorateRequest('mashlibCdn', null); fastify.decorateRequest('mashlibVersion', null); fastify.decorateRequest('mashlibModule', null); fastify.decorateRequest('defaultQuota', null); fastify.decorateRequest('config', null); fastify.decorateRequest('liveReloadEnabled', null); fastify.decorateRequest('singleUser', null); fastify.decorateRequest('singleUserName', null); fastify.addHook('onRequest', async (request) => { request.connegEnabled = connegEnabled; request.notificationsEnabled = notificationsEnabled || liveReloadEnabled; request.idpEnabled = idpEnabled; request.subdomainsEnabled = subdomainsEnabled; request.baseDomain = baseDomain; request.mashlibEnabled = mashlibEnabled; request.mashlibCdn = mashlibCdn; request.mashlibVersion = mashlibVersion; request.mashlibModule = mashlibModule; request.defaultQuota = defaultQuota; request.config = { public: options.public, readOnly: options.readOnly }; request.liveReloadEnabled = liveReloadEnabled; request.singleUser = singleUser; request.singleUserName = singleUserName; // 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)) { // Extract subdomain (e.g., "alice.example.com" -> "alice") const subdomain = host.slice(0, -(baseDomain.length + 1)); // Only single-level subdomains (no dots) if (!subdomain.includes('.')) { request.podName = subdomain; } } } }); // Unified access log — one line per request fastify.addHook('onResponse', async (request, reply) => { if (!request.log.isLevelEnabled('info')) return; request.log.info({ method: request.method, url: request.url, statusCode: reply.statusCode, remoteAddress: request.ip || request.headers['x-forwarded-for'] || request.socket?.remoteAddress, responseTime: Math.round(reply.elapsedTime * 100) / 100, userAgent: request.headers['user-agent'] || undefined, referrer: request.headers.referer || undefined, contentLength: reply.getHeader('content-length') || undefined, }, `${request.method} ${request.url} ${reply.statusCode} ${Math.round(reply.elapsedTime)}ms`); }); // Register WebSocket notifications plugin if enabled (or live reload needs it) if (notificationsEnabled || liveReloadEnabled) { fastify.register(notificationsPlugin); } // Register Identity Provider plugin if enabled if (idpEnabled) { fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly, singleUser }); } // Register Nostr relay if enabled if (nostrEnabled) { fastify.register(async (instance) => { await registerNostrRelay(instance, { path: nostrPath, maxEvents: nostrMaxEvents }); }); } // Register WebRTC signaling if enabled if (webrtcEnabled) { fastify.register(webrtcPlugin, { path: webrtcPath }); } // Register terminal (WebSocket shell) if enabled if (terminalEnabled) { fastify.register(terminalPlugin, { path: '/.terminal', public: options.public || false }); } // Register tunnel proxy if enabled if (tunnelEnabled) { fastify.register(tunnelPlugin, { path: tunnelPath }); } // Register ActivityPub plugin if enabled if (activitypubEnabled) { fastify.register(activityPubPlugin, { username: apUsername, displayName: apDisplayName, summary: apSummary, nostrPubkey: apNostrPubkey }); } // Register remoteStorage plugin (always on — no flag needed) fastify.register(remoteStoragePlugin, { username: singleUserName || 'me', ownerWebId: null // single-user: any authenticated user can access }); // Register MongoDB /db/ route if enabled if (mongoEnabled) { fastify.register(dbPlugin, { mongoUrl, mongoDatabase, singleUser }); } // Register rate limiting plugin // Protects against brute force attacks and resource exhaustion fastify.register(rateLimit, { global: false, // Don't apply globally, only to specific routes max: 100, // Default max requests per window timeWindow: '1 minute', // Custom error response errorResponseBuilder: (request, context) => ({ error: 'Too Many Requests', message: `Rate limit exceeded. Try again in ${Math.ceil(context.after / 1000)} seconds.`, retryAfter: Math.ceil(context.after / 1000) }) }); // Global CORS preflight fastify.addHook('onRequest', async (request, reply) => { // Add CORS headers to all responses const corsHeaders = getCorsHeaders(request.headers.origin); Object.entries(corsHeaders).forEach(([k, v]) => reply.header(k, v)); // Add Updates-Via header for WebSocket notification discovery if (notificationsEnabled) { const wsProtocol = request.protocol === 'https' ? 'wss' : 'ws'; reply.header('Updates-Via', `${wsProtocol}://${request.hostname}/.notifications`); } // Note: OPTIONS requests are handled by handleOptions to include Accept-* headers }); // ActivityPub actor endpoint - dedicated route for /profile/card.jsonld with AP Accept header // Registered before wildcard routes to take priority if (activitypubEnabled) { fastify.route({ method: 'GET', url: '/profile/card.jsonld', handler: async (request, reply) => { const accept = request.headers.accept || ''; const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"'); const actorHandler = getActorHandler(); if (wantsAP && actorHandler) { const actor = actorHandler(request); return reply .type('application/activity+json') .send(actor); } // Not AP request - serve the HTML profile from disk // This is handled by importing the resource handler const { handleGet } = await import('./handlers/resource.js'); return handleGet(request, reply); } }); } // Security: Block access to dotfiles except allowed Solid-specific ones // This prevents exposure of .git/, .env, .htpasswd, etc. // Git protocol requests bypass this check when git is enabled const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications', '.account']; fastify.addHook('onRequest', async (request, reply) => { // Allow git protocol requests through when git is enabled if (gitEnabled && isGitRequest(request.url)) { return; } // Allow pay routes through when pay is enabled (.balance, .deposit) if (payEnabled && isPayRequest(request.url)) { return; } // Allow WebRTC and tunnel endpoints through when enabled const urlNoQuery = request.url.split('?')[0]; if (tunnelEnabled && (urlNoQuery === tunnelPath || urlNoQuery.startsWith('/tunnel/'))) { return; } if (webrtcEnabled && urlNoQuery === webrtcPath) { return; } if (terminalEnabled && urlNoQuery === '/.terminal') { return; } const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings const hasForbiddenDotfile = segments.some(seg => seg.startsWith('.') && seg.length > 1 && !ALLOWED_DOTFILES.includes(seg) ); if (hasForbiddenDotfile) { return reply.code(403).send({ error: 'Forbidden', message: 'Dotfile access is not allowed' }); } }); // Git HTTP backend handler - uses git http-backend CGI // Authorization: Read for clone/fetch, Write for push if (gitEnabled) { fastify.addHook('preHandler', async (request, reply) => { if (!isGitRequest(request.url)) { return; } // Determine required mode: Write for push, Read for clone/fetch const needsWrite = isGitWriteOperation(request.url); const requiredMode = needsWrite ? AccessMode.WRITE : AccessMode.READ; // Run WAC authorization with the correct mode for git operations const { authorized, webId, wacAllow, authError, paymentRequired } = await authorize(request, reply, { requiredMode }); request.webId = webId; request.wacAllow = wacAllow; if (paymentRequired) { return reply.code(402).send({ type: 'PaymentRequired', ...paymentRequired }); } if (!authorized) { const message = needsWrite ? 'Write access required for push' : 'Read access required for clone'; reply.header('WAC-Allow', wacAllow); if (!webId) { // No authentication - request Basic auth for git clients reply.header('WWW-Authenticate', 'Basic realm="Solid"'); } return reply.code(webId ? 403 : 401).send({ error: message }); } // Handle the git request directly return handleGit(request, reply); }); } // HTTP 402 Payment Required handler for /pay/* routes if (payEnabled) { fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate, payChains })); } // Authorization hook - check WAC permissions // Skip for pod creation endpoint (needs special handling) 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', '/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials', '/oauth/authorize', '/oauth/token']; // 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"'); const isProfileAP = activitypubEnabled && wantsAP && (request.url === '/profile/card.jsonld' || request.url.startsWith('/profile/card.jsonld?')); if (request.url === '/.pods' || request.url === '/.notifications' || request.method === 'OPTIONS' || request.url === '/idp' || request.url.startsWith('/idp/') || request.url.startsWith('/idp?') || 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 + '?'))) || isProfileAP || request.url.startsWith('/storage/') || (payEnabled && isPayRequest(request.url)) || (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) || (webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) || (terminalEnabled && (request.url === '/.terminal' || request.url.startsWith('/.terminal?'))) || (tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) || mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) { return; } const { authorized, webId, wacAllow, authError, paymentRequired, paid, balance, currency } = await authorize(request, reply); // Store webId and wacAllow on request for handlers to use request.webId = webId; request.wacAllow = wacAllow; // Set WAC-Allow header for all responses (handlers may override) reply.header('WAC-Allow', wacAllow); // Set payment headers for paid access if (paid !== undefined) { reply.header('X-Cost', String(paid)); reply.header('X-Balance', String(balance)); if (currency) reply.header('X-Pay-Currency', currency); } // Handle payment-gated resources if (paymentRequired) { return reply.code(402).send({ type: 'PaymentRequired', ...paymentRequired }); } if (!authorized) { return handleUnauthorized(request, reply, webId !== null, wacAllow, authError); } }); // Pod creation endpoint with rate limiting // Limit: 1 pod per IP per day to prevent resource exhaustion and namespace squatting // Disabled in single-user mode if (singleUser) { fastify.post('/.pods', async (request, reply) => { return reply.code(403).send({ error: 'Forbidden', message: 'Pod creation disabled in single-user mode' }); }); } else { fastify.post('/.pods', { config: { rateLimit: { max: 1, timeWindow: '1 day', keyGenerator: (request) => request.ip } } }, handleCreatePod); } // Mashlib CDN mode: redirect chunk requests to CDN if (mashlibEnabled && mashlibCdn) { const cdnBase = `https://unpkg.com/mashlib@${mashlibVersion}/dist`; const chunkPattern = /^\/\d+\.mashlib\.min\.js(\.map)?$/; fastify.addHook('onRequest', async (request, reply) => { if (chunkPattern.test(request.url)) { const filename = request.url.split('/').pop(); return reply.redirect(302, `${cdnBase}/${filename}`); } }); } // Rate limit configuration for write operations // Protects against resource exhaustion and abuse const writeRateLimit = { config: { rateLimit: { max: 60, timeWindow: '1 minute', keyGenerator: (request) => request.webId || request.ip } } }; // LDP routes - using wildcard routing // Read operations - no rate limit (handled by bodyLimit) fastify.get('/*', handleGet); fastify.head('/*', handleHead); fastify.options('/*', handleOptions); // Write operations - rate limited fastify.put('/*', writeRateLimit, handlePut); fastify.delete('/*', writeRateLimit, handleDelete); fastify.post('/*', writeRateLimit, handlePost); fastify.patch('/*', writeRateLimit, handlePatch); // Root route fastify.get('/', handleGet); fastify.head('/', handleHead); fastify.options('/', handleOptions); fastify.post('/', writeRateLimit, handlePost); // Single-user mode: create pod on startup if it doesn't exist if (singleUser) { fastify.addHook('onReady', async () => { // Determine base URL for pod URIs const protocol = options.ssl ? 'https' : 'http'; const host = options.host === '0.0.0.0' ? 'localhost' : (options.host || 'localhost'); const port = options.port || 3000; const baseUrl = idpIssuer?.replace(/\/$/, '') || `${protocol}://${host}:${port}`; const issuer = idpIssuer || `${baseUrl}/`; // Root-level pod (empty or '/' name) vs named pod const isRootPod = !singleUserName || singleUserName === '/'; const podPath = isRootPod ? '/' : `/${singleUserName}/`; const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`; const displayName = isRootPod ? 'me' : singleUserName; // Check if pod already exists. Accept either the new `card.jsonld` // or legacy extensionless `card` layout so we don't re-seed a pod // that was created by an older JSS version. Compute the effective // WebID against whichever profile file actually resolves — a // legacy pod must keep its `/profile/card#me` WebID, otherwise the // seeded IDP account would point at a non-existent document. const hasJsonLd = await storage.exists(`${podPath}profile/card.jsonld`); const hasLegacy = !hasJsonLd && await storage.exists(`${podPath}profile/card`); const profileFile = hasJsonLd ? 'profile/card.jsonld' : hasLegacy ? 'profile/card' : 'profile/card.jsonld'; // fresh pod default const webId = `${podUri}${profileFile}#me`; const profileExists = hasJsonLd || hasLegacy; if (!profileExists) { fastify.log.info(`Creating single-user pod at ${podUri}...`); if (isRootPod) { // Root-level pod - create structure directly at / await createRootPodStructure(webId, podUri, issuer, displayName); } else { // Named pod at /{name}/ await createPodStructure(singleUserName, webId, podUri, issuer, defaultQuota); } fastify.log.info(`Single-user pod created at ${podUri}`); } // Seed an IDP account so the operator can actually log in. Without // this, single-user + --idp produces a pod but no credential, and // registration is intentionally disabled in single-user mode — so // the pod is unloggable until a password is set externally (#323). if (idpEnabled && !isRootPod) { await seedSingleUserIdpAccount({ fastify, username: singleUserName, webId, podName: singleUserName, providedPassword: singleUserPassword }); } }); } /** * Seed an IDP account for the single-user pod owner if one doesn't * already exist. Password sources, in priority order: * 1. `--single-user-password` / `JSS_SINGLE_USER_PASSWORD` * 2. interactive prompt (TTY only) * 3. error — server stays up but logs that login won't work yet */ async function seedSingleUserIdpAccount({ fastify, username, webId, podName, providedPassword }) { const { findByUsername, createAccount } = await import('./idp/accounts.js'); const existing = await findByUsername(username); if (existing) return; // already seeded — idempotent // Treat anything that isn't a non-empty string as "not provided" so // a misconfigured env coercion or stray boolean can't reach bcrypt. let password = (typeof providedPassword === 'string' && providedPassword.length > 0) ? providedPassword : null; if (!password) { if (process.stdin.isTTY && process.stdout.isTTY) { try { password = await promptPasswordOnce(`[jss] Set initial IDP password for "${username}": `); } catch (err) { fastify.log.warn({ err }, `Password prompt failed for "${username}"`); return; } } else { fastify.log.warn( `--single-user --idp: no password provided. Set --single-user-password or ` + `JSS_SINGLE_USER_PASSWORD before starting (or run on a TTY to be prompted). ` + `Login is currently not possible for "${username}".` ); return; } } if (typeof password !== 'string' || password.length === 0) { fastify.log.warn(`Empty password — skipping IDP account creation for "${username}".`); return; } try { await createAccount({ username, password, webId, podName }); fastify.log.info(`IDP account seeded for single-user "${username}".`); } catch (err) { fastify.log.error({ err }, `Failed to seed IDP account for "${username}"`); } } /** * Read a password from stdin without echoing it. Uses the public * `emitKeypressEvents` + raw-mode keypress API rather than overriding * the underscored `_writeToOutput` on a `readline.Interface`, which is * a private/unstable hook. */ async function promptPasswordOnce(prompt) { const { emitKeypressEvents } = await import('node:readline'); const stdin = process.stdin; const stdout = process.stdout; if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { throw new Error('Interactive password prompt requires a TTY'); } return new Promise((resolve, reject) => { let password = ''; const wasRaw = stdin.isRaw === true; const onKeypress = (str, key = {}) => { if (key.ctrl && key.name === 'c') { cleanup(); reject(new Error('Password prompt cancelled')); return; } if (key.name === 'return' || key.name === 'enter') { cleanup(); resolve(password); return; } if (key.name === 'backspace' || key.name === 'delete') { password = password.slice(0, -1); return; } // Only accept printable input — \P{C} excludes control codes, // so escape sequences from arrow keys, function keys, etc. don't // sneak invisible bytes into the password buffer. if (!key.ctrl && !key.meta && typeof str === 'string' && str.length > 0 && /^\P{C}+$/u.test(str)) { password += str; } }; const cleanup = () => { stdin.removeListener('keypress', onKeypress); if (!wasRaw) stdin.setRawMode(false); stdout.write('\n'); stdin.pause(); }; emitKeypressEvents(stdin); stdout.write(prompt); if (!wasRaw) stdin.setRawMode(true); stdin.resume(); stdin.on('keypress', onKeypress); }); } /** * Create root-level pod structure (for single-user mode with pod at /) */ async function createRootPodStructure(webId, podUri, issuer, displayName) { const { generateProfile, generatePreferences, generateTypeIndex, serialize } = await import('./webid/profile.js'); const { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } = await import('./wac/parser.js'); // Create directories at root await storage.createContainer('/inbox/'); await storage.createContainer('/public/'); await storage.createContainer('/private/'); await storage.createContainer('/settings/'); await storage.createContainer('/profile/'); // Generate profile const profile = generateProfile({ webId, name: displayName, podUri, issuer }); await storage.write('/profile/card.jsonld', serialize(profile)); // Preferences and type indexes const prefs = generatePreferences({ webId, podUri }); await storage.write('/settings/prefs.jsonld', serialize(prefs)); const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.jsonld`, { listed: true }); await storage.write('/settings/publicTypeIndex.jsonld', serialize(publicTypeIndex)); const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`, { listed: false }); await storage.write('/settings/privateTypeIndex.jsonld', serialize(privateTypeIndex)); // ACL files const rootAcl = generateOwnerAcl(podUri, webId, true); await storage.write('/.acl', serializeAcl(rootAcl)); const privateAcl = generatePrivateAcl(`${podUri}private/`, webId); await storage.write('/private/.acl', serializeAcl(privateAcl)); const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId); await storage.write('/settings/.acl', serializeAcl(settingsAcl)); // publicTypeIndex: public read, overrides the private default inherited from /settings/ const publicTypeIndexAcl = generateOwnerAcl(`${podUri}settings/publicTypeIndex.jsonld`, webId, false); await storage.write('/settings/publicTypeIndex.jsonld.acl', serializeAcl(publicTypeIndexAcl)); const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId); await storage.write('/inbox/.acl', serializeAcl(inboxAcl)); const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId); await storage.write('/public/.acl', serializeAcl(publicAcl)); const profileAcl = generatePublicFolderAcl(`${podUri}profile/`, webId); await storage.write('/profile/.acl', serializeAcl(profileAcl)); // Note: Quota not initialized for root-level pods (no user directory) } // Start file watcher for live reload (watches filesystem for external changes) if (liveReloadEnabled) { const dataRoot = options.root || process.env.DATA_ROOT || './data'; const protocol = options.ssl ? 'https' : 'http'; // Use configured port, or default; actual URL will be localhost const port = options.port || 3000; const baseUrl = `${protocol}://localhost:${port}`; startFileWatcher(dataRoot, baseUrl); } return fastify; } /** * Start the server */ export async function startServer(port = 3000, host = '0.0.0.0') { const server = createServer(); try { await server.listen({ port, host }); return server; } catch (err) { server.log.error(err); process.exit(1); } }

Back | FazBrowse Home | New Git URL