Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s
Build and Push Docker Image / build-and-push (push) Successful in 6m47s
- Admin: sign-up Open / Invite only / Closed with single-use invite links - Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes) - Series: group studies on the dashboard, filter by series, calendar file for a study's sessions - Lesson handout: printable page per session (passage, outline, questions, writing lines) - Save a chunk's notes as your own study template (synced) - Voice dictation for notes and scripts (browser speech recognition) - Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices - Export highlights and bookmarks as Markdown or Word, grouped by named colour - Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY) - Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88bc59bac0
commit
54cb1c217d
73 files changed
+2739
-134
No files matched your search
@@ -21,6 +21,7 @@ export function buildAccountExport(user, store, now = new Date()) {
|
||||
colorLabels: prefs.colorLabels?.v ?? {},
|
||||
ink: store.ink() ?? {},
|
||||
},
|
||||
readingHistory: store.history?.() ?? { chapters: {}, days: [] },
|
||||
readingPlans: {
|
||||
progress: plans.progress ?? [],
|
||||
customPlans: plans.customPlans ?? [],
|
||||
|
||||
@@ -28,6 +28,12 @@ const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_OR
|
||||
ABBREV_BY_NAME.set('psalms', 'PSA');
|
||||
ABBREV_BY_NAME.set('song of songs', 'SNG');
|
||||
|
||||
// The full book name for a USFM code ("JHN" -> "John"), or null.
|
||||
export function bookNameFor(abbrev) {
|
||||
const i = BOOK_ORDER.indexOf(String(abbrev ?? '').toUpperCase());
|
||||
return i >= 0 ? ESV_NAMES[i] : null;
|
||||
}
|
||||
|
||||
export function parseEsvReference(ref) {
|
||||
const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/);
|
||||
const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase());
|
||||
|
||||
+231
-13
@@ -81,6 +81,15 @@ export function initDb() {
|
||||
`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
`);
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -93,9 +102,56 @@ export function initDb() {
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
// 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 (
|
||||
@@ -182,6 +238,7 @@ function parseUserRow(row) {
|
||||
...row,
|
||||
totpEnabled: !!row.totpEnabled,
|
||||
reminderEnabled: !!row.reminderEnabled,
|
||||
votdEnabled: !!row.votdEnabled,
|
||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||
};
|
||||
}
|
||||
@@ -191,7 +248,8 @@ const USER_SELECT = `
|
||||
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
|
||||
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
|
||||
FROM users
|
||||
`;
|
||||
|
||||
@@ -234,6 +292,25 @@ export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime,
|
||||
`).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);
|
||||
}
|
||||
|
||||
/** 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(`
|
||||
@@ -249,6 +326,41 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -325,6 +437,18 @@ export function setReaderMarks(userId, { bookmarks, highlights }) {
|
||||
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(`
|
||||
@@ -376,20 +500,33 @@ export function claimOrphanProjects(userId) {
|
||||
* 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 id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
|
||||
FROM projects
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_edited DESC
|
||||
`).all(userId);
|
||||
return rows;
|
||||
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) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(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);
|
||||
@@ -400,7 +537,8 @@ export function getProject(id, userId) {
|
||||
|
||||
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
|
||||
export function getProjectEdited(id, userId) {
|
||||
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(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;
|
||||
}
|
||||
|
||||
@@ -410,7 +548,7 @@ export function getProjectEdited(id, userId) {
|
||||
*/
|
||||
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) {
|
||||
if (existing && existing.userId !== userId && !canEditProject(project.id, userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -437,7 +575,54 @@ export function upsertProject(project, userId) {
|
||||
* Delete a project by id, scoped to userId. No-op if not found/not owned.
|
||||
*/
|
||||
export function deleteProject(id, userId) {
|
||||
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(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);
|
||||
})();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 };
|
||||
})();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -458,7 +643,7 @@ export function setShareToken(id, userId, token) {
|
||||
|
||||
/** Revokes a project's share link, scoped to userId. */
|
||||
export function clearShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(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 ----
|
||||
@@ -473,7 +658,39 @@ export function setOutlineShareToken(id, userId, token) {
|
||||
}
|
||||
|
||||
export function clearOutlineShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(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). */
|
||||
@@ -513,6 +730,7 @@ export function adminGetAllUsers() {
|
||||
export function adminDeleteUser(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);
|
||||
})();
|
||||
|
||||
+151
-36
@@ -10,13 +10,16 @@ import {
|
||||
initDb, dataDir, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken,
|
||||
setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash,
|
||||
setOutlineShareToken, clearOutlineShareToken,
|
||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||||
getReadingProgress, setReadingProgress,
|
||||
setReminderSettings,
|
||||
setReminderSettings, setVotdSettings,
|
||||
getReaderHistory, setReaderHistory,
|
||||
isProjectOwner, createEditInvite, listCollaborators, countOpenEditInvites, removeCollaborator, acceptEditInvite,
|
||||
getSetting, setSetting, createInvite, listInvites, deleteInvite, findOpenInvite, useInvite,
|
||||
setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset,
|
||||
} from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
@@ -26,9 +29,13 @@ import {
|
||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||
generateResetCode, normalizeResetCode,
|
||||
} from './auth.js';
|
||||
import { votdAvailable } from './votd.js';
|
||||
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
|
||||
import { registerBibleRoutes } from './bibleApi.js';
|
||||
import { registerIqBibleRoutes } from './iqbible.js';
|
||||
import { expiryFromDays, hashPasscode, checkShareAccess } from './shareLinks.js';
|
||||
import { SIGNUP_MODES, isSignupMode, checkSignup, generateInviteCode, normalizeInviteCode } from './signup.js';
|
||||
import { mergeHistory } from './readerHistory.js';
|
||||
import { buildAccountExport, accountExportName } from './accountExport.js';
|
||||
import { startBackupScheduler } from './backups.js';
|
||||
import { cleanMarks } from './readerMarks.js';
|
||||
@@ -43,6 +50,7 @@ function userPayload(user) {
|
||||
isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
|
||||
reminderTime: user.reminderTime ?? null,
|
||||
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,6 +154,9 @@ app.post('/api/auth/register', async (req, res) => {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
const inviteCode = normalizeInviteCode(req.body?.invite);
|
||||
const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) });
|
||||
if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error });
|
||||
if (!isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'Enter a valid email address.' });
|
||||
}
|
||||
@@ -158,6 +169,10 @@ app.post('/api/auth/register', async (req, res) => {
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = createUser({ id: randomUUID(), email, passwordHash });
|
||||
if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) {
|
||||
adminDeleteUser(user.id); // someone used the code between the check and now
|
||||
return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' });
|
||||
}
|
||||
|
||||
// The very first account inherits any projects created before multi-user support existed.
|
||||
if (countUsers() === 1) {
|
||||
@@ -313,6 +328,11 @@ app.post('/api/auth/reset', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code.
|
||||
app.get('/api/auth/signup-mode', (_req, res) => {
|
||||
res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
@@ -367,6 +387,14 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// the verse of the day is optional, and only offered when the server can look it up
|
||||
const votdEnabled = !!req.body?.votdEnabled && votdAvailable();
|
||||
const votdTime = String(req.body?.votdTime ?? '').trim();
|
||||
if (votdEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' });
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' });
|
||||
}
|
||||
setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null });
|
||||
setReminderSettings(req.session.userId, {
|
||||
ntfyUrl: ntfyUrl || null,
|
||||
ntfyTopic: ntfyTopic || null,
|
||||
@@ -376,6 +404,7 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
|
||||
});
|
||||
res.json({
|
||||
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||||
votdEnabled, votdTime: votdTime || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||
@@ -489,6 +518,7 @@ app.get('/api/account/export', requireAuth, (req, res) => {
|
||||
marks: () => getReaderMarks(userId),
|
||||
ink: () => getAllReaderInk(userId),
|
||||
plans: () => getReadingProgress(userId),
|
||||
history: () => getReaderHistory(userId),
|
||||
});
|
||||
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
|
||||
res.json(backup);
|
||||
@@ -632,6 +662,24 @@ app.get('/api/reader/marks', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole
|
||||
app.get('/api/reader/history', requireAuth, (req, res) => {
|
||||
try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) {
|
||||
console.error('GET /api/reader/history error:', err);
|
||||
res.status(500).json({ error: 'Failed to load your reading history.' });
|
||||
}
|
||||
});
|
||||
app.put('/api/reader/history', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergeHistory(getReaderHistory(req.session.userId), req.body);
|
||||
setReaderHistory(req.session.userId, merged);
|
||||
res.json(merged);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/history error:', err);
|
||||
res.status(500).json({ error: 'Failed to save your reading history.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
|
||||
app.put('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
@@ -715,28 +763,37 @@ app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
|
||||
// GET /api/projects/:id/share — current share status for the project owner
|
||||
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) });
|
||||
const settings = getShareSettings(req.params.id, req.session.userId);
|
||||
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token
|
||||
app.post('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
// Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }.
|
||||
// Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token.
|
||||
async function saveLink(req, res, outline) {
|
||||
try {
|
||||
const existing = getShareToken(req.params.id, req.session.userId);
|
||||
const token = existing || randomUUID().replace(/-/g, '');
|
||||
const ok = setShareToken(req.params.id, req.session.userId, token);
|
||||
if (!ok) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
const userId = req.session.userId;
|
||||
const settings = getShareSettings(req.params.id, userId, outline);
|
||||
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||
const token = settings.token || randomUUID().replace(/-/g, '');
|
||||
if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' });
|
||||
const body = req.body ?? {};
|
||||
const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt;
|
||||
// a blank passcode field means "keep it"; null clears; a string sets
|
||||
const current = getSharePassHash(req.params.id, userId, outline);
|
||||
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
|
||||
setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline);
|
||||
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/share error:', err);
|
||||
console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false));
|
||||
|
||||
// DELETE /api/projects/:id/share — revoke the share link
|
||||
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
@@ -749,27 +806,49 @@ app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever
|
||||
// opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => {
|
||||
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) });
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => {
|
||||
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
const token = randomUUID().replace(/-/g, '');
|
||||
const expiresAt = Date.now() + 7 * 86_400_000;
|
||||
createEditInvite(token, req.params.id, expiresAt);
|
||||
res.json({ token, expiresAt });
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => {
|
||||
const me = req.session.userId;
|
||||
if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' });
|
||||
removeCollaborator(req.params.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/join/:token', requireAuth, (req, res) => {
|
||||
const result = acceptEditInvite(req.params.token, req.session.userId);
|
||||
if (result.error) return res.status(400).json({ error: result.error });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
|
||||
// only the outlines (see outlineShare.js), never the notes or scripts.
|
||||
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({ shareToken: getOutlineShareToken(req.params.id, req.session.userId) });
|
||||
const settings = getShareSettings(req.params.id, req.session.userId, true);
|
||||
res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const token = getOutlineShareToken(req.params.id, req.session.userId) || randomUUID().replace(/-/g, '');
|
||||
if (!setOutlineShareToken(req.params.id, req.session.userId, token)) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true));
|
||||
|
||||
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
@@ -781,24 +860,37 @@ app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share-outline/:token — PUBLIC, no login required
|
||||
app.get('/api/share-outline/:token', (req, res) => {
|
||||
// A wrong passcode counts as a failed attempt for that link and address
|
||||
async function openSharedLink(req, res, outline) {
|
||||
const found = getSharedByToken(req.params.token, outline);
|
||||
if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; }
|
||||
const keys = [`ip:${req.ip}`, `share:${req.params.token}`];
|
||||
if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; }
|
||||
const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') });
|
||||
if (!access.ok) {
|
||||
if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys);
|
||||
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
|
||||
return null;
|
||||
}
|
||||
return found.project;
|
||||
}
|
||||
|
||||
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
|
||||
app.get('/api/share-outline/:token', async (req, res) => {
|
||||
try {
|
||||
const project = getProjectByOutlineToken(req.params.token);
|
||||
if (!project) return res.status(404).json({ error: 'This outline link is invalid or has been turned off.' });
|
||||
res.json(buildOutlinePayload(project));
|
||||
const project = await openSharedLink(req, res, true);
|
||||
if (project) res.json(buildOutlinePayload(project));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share-outline/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
|
||||
app.get('/api/share/:token', (req, res) => {
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
|
||||
app.get('/api/share/:token', async (req, res) => {
|
||||
try {
|
||||
const project = getProjectByShareToken(req.params.token);
|
||||
if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' });
|
||||
res.json(project);
|
||||
const project = await openSharedLink(req, res, false);
|
||||
if (project) res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/share/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||
@@ -848,6 +940,29 @@ app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async
|
||||
}
|
||||
});
|
||||
|
||||
// Who may sign up, and the invite codes for it
|
||||
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
|
||||
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
|
||||
});
|
||||
|
||||
app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => {
|
||||
if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` });
|
||||
setSetting('signup', req.body.mode);
|
||||
res.json({ mode: req.body.mode });
|
||||
});
|
||||
|
||||
app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => {
|
||||
const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7));
|
||||
const code = generateInviteCode();
|
||||
createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000);
|
||||
res.json({ code, expiresInDays: days });
|
||||
});
|
||||
|
||||
app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => {
|
||||
deleteInvite(normalizeInviteCode(req.params.code));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllProjects());
|
||||
|
||||
@@ -24,6 +24,21 @@ export function cleanPrefValue(key, value) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (key === 'studyTemplates') {
|
||||
// The person's own study templates: prompts for the note boxes and scaffold text to start each chunk with
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
||||
const fields = (o, names, max) => {
|
||||
const out = {};
|
||||
for (const n of names) { const t = text(o?.[n], max); if (t.trim()) out[n] = t; }
|
||||
return out;
|
||||
};
|
||||
return value.slice(0, 12).filter((t) => t && typeof t.id === 'string' && /^user-[\w-]{1,40}$/.test(t.id) && typeof t.name === 'string' && t.name.trim()).map((t) => {
|
||||
const prompts = fields(t.prompts, ['observation', 'interpretation', 'application'], 800);
|
||||
const starters = fields(t.starters, ['generalNotes', 'observation', 'interpretation', 'application'], 2000);
|
||||
return { id: t.id, name: text(t.name, 40).trim(), description: text(t.description, 120), ...(Object.keys(prompts).length ? { prompts } : {}), ...(Object.keys(starters).length ? { starters } : {}) };
|
||||
});
|
||||
}
|
||||
if (key === 'position') {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const book = String(value.book ?? '').toUpperCase();
|
||||
|
||||
@@ -67,3 +67,14 @@ test('the database keeps one row per user', async () => {
|
||||
expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } });
|
||||
expect(db.getUserPrefs('u2')).toEqual({});
|
||||
});
|
||||
|
||||
test('study templates are kept only when well-formed, and trimmed to sane sizes', async () => {
|
||||
const { cleanPrefValue } = await import('./prefs.js');
|
||||
const good = { id: 'user-abc1', name: ' Lesson ', description: 'x', prompts: { observation: 'Look', junk: 'no' }, starters: { generalNotes: 'Context:', bogus: 'no' } };
|
||||
expect(cleanPrefValue('studyTemplates', [good, { id: 'other', name: 'bad id' }, { id: 'user-2' }, null])).toEqual([
|
||||
{ id: 'user-abc1', name: 'Lesson', description: 'x', prompts: { observation: 'Look' }, starters: { generalNotes: 'Context:' } },
|
||||
]);
|
||||
expect(cleanPrefValue('studyTemplates', 'nope')).toBeUndefined();
|
||||
expect(cleanPrefValue('studyTemplates', Array.from({ length: 30 }, (_, i) => ({ id: `user-${i}`, name: `T${i}` }))).length).toBe(12);
|
||||
expect(cleanPrefValue('studyTemplates', [{ id: 'user-a', name: 'x', starters: { observation: 'y'.repeat(5000) } }])[0].starters.observation.length).toBe(2000);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// What a person has read in the Reader: which chapters (how many times, and when last) and on which days.
|
||||
// Merging two copies never loses anything, so two devices can both add to it: each chapter keeps the larger
|
||||
// count and the later date, and the days are combined. The data comes from the browser, so it is rebuilt field by field.
|
||||
const KEY = /^[1-3A-Z]{3}\.\d{1,3}$/;
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const MAX_DAYS = 5000;
|
||||
|
||||
export const emptyHistory = () => ({ chapters: {}, days: [] });
|
||||
|
||||
export function cleanHistory(raw) {
|
||||
const chapters = {};
|
||||
if (raw?.chapters && typeof raw.chapters === 'object' && !Array.isArray(raw.chapters)) {
|
||||
for (const [key, v] of Object.entries(raw.chapters).slice(0, 1300)) {
|
||||
if (!KEY.test(key) || !v || typeof v !== 'object') continue;
|
||||
const n = Number.isInteger(v.n) ? Math.min(9999, Math.max(1, v.n)) : 1;
|
||||
chapters[key] = { n, last: DATE.test(v.last ?? '') ? v.last : '' };
|
||||
}
|
||||
}
|
||||
const days = Array.isArray(raw?.days) ? [...new Set(raw.days.filter((d) => typeof d === 'string' && DATE.test(d)))].sort().slice(-MAX_DAYS) : [];
|
||||
return { chapters, days };
|
||||
}
|
||||
|
||||
export function mergeHistory(a, b) {
|
||||
const x = cleanHistory(a);
|
||||
const y = cleanHistory(b);
|
||||
const chapters = { ...x.chapters };
|
||||
for (const [key, v] of Object.entries(y.chapters)) {
|
||||
const mine = chapters[key];
|
||||
chapters[key] = mine ? { n: Math.max(mine.n, v.n), last: mine.last > v.last ? mine.last : v.last } : v;
|
||||
}
|
||||
return { chapters, days: [...new Set([...x.days, ...y.days])].sort().slice(-MAX_DAYS) };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanHistory, mergeHistory } from './readerHistory.js';
|
||||
|
||||
test('only well-formed chapters and days are kept', () => {
|
||||
const out = cleanHistory({
|
||||
chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'bad': { n: 1 }, 'GEN.1': { n: -5, last: 'yesterday' }, '1JN.2': null },
|
||||
days: ['2026-10-02', 'nope', '2026-10-01', '2026-10-01'],
|
||||
});
|
||||
expect(out.chapters).toEqual({ 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '' } });
|
||||
expect(out.days).toEqual(['2026-10-01', '2026-10-02']);
|
||||
expect(cleanHistory(null)).toEqual({ chapters: {}, days: [] });
|
||||
});
|
||||
|
||||
test('merging keeps the larger count, the later date and every day', () => {
|
||||
const a = { chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '2026-09-01' } }, days: ['2026-10-01'] };
|
||||
const b = { chapters: { 'JHN.3': { n: 1, last: '2026-10-05' }, 'ROM.8': { n: 1, last: '2026-10-04' } }, days: ['2026-10-04', '2026-10-05'] };
|
||||
const m = mergeHistory(a, b);
|
||||
expect(m.chapters['JHN.3']).toEqual({ n: 2, last: '2026-10-05' });
|
||||
expect(Object.keys(m.chapters).sort()).toEqual(['GEN.1', 'JHN.3', 'ROM.8']);
|
||||
expect(m.days).toEqual(['2026-10-01', '2026-10-04', '2026-10-05']);
|
||||
expect(mergeHistory(m, m)).toEqual(m); // merging again changes nothing
|
||||
});
|
||||
+23
-1
@@ -4,7 +4,8 @@
|
||||
// No API keys or push-subscription plumbing needed.
|
||||
import { lookup } from 'dns/promises';
|
||||
import { isIP } from 'net';
|
||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
|
||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate } from './db.js';
|
||||
import { fetchVerseOfDay, votdMessage } from './votd.js';
|
||||
|
||||
// A reminder still goes out if the server was briefly down (or restarting) at the
|
||||
// exact minute — as long as it's within this window after the chosen time.
|
||||
@@ -121,9 +122,30 @@ export async function checkReminders(now = new Date()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sends the verse of the day to everyone who asked for it, at their own time. One verse a day, the same for all.
|
||||
export async function checkVotd(now = new Date()) {
|
||||
const due = getUsersWithVotdEnabled().filter((u) => isDue({ reminderTime: u.votdTime, reminderTz: u.reminderTz, lastReminderDate: u.lastVotdDate }, now));
|
||||
if (due.length === 0) return;
|
||||
const verse = await fetchVerseOfDay(now);
|
||||
if (!verse) return; // no key, or IQ Bible didn't answer: try again next minute (within the grace window)
|
||||
for (const user of due) {
|
||||
const key = `votd:${user.id}`;
|
||||
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
|
||||
_lastAttempt.set(key, now.getTime());
|
||||
try {
|
||||
await sendNtfy(user, votdMessage(verse));
|
||||
_lastAttempt.delete(key);
|
||||
setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||
} catch (err) {
|
||||
console.error(`Verse of the day failed for user ${user.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks once a minute whether any user's reminder time has arrived. */
|
||||
export function startReminderScheduler() {
|
||||
setInterval(() => {
|
||||
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
|
||||
checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err));
|
||||
}, 60 * 1000);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// The rules for a read-only link: it can expire, and it can need a passcode. Kept separate from the routes
|
||||
// so they can be tested on their own.
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export const MAX_PASSCODE = 100;
|
||||
|
||||
// "never" (null/0) or a number of days, up to a year -> an expiry time in ms (or null)
|
||||
export function expiryFromDays(days, now = Date.now()) {
|
||||
const n = Number(days);
|
||||
if (!days || !Number.isFinite(n) || n <= 0) return null;
|
||||
return now + Math.min(365, Math.round(n)) * 86_400_000;
|
||||
}
|
||||
|
||||
export async function hashPasscode(passcode) {
|
||||
const p = String(passcode ?? '').slice(0, MAX_PASSCODE);
|
||||
return p.trim() ? bcrypt.hash(p.trim(), 10) : null;
|
||||
}
|
||||
|
||||
// -> { ok: true } | { ok: false, status: 410, error } (expired) | { ok: false, status: 401, needsPasscode: true, error }
|
||||
export async function checkShareAccess({ expiresAt, passHash, supplied }, now = Date.now()) {
|
||||
if (expiresAt && expiresAt <= now) return { ok: false, status: 410, error: 'This link has expired. Ask the person who shared it for a new one.' };
|
||||
if (!passHash) return { ok: true };
|
||||
const given = String(supplied ?? '').slice(0, MAX_PASSCODE).trim();
|
||||
if (!given) return { ok: false, status: 401, needsPasscode: true, error: 'This link needs a passcode.' };
|
||||
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { checkShareAccess, expiryFromDays, hashPasscode } from './shareLinks.js';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
test('expiry is a number of days from now, or never', () => {
|
||||
expect(expiryFromDays(null, NOW)).toBeNull();
|
||||
expect(expiryFromDays(0, NOW)).toBeNull();
|
||||
expect(expiryFromDays('x', NOW)).toBeNull();
|
||||
expect(expiryFromDays(7, NOW)).toBe(NOW + 7 * 86_400_000);
|
||||
expect(expiryFromDays(9999, NOW)).toBe(NOW + 365 * 86_400_000); // never more than a year
|
||||
});
|
||||
|
||||
test('a link with no rules is open; an expired one says so', async () => {
|
||||
expect((await checkShareAccess({}, NOW)).ok).toBe(true);
|
||||
expect(await checkShareAccess({ expiresAt: NOW + 1000 }, NOW)).toEqual({ ok: true });
|
||||
expect(await checkShareAccess({ expiresAt: NOW - 1 }, NOW)).toMatchObject({ ok: false, status: 410 });
|
||||
});
|
||||
|
||||
test('a passcode is asked for, and only the right one opens the link', async () => {
|
||||
const passHash = await hashPasscode(' moses ');
|
||||
expect(passHash).not.toContain('moses');
|
||||
expect(await checkShareAccess({ passHash }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true });
|
||||
expect(await checkShareAccess({ passHash, supplied: 'aaron' }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true, error: expect.stringMatching(/not right/) });
|
||||
expect(await checkShareAccess({ passHash, supplied: ' moses ' }, NOW)).toEqual({ ok: true });
|
||||
expect(await hashPasscode(' ')).toBeNull();
|
||||
});
|
||||
|
||||
test('an expired link is reported as expired even before the passcode is checked', async () => {
|
||||
const passHash = await hashPasscode('x');
|
||||
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Who may create an account. The administrator picks one of three settings:
|
||||
// open anyone who can reach the server (the original behaviour)
|
||||
// invite only someone with an unused invite code from the administrator
|
||||
// closed nobody new
|
||||
// The very first account can always be made, so a fresh install can't lock itself out.
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const SIGNUP_MODES = ['open', 'invite', 'closed'];
|
||||
export const isSignupMode = (m) => SIGNUP_MODES.includes(m);
|
||||
|
||||
// -> { ok: true } or { ok: false, status, error }. `inviteOk` says whether the code given is a valid unused one.
|
||||
export function checkSignup({ mode, userCount, inviteOk }) {
|
||||
if (userCount === 0 || mode === 'open') return { ok: true };
|
||||
if (mode === 'closed') return { ok: false, status: 403, error: 'Sign-up is closed. Ask the administrator for an account.' };
|
||||
if (!inviteOk) return { ok: false, status: 403, error: 'Sign-up needs an invite code from the administrator.' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// An easy-to-read invite code (no look-alike characters), e.g. K7QP-4MXD-9TRW.
|
||||
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
export function generateInviteCode() {
|
||||
const chars = Array.from(randomBytes(12), (b) => ALPHABET[b % ALPHABET.length]).join('');
|
||||
return `${chars.slice(0, 4)}-${chars.slice(4, 8)}-${chars.slice(8, 12)}`;
|
||||
}
|
||||
export const normalizeInviteCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{4})(.{4})(.{4})$/, '$1-$2-$3');
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { checkSignup, generateInviteCode, normalizeInviteCode, isSignupMode } from './signup.js';
|
||||
|
||||
test('open lets anyone in; the first account is always allowed', () => {
|
||||
expect(checkSignup({ mode: 'open', userCount: 5, inviteOk: false }).ok).toBe(true);
|
||||
expect(checkSignup({ mode: 'closed', userCount: 0, inviteOk: false }).ok).toBe(true);
|
||||
expect(checkSignup({ mode: 'invite', userCount: 0, inviteOk: false }).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('invite-only needs a good code, closed lets nobody in', () => {
|
||||
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: false })).toMatchObject({ ok: false, status: 403 });
|
||||
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: true }).ok).toBe(true);
|
||||
expect(checkSignup({ mode: 'closed', userCount: 2, inviteOk: true })).toMatchObject({ ok: false, status: 403 });
|
||||
});
|
||||
|
||||
test('invite codes are readable and typed forgivingly', () => {
|
||||
const code = generateInviteCode();
|
||||
expect(code).toMatch(/^[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}$/);
|
||||
expect(normalizeInviteCode(` ${code.toLowerCase().replace(/-/g, ' ')} `)).toBe(code);
|
||||
expect(normalizeInviteCode('abcd2345jkmn')).toBe('ABCD-2345-JKMN');
|
||||
expect(isSignupMode('invite')).toBe(true);
|
||||
expect(isSignupMode('nope')).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Today's verse of the day for the push notification: IQ Bible picks it (the same verse for everyone each UTC day),
|
||||
// in the KJV, which is free to quote. Needs IQBIBLE_API_KEY on the server. One lookup a day serves every user.
|
||||
import { bookNameFor } from './bibleApi.js';
|
||||
|
||||
let cached = null; // { date, verse }
|
||||
|
||||
export const votdAvailable = () => Boolean(process.env.IQBIBLE_API_KEY);
|
||||
|
||||
// IQ Bible's answer -> { reference, text }, or null when it isn't one we can use
|
||||
export function parseVotd(body) {
|
||||
const d = body?.data;
|
||||
const book = bookNameFor(d?.book_usfm);
|
||||
if (!book || !d?.chapter_number || !d?.verse_number || typeof d.text !== 'string' || !d.text.trim()) return null;
|
||||
return { reference: `${book} ${d.chapter_number}:${d.verse_number}`, text: d.text.replace(/^[¶§*\s]+/, '').replace(/\s+/g, ' ').trim() };
|
||||
}
|
||||
|
||||
export async function fetchVerseOfDay(now = new Date(), fetcher = fetch) {
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
if (cached?.date === date) return cached.verse;
|
||||
if (!votdAvailable()) return null;
|
||||
const base = (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
|
||||
try {
|
||||
const res = await fetcher(`${base}/bibles/eng_kjv/verse-of-the-day`, { headers: { 'X-IQBible-API-Key': process.env.IQBIBLE_API_KEY }, signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) return null;
|
||||
const verse = parseVotd(await res.json());
|
||||
if (verse) cached = { date, verse };
|
||||
return verse;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export const clearVotdCache = () => { cached = null; };
|
||||
|
||||
export const votdMessage = ({ reference, text }) => ({ title: `Verse of the day: ${reference}`, body: `${text}\n\n${reference} (KJV)`, tags: 'sunrise' });
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { parseVotd, fetchVerseOfDay, clearVotdCache, votdMessage } from './votd.js';
|
||||
|
||||
beforeEach(() => { clearVotdCache(); process.env.IQBIBLE_API_KEY = 'k'; });
|
||||
afterEach(() => { delete process.env.IQBIBLE_API_KEY; vi.restoreAllMocks(); });
|
||||
|
||||
const body = { data: { book_usfm: 'ROM', chapter_number: 8, verse_number: 28, text: '¶ And we know that all things work together for good.' } };
|
||||
const ok = () => vi.fn(async () => ({ ok: true, json: async () => body }));
|
||||
|
||||
test('IQ Bible\'s verse becomes a reference and clean text', () => {
|
||||
expect(parseVotd(body)).toEqual({ reference: 'Romans 8:28', text: 'And we know that all things work together for good.' });
|
||||
expect(parseVotd({ data: { book_usfm: 'XXX', chapter_number: 1, verse_number: 1, text: 'x' } })).toBeNull();
|
||||
expect(parseVotd({})).toBeNull();
|
||||
});
|
||||
|
||||
test('the notification carries the verse and its reference', () => {
|
||||
expect(votdMessage({ reference: 'Romans 8:28', text: 'All things.' })).toEqual({ title: 'Verse of the day: Romans 8:28', body: 'All things.\n\nRomans 8:28 (KJV)', tags: 'sunrise' });
|
||||
});
|
||||
|
||||
test('one lookup a day serves everyone, and a new day looks again', async () => {
|
||||
const fetcher = ok();
|
||||
const day1 = new Date('2026-10-06T12:00:00Z');
|
||||
expect((await fetchVerseOfDay(day1, fetcher)).reference).toBe('Romans 8:28');
|
||||
await fetchVerseOfDay(new Date('2026-10-06T23:00:00Z'), fetcher);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
await fetchVerseOfDay(new Date('2026-10-07T01:00:00Z'), fetcher);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(fetcher.mock.calls[0][0]).toContain('/bibles/eng_kjv/verse-of-the-day');
|
||||
expect(fetcher.mock.calls[0][1].headers['X-IQBible-API-Key']).toBe('k');
|
||||
});
|
||||
|
||||
test('without a key, or when IQ Bible fails, there is no verse (and nothing is remembered)', async () => {
|
||||
delete process.env.IQBIBLE_API_KEY;
|
||||
expect(await fetchVerseOfDay(new Date(), ok())).toBeNull();
|
||||
process.env.IQBIBLE_API_KEY = 'k';
|
||||
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => ({ ok: false })))).toBeNull();
|
||||
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => { throw new Error('down'); }))).toBeNull();
|
||||
expect((await fetchVerseOfDay(new Date(), ok())).reference).toBe('Romans 8:28'); // a later good answer still works
|
||||
});
|
||||
Reference in new issue
Block a user