Add hosted login persistence

This commit is contained in:
2026-07-15 15:33:47 -05:00
parent 02ffa1dfc4
commit e62ec49c59
9 changed files with 508 additions and 38 deletions
+191 -6
View File
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto'
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'
@@ -12,6 +12,9 @@ const dbPath = resolve(process.env.DB_PATH ?? join(stateDir, 'libre-study-web.sq
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'],
@@ -29,12 +32,30 @@ const mimeTypes = new Map([
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 (
id INTEGER PRIMARY KEY CHECK (id = 1),
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], {
@@ -48,6 +69,83 @@ 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, {
@@ -58,6 +156,17 @@ function sendJson(response, status, value) {
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',
@@ -141,8 +250,60 @@ async function handleApi(request, response, url) {
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 rows = sqlite('SELECT state_json FROM workspace_state WHERE id = 1;')
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()
@@ -158,6 +319,8 @@ async function handleApi(request, response, url) {
}
if (url.pathname === '/api/workspace' && request.method === 'PUT') {
const user = requireUser(request, response)
if (!user) return
const body = await readBody(request)
let parsed
try {
@@ -168,9 +331,9 @@ async function handleApi(request, response, url) {
}
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;`,
`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,
@@ -182,6 +345,28 @@ async function handleApi(request, response, url) {
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')