Add cross-reference map, admin health page, passkeys and device list, settings tabs
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph - Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs - Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts - Settings split into Account, Preferences, Notifications, Security, Your data tabs - ntfy server field: catch a topic typed into the server box Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
207bfcb919
commit
bdb04cdedc
35 files changed
+1860
-22
No files matched your search
+60
-2
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import {
|
||||
initDb, dataDir, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
|
||||
initDb, dataDir, listPasskeys, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash,
|
||||
@@ -35,11 +35,13 @@ 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 { registerDeviceRoutes, noteSignIn } from './devices.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 { startBackupScheduler, runBackup, restoreSnapshot, snapshotPath } from './backups.js';
|
||||
import { logEvent, recentEvents, diskUse, backupStatus, uptimeSeconds } from './health.js';
|
||||
import { cleanMarks } from './readerMarks.js';
|
||||
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||
import { isSaveConflict, parseBase } from './projectSync.js';
|
||||
@@ -54,6 +56,7 @@ function userPayload(user) {
|
||||
reminderTime: user.reminderTime ?? null,
|
||||
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
|
||||
prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null,
|
||||
loginAlerts: !!user.loginAlerts, passkeyCount: listPasskeys(user.id).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,6 +188,7 @@ app.post('/api/auth/register', async (req, res) => {
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'register');
|
||||
res.json({
|
||||
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
||||
@@ -207,6 +211,7 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
|
||||
if (!valid) {
|
||||
recordFailure(...keys);
|
||||
logEvent('signin', `Wrong password for ${email.slice(0, 80) || '(blank)'} from ${req.ip}`);
|
||||
return res.status(401).json({ error: 'Incorrect email or password.' });
|
||||
}
|
||||
clearFailures(`acct:${email}`);
|
||||
@@ -220,6 +225,7 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
return res.json({ mfaRequired: true });
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'password');
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -256,6 +262,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||
|
||||
if (!ok) {
|
||||
recordFailure(...keys);
|
||||
logEvent('signin', `Wrong two-factor code for ${user.email} from ${req.ip}`, { userId: user.id });
|
||||
return res.status(401).json({ error: 'Invalid code.' });
|
||||
}
|
||||
clearFailures(`mfa:${pendingUserId}`);
|
||||
@@ -263,6 +270,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'mfa');
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -754,6 +762,7 @@ app.put('/api/reader/marks', requireAuth, (req, res) => {
|
||||
// GET /api/reading-plans — the current user's whole progress list
|
||||
registerBibleRoutes(app, requireAuth);
|
||||
registerIqBibleRoutes(app, requireAuth);
|
||||
registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY });
|
||||
|
||||
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
@@ -996,6 +1005,55 @@ app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async
|
||||
});
|
||||
|
||||
// Who may sign up, and the invite codes for it
|
||||
// Server health: disk use, snapshots, and a short log of failures (in memory; resets on restart).
|
||||
app.get('/api/admin/health', requireAuth, requireAdmin, (_req, res) => {
|
||||
try {
|
||||
const emailOf = (id) => (id ? getUserById(id)?.email ?? null : null);
|
||||
const withUser = (e) => ({ ...e, email: emailOf(e.userId) });
|
||||
res.json({
|
||||
uptimeSeconds: uptimeSeconds(), node: process.version, dataDir: dataDir(),
|
||||
disk: diskUse(), backups: backupStatus(),
|
||||
iqConfigured: !!process.env.IQBIBLE_API_KEY,
|
||||
signinFailures: recentEvents('signin', 30).map(withUser),
|
||||
notifyFailures: recentEvents('notify', 30).map(withUser),
|
||||
backupFailures: recentEvents('backup', 10),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/health error:', err);
|
||||
res.status(500).json({ error: 'Failed to read server health.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/backups', requireAuth, requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
await runBackup();
|
||||
res.json(backupStatus());
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/backups error:', err);
|
||||
logEvent('backup', err.message);
|
||||
res.status(500).json({ error: 'The snapshot could not be made.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/backups/:name', requireAuth, requireAdmin, (req, res) => {
|
||||
const file = snapshotPath(req.params.name);
|
||||
if (!file) return res.status(404).json({ error: 'No such snapshot.' });
|
||||
res.download(file, req.params.name);
|
||||
});
|
||||
|
||||
// Restoring replaces every account's data, so the snapshot's name has to be typed back as confirmation.
|
||||
app.post('/api/admin/backups/:name/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||
if (!snapshotPath(req.params.name)) return res.status(404).json({ error: 'No such snapshot.' });
|
||||
if (req.body?.confirm !== req.params.name) return res.status(400).json({ error: 'Type the snapshot’s name to confirm.' });
|
||||
try {
|
||||
const result = await restoreSnapshot(req.params.name);
|
||||
res.json({ restored: result.restored, safetyCopy: result.safetyCopy.split('/').pop() });
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/backups/:name/restore error:', err);
|
||||
res.status(500).json({ error: 'The snapshot could not be restored.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
|
||||
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user