Files
study-bible-project/server/index.js
T
nmemmertandClaude Sonnet 5.5 9893883b19
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Split server/index.js into one file per area
Routes move to server/routes/ (auth, account, projects, reader, sharing, admin);
index.js keeps setup only. A smoke test walks 90 API calls against the real
server and compares every status with the pre-split behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:25:52 -04:00

126 lines
5.1 KiB
JavaScript

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');
}
});