import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { extname, join, normalize, resolve } from 'node:path' import { execFileSync } from 'node:child_process' import http from 'node:http' const port = Number(process.env.PORT ?? 9174) const publicDir = resolve(process.env.PUBLIC_DIR ?? 'dist') const dataDir = resolve(process.env.LIBREBIBLE_DATA_DIR ?? '../libre-bible-data/packages/json') const stateDir = resolve(process.env.STATE_DIR ?? 'web-state') const dbPath = resolve(process.env.DB_PATH ?? join(stateDir, 'libre-study-web.sqlite3')) const deployInfoPath = process.env.DEPLOY_INFO_PATH ? resolve(process.env.DEPLOY_INFO_PATH) : '' const sqliteBin = process.env.SQLITE_BIN ?? 'sqlite3' const maxBodyBytes = Number(process.env.MAX_BODY_BYTES ?? 4 * 1024 * 1024) const sessionCookieName = process.env.SESSION_COOKIE_NAME ?? 'libre_study_session' const bootstrapUser = process.env.LIBRESTUDY_BOOTSTRAP_USER ?? '' const bootstrapPassword = process.env.LIBRESTUDY_BOOTSTRAP_PASSWORD ?? '' const mimeTypes = new Map([ ['.css', 'text/css; charset=utf-8'], ['.html', 'text/html; charset=utf-8'], ['.ico', 'image/x-icon'], ['.js', 'application/javascript; charset=utf-8'], ['.json', 'application/json; charset=utf-8'], ['.jsonl', 'application/x-ndjson; charset=utf-8'], ['.map', 'application/json; charset=utf-8'], ['.png', 'image/png'], ['.svg', 'image/svg+xml'], ['.txt', 'text/plain; charset=utf-8'], ['.woff2', 'font/woff2'], ]) mkdirSync(stateDir, { recursive: true }) sqlite(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS login_sessions ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS workspace_state ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, state_json TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `) ensureWorkspaceStateSchema() bootstrapAccount() migrateLegacyWorkspaceToBootstrap() function sqlite(sql) { const output = execFileSync(sqliteBin, ['-json', dbPath, sql], { encoding: 'utf8', maxBuffer: 1024 * 1024 * 20, }) return output.trim() ? JSON.parse(output) : [] } function quoteSql(value) { return `'${String(value).replaceAll("'", "''")}'` } function passwordHash(password) { const salt = randomBytes(16).toString('hex') const hash = scryptSync(password, salt, 64).toString('hex') return `scrypt:${salt}:${hash}` } function verifyPassword(password, stored) { const [scheme, salt, expected] = String(stored).split(':') if (scheme !== 'scrypt' || !salt || !expected) return false const actual = scryptSync(password, salt, 64) const expectedBuffer = Buffer.from(expected, 'hex') return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer) } function tokenHash(token) { return createHash('sha256').update(token).digest('hex') } function parseCookies(header = '') { const cookies = new Map() for (const pair of header.split(';')) { const [name, ...valueParts] = pair.trim().split('=') if (!name || !valueParts.length) continue cookies.set(name, decodeURIComponent(valueParts.join('='))) } return cookies } function sessionCookie(token, expiresAt) { return `${sessionCookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Expires=${new Date(expiresAt).toUTCString()}` } function expiredSessionCookie() { return `${sessionCookieName}=; Path=/; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT` } function bootstrapAccount() { if (!bootstrapUser || !bootstrapPassword) return const existing = sqlite(`SELECT id FROM users WHERE username = ${quoteSql(bootstrapUser)} LIMIT 1;`) if (existing.length) return sqlite( `INSERT INTO users (username, password_hash) VALUES (${quoteSql(bootstrapUser)}, ${quoteSql(passwordHash(bootstrapPassword))});`, ) } function ensureWorkspaceStateSchema() { const columns = sqlite('PRAGMA table_info(workspace_state);') if (columns.some((column) => column.name === 'user_id')) return sqlite('ALTER TABLE workspace_state RENAME TO workspace_state_legacy;') sqlite(` CREATE TABLE workspace_state ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, state_json TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `) } function migrateLegacyWorkspaceToBootstrap() { if (!bootstrapUser) return const legacyTable = sqlite( `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workspace_state_legacy';`, ) if (!legacyTable.length) return const user = sqlite(`SELECT id FROM users WHERE username = ${quoteSql(bootstrapUser)} LIMIT 1;`) if (!user.length) return const existing = sqlite(`SELECT user_id FROM workspace_state WHERE user_id = ${Number(user[0].id)};`) if (existing.length) return const legacy = sqlite(`SELECT state_json FROM workspace_state_legacy ORDER BY updated_at DESC LIMIT 1;`) if (!legacy.length) return sqlite( `INSERT INTO workspace_state (user_id, state_json, updated_at) VALUES (${Number(user[0].id)}, json(${quoteSql(legacy[0].state_json)}), CURRENT_TIMESTAMP);`, ) } function sendJson(response, status, value) { const body = JSON.stringify(value) response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', }) response.end(body) } function sendJsonWithHeaders(response, status, value, headers = {}) { const body = JSON.stringify(value) response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', ...headers, }) response.end(body) } function sendText(response, status, value) { response.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(value), 'Cache-Control': 'no-store', }) response.end(value) } function readBody(request) { return new Promise((resolveBody, reject) => { let size = 0 const chunks = [] request.on('data', (chunk) => { size += chunk.length if (size > maxBodyBytes) { reject(new Error('Request body is too large.')) request.destroy() return } chunks.push(chunk) }) request.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf8'))) request.on('error', reject) }) } function safeFile(root, requestPath) { const decoded = decodeURIComponent(requestPath) const candidate = normalize(join(root, decoded)) const rootWithSeparator = root.endsWith('\\') || root.endsWith('/') ? root : `${root}/` if (candidate !== root && !candidate.startsWith(rootWithSeparator)) return null return candidate } function serveFile(response, filePath, cache = 'no-store') { if (!existsSync(filePath) || !statSync(filePath).isFile()) return false const stat = statSync(filePath) const contentType = mimeTypes.get(extname(filePath).toLowerCase()) ?? 'application/octet-stream' response.writeHead(200, { 'Content-Type': contentType, 'Content-Length': stat.size, 'Cache-Control': cache, }) createReadStream(filePath).pipe(response) return true } function serveStatic(response, requestPath) { const pathWithoutSlash = requestPath.replace(/^\/+/, '') const staticPath = safeFile(publicDir, pathWithoutSlash || 'index.html') if (staticPath && serveFile(response, staticPath, requestPath.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-store')) { return true } return serveFile(response, join(publicDir, 'index.html'), 'no-store') } function serveLibreBible(response, requestPath) { const relative = requestPath.replace(/^\/librebible\/?/, '') const filePath = safeFile(dataDir, relative) if (!filePath || !serveFile(response, filePath, 'public, max-age=300')) { sendText(response, 404, 'Not found') } } function deployInfo() { if (!deployInfoPath || !existsSync(deployInfoPath)) return { commit: 'unknown' } try { return JSON.parse(readFileSync(deployInfoPath, 'utf8')) } catch { return { commit: readFileSync(deployInfoPath, 'utf8').trim() || 'unknown' } } } async function handleApi(request, response, url) { if (url.pathname === '/api/health') { sendJson(response, 200, { ok: true, deploy: deployInfo() }) return } if (url.pathname === '/api/session' && request.method === 'GET') { const user = currentUser(request) sendJson(response, 200, { authenticated: Boolean(user), user }) return } if (url.pathname === '/api/login' && request.method === 'POST') { const body = await readBody(request) let parsed try { parsed = JSON.parse(body) } catch { sendJson(response, 400, { error: 'Login payload must be valid JSON.' }) return } const username = String(parsed.username ?? '').trim() const password = String(parsed.password ?? '') const rows = username ? sqlite(`SELECT id, username, password_hash FROM users WHERE username = ${quoteSql(username)} LIMIT 1;`) : [] if (!rows.length || !verifyPassword(password, rows[0].password_hash)) { sendJson(response, 401, { error: 'Invalid username or password.' }) return } const token = randomBytes(32).toString('base64url') const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString() sqlite( `INSERT INTO login_sessions (token_hash, user_id, expires_at) VALUES (${quoteSql(tokenHash(token))}, ${Number(rows[0].id)}, ${quoteSql(expiresAt)});`, ) sendJsonWithHeaders( response, 200, { authenticated: true, user: { id: Number(rows[0].id), username: rows[0].username } }, { 'Set-Cookie': sessionCookie(token, expiresAt) }, ) return } if (url.pathname === '/api/logout' && request.method === 'POST') { const cookies = parseCookies(request.headers.cookie) const token = cookies.get(sessionCookieName) if (token) sqlite(`DELETE FROM login_sessions WHERE token_hash = ${quoteSql(tokenHash(token))};`) sendJsonWithHeaders(response, 200, { ok: true }, { 'Set-Cookie': expiredSessionCookie() }) return } if (url.pathname === '/api/workspace' && request.method === 'GET') { const user = requireUser(request, response) if (!user) return const rows = sqlite(`SELECT state_json FROM workspace_state WHERE user_id = ${user.id};`) if (!rows.length) { response.writeHead(204, { 'Cache-Control': 'no-store' }) response.end() return } response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', }) response.end(rows[0].state_json) return } if (url.pathname === '/api/workspace' && request.method === 'PUT') { const user = requireUser(request, response) if (!user) return const body = await readBody(request) let parsed try { parsed = JSON.parse(body) } catch { sendJson(response, 400, { error: 'Workspace payload must be valid JSON.' }) return } sqlite( `INSERT INTO workspace_state (user_id, state_json, updated_at) VALUES (${user.id}, json(${quoteSql(JSON.stringify(parsed))}), CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET state_json = excluded.state_json, updated_at = CURRENT_TIMESTAMP;`, ) sendJson(response, 200, { ok: true, checksum: createHash('sha256').update(JSON.stringify(parsed)).digest('hex'), }) return } sendJson(response, 404, { error: 'Unknown API route.' }) } function currentUser(request) { const cookies = parseCookies(request.headers.cookie) const token = cookies.get(sessionCookieName) if (!token) return null const rows = sqlite( `SELECT users.id, users.username FROM login_sessions JOIN users ON users.id = login_sessions.user_id WHERE login_sessions.token_hash = ${quoteSql(tokenHash(token))} AND login_sessions.expires_at > CURRENT_TIMESTAMP LIMIT 1;`, ) if (!rows.length) return null return { id: Number(rows[0].id), username: rows[0].username } } function requireUser(request, response) { const user = currentUser(request) if (!user) sendJson(response, 401, { error: 'Login is required.' }) return user } const server = http.createServer((request, response) => { const url = new URL(request.url ?? '/', 'http://localhost') Promise.resolve() .then(() => { if (url.pathname.startsWith('/api/')) return handleApi(request, response, url) if (url.pathname.startsWith('/librebible/')) return serveLibreBible(response, url.pathname) return serveStatic(response, url.pathname) }) .catch((error) => { sendJson(response, 500, { error: String(error.message ?? error) }) }) }) server.listen(port, '127.0.0.1', () => { console.log(`Libre Study web server listening on http://127.0.0.1:${port}`) console.log(`Serving app from ${publicDir}`) console.log(`Serving LibreBible data from ${dataDir}`) console.log(`Persisting workspace to ${dbPath}`) })