Add private hosted web alpha
This commit is contained in:
@@ -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}`)
|
||||
})
|
||||
Reference in New Issue
Block a user