390 lines
13 KiB
JavaScript
390 lines
13 KiB
JavaScript
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}`)
|
|
})
|