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
+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());
|
||||
|
||||
Reference in new issue
Block a user