Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:26:43 -04:00
1 parent 88bc59bac0
commit 54cb1c217d
73 files changed
+2739 -134

No files matched your search

+151 -36
View File
@@ -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());