1a586db785
Previously reader ink was localStorage-only so it never followed the user to a different browser or device. - New reader_ink table (user_id, book_abbrev, chapter, strokes JSON) - GET /api/reader/ink — loads all pages for the signed-in user - PUT /api/reader/ink/:book/:chapter — upserts one page - On startup, client fetches server ink and merges it over localStorage (server wins so the most-recently saved version always wins) - updateReaderPageInk debounces a PUT call 1 s after the last stroke, so rapid Apple Pencil strokes don't flood the server Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
537 lines
21 KiB
JavaScript
537 lines
21 KiB
JavaScript
import express from 'express';
|
|
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,
|
|
} 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';
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
secure: isProd,
|
|
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) });
|
|
});
|
|
} 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({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
|
});
|
|
} 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({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
|
});
|
|
} 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({
|
|
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
|
|
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.' });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.' });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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');
|
|
app.use(express.static(distPath));
|
|
app.get('*', (_req, res) => {
|
|
res.sendFile(join(distPath, 'index.html'));
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Start
|
|
// ---------------------------------------------------------------------------
|
|
initDb();
|
|
app.listen(PORT, () => {
|
|
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
|
if (isProd) {
|
|
console.log('Serving Vite build from /dist');
|
|
}
|
|
});
|