import express from 'express'; import compression from 'compression'; import session from 'express-session'; import { randomBytes } from 'crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { initDb, dataDir } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; import { startReminderScheduler } from './reminders.js'; import { startBackupScheduler } from './backups.js'; import { registerBibleRoutes } from './bibleApi.js'; import { registerIqBibleRoutes } from './iqbible.js'; import { registerDeviceRoutes } from './devices.js'; import { requireAuth } from './auth.js'; import { userPayload } from './userPayload.js'; import { isLimited, recordFailure, clearFailures, TOO_MANY } from './limiter.js'; import { registerAuthRoutes } from './routes/auth.js'; import { registerAccountRoutes } from './routes/account.js'; import { registerProjectRoutes } from './routes/projects.js'; import { registerReaderRoutes } from './routes/reader.js'; import { registerSharingRoutes } from './routes/sharing.js'; import { registerAdminRoutes } from './routes/admin.js'; // The server's setup: sessions and middleware, then each area registers its own routes (server/routes/*.js, // plus the Bible text, IQ Bible and passkey modules), then the built app is served and the server starts. 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); // 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 }); }); registerAuthRoutes(app); registerAccountRoutes(app); registerProjectRoutes(app); registerReaderRoutes(app); registerBibleRoutes(app, requireAuth); registerIqBibleRoutes(app, requireAuth); registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY }); registerSharingRoutes(app); registerAdminRoutes(app); // --------------------------------------------------------------------------- // 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'); } });