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>
1143 lines
51 KiB
JavaScript
1143 lines
51 KiB
JavaScript
import express from 'express';
|
||
import compression from 'compression';
|
||
import session from 'express-session';
|
||
import QRCode from 'qrcode';
|
||
import { randomUUID, randomBytes } from 'crypto';
|
||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||
import { fileURLToPath } from 'url';
|
||
import { dirname, join } from 'path';
|
||
import {
|
||
initDb, dataDir, listPasskeys, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
|
||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||
setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash,
|
||
setOutlineShareToken, clearOutlineShareToken,
|
||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||
setUserPassword, destroyAllSessionsForUser,
|
||
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||
getReadingProgress, setReadingProgress,
|
||
setReminderSettings, setVotdSettings, setPrayerReminder, getPrayers, setPrayers,
|
||
getReaderHistory, setReaderHistory,
|
||
canEditProject, listVersions, getVersion, restoreVersion,
|
||
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';
|
||
import {
|
||
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
|
||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||
generateResetCode, normalizeResetCode,
|
||
} from './auth.js';
|
||
import { mergePrayers } from './prayers.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 { 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, 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';
|
||
import { buildOutlinePayload } from './outlineShare.js';
|
||
|
||
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||
function userPayload(user) {
|
||
return {
|
||
id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null,
|
||
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(),
|
||
prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null,
|
||
loginAlerts: !!user.loginAlerts, passkeyCount: listPasskeys(user.id).length,
|
||
};
|
||
}
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const app = express();
|
||
const PORT = process.env.PORT || 3001;
|
||
const isProd = process.env.NODE_ENV === 'production';
|
||
|
||
// Session cookies are signed with SESSION_SECRET. When it isn't set (setup.sh doesn't set one), a random secret
|
||
// is made once and kept in DATA_DIR, so it survives restarts and nobody has to configure anything.
|
||
function sessionSecret() {
|
||
if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET;
|
||
if (!isProd) return 'dev-only-secret-change-me';
|
||
const file = join(dataDir(), '.session-secret');
|
||
try {
|
||
if (existsSync(file)) return readFileSync(file, 'utf8').trim();
|
||
mkdirSync(dataDir(), { recursive: true });
|
||
const made = randomBytes(48).toString('hex');
|
||
writeFileSync(file, made, { mode: 0o600 });
|
||
console.log(`SESSION_SECRET is not set; generated one and saved it to ${file}.`);
|
||
return made;
|
||
} catch (err) {
|
||
console.error('Could not read or save a session secret; set SESSION_SECRET instead.', err.message);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
|
||
app.set('trust proxy', 1);
|
||
|
||
// In-memory limiter for sign-in style endpoints: 10 FAILED attempts per 15 min, tracked per IP and per
|
||
// account (key). Successful sign-ins don't count, so a shared IP doesn't lock everyone out.
|
||
const _failures = new Map();
|
||
const FAIL_WINDOW = 15 * 60 * 1000;
|
||
const FAIL_MAX = 10;
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k);
|
||
}, 60 * 60 * 1000).unref();
|
||
function isLimited(...keys) {
|
||
const now = Date.now();
|
||
return keys.some((k) => { const e = _failures.get(k); return e && now <= e.resetAt && e.count >= FAIL_MAX; });
|
||
}
|
||
function recordFailure(...keys) {
|
||
const now = Date.now();
|
||
for (const k of keys) {
|
||
const e = _failures.get(k);
|
||
if (!e || now > e.resetAt) _failures.set(k, { count: 1, resetAt: now + FAIL_WINDOW });
|
||
else e.count += 1;
|
||
}
|
||
}
|
||
function clearFailures(...keys) { keys.forEach((k) => _failures.delete(k)); }
|
||
const TOO_MANY = { error: 'Too many attempts. Please wait 15 minutes.' };
|
||
|
||
// Compared against when an email isn't registered, so a login takes the same time either way.
|
||
const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK';
|
||
|
||
// TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window.
|
||
const _usedTotp = new Map();
|
||
function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; }
|
||
function markTotpUsed(userId, token) {
|
||
_usedTotp.set(userId, token);
|
||
setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref();
|
||
}
|
||
|
||
// Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too.
|
||
app.use(compression());
|
||
app.use(express.json({ limit: '10mb' }));
|
||
app.use(session({
|
||
store: new SqliteSessionStore(),
|
||
secret: sessionSecret(),
|
||
resave: false,
|
||
saveUninitialized: false,
|
||
rolling: true,
|
||
cookie: {
|
||
httpOnly: true,
|
||
// 'auto' = Secure when the request arrived over HTTPS (via the proxy's
|
||
// X-Forwarded-Proto). A hard `true` silently breaks login if the proxy
|
||
// doesn't send that header, or when the app is opened over plain http.
|
||
secure: isProd ? 'auto' : false,
|
||
sameSite: 'lax',
|
||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||
},
|
||
}));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Health check
|
||
// ---------------------------------------------------------------------------
|
||
app.get('/api/health', (_req, res) => {
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Auth
|
||
// ---------------------------------------------------------------------------
|
||
|
||
app.post('/api/auth/register', async (req, res) => {
|
||
try {
|
||
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY);
|
||
recordFailure(`ip:${req.ip}`); // every registration attempt counts
|
||
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.' });
|
||
}
|
||
if (!isValidPassword(password)) {
|
||
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||
}
|
||
if (getUserByEmail(email)) {
|
||
return res.status(409).json({ error: 'An account with that email already exists.' });
|
||
}
|
||
|
||
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) {
|
||
claimOrphanProjects(user.id);
|
||
}
|
||
|
||
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,
|
||
});
|
||
});
|
||
} catch (err) {
|
||
console.error('POST /api/auth/register error:', err);
|
||
res.status(500).json({ error: 'Failed to register.' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/auth/login', async (req, res) => {
|
||
try {
|
||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||
const password = String(req.body?.password ?? '');
|
||
const keys = [`ip:${req.ip}`, `acct:${email}`];
|
||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||
|
||
const user = getUserByEmail(email);
|
||
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}`);
|
||
|
||
req.session.regenerate((err) => {
|
||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||
if (user.totpEnabled) {
|
||
// Password is correct, but the session stays unauthenticated (no userId)
|
||
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
|
||
req.session.pendingUserId = user.id;
|
||
return res.json({ mfaRequired: true });
|
||
}
|
||
req.session.userId = user.id;
|
||
noteSignIn(req, user, 'password');
|
||
res.json(userPayload(user));
|
||
});
|
||
} catch (err) {
|
||
console.error('POST /api/auth/login error:', err);
|
||
res.status(500).json({ error: 'Failed to log in.' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/auth/mfa/verify', async (req, res) => {
|
||
try {
|
||
const pendingUserId = req.session?.pendingUserId;
|
||
if (!pendingUserId) {
|
||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||
}
|
||
const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`];
|
||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||
const user = getUserById(pendingUserId);
|
||
if (!user || !user.totpEnabled) {
|
||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||
}
|
||
|
||
const token = req.body?.token;
|
||
const backupCode = req.body?.backupCode;
|
||
let ok = token ? verifyTotpToken(String(token), user.totpSecret) && !totpAlreadyUsed(user.id, String(token)) : false;
|
||
if (ok) markTotpUsed(user.id, String(token));
|
||
|
||
if (!ok && backupCode) {
|
||
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
|
||
if (remaining) {
|
||
setBackupCodeHashes(user.id, remaining);
|
||
ok = true;
|
||
}
|
||
}
|
||
|
||
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}`);
|
||
|
||
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) {
|
||
console.error('POST /api/auth/mfa/verify error:', err);
|
||
res.status(500).json({ error: 'Failed to verify code.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same
|
||
// one used for reading reminders). Accounts without a topic still go through the admin. The answer is the
|
||
// same whether or not the account exists, so this can't be used to find out who has one.
|
||
// ---------------------------------------------------------------------------
|
||
const RESET_MINUTES = 15;
|
||
const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' };
|
||
|
||
app.post('/api/auth/forgot', async (req, res) => {
|
||
try {
|
||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||
const keys = [`ip:${req.ip}`, `forgot:${email}`];
|
||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||
recordFailure(...keys); // every request counts
|
||
const user = isValidEmail(email) ? getUserByEmail(email) : null;
|
||
if (user?.ntfyTopic) {
|
||
const code = generateResetCode();
|
||
setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000);
|
||
sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' })
|
||
.catch((err) => console.error('Password reset notification failed:', err.message));
|
||
}
|
||
res.json(RESET_REPLY);
|
||
} catch (err) {
|
||
console.error('POST /api/auth/forgot error:', err);
|
||
res.status(500).json({ error: 'Failed to start the reset.' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/auth/reset', async (req, res) => {
|
||
try {
|
||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||
const keys = [`ip:${req.ip}`, `reset:${email}`];
|
||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||
const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); };
|
||
const newPassword = String(req.body?.newPassword ?? '');
|
||
if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||
|
||
const user = getUserByEmail(email);
|
||
const reset = user ? getPasswordReset(user.id) : null;
|
||
const code = normalizeResetCode(req.body?.code);
|
||
// Always spend one bcrypt compare, so timing doesn't show whether a code was issued
|
||
const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5;
|
||
if (reset && !codeOk) bumpPasswordResetAttempts(user.id);
|
||
if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.');
|
||
if (user.totpEnabled) {
|
||
const token = String(req.body?.token ?? '');
|
||
if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.');
|
||
markTotpUsed(user.id, token);
|
||
}
|
||
setUserPassword(user.id, await hashPassword(newPassword));
|
||
clearPasswordReset(user.id);
|
||
destroyAllSessionsForUser(user.id);
|
||
clearFailures(...keys);
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('POST /api/auth/reset error:', err);
|
||
res.status(500).json({ error: 'Failed to reset the password.' });
|
||
}
|
||
});
|
||
|
||
// 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');
|
||
res.json({ ok: true });
|
||
});
|
||
});
|
||
|
||
app.get('/api/auth/me', (req, res) => {
|
||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||
res.json(userPayload(user));
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
|
||
// ---------------------------------------------------------------------------
|
||
app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
||
try {
|
||
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
|
||
setPodcastName(req.session.userId, podcastName || null);
|
||
res.json({ podcastName: podcastName || null });
|
||
} catch (err) {
|
||
console.error('PATCH /api/auth/profile error:', err);
|
||
res.status(500).json({ error: 'Failed to save profile.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
||
// ---------------------------------------------------------------------------
|
||
app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
|
||
try {
|
||
const reminderEnabled = !!req.body?.reminderEnabled;
|
||
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
|
||
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
|
||
const reminderTime = String(req.body?.reminderTime ?? '').trim();
|
||
const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null;
|
||
|
||
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||
}
|
||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||
}
|
||
if (ntfyUrl) {
|
||
try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); }
|
||
}
|
||
if (reminderEnabled) {
|
||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
|
||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
|
||
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
|
||
}
|
||
}
|
||
|
||
// 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 });
|
||
const prayerEnabled = !!req.body?.prayerEnabled;
|
||
const prayerTime = String(req.body?.prayerTime ?? '').trim();
|
||
if (prayerEnabled) {
|
||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' });
|
||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' });
|
||
}
|
||
setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null });
|
||
setReminderSettings(req.session.userId, {
|
||
ntfyUrl: ntfyUrl || null,
|
||
ntfyTopic: ntfyTopic || null,
|
||
reminderTime: reminderTime || null,
|
||
reminderEnabled,
|
||
reminderTz,
|
||
});
|
||
res.json({
|
||
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||
votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null,
|
||
});
|
||
} catch (err) {
|
||
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||
// ---------------------------------------------------------------------------
|
||
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||
try {
|
||
const user = getUserById(req.session.userId);
|
||
const currentPassword = String(req.body?.currentPassword ?? '');
|
||
const newPassword = String(req.body?.newPassword ?? '');
|
||
|
||
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||
if (!valid) {
|
||
recordFailure(`pw:${user.id}`);
|
||
return res.status(401).json({ error: 'Current password is incorrect.' });
|
||
}
|
||
if (!isValidPassword(newPassword)) {
|
||
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||
}
|
||
|
||
clearFailures(`pw:${user.id}`);
|
||
const passwordHash = await hashPassword(newPassword);
|
||
setUserPassword(user.id, passwordHash);
|
||
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
|
||
destroyAllSessionsForUser(user.id, req.sessionID);
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('POST /api/auth/change-password error:', err);
|
||
res.status(500).json({ error: 'Failed to change password.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Two-factor auth setup (requires an already-authenticated session)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
|
||
try {
|
||
const user = getUserById(req.session.userId);
|
||
const secret = generateTotpSecret();
|
||
// Held only in the session until confirmed with a real code — never written
|
||
// to the DB (and 2FA never turned on) unless /mfa/enable succeeds below.
|
||
req.session.pendingTotpSecret = secret;
|
||
QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => {
|
||
if (err) return res.status(500).json({ error: 'Failed to generate QR code.' });
|
||
res.json({ secret, qrCodeDataUrl });
|
||
});
|
||
} catch (err) {
|
||
console.error('POST /api/auth/mfa/setup error:', err);
|
||
res.status(500).json({ error: 'Failed to start 2FA setup.' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
|
||
try {
|
||
const secret = req.session.pendingTotpSecret;
|
||
if (!secret) {
|
||
return res.status(400).json({ error: 'Start 2FA setup first.' });
|
||
}
|
||
if (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY);
|
||
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
|
||
recordFailure(`mfaset:${req.session.userId}`);
|
||
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
|
||
}
|
||
|
||
const backupCodes = generateBackupCodes();
|
||
const backupCodeHashes = await hashBackupCodes(backupCodes);
|
||
enableTotp(req.session.userId, secret, backupCodeHashes);
|
||
delete req.session.pendingTotpSecret;
|
||
res.json({ backupCodes });
|
||
} catch (err) {
|
||
console.error('POST /api/auth/mfa/enable error:', err);
|
||
res.status(500).json({ error: 'Failed to enable 2FA.' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
|
||
try {
|
||
const user = getUserById(req.session.userId);
|
||
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
|
||
if (!valid) {
|
||
recordFailure(`pw:${user.id}`);
|
||
return res.status(401).json({ error: 'Incorrect password.' });
|
||
}
|
||
disableTotp(user.id);
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('POST /api/auth/mfa/disable error:', err);
|
||
res.status(500).json({ error: 'Failed to disable 2FA.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ---------------------------------------------------------------------------
|
||
// GET /api/account/export — everything this account holds, as one restorable file
|
||
// ---------------------------------------------------------------------------
|
||
app.get('/api/account/export', requireAuth, (req, res) => {
|
||
try {
|
||
const userId = req.session.userId;
|
||
const user = getUserById(userId);
|
||
const backup = buildAccountExport(user, {
|
||
projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean),
|
||
prefs: () => getUserPrefs(userId),
|
||
marks: () => getReaderMarks(userId),
|
||
ink: () => getAllReaderInk(userId),
|
||
plans: () => getReadingProgress(userId),
|
||
history: () => getReaderHistory(userId),
|
||
prayers: () => getPrayers(userId).filter((p) => !p.deleted),
|
||
});
|
||
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
|
||
res.json(backup);
|
||
} catch (err) {
|
||
console.error('GET /api/account/export error:', err);
|
||
res.status(500).json({ error: 'Failed to export your data.' });
|
||
}
|
||
});
|
||
|
||
// GET /api/projects — list all project summaries owned by the current user
|
||
// ---------------------------------------------------------------------------
|
||
app.get('/api/projects', requireAuth, (req, res) => {
|
||
try {
|
||
const projects = getAllProjects(req.session.userId);
|
||
res.json(projects);
|
||
} catch (err) {
|
||
console.error('GET /api/projects error:', err);
|
||
res.status(500).json({ error: 'Failed to list projects.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GET /api/projects/:id — fetch a single full project owned by the current user
|
||
// ---------------------------------------------------------------------------
|
||
app.get('/api/projects/:id', 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(project);
|
||
} catch (err) {
|
||
console.error('GET /api/projects/:id error:', err);
|
||
res.status(500).json({ error: 'Failed to load project.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PUT /api/projects/:id — create or update a project owned by the current user
|
||
// ---------------------------------------------------------------------------
|
||
app.put('/api/projects/:id', requireAuth, (req, res) => {
|
||
try {
|
||
const body = req.body;
|
||
if (!body || typeof body !== 'object') {
|
||
return res.status(400).json({ error: 'Invalid JSON body.' });
|
||
}
|
||
if (!body.id || !body.title || !Array.isArray(body.chapters)) {
|
||
return res.status(400).json({ error: 'Missing required fields: id, title, chapters.' });
|
||
}
|
||
if (body.id !== req.params.id) {
|
||
return res.status(400).json({ error: 'URL id does not match body id.' });
|
||
}
|
||
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
||
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
||
// Refuse to overwrite a copy another device saved after the one this device last saw
|
||
const stored = getProjectEdited(body.id, req.session.userId);
|
||
if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) {
|
||
return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored });
|
||
}
|
||
const saved = upsertProject(body, req.session.userId);
|
||
if (!saved) {
|
||
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||
}
|
||
res.json(saved);
|
||
} catch (err) {
|
||
console.error('PUT /api/projects/:id error:', err);
|
||
res.status(500).json({ error: 'Failed to save project.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Earlier versions of a study: list, look at one, and put one back (the copy it replaces is kept too)
|
||
// ---------------------------------------------------------------------------
|
||
app.get('/api/projects/:id/versions', requireAuth, (req, res) => {
|
||
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||
res.json({ versions: listVersions(req.params.id) });
|
||
});
|
||
|
||
app.get('/api/projects/:id/versions/:versionId', requireAuth, (req, res) => {
|
||
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||
const version = getVersion(req.params.id, Number(req.params.versionId));
|
||
if (!version) return res.status(404).json({ error: 'That version is gone.' });
|
||
res.json(version);
|
||
});
|
||
|
||
app.post('/api/projects/:id/versions/:versionId/restore', requireAuth, (req, res) => {
|
||
try {
|
||
const restored = restoreVersion(req.params.id, Number(req.params.versionId), req.session.userId);
|
||
if (!restored) return res.status(404).json({ error: 'That version is gone.' });
|
||
res.json(restored);
|
||
} catch (err) {
|
||
console.error('POST restore error:', err);
|
||
res.status(500).json({ error: 'Failed to restore that version.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DELETE /api/projects/:id — remove a project owned by the current user
|
||
// ---------------------------------------------------------------------------
|
||
app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
||
try {
|
||
deleteProject(req.params.id, req.session.userId);
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('DELETE /api/projects/:id error:', err);
|
||
res.status(500).json({ error: 'Failed to delete project.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reader ink — cross-device draw annotations on Bible chapters
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// GET /api/reader/ink — all saved ink pages for the current user
|
||
app.get('/api/reader/ink', requireAuth, (req, res) => {
|
||
try {
|
||
res.json(getAllReaderInk(req.session.userId));
|
||
} catch (err) {
|
||
console.error('GET /api/reader/ink error:', err);
|
||
res.status(500).json({ error: 'Failed to load reader ink.' });
|
||
}
|
||
});
|
||
|
||
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
|
||
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||
try {
|
||
const { book, chapter } = req.params;
|
||
const { strokes } = req.body ?? {};
|
||
if (!/^[1-3A-Za-z]{3}$/.test(book) || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
|
||
if (strokes.length > 20000) return res.status(413).json({ error: 'Too much ink on one page.' });
|
||
setReaderInkPage(req.session.userId, book, chapter, strokes);
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('PUT /api/reader/ink error:', err);
|
||
res.status(500).json({ error: 'Failed to save reader ink.' });
|
||
}
|
||
});
|
||
|
||
// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } })
|
||
app.get('/api/prefs', requireAuth, (req, res) => {
|
||
try {
|
||
res.json({ prefs: getUserPrefs(req.session.userId) });
|
||
} catch (err) {
|
||
console.error('GET /api/prefs error:', err);
|
||
res.status(500).json({ error: 'Failed to load preferences.' });
|
||
}
|
||
});
|
||
|
||
// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result
|
||
app.put('/api/prefs', requireAuth, (req, res) => {
|
||
try {
|
||
const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body));
|
||
setUserPrefs(req.session.userId, merged);
|
||
res.json({ prefs: merged });
|
||
} catch (err) {
|
||
console.error('PUT /api/prefs error:', err);
|
||
res.status(500).json({ error: 'Failed to save preferences.' });
|
||
}
|
||
});
|
||
|
||
// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against
|
||
app.get('/api/reader/marks', requireAuth, (req, res) => {
|
||
try {
|
||
res.json(getReaderMarks(req.session.userId));
|
||
} catch (err) {
|
||
console.error('GET /api/reader/marks error:', err);
|
||
res.status(500).json({ error: 'Failed to load bookmarks and highlights.' });
|
||
}
|
||
});
|
||
|
||
// GET /api/prayers — the prayer journal; PUT merges this device's copy in (newer prayer wins) and returns the whole
|
||
app.get('/api/prayers', requireAuth, (req, res) => {
|
||
try { res.json({ prayers: mergePrayers(getPrayers(req.session.userId), []) }); } catch (err) {
|
||
console.error('GET /api/prayers error:', err);
|
||
res.status(500).json({ error: 'Failed to load your prayers.' });
|
||
}
|
||
});
|
||
app.put('/api/prayers', requireAuth, (req, res) => {
|
||
try {
|
||
const merged = mergePrayers(getPrayers(req.session.userId), req.body?.prayers);
|
||
setPrayers(req.session.userId, merged);
|
||
res.json({ prayers: merged });
|
||
} catch (err) {
|
||
console.error('PUT /api/prayers error:', err);
|
||
res.status(500).json({ error: 'Failed to save your prayers.' });
|
||
}
|
||
});
|
||
|
||
// 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 {
|
||
const marks = cleanMarks(req.body);
|
||
if (!marks) return res.status(400).json({ error: 'Body must be an object.' });
|
||
res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) });
|
||
} catch (err) {
|
||
console.error('PUT /api/reader/marks error:', err);
|
||
res.status(500).json({ error: 'Failed to save bookmarks and highlights.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reading plan progress — cross-device sync for started/completed reading plans
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// 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 {
|
||
res.json(getReadingProgress(req.session.userId));
|
||
} catch (err) {
|
||
console.error('GET /api/reading-plans error:', err);
|
||
res.status(500).json({ error: 'Failed to load reading plan progress.' });
|
||
}
|
||
});
|
||
|
||
// PUT /api/reading-plans — overwrite the current user's whole progress list
|
||
// (an object: { progress: [...], customPlans: [...] })
|
||
app.put('/api/reading-plans', requireAuth, (req, res) => {
|
||
try {
|
||
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||
return res.status(400).json({ error: 'Body must be an object.' });
|
||
}
|
||
const { progress, customPlans, activity } = req.body;
|
||
if ([progress, customPlans, activity].some((v) => v !== undefined && !Array.isArray(v))
|
||
|| (progress?.length ?? 0) > 500 || (customPlans?.length ?? 0) > 200 || (activity?.length ?? 0) > 20000) {
|
||
return res.status(400).json({ error: 'progress, customPlans and activity must be reasonably sized lists.' });
|
||
}
|
||
setReadingProgress(req.session.userId, { progress: progress ?? [], customPlans: customPlans ?? [], activity: activity ?? [] });
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('PUT /api/reading-plans error:', err);
|
||
res.status(500).json({ error: 'Failed to save reading plan progress.' });
|
||
}
|
||
});
|
||
|
||
// POST /api/auth/reminder-test — sends a test notification to the topic in the request
|
||
// body (so it can be tried before saving), or to the saved settings.
|
||
const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed
|
||
app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
|
||
const userId = req.session.userId;
|
||
if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) {
|
||
return res.status(429).json({ error: 'Wait a few seconds before sending another test.' });
|
||
}
|
||
_lastReminderTest.set(userId, Date.now());
|
||
const saved = getUserById(userId);
|
||
const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim();
|
||
const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim();
|
||
if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' });
|
||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||
}
|
||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||
}
|
||
try {
|
||
await sendTestReminder({ ntfyTopic, ntfyUrl });
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
res.status(502).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Read-only share links
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// GET /api/projects/:id/share — current share status for the project owner
|
||
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
|
||
try {
|
||
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.' });
|
||
}
|
||
});
|
||
|
||
// 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 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 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) => {
|
||
try {
|
||
clearShareToken(req.params.id, req.session.userId);
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('DELETE /api/projects/:id/share error:', err);
|
||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 {
|
||
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) => saveLink(req, res, true));
|
||
|
||
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||
try {
|
||
clearOutlineShareToken(req.params.id, req.session.userId);
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('DELETE /api/projects/:id/outline-share error:', err);
|
||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||
}
|
||
});
|
||
|
||
// 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 = 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 (a passcode may be needed)
|
||
app.get('/api/share/:token', async (req, res) => {
|
||
try {
|
||
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.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Admin — restricted to the single designated admin account (see ADMIN_EMAIL
|
||
// in server/auth.js). Full visibility/control over every user and project.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||
try {
|
||
res.json(adminGetAllUsers());
|
||
} catch (err) {
|
||
console.error('GET /api/admin/users error:', err);
|
||
res.status(500).json({ error: 'Failed to list users.' });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||
try {
|
||
if (req.params.id === req.session.userId) {
|
||
return res.status(400).json({ error: "Can't delete your own admin account." });
|
||
}
|
||
adminDeleteUser(req.params.id);
|
||
destroyAllSessionsForUser(req.params.id);
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('DELETE /api/admin/users/:id error:', err);
|
||
res.status(500).json({ error: 'Failed to delete user.' });
|
||
}
|
||
});
|
||
|
||
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
|
||
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
|
||
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
|
||
try {
|
||
const temporaryPassword = generateTemporaryPassword();
|
||
const passwordHash = await hashPassword(temporaryPassword);
|
||
setUserPassword(req.params.id, passwordHash);
|
||
destroyAllSessionsForUser(req.params.id);
|
||
res.json({ temporaryPassword });
|
||
} catch (err) {
|
||
console.error('POST /api/admin/users/:id/reset-password error:', err);
|
||
res.status(500).json({ error: 'Failed to reset password.' });
|
||
}
|
||
});
|
||
|
||
// 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() });
|
||
});
|
||
|
||
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());
|
||
} catch (err) {
|
||
console.error('GET /api/admin/projects error:', err);
|
||
res.status(500).json({ error: 'Failed to list projects.' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||
try {
|
||
const project = adminGetProject(req.params.id);
|
||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||
res.json(project);
|
||
} catch (err) {
|
||
console.error('GET /api/admin/projects/:id error:', err);
|
||
res.status(500).json({ error: 'Failed to load project.' });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||
try {
|
||
adminDeleteProject(req.params.id);
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
console.error('DELETE /api/admin/projects/:id error:', err);
|
||
res.status(500).json({ error: 'Failed to delete project.' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Serve Vite production build (when NODE_ENV=production)
|
||
// ---------------------------------------------------------------------------
|
||
if (isProd) {
|
||
const distPath = join(__dirname, '..', 'dist');
|
||
// Vite's /assets files have content hashes in their names, so they can be
|
||
// cached forever. Other static files (interlinear JSON, icons) cache for a
|
||
// day; index.html and the service worker always revalidate so deploys show up.
|
||
const noCache = (res) => res.setHeader('Cache-Control', 'no-cache');
|
||
app.use('/assets', express.static(join(distPath, 'assets'), { immutable: true, maxAge: '1y' }));
|
||
app.use(express.static(distPath, {
|
||
maxAge: '1d',
|
||
setHeaders: (res, path) => {
|
||
if (path.endsWith('index.html') || path.endsWith('sw.js')) noCache(res);
|
||
},
|
||
}));
|
||
app.get('*', (_req, res) => {
|
||
noCache(res);
|
||
res.sendFile(join(distPath, 'index.html'));
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Start
|
||
// ---------------------------------------------------------------------------
|
||
initDb();
|
||
startReminderScheduler();
|
||
startBackupScheduler();
|
||
app.listen(PORT, () => {
|
||
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
||
if (isProd) {
|
||
console.log('Serving Vite build from /dist');
|
||
}
|
||
});
|