import Database from 'better-sqlite3'; import { copyFileSync, mkdirSync, rmSync } from 'fs'; import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data'); const DB_PATH = join(DATA_DIR, 'projects.db'); let db; export const dataDir = () => DATA_DIR; /** A consistent copy of the whole database, made while it is in use. */ export function backupDatabase(destPath) { return db.backup(destPath); } /** Swaps the live database for a copy of `srcFile` (a snapshot). The connection is reopened either way. */ export function replaceDatabase(srcFile) { db.close(); try { copyFileSync(srcFile, DB_PATH); rmSync(`${DB_PATH}-wal`, { force: true }); rmSync(`${DB_PATH}-shm`, { force: true }); } finally { initDb(); } } // --------------------------------------------------------------------------- // Init — create tables if they don't exist, migrate older schemas // --------------------------------------------------------------------------- export function initDb() { mkdirSync(DATA_DIR, { recursive: true }); db = new Database(DB_PATH); // Enable WAL for better concurrent read performance db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, title TEXT NOT NULL, last_edited INTEGER NOT NULL, chapter_summary TEXT, data TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( sid TEXT PRIMARY KEY, sess TEXT NOT NULL, expires INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS passkeys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, public_key TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, transports TEXT, name TEXT NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id); `); // Older databases predate multi-user support — add the ownership column. const projectCols = db.prepare('PRAGMA table_info(projects)').all(); if (!projectCols.some((c) => c.name === 'user_id')) { db.exec('ALTER TABLE projects ADD COLUMN user_id TEXT REFERENCES users(id)'); } // Older databases predate 2FA support — add the TOTP columns. const userCols = db.prepare('PRAGMA table_info(users)').all(); if (!userCols.some((c) => c.name === 'totp_secret')) { db.exec(` ALTER TABLE users ADD COLUMN totp_secret TEXT; ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN backup_codes TEXT; `); } // Older databases predate the podcast-name profile setting. if (!userCols.some((c) => c.name === 'podcast_name')) { db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT'); } // Older databases predate ntfy.sh daily reading reminders. if (!userCols.some((c) => c.name === 'ntfy_url')) { db.exec(` ALTER TABLE users ADD COLUMN ntfy_url TEXT; ALTER TABLE users ADD COLUMN ntfy_topic TEXT; ALTER TABLE users ADD COLUMN reminder_time TEXT; ALTER TABLE users ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN last_reminder_date TEXT; `); } // Optional "new sign-in" notification, sent to the same ntfy topic. if (!userCols.some((c) => c.name === 'login_alerts')) db.exec('ALTER TABLE users ADD COLUMN login_alerts INTEGER NOT NULL DEFAULT 0'); // Optional daily verse of the day, sent to the same ntfy topic. if (!userCols.some((c) => c.name === 'votd_enabled')) { db.exec(` ALTER TABLE users ADD COLUMN votd_enabled INTEGER NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN votd_time TEXT; ALTER TABLE users ADD COLUMN last_votd_date TEXT; `); } // Optional daily prayer reminder, sent to the same ntfy topic. if (!userCols.some((c) => c.name === 'prayer_enabled')) { db.exec(` ALTER TABLE users ADD COLUMN prayer_enabled INTEGER NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN prayer_time TEXT; ALTER TABLE users ADD COLUMN last_prayer_date TEXT; `); } // Reminders fire at the user's own local time, so remember their timezone. if (!userCols.some((c) => c.name === 'reminder_tz')) { db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT'); } // Older databases predate shareable read-only links. if (!projectCols.some((c) => c.name === 'share_token')) { db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT'); } if (!projectCols.some((c) => c.name === 'outline_share_token')) { db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT'); } // Share links can expire and be protected by a passcode. for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) { if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`); } db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL'); // Server-wide settings (who may sign up, ...) and the invite codes that let someone sign up when sign-up is invite-only. db.exec(` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS invites ( code TEXT PRIMARY KEY, note TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_by TEXT, used_at INTEGER ); `); // The prayer journal: one row per account (the whole list as JSON; see server/prayers.js). db.exec(` CREATE TABLE IF NOT EXISTS prayers ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, data TEXT NOT NULL, updated_at INTEGER NOT NULL ); `); // Earlier copies of each study (compressed), for "Restore an earlier version". db.exec(` CREATE TABLE IF NOT EXISTS project_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, state_at INTEGER NOT NULL, title TEXT, chunks INTEGER NOT NULL DEFAULT 0, words INTEGER NOT NULL DEFAULT 0, data BLOB NOT NULL ); CREATE INDEX IF NOT EXISTS idx_versions_project ON project_versions(project_id, state_at); `); // Co-teachers: other accounts allowed to edit a study, and the single-use links that let them join. db.exec(` CREATE TABLE IF NOT EXISTS collaborators ( project_id TEXT NOT NULL, user_id TEXT NOT NULL, added_at INTEGER NOT NULL, PRIMARY KEY (project_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_collaborators_user ON collaborators(user_id); CREATE TABLE IF NOT EXISTS edit_invites ( token TEXT PRIMARY KEY, project_id TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_by TEXT ); `); // What the person has read in the Reader (chapters and days), merged from every device. db.exec(` CREATE TABLE IF NOT EXISTS reader_history ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, data TEXT NOT NULL, updated_at INTEGER NOT NULL ); `); // One-time codes for "forgot password": hashed, short-lived, limited tries. db.exec(` CREATE TABLE IF NOT EXISTS password_resets ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, code_hash TEXT NOT NULL, expires INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 ); `); // Reader ink — one row per user + book + chapter, stored as JSON db.exec(` CREATE TABLE IF NOT EXISTS reader_ink ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, book_abbrev TEXT NOT NULL, chapter INTEGER NOT NULL, strokes TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (user_id, book_abbrev, chapter) ); `); // 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(` CREATE TABLE IF NOT EXISTS reading_progress ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, data TEXT NOT NULL, updated_at INTEGER NOT NULL ); `); console.log(`SQLite database ready at ${DB_PATH}`); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function buildSummary(project) { if (!Array.isArray(project.chapters)) return ''; return project.chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', '); } // --------------------------------------------------------------------------- // Users // --------------------------------------------------------------------------- export function countUsers() { return db.prepare('SELECT COUNT(*) AS n FROM users').get().n; } export function createUser({ id, email, passwordHash }) { const createdAt = Date.now(); db.prepare(` INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?) `).run(id, email, passwordHash, createdAt); return { id, email, createdAt }; } function parseUserRow(row) { if (!row) return null; return { ...row, totpEnabled: !!row.totpEnabled, reminderEnabled: !!row.reminderEnabled, votdEnabled: !!row.votdEnabled, prayerEnabled: !!row.prayerEnabled, loginAlerts: !!row.loginAlerts, backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [], }; } const USER_SELECT = ` SELECT id, email, password_hash AS passwordHash, created_at AS createdAt, totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw, podcast_name AS podcastName, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime, reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz, votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate, prayer_enabled AS prayerEnabled, prayer_time AS prayerTime, login_alerts AS loginAlerts FROM users `; export function getUserByEmail(email) { return parseUserRow(db.prepare(`${USER_SELECT} WHERE email = ?`).get(email)); } export function getUserById(id) { return parseUserRow(db.prepare(`${USER_SELECT} WHERE id = ?`).get(id)); } /** Persists a confirmed TOTP secret + one-time backup code hashes, turning 2FA on. */ export function enableTotp(userId, secret, backupCodeHashes) { db.prepare(` UPDATE users SET totp_secret = ?, totp_enabled = 1, backup_codes = ? WHERE id = ? `).run(secret, JSON.stringify(backupCodeHashes), userId); } /** Turns 2FA off and forgets the secret/backup codes entirely. */ export function disableTotp(userId) { db.prepare(` UPDATE users SET totp_secret = NULL, totp_enabled = 0, backup_codes = NULL WHERE id = ? `).run(userId); } /** Sets or clears the show name used in the "Prepare for Podcast" prompt. */ export function setPodcastName(userId, podcastName) { db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId); } // --------------------------------------------------------------------------- // Daily reading reminders (sent via ntfy.sh — see server/reminders.js) // --------------------------------------------------------------------------- /** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */ export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled, reminderTz }) { db.prepare(` UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?, reminder_tz = ? WHERE id = ? `).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId); } /** Turns the daily verse of the day on or off, and says when to send it. */ export function setVotdSettings(userId, { votdEnabled, votdTime }) { db.prepare('UPDATE users SET votd_enabled = ?, votd_time = ? WHERE id = ?').run(votdEnabled ? 1 : 0, votdTime || null, userId); } /** Turns the daily prayer reminder on or off, and says when to send it. */ export function setPrayerReminder(userId, { prayerEnabled, prayerTime }) { db.prepare('UPDATE users SET prayer_enabled = ?, prayer_time = ? WHERE id = ?').run(prayerEnabled ? 1 : 0, prayerTime || null, userId); } export function getUsersWithPrayerReminder() { return db.prepare(` SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, prayer_time AS prayerTime, last_prayer_date AS lastPrayerDate, reminder_tz AS reminderTz FROM users WHERE prayer_enabled = 1 AND ntfy_topic IS NOT NULL AND prayer_time IS NOT NULL `).all(); } export function setLastPrayerDate(userId, dateString) { db.prepare('UPDATE users SET last_prayer_date = ? WHERE id = ?').run(dateString, userId); } export function getPrayers(userId) { const row = db.prepare('SELECT data FROM prayers WHERE user_id = ?').get(userId); try { return row ? JSON.parse(row.data) : []; } catch { return []; } } export function setPrayers(userId, list) { db.prepare(` INSERT INTO prayers (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(list), Date.now()); } /** Every account with the verse of the day turned on and a topic to send it to. */ export function getUsersWithVotdEnabled() { return db.prepare(` SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, votd_time AS votdTime, last_votd_date AS lastVotdDate, reminder_tz AS reminderTz FROM users WHERE votd_enabled = 1 AND ntfy_topic IS NOT NULL AND votd_time IS NOT NULL `).all(); } export function setLastVotdDate(userId, dateString) { db.prepare('UPDATE users SET last_votd_date = ? WHERE id = ?').run(dateString, userId); } /** Every account with reminders turned on and a topic configured — checked once a minute. */ export function getUsersWithRemindersEnabled() { return db.prepare(` SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz FROM users WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL `).all(); } /** Marks that a user's reminder was sent today, so it isn't sent twice. */ export function setLastReminderDate(userId, dateString) { db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId); } // --------------------------------------------------------------------------- // Settings and sign-up invites // --------------------------------------------------------------------------- export function getSetting(key, fallback = null) { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return row ? row.value : fallback; } export function setSetting(key, value) { db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value)); } export function createInvite(code, note, expiresAt) { db.prepare('INSERT INTO invites (code, note, created_at, expires_at) VALUES (?, ?, ?, ?)').run(code, note || null, Date.now(), expiresAt); } export function listInvites() { return db.prepare('SELECT code, note, created_at AS createdAt, expires_at AS expiresAt, used_by AS usedBy, used_at AS usedAt FROM invites ORDER BY created_at DESC LIMIT 100').all(); } export function deleteInvite(code) { db.prepare('DELETE FROM invites WHERE code = ?').run(code); } /** An invite that exists, hasn't expired and hasn't been used. */ export function findOpenInvite(code) { return db.prepare('SELECT code FROM invites WHERE code = ? AND used_by IS NULL AND expires_at > ?').get(code, Date.now()) ?? null; } /** Marks an invite used by a new account; false if someone else got there first. */ export function useInvite(code, userId) { return db.prepare('UPDATE invites SET used_by = ?, used_at = ? WHERE code = ? AND used_by IS NULL AND expires_at > ?').run(userId, Date.now(), code, Date.now()).changes > 0; } // --------------------------------------------------------------------------- // Password reset codes // --------------------------------------------------------------------------- export function setPasswordReset(userId, codeHash, expires) { db.prepare(` INSERT INTO password_resets (user_id, code_hash, expires, attempts) VALUES (?, ?, ?, 0) ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires = excluded.expires, attempts = 0 `).run(userId, codeHash, expires); } export function getPasswordReset(userId) { const row = db.prepare('SELECT code_hash AS codeHash, expires, attempts FROM password_resets WHERE user_id = ?').get(userId); return row && row.expires > Date.now() ? row : null; } export function bumpPasswordResetAttempts(userId) { db.prepare('UPDATE password_resets SET attempts = attempts + 1 WHERE user_id = ?').run(userId); } export function clearPasswordReset(userId) { db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(userId); } /** Rewrites the remaining backup-code hashes after one is used (single-use codes). */ export function setBackupCodeHashes(userId, backupCodeHashes) { db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId); } // --------------------------------------------------------------------------- // Reader ink — cross-device sync for draw-mode annotations in the reader // --------------------------------------------------------------------------- /** Returns all saved reader ink pages for a user as { "BOOK_CH": strokes[] }. */ export function getAllReaderInk(userId) { const rows = db.prepare( 'SELECT book_abbrev AS book, chapter, strokes FROM reader_ink WHERE user_id = ?' ).all(userId); return Object.fromEntries( rows.map((r) => [`${r.book}_${r.chapter}`, JSON.parse(r.strokes)]) ); } /** 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: [], bookmarkNotes: {}, updatedAt: 0 }; const data = JSON.parse(row.data); return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], bookmarkNotes: data.bookmarkNotes ?? {}, updatedAt: row.updatedAt }; } /** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */ export function setReaderMarks(userId, { bookmarks, highlights, bookmarkNotes = {} }) { 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, bookmarkNotes }), updatedAt); return updatedAt; } export function getReaderHistory(userId) { const row = db.prepare('SELECT data FROM reader_history WHERE user_id = ?').get(userId); try { return row ? JSON.parse(row.data) : { chapters: {}, days: [] }; } catch { return { chapters: {}, days: [] }; } } export function setReaderHistory(userId, history) { db.prepare(` INSERT INTO reader_history (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(history), Date.now()); } /** Upserts the ink strokes for one reader page. */ export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) { db.prepare(` INSERT INTO reader_ink (user_id, book_abbrev, chapter, strokes, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, book_abbrev, chapter) DO UPDATE SET strokes = excluded.strokes, updated_at = excluded.updated_at `).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now()); } // --------------------------------------------------------------------------- // Reading plan progress — cross-device sync for started/completed reading plans // --------------------------------------------------------------------------- /** Returns the user's reading-plan data ({ progress, customPlans }, parsed JSON), or an empty shell. */ export function getReadingProgress(userId) { const row = db.prepare('SELECT data FROM reading_progress WHERE user_id = ?').get(userId); if (!row) return { progress: [], customPlans: [] }; try { return JSON.parse(row.data); } catch { return { progress: [], customPlans: [] }; } } /** Overwrites the user's whole reading-plan progress list. */ export function setReadingProgress(userId, progress) { db.prepare(` INSERT INTO reading_progress (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(progress), Date.now()); } /** * Assigns any pre-existing, unowned projects (from before multi-user support) * to the given user. Intended to run once, right after the first account is created. */ export function claimOrphanProjects(userId) { db.prepare('UPDATE projects SET user_id = ? WHERE user_id IS NULL').run(userId); } // --------------------------------------------------------------------------- // Project queries — all scoped to the owning user // --------------------------------------------------------------------------- /** * Returns all project summaries owned by userId (id, title, lastEdited, chapterSummary). * Does NOT return full project data to keep the response small. */ export function getAllProjects(userId) { // the studies you own, plus the ones another account has invited you to edit const rows = db.prepare(` SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary, CASE WHEN p.user_id = ? THEN 0 ELSE 1 END AS shared, u.email AS ownerEmail FROM projects p LEFT JOIN users u ON u.id = p.user_id WHERE p.user_id = ? OR p.id IN (SELECT project_id FROM collaborators WHERE user_id = ?) ORDER BY p.last_edited DESC `).all(userId, userId, userId); return rows.map((r) => (r.shared ? { ...r, shared: true } : { id: r.id, title: r.title, lastEdited: r.lastEdited, chapterSummary: r.chapterSummary })); } /** True when the account owns the study or has been invited to edit it. */ export function canEditProject(id, userId) { return !!db.prepare(` SELECT 1 FROM projects p WHERE p.id = ? AND (p.user_id = ? OR EXISTS (SELECT 1 FROM collaborators c WHERE c.project_id = p.id AND c.user_id = ?)) `).get(id, userId, userId); } export const isProjectOwner = (id, userId) => !!db.prepare('SELECT 1 FROM projects WHERE id = ? AND user_id = ?').get(id, userId); /** * Returns a single full project by id, scoped to userId, or null if not found/not owned. */ export function getProject(id, userId) { if (!canEditProject(id, userId)) return null; const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id); if (!row) return null; try { return JSON.parse(row.data); } catch { return null; } } /** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */ export function getProjectEdited(id, userId) { if (!canEditProject(id, userId)) return null; const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(id); return row ? row.lastEdited : null; } /** * Insert or replace a project owned by userId. * Returns the summary, or null if the id already belongs to a different user. */ export function upsertProject(project, userId) { const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id); if (existing && existing.userId !== userId && !canEditProject(project.id, userId)) { return null; } // The server's clock decides the version, so a device with a wrong clock can't hide or fake changes. const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(project.id); if (prev) keepVersion(project.id, prev); const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1); const chapterSummary = buildSummary(project); const updated = { ...project, lastEdited }; db.prepare(` INSERT INTO projects (id, title, last_edited, chapter_summary, data, user_id) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, last_edited = excluded.last_edited, chapter_summary = excluded.chapter_summary, data = excluded.data `).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated), userId); return { id: project.id, title: project.title, lastEdited, chapterSummary }; } // --------------------------------------------------------------------------- // Earlier versions // --------------------------------------------------------------------------- /** Keeps the copy that is about to be replaced (when it's been long enough since the last kept one), then trims the oldest. */ function keepVersion(projectId, prev, force = false) { const last = db.prepare('SELECT MAX(state_at) AS at FROM project_versions WHERE project_id = ?').get(projectId)?.at; if (!shouldSnapshot({ prevEditedAt: prev.lastEdited, lastKeptAt: last ?? undefined, force })) return; let project; try { project = JSON.parse(prev.data); } catch { return; } if (db.prepare('SELECT 1 FROM project_versions WHERE project_id = ? AND state_at = ?').get(projectId, prev.lastEdited)) return; const { chunks, words } = summarizeProject(project); db.prepare('INSERT INTO project_versions (project_id, state_at, title, chunks, words, data) VALUES (?, ?, ?, ?, ?, ?)') .run(projectId, prev.lastEdited, project.title ?? '', chunks, words, pack(prev.data)); db.prepare(`DELETE FROM project_versions WHERE project_id = ? AND id NOT IN (SELECT id FROM project_versions WHERE project_id = ? ORDER BY state_at DESC LIMIT ?)`).run(projectId, projectId, MAX_VERSIONS); } export function listVersions(projectId) { return db.prepare('SELECT id, state_at AS savedAt, title, chunks, words FROM project_versions WHERE project_id = ? ORDER BY state_at DESC').all(projectId); } export function getVersion(projectId, versionId) { const row = db.prepare('SELECT data FROM project_versions WHERE project_id = ? AND id = ?').get(projectId, versionId); if (!row) return null; try { return unpack(row.data); } catch { return null; } } /** Puts an earlier version back as the current one. The copy it replaces is kept first, so a restore can be undone too. */ export function restoreVersion(projectId, versionId, userId) { const old = getVersion(projectId, versionId); if (!old || !canEditProject(projectId, userId)) return null; return db.transaction(() => { const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(projectId); if (prev) keepVersion(projectId, prev, true); const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1); const restored = { ...old, id: projectId, lastEdited }; db.prepare('UPDATE projects SET title = ?, last_edited = ?, chapter_summary = ?, data = ? WHERE id = ?') .run(restored.title, lastEdited, buildSummary(restored), JSON.stringify(restored), projectId); return restored; })(); } /** * Delete a project by id, scoped to userId. No-op if not found/not owned. */ export function deleteProject(id, userId) { db.transaction(() => { if (!db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId).changes) { // not the owner: "deleting" a study you were invited to just removes you from it db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(id, userId); return; } db.prepare('DELETE FROM collaborators WHERE project_id = ?').run(id); db.prepare('DELETE FROM edit_invites WHERE project_id = ?').run(id); db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id); })(); } // --------------------------------------------------------------------------- // Co-teachers // --------------------------------------------------------------------------- export function createEditInvite(token, projectId, expiresAt) { db.prepare('INSERT INTO edit_invites (token, project_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, projectId, Date.now(), expiresAt); } export function listCollaborators(projectId) { return db.prepare(` SELECT c.user_id AS userId, u.email, c.added_at AS addedAt FROM collaborators c JOIN users u ON u.id = c.user_id WHERE c.project_id = ? ORDER BY c.added_at `).all(projectId); } export function countOpenEditInvites(projectId) { return db.prepare('SELECT COUNT(*) AS n FROM edit_invites WHERE project_id = ? AND used_by IS NULL AND expires_at > ?').get(projectId, Date.now()).n; } export function removeCollaborator(projectId, userId) { db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(projectId, userId); } /** Uses a single-use invite: -> { projectId, title } | { error }. The owner is told "you own this" rather than added. */ export function acceptEditInvite(token, userId) { return db.transaction(() => { const inv = db.prepare('SELECT project_id AS projectId, expires_at AS expiresAt, used_by AS usedBy FROM edit_invites WHERE token = ?').get(token); if (!inv) return { error: 'This invite link is not valid.' }; if (inv.usedBy) return { error: 'This invite link has already been used.' }; if (inv.expiresAt <= Date.now()) return { error: 'This invite link has expired. Ask for a new one.' }; const project = db.prepare('SELECT title, user_id AS ownerId FROM projects WHERE id = ?').get(inv.projectId); if (!project) return { error: 'That study no longer exists.' }; if (project.ownerId === userId) return { error: 'That is your own study.' }; db.prepare('INSERT OR IGNORE INTO collaborators (project_id, user_id, added_at) VALUES (?, ?, ?)').run(inv.projectId, userId, Date.now()); db.prepare('UPDATE edit_invites SET used_by = ? WHERE token = ?').run(userId, token); return { projectId: inv.projectId, title: project.title }; })(); } // --------------------------------------------------------------------------- // Read-only share links // --------------------------------------------------------------------------- /** Returns the current share token for a project owned by userId, or null. */ export function getShareToken(id, userId) { const row = db.prepare('SELECT share_token AS shareToken FROM projects WHERE id = ? AND user_id = ?').get(id, userId); return row?.shareToken ?? null; } /** Sets a project's share token (enabling its public read-only link), scoped to userId. */ export function setShareToken(id, userId, token) { const result = db.prepare('UPDATE projects SET share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId); return result.changes > 0; } /** Revokes a project's share link, scoped to userId. */ export function clearShareToken(id, userId) { db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId); } // ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ---- export function getOutlineShareToken(id, userId) { const row = db.prepare('SELECT outline_share_token AS t FROM projects WHERE id = ? AND user_id = ?').get(id, userId); return row?.t ?? null; } export function setOutlineShareToken(id, userId, token) { return db.prepare('UPDATE projects SET outline_share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId).changes > 0; } export function clearOutlineShareToken(id, userId) { db.prepare('UPDATE projects SET outline_share_token = NULL, outline_share_expires = NULL, outline_share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId); } // ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ---- const shareCols = (outline) => (outline ? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' } : { token: 'share_token', expires: 'share_expires', pass: 'share_pass' }); /** { token, expiresAt, hasPasscode } for the owner's link settings. */ export function getShareSettings(id, userId, outline = false) { const c = shareCols(outline); const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId); return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null; } /** The stored passcode hash of a link (kept when only the expiry is changed). */ export function getSharePassHash(id, userId, outline = false) { const c = shareCols(outline); return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null; } /** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */ export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) { const c = shareCols(outline); return db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0; } /** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */ export function getSharedByToken(token, outline = false) { const c = shareCols(outline); const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash FROM projects WHERE ${c.token} = ?`).get(token); if (!row) return null; try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null }; } catch { return null; } } /** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */ export function getProjectByOutlineToken(token) { const row = db.prepare('SELECT data FROM projects WHERE outline_share_token = ?').get(token); if (!row) return null; try { return JSON.parse(row.data); } catch { return null; } } /** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */ export function getProjectByShareToken(token) { const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token); if (!row) return null; try { return JSON.parse(row.data); } catch { return null; } } // --------------------------------------------------------------------------- // Admin — unscoped views across every user/project. Callers must gate access // themselves (see requireAdmin in server/auth.js); nothing here checks who's asking. // --------------------------------------------------------------------------- /** Every account, with a project count, for the admin users list. */ export function adminGetAllUsers() { return db.prepare(` SELECT u.id, u.email, u.created_at AS createdAt, u.totp_enabled AS totpEnabled, (SELECT COUNT(*) FROM projects p WHERE p.user_id = u.id) AS projectCount FROM users u ORDER BY u.created_at ASC `).all().map((r) => ({ ...r, totpEnabled: !!r.totpEnabled })); } /** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */ export function adminDeleteUser(userId) { db.prepare('DELETE FROM passkeys WHERE user_id = ?').run(userId); // projects.user_id is a foreign key, so release the projects first (they stay as unowned rows). db.transaction(() => { db.prepare('DELETE FROM collaborators WHERE user_id = ?').run(userId); db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId); db.prepare('DELETE FROM users WHERE id = ?').run(userId); })(); } /** Every project across every user, with the owner's email, for the admin projects list. */ export function adminGetAllProjects() { return db.prepare(` SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary, p.share_token AS shareToken, u.email AS ownerEmail FROM projects p LEFT JOIN users u ON u.id = p.user_id ORDER BY p.last_edited DESC `).all(); } /** Full project data by id, regardless of owner — for admin inspection. */ export function adminGetProject(id) { const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id); if (!row) return null; try { return JSON.parse(row.data); } catch { return null; } } /** Deletes any project by id, regardless of owner. */ export function adminDeleteProject(id) { db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id); db.prepare('DELETE FROM projects WHERE id = ?').run(id); } /** Overwrites a user's password hash directly — used for both self-service and admin-assisted resets. */ export function setUserPassword(userId, passwordHash) { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId); } // --------------------------------------------------------------------------- // Session store backing (used by server/sessionStore.js) // --------------------------------------------------------------------------- export function getSession(sid) { const row = db.prepare('SELECT sess, expires FROM sessions WHERE sid = ?').get(sid); if (!row || row.expires < Date.now()) return null; try { return JSON.parse(row.sess); } catch { return null; } } export function setSession(sid, sess, expires) { db.prepare(` INSERT INTO sessions (sid, sess, expires) VALUES (?, ?, ?) ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires = excluded.expires `).run(sid, JSON.stringify(sess), expires); } export function destroySession(sid) { db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); } export function pruneExpiredSessions() { db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now()); } /** * Logs a user out everywhere by deleting every session that belongs to them. * Sessions don't have an indexed user_id column (they're just an opaque JSON * blob to express-session), so this scans and parses — fine at this app's scale. * Pass exceptSid to keep one session alive (e.g. the one completing a self-service * password change, so the user isn't immediately logged out of their own action). */ export function destroyAllSessionsForUser(userId, exceptSid = null) { const rows = db.prepare('SELECT sid, sess FROM sessions').all(); const staleSids = rows .filter((row) => row.sid !== exceptSid) .filter((row) => { try { return JSON.parse(row.sess)?.userId === userId; } catch { return false; } }) .map((row) => row.sid); if (staleSids.length === 0) return; const placeholders = staleSids.map(() => '?').join(','); db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids); } // --------------------------------------------------------------------------- // Passkeys, sign-in alerts and the list of signed-in devices // --------------------------------------------------------------------------- export function setLoginAlerts(userId, enabled) { db.prepare('UPDATE users SET login_alerts = ? WHERE id = ?').run(enabled ? 1 : 0, userId); } export function addPasskey({ id, userId, publicKey, counter, transports, name }) { db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') .run(id, userId, publicKey, counter, JSON.stringify(transports ?? []), name, Date.now()); } const passkeyRow = (r) => (r ? { ...r, transports: JSON.parse(r.transports || '[]') } : null); const PASSKEY_COLS = 'id, user_id AS userId, public_key AS publicKey, counter, transports, name, created_at AS createdAt, last_used_at AS lastUsedAt'; export const getPasskey = (id) => passkeyRow(db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE id = ?`).get(id)); export const listPasskeys = (userId) => db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE user_id = ? ORDER BY created_at`).all(userId).map(passkeyRow); export const touchPasskey = (id, counter) => db.prepare('UPDATE passkeys SET counter = ?, last_used_at = ? WHERE id = ?').run(counter, Date.now(), id); export const deletePasskey = (id, userId) => db.prepare('DELETE FROM passkeys WHERE id = ? AND user_id = ?').run(id, userId).changes > 0; export const renamePasskey = (id, userId, name) => db.prepare('UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?').run(name, id, userId).changes > 0; /** Every live session of one user: [{ sid, sess, expires }]. */ export function listUserSessions(userId) { return db.prepare('SELECT sid, sess, expires FROM sessions WHERE expires > ?').all(Date.now()) .map((r) => { try { return { sid: r.sid, sess: JSON.parse(r.sess), expires: r.expires }; } catch { return null; } }) .filter((r) => r?.sess?.userId === userId); }