Add private hosted web alpha

This commit is contained in:
2026-07-12 12:10:39 -05:00
parent dad6ee4ff4
commit 6559295a64
12 changed files with 521 additions and 117 deletions
+204
View File
@@ -0,0 +1,204 @@
import { createHash } 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 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 workspace_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
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 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 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/workspace' && request.method === 'GET') {
const rows = sqlite('SELECT state_json FROM workspace_state WHERE id = 1;')
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 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 (id, state_json, updated_at)
VALUES (1, json(${quoteSql(JSON.stringify(parsed))}), CURRENT_TIMESTAMP)
ON CONFLICT(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.' })
}
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}`)
})