Files
study-bible-project/server/index.js
T
nmemmertandClaude Sonnet 5.5 760c1e568b
Build and Push Docker Image / build-and-push (push) Failing after 19s
Sync bookmarks, highlights and display preferences to the account
Bookmarks and text highlights are saved per user and merged across devices
(union on first contact, newer server copy when this device is clean). Theme,
default translation, verse layout, commentary source and reading position sync
too, each with its own timestamp so the newest change to each setting wins.
The Reader never moves under you while it is open. Signing out clears this
device's marks and forgets sync timestamps.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:45:23 -04:00

713 lines
28 KiB
JavaScript

import express from 'express';
import compression from 'compression';
import session from 'express-session';
import QRCode from 'qrcode';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import {
initDb, getAllProjects, getProject, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
getReadingProgress, setReadingProgress,
setReminderSettings,
} from './db.js';
import { SqliteSessionStore } from './sessionStore.js';
import {
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
generateTotpSecret, totpKeyUri, verifyTotpToken,
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
} from './auth.js';
import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js';
import { registerBibleRoutes } from './bibleApi.js';
import { cleanMarks } from './readerMarks.js';
import { cleanPrefsPatch, mergePrefs } from './prefs.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,
};
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3001;
const isProd = process.env.NODE_ENV === 'production';
if (isProd && !process.env.SESSION_SECRET) {
console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.');
}
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
app.set('trust proxy', 1);
// Simple in-memory rate limiter for auth endpoints — 10 attempts per 15 min per IP.
const _authAttempts = new Map();
setInterval(() => {
const now = Date.now();
for (const [ip, e] of _authAttempts) if (now > e.resetAt) _authAttempts.delete(ip);
}, 60 * 60 * 1000);
function checkAuthRateLimit(ip) {
const now = Date.now();
const window = 15 * 60 * 1000;
const e = _authAttempts.get(ip) ?? { count: 0, resetAt: now + window };
if (now > e.resetAt) { e.count = 0; e.resetAt = now + window; }
e.count += 1;
_authAttempts.set(ip, e);
return e.count > 10;
}
// 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: process.env.SESSION_SECRET || 'dev-only-secret-change-me',
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 (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? '');
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 });
// 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;
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 {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? '');
const user = getUserByEmail(email);
const valid = user && await verifyPassword(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Incorrect email or password.' });
}
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;
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 {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
const pendingUserId = req.session?.pendingUserId;
if (!pendingUserId) {
return res.status(400).json({ error: 'No sign-in in progress.' });
}
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) : false;
if (!ok && backupCode) {
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
if (remaining) {
setBackupCodeHashes(user.id, remaining);
ok = true;
}
}
if (!ok) {
return res.status(401).json({ error: 'Invalid code.' });
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
req.session.userId = user.id;
res.json(userPayload(user));
});
} catch (err) {
console.error('POST /api/auth/mfa/verify error:', err);
res.status(500).json({ error: 'Failed to verify code.' });
}
});
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, (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 (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.' });
}
}
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,
});
} 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 ?? '');
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
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.' });
}
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 (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
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);
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
if (!valid) {
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/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.' });
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.' });
}
});
// ---------------------------------------------------------------------------
// 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 (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
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.' });
}
});
// 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);
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.' });
}
setReadingProgress(req.session.userId, req.body);
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 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) });
} 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) => {
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 });
} catch (err) {
console.error('POST /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to enable sharing.' });
}
});
// 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.' });
}
});
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
app.get('/api/share/:token', (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);
} 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);
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.' });
}
});
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();
app.listen(PORT, () => {
console.log(`Bible Study API running on http://localhost:${PORT}`);
if (isProd) {
console.log('Serving Vite build from /dist');
}
});