diff --git a/server/db.js b/server/db.js index 5b88a34..7dbf94e 100644 --- a/server/db.js +++ b/server/db.js @@ -97,6 +97,25 @@ export function initDb() { ); `); + // Display preferences (theme, default translation, layout, ...) — one row per user. + db.exec(` + CREATE TABLE IF NOT EXISTS user_prefs ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + + // Reader bookmarks + text highlights — one row per user (small JSON), so they follow the + // user across devices. updated_at lets a device tell whether the server copy is newer. + db.exec(` + CREATE TABLE IF NOT EXISTS reader_marks ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + // Reading plan progress — one row per user, holding the whole list of // in-progress/completed plans as JSON (small; no need to normalize). db.exec(` @@ -228,6 +247,39 @@ export function getAllReaderInk(userId) { ); } +/** The user's synced preferences: { key: { v, at } } (empty when none saved yet). */ +export function getUserPrefs(userId) { + const row = db.prepare('SELECT data FROM user_prefs WHERE user_id = ?').get(userId); + return row ? JSON.parse(row.data) : {}; +} + +/** Replaces the user's stored preferences with `prefs` ({ key: { v, at } }). */ +export function setUserPrefs(userId, prefs) { + db.prepare(` + INSERT INTO user_prefs (user_id, data, updated_at) VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at + `).run(userId, JSON.stringify(prefs), Date.now()); +} + +/** The user's bookmarks + highlights: { bookmarks, highlights, updatedAt } (updatedAt 0 = never saved). */ +export function getReaderMarks(userId) { + const row = db.prepare('SELECT data, updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId); + if (!row) return { bookmarks: {}, highlights: [], updatedAt: 0 }; + const data = JSON.parse(row.data); + return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], updatedAt: row.updatedAt }; +} + +/** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */ +export function setReaderMarks(userId, { bookmarks, highlights }) { + const prev = db.prepare('SELECT updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId); + const updatedAt = Math.max(Date.now(), (prev?.updatedAt ?? 0) + 1); + db.prepare(` + INSERT INTO reader_marks (user_id, data, updated_at) VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at + `).run(userId, JSON.stringify({ bookmarks, highlights }), updatedAt); + return updatedAt; +} + /** Upserts the ink strokes for one reader page. */ export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) { db.prepare(` diff --git a/server/index.js b/server/index.js index 0b83b18..bbf22e2 100644 --- a/server/index.js +++ b/server/index.js @@ -12,7 +12,7 @@ import { getShareToken, setShareToken, clearShareToken, getProjectByShareToken, adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, setUserPassword, destroyAllSessionsForUser, - getAllReaderInk, setReaderInkPage, + getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs, getReadingProgress, setReadingProgress, setReminderSettings, } from './db.js'; @@ -24,6 +24,8 @@ import { } from './auth.js'; import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js'; import { registerBibleRoutes } from './bibleApi.js'; +import { cleanMarks } from './readerMarks.js'; +import { cleanPrefsPatch, mergePrefs } from './prefs.js'; // Shape returned to the client for the signed-in user (login, MFA verify, /auth/me). function userPayload(user) { @@ -448,6 +450,50 @@ app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => { } }); +// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } }) +app.get('/api/prefs', requireAuth, (req, res) => { + try { + res.json({ prefs: getUserPrefs(req.session.userId) }); + } catch (err) { + console.error('GET /api/prefs error:', err); + res.status(500).json({ error: 'Failed to load preferences.' }); + } +}); + +// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result +app.put('/api/prefs', requireAuth, (req, res) => { + try { + const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body)); + setUserPrefs(req.session.userId, merged); + res.json({ prefs: merged }); + } catch (err) { + console.error('PUT /api/prefs error:', err); + res.status(500).json({ error: 'Failed to save preferences.' }); + } +}); + +// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against +app.get('/api/reader/marks', requireAuth, (req, res) => { + try { + res.json(getReaderMarks(req.session.userId)); + } catch (err) { + console.error('GET /api/reader/marks error:', err); + res.status(500).json({ error: 'Failed to load bookmarks and highlights.' }); + } +}); + +// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights }) +app.put('/api/reader/marks', requireAuth, (req, res) => { + try { + const marks = cleanMarks(req.body); + if (!marks) return res.status(400).json({ error: 'Body must be an object.' }); + res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) }); + } catch (err) { + console.error('PUT /api/reader/marks error:', err); + res.status(500).json({ error: 'Failed to save bookmarks and highlights.' }); + } +}); + // --------------------------------------------------------------------------- // Reading plan progress — cross-device sync for started/completed reading plans // --------------------------------------------------------------------------- diff --git a/server/prefs.js b/server/prefs.js new file mode 100644 index 0000000..6bc514b --- /dev/null +++ b/server/prefs.js @@ -0,0 +1,45 @@ +// A user's synced display preferences: theme, default Reader translation, verse layout, the +// commentary they last chose, and where they left off in the Reader. Each preference carries +// its own timestamp ({ v, at }) so the most recent change to each one wins, device by device. +const ENUMS = { + theme: ['paper', 'sepia', 'dark'], + translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], + layout: ['flow', 'verses'], + commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'], +}; +const DAY_MS = 86_400_000; + +// A cleaned value, or undefined when it isn't acceptable for that preference. +export function cleanPrefValue(key, value) { + if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined; + if (key === 'position') { + if (!value || typeof value !== 'object') return undefined; + const book = String(value.book ?? '').toUpperCase(); + if (!/^[1-3A-Z]{3}$/.test(book) || !Number.isInteger(value.chapter) || value.chapter < 1 || value.chapter > 200) return undefined; + return { book, chapter: value.chapter }; + } + return undefined; +} + +// Body -> { key: { v, at } } with only known keys, valid values and sane timestamps. +export function cleanPrefsPatch(body, now = Date.now()) { + const out = {}; + const prefs = body?.prefs; + if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return out; + for (const [key, entry] of Object.entries(prefs)) { + const v = cleanPrefValue(key, entry?.v); + const at = entry?.at; + if (v === undefined || !Number.isFinite(at) || at < 0 || at > now + DAY_MS) continue; + out[key] = { v, at }; + } + return out; +} + +// Per key, the entry with the newer timestamp wins (the stored one on a tie). +export function mergePrefs(existing = {}, patch = {}) { + const merged = { ...existing }; + for (const [key, entry] of Object.entries(patch)) { + if (!merged[key] || entry.at > merged[key].at) merged[key] = entry; + } + return merged; +} diff --git a/server/prefs.test.js b/server/prefs.test.js new file mode 100644 index 0000000..3f3f87e --- /dev/null +++ b/server/prefs.test.js @@ -0,0 +1,64 @@ +// @vitest-environment node +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test, expect } from 'vitest'; +import { cleanPrefValue, cleanPrefsPatch, mergePrefs } from './prefs.js'; + +const NOW = 1_800_000_000_000; + +test('only known preferences with valid values are accepted', () => { + expect(cleanPrefValue('theme', 'dark')).toBe('dark'); + expect(cleanPrefValue('theme', 'neon')).toBeUndefined(); + expect(cleanPrefValue('translation', 'ESV')).toBe('ESV'); + expect(cleanPrefValue('translation', 'KJV')).toBeUndefined(); + expect(cleanPrefValue('layout', 'flow')).toBe('flow'); + expect(cleanPrefValue('commentarySource', 'tyndale')).toBe('tyndale'); + expect(cleanPrefValue('fontSize', 2)).toBeUndefined(); // not a synced preference +}); + +test('position needs a real book and chapter, and the book is normalised', () => { + expect(cleanPrefValue('position', { book: 'jhn', chapter: 3 })).toEqual({ book: 'JHN', chapter: 3 }); + expect(cleanPrefValue('position', { book: 'JOHN', chapter: 3 })).toBeUndefined(); + expect(cleanPrefValue('position', { book: 'JHN', chapter: 0 })).toBeUndefined(); + expect(cleanPrefValue('position', { book: 'JHN', chapter: '3' })).toBeUndefined(); + expect(cleanPrefValue('position', null)).toBeUndefined(); +}); + +test('cleanPrefsPatch drops unknown keys, bad values and silly timestamps', () => { + const patch = cleanPrefsPatch({ prefs: { + theme: { v: 'sepia', at: NOW }, + layout: { v: 'nope', at: NOW }, + evil: { v: 'x', at: NOW }, + translation: { v: 'NLT', at: 'yesterday' }, + commentarySource: { v: 'tyndale', at: NOW + 5 * 86_400_000 }, // far in the future + position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 }, + } }, NOW); + expect(patch).toEqual({ theme: { v: 'sepia', at: NOW }, position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 } }); + expect(cleanPrefsPatch(null)).toEqual({}); + expect(cleanPrefsPatch({ prefs: [] })).toEqual({}); +}); + +test('the newest change to each preference wins, independently', () => { + const existing = { theme: { v: 'dark', at: 100 }, layout: { v: 'flow', at: 500 } }; + const patch = { theme: { v: 'paper', at: 200 }, layout: { v: 'verses', at: 300 }, translation: { v: 'ESV', at: 50 } }; + expect(mergePrefs(existing, patch)).toEqual({ + theme: { v: 'paper', at: 200 }, // patch is newer + layout: { v: 'flow', at: 500 }, // stored is newer + translation: { v: 'ESV', at: 50 }, // new key + }); + expect(mergePrefs({ theme: { v: 'dark', at: 100 } }, { theme: { v: 'paper', at: 100 } }).theme.v).toBe('dark'); // tie keeps stored +}); + +test('the database keeps one row per user', async () => { + process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'prefs-')); + const db = await import('./db.js'); + db.initDb(); + db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' }); + db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' }); + expect(db.getUserPrefs('u1')).toEqual({}); + db.setUserPrefs('u1', { theme: { v: 'dark', at: 1 } }); + db.setUserPrefs('u1', { theme: { v: 'sepia', at: 2 } }); + expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } }); + expect(db.getUserPrefs('u2')).toEqual({}); +}); diff --git a/server/readerMarks.js b/server/readerMarks.js new file mode 100644 index 0000000..9666ccc --- /dev/null +++ b/server/readerMarks.js @@ -0,0 +1,40 @@ +// Validation for a user's synced Reader bookmarks and text highlights. The data comes +// from the browser, so everything is checked and malformed entries are dropped. +const COLOR = /^#[0-9a-fA-F]{3,8}$/; +const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16", "1SA-3-5" +const BOOK = /^[1-3A-Za-z]{3}$/; +const MAX_BOOKMARKS = 5000; +const MAX_HIGHLIGHTS = 20000; + +const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max; + +// Returns { bookmarks, highlights } with only well-formed entries, or null when the body is +// not an object at all. +export function cleanMarks(body) { + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; + + const bookmarks = {}; + if (body.bookmarks && typeof body.bookmarks === 'object' && !Array.isArray(body.bookmarks)) { + for (const [key, color] of Object.entries(body.bookmarks).slice(0, MAX_BOOKMARKS)) { + if (BOOKMARK_KEY.test(key) && typeof color === 'string' && COLOR.test(color)) bookmarks[key] = color; + } + } + + const highlights = []; + if (Array.isArray(body.highlights)) { + const seen = new Set(); + for (const h of body.highlights.slice(0, MAX_HIGHLIGHTS)) { + if (!h || typeof h !== 'object') continue; + if (typeof h.id !== 'string' || h.id.length === 0 || h.id.length > 64 || seen.has(h.id)) continue; + if (!BOOK.test(String(h.book)) || !int(h.chapter, 1, 200) || !int(h.verse, 1, 200)) continue; + if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue; + if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue; + seen.add(h.id); + highlights.push({ + id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse, + startOffset: h.startOffset, endOffset: h.endOffset, color: h.color, + }); + } + } + return { bookmarks, highlights }; +} diff --git a/server/readerMarks.test.js b/server/readerMarks.test.js new file mode 100644 index 0000000..65612ca --- /dev/null +++ b/server/readerMarks.test.js @@ -0,0 +1,60 @@ +// @vitest-environment node +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test, expect } from 'vitest'; +import { cleanMarks } from './readerMarks.js'; + +const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 4, endOffset: 20, color: '#fef08a', ...over }); + +test('a non-object body is rejected', () => { + expect(cleanMarks(null)).toBeNull(); + expect(cleanMarks([])).toBeNull(); + expect(cleanMarks('x')).toBeNull(); +}); + +test('good bookmarks and highlights pass through unchanged', () => { + const body = { bookmarks: { 'JHN-3-16': '#fef08a', '1SA-3-5': '#bbf7d0' }, highlights: [hl()] }; + expect(cleanMarks(body)).toEqual(body); +}); + +test('malformed bookmarks are dropped, good ones kept', () => { + const out = cleanMarks({ bookmarks: { 'JHN-3-16': '#fef08a', 'bad key': '#fef08a', 'GEN-1-1': 'red', 'GEN-1-2': 5, '../x-1-1': '#fff' } }); + expect(out.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' }); +}); + +test('highlights are validated field by field and de-duplicated by id', () => { + const out = cleanMarks({ highlights: [ + hl(), + hl({ id: 'h1', verse: 17 }), // duplicate id + hl({ id: '' }), hl({ id: 'x'.repeat(65) }), + hl({ id: 'a', book: 'JOHN' }), hl({ id: 'b', chapter: 0 }), hl({ id: 'c', verse: 201 }), + hl({ id: 'd', startOffset: 10, endOffset: 10 }), hl({ id: 'e', startOffset: -1 }), hl({ id: 'f', color: 'yellow' }), + 'nope', null, + hl({ id: 'ok', book: 'gen' }), + ] }); + expect(out.highlights.map((h) => h.id)).toEqual(['h1', 'ok']); + expect(out.highlights[1].book).toBe('GEN'); // normalised +}); + +test('missing sections become empty ones, and unknown fields are not stored', () => { + expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [] }); + expect(cleanMarks({ highlights: [{ ...hl(), evil: '