diff --git a/CHANGELOG.md b/CHANGELOG.md index 228c6b8..16843cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.30 - 2026-07-15 + +- Started Phase 2 hosted accounts with a server-side user table, password hashing, login sessions, and authenticated per-user workspace persistence. +- Added hosted-only login and logout UI while keeping local browser preview and future desktop mode account-free. +- Added optional deployment bootstrap credentials through `LIBRESTUDY_BOOTSTRAP_USER` and `LIBRESTUDY_BOOTSTRAP_PASSWORD`. + ## 0.1.29 - 2026-07-15 - Changed the Vite dev server to bind to `0.0.0.0` and allow the `desktop` MagicDNS host so the hot view can be reached from Pinky at `http://desktop:5174/`. diff --git a/ROADMAP.md b/ROADMAP.md index ab69452..8a9b5f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,12 +79,14 @@ Definition of done: Goal: make the hosted app personally usable without relying on browser `localStorage`. +Current foundation note: hosted mode now has username/password login, signed cookie sessions, and per-user SQLite workspace persistence. Account creation is intentionally bootstrap/manual-only for the private alpha. + Tasks: -- Add a minimal server API for sessions, notes, settings, timelines, and active workspace state. -- Add a simple login path, starting with manually created accounts or invite-only access. +- Add a minimal server API for sessions, notes, settings, timelines, and active workspace state. Started in `server/libre-study-server.mjs`. +- Add a simple login path, starting with manually created accounts or invite-only access. Started with bootstrap-created users. - Store only necessary account data: username or email, password hash, sessions, notes, settings, timeline events, created/updated timestamps. -- Move hosted sessions from `localStorage` to server persistence. +- Move hosted sessions from `localStorage` to server persistence. Started through the authenticated workspace snapshot endpoint. - Keep local browser preview and Tauri desktop able to run without accounts. - Add basic backup expectations for the server-side user database. diff --git a/package-lock.json b/package-lock.json index f932829..6035db3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "libre-study", - "version": "0.1.29", + "version": "0.1.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "libre-study", - "version": "0.1.29", + "version": "0.1.30", "dependencies": { "@tauri-apps/api": "^2.11.1", "jszip": "^3.10.1", diff --git a/package.json b/package.json index 404d0f1..de2e3aa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "libre-study", "private": true, - "version": "0.1.29", + "version": "0.1.30", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/scripts/deploy-web.ps1 b/scripts/deploy-web.ps1 index 8ec9566..340a654 100644 --- a/scripts/deploy-web.ps1 +++ b/scripts/deploy-web.ps1 @@ -2,7 +2,9 @@ param( [string]$HostName = "christunscripted", [string]$RemoteRoot = "/home/jbird/libre-study", [string]$RemoteBibleDataRoot = "/home/jbird/libre-bible-data/packages/json", - [int]$Port = 9174 + [int]$Port = 9174, + [string]$BootstrapUser = $env:LIBRESTUDY_BOOTSTRAP_USER, + [string]$BootstrapPassword = $env:LIBRESTUDY_BOOTSTRAP_PASSWORD ) $ErrorActionPreference = "Stop" @@ -12,6 +14,10 @@ function ConvertTo-WslPath([string]$Path) { return (wsl wslpath -a "$resolved").Trim() } +function ConvertTo-BashSingleQuoted([string]$Value) { + return "'" + $Value.Replace("'", "'\''") + "'" +} + $repoRoot = Split-Path -Parent $PSScriptRoot $dataRepo = Resolve-Path (Join-Path $repoRoot "..\libre-bible-data") $repoRootWsl = ConvertTo-WslPath $repoRoot @@ -41,6 +47,8 @@ $deployInfo = @{ $deployInfoPath = Join-Path $env:TEMP "libre-study-deploy-info.json" Set-Content -Path $deployInfoPath -Value $deployInfo -NoNewline $deployInfoPathWsl = ConvertTo-WslPath $deployInfoPath +$bootstrapUserQuoted = ConvertTo-BashSingleQuoted $BootstrapUser +$bootstrapPasswordQuoted = ConvertTo-BashSingleQuoted $BootstrapPassword wsl ssh $HostName "mkdir -p '$RemoteRoot/app' '$RemoteRoot/server' '$RemoteRoot/state' '$RemoteBibleDataRoot'" if ($LASTEXITCODE -ne 0) { throw "Remote directory preparation failed." } @@ -55,6 +63,8 @@ if ($LASTEXITCODE -ne 0) { throw "LibreBible rsync failed." } $pm2Command = @" cd '$RemoteRoot' && +export LIBRESTUDY_BOOTSTRAP_USER=$bootstrapUserQuoted && +export LIBRESTUDY_BOOTSTRAP_PASSWORD=$bootstrapPasswordQuoted && if pm2 describe libre-study-web >/dev/null 2>&1; then PORT=$Port PUBLIC_DIR='$RemoteRoot/app' LIBREBIBLE_DATA_DIR='$RemoteBibleDataRoot' STATE_DIR='$RemoteRoot/state' DB_PATH='$RemoteRoot/state/libre-study-web.sqlite3' DEPLOY_INFO_PATH='$RemoteRoot/deploy-info.json' pm2 restart libre-study-web --update-env; else diff --git a/server/libre-study-server.mjs b/server/libre-study-server.mjs index 28038f6..bd5f030 100644 --- a/server/libre-study-server.mjs +++ b/server/libre-study-server.mjs @@ -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') diff --git a/src/App.css b/src/App.css index 5c0e4f9..584b900 100644 --- a/src/App.css +++ b/src/App.css @@ -7,6 +7,83 @@ color: #202124; } +.login-shell { + display: grid; + min-height: 100vh; + place-items: center; + padding: 28px; + background: + linear-gradient(135deg, rgba(47, 111, 94, 0.16), transparent 36%), + #ece9df; + color: #202124; + box-sizing: border-box; +} + +.login-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 340px); + gap: 28px; + width: min(760px, 100%); + padding: 28px; + border: 1px solid #d4cdbd; + border-radius: 8px; + background: #fffef8; + box-shadow: 0 20px 50px rgba(44, 48, 42, 0.14); + box-sizing: border-box; +} + +.login-panel h1 { + margin: 12px 0 10px; + font-size: 42px; + line-height: 1.05; + letter-spacing: 0; +} + +.login-panel p { + margin: 0; + color: #657061; + line-height: 1.5; +} + +.login-panel form, +.login-panel label { + display: grid; + gap: 10px; +} + +.login-panel form { + align-content: start; + padding-top: 4px; +} + +.login-panel label { + color: #657061; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.login-panel input { + min-height: 40px; + font-size: 15px; +} + +.login-panel button { + min-height: 40px; + background: #2f6f5e; + color: #fffef8; + font-size: 14px; +} + +.login-error { + padding: 10px 12px; + border: 1px solid #d6a5a0; + border-radius: 6px; + background: #fff4f2; + color: #8b231a; + font-weight: 700; +} + .app-shell.menu-open .reader { filter: blur(1.5px) brightness(0.62) saturate(0.72); } @@ -260,6 +337,11 @@ button { cursor: pointer; } +button:disabled { + cursor: not-allowed; + opacity: 0.62; +} + button:focus-visible, input:focus-visible, select:focus-visible, @@ -1531,6 +1613,29 @@ textarea:focus-visible { color: #eee8d8; } +.theme-dark.login-shell { + background: + linear-gradient(135deg, rgba(47, 111, 94, 0.24), transparent 36%), + #161a18; +} + +.theme-dark .login-panel { + border-color: #3f493f; + background: #222820; + color: #eee8d8; +} + +.theme-dark .login-panel p, +.theme-dark .login-panel label { + color: #c4cbbb; +} + +.theme-dark .login-error { + border-color: #71443d; + background: #35221f; + color: #f2b0a8; +} + .theme-dark .reader { background: #161a18; } @@ -1965,6 +2070,20 @@ textarea:focus-visible { } @media (max-width: 760px) { + .login-shell { + padding: 18px; + } + + .login-panel { + grid-template-columns: 1fr; + gap: 22px; + padding: 22px; + } + + .login-panel h1 { + font-size: 34px; + } + .app-shell { grid-template-columns: 1fr; } diff --git a/src/App.tsx b/src/App.tsx index beb96e5..458b4ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,12 +15,15 @@ import { createSession, defaultTranslationId, hostedWorkspaceEnabled, + loadHostedSession, loadWorkspaceSnapshot, + loginHostedWorkspace, + logoutHostedWorkspace, saveWorkspaceSnapshot, timelineId, timelineLimit, } from './workspace' -import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react' +import type { DragEvent, FormEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react' import type { AudioBibleContext, AudioBibleSegment, @@ -32,6 +35,7 @@ import type { } from './types' import type { StrongDisplayMode, + HostedUser, StudySession, StudySettings, ThemeMode, @@ -245,6 +249,12 @@ function App() { const [audioSpeed, setAudioSpeed] = useState(1) const [marginResourcesLoading, setMarginResourcesLoading] = useState(false) const [mapResourcesLoading, setMapResourcesLoading] = useState(false) + const [authRequired, setAuthRequired] = useState(false) + const [hostedUser, setHostedUser] = useState(null) + const [loginUsername, setLoginUsername] = useState('') + const [loginPassword, setLoginPassword] = useState('') + const [loginBusy, setLoginBusy] = useState(false) + const [loginError, setLoginError] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const restoredSessionId = useRef('') @@ -445,33 +455,45 @@ function App() { ], ) + async function restoreWorkspace(cancelled = () => false) { + setLoading(true) + setError(null) + setAuthRequired(false) + workspaceHydrated.current = false + + if (hostedWorkspaceEnabled()) { + const session = await loadHostedSession() + if (cancelled()) return + setHostedUser(session.user) + if (!session.authenticated) { + setAuthRequired(true) + setLoading(false) + return + } + } + + const workspace = await loadWorkspaceSnapshot() + const selectedSession = + workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0] + + if (cancelled()) return + applyStudySettings(workspace.studySettings) + setSessions(workspace.sessions) + setActiveSessionId(workspace.activeSessionId) + getTranslations().then(setTranslations).catch((reason) => setError(String(reason))) + + await loadLibrary(selectedSession.baseTranslation) + if (cancelled()) return + setLoading(false) + window.setTimeout(() => { + workspaceHydrated.current = true + }, 0) + } + useEffect(() => { let cancelled = false - async function restoreWorkspace() { - const workspace = await loadWorkspaceSnapshot() - const selectedSession = - workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0] - - if (cancelled) return - applyStudySettings(workspace.studySettings) - setSessions(workspace.sessions) - setActiveSessionId(workspace.activeSessionId) - getTranslations().then(setTranslations).catch((reason) => setError(String(reason))) - - loadLibrary(selectedSession.baseTranslation) - .catch((reason) => setError(String(reason))) - .finally(() => { - if (!cancelled) { - setLoading(false) - window.setTimeout(() => { - workspaceHydrated.current = true - }, 0) - } - }) - } - - restoreWorkspace().catch((reason) => { + restoreWorkspace(() => cancelled).catch((reason) => { if (!cancelled) { setError(String(reason)) setLoading(false) @@ -1042,6 +1064,33 @@ function App() { URL.revokeObjectURL(url) } + async function submitHostedLogin(event: FormEvent) { + event.preventDefault() + setLoginBusy(true) + setLoginError(null) + try { + const session = await loginHostedWorkspace(loginUsername.trim(), loginPassword) + setHostedUser(session.user) + setLoginPassword('') + await restoreWorkspace() + } catch (reason) { + setLoginError(String(reason)) + } finally { + setLoginBusy(false) + } + } + + async function signOutHostedWorkspace() { + setError(null) + await logoutHostedWorkspace() + setHostedUser(null) + setSessions([]) + setActiveSessionId('') + setAuthRequired(true) + setLoading(false) + workspaceHydrated.current = false + } + function restoreTimelineEntry(entry: TimelineEntry) { if (!entry.reference) return const [bookPart, rest] = entry.reference.split(/ (?=\d+:\d+$)/) @@ -1897,6 +1946,44 @@ function App() { ) } + function renderHostedLogin() { + return ( +
+
+
+ LS + Hosted Workspace +

Libre Study

+

Sign in to load your private sessions, notes, settings, and study timeline.

+
+
+ + + {loginError ?

{loginError}

: null} + +
+
+
+ ) + } + function renderTimelineModal() { if (!timelineModalOpen) return null @@ -2460,6 +2547,10 @@ function App() { const splitTemplate = `${splitPercent}fr 6px ${100 - splitPercent}fr` + if (authRequired && hostedWorkspaceEnabled()) { + return renderHostedLogin() + } + return (
A readable Bible page, study margin, notes workspace, and linked public Bible data.

+ {hostedUser ? ( + + ) : null}