diff --git a/.gitignore b/.gitignore index ebd9c7f..43ef431 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage/ .api.log .DS_Store .env +.session-secret +server/data/backups/ diff --git a/README.md b/README.md index 6d57380..8f23038 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ http://localhost:5173. Dev mode is much slower, so don't use it for real users. | Variable | What it turns on | | --- | --- | -| `SESSION_SECRET` | **Required in production.** A long random string; the server refuses to start without it. | +| `SESSION_SECRET` | A long random string that signs login cookies. Optional: if unset in production, the server makes one and keeps it in `DATA_DIR/.session-secret`. | | `ADMIN_EMAIL` | The one admin account. | | `ESV_API_KEY`, `API_BIBLE_KEY` | ESV, NLT, CSB and NASB in the Reader. | | `IQBIBLE_API_KEY` | IQ Bible: more commentaries, lexicons (BDB, LSJ, Abbott-Smith), places, topics, people, textual notes, Gospel parallels, daily verse and Spurgeon devotional, plan calendar export, more audio narrations, and background for podcast prompts. Hidden when unset. | diff --git a/server/index.js b/server/index.js index 2614353..852ad8b 100644 --- a/server/index.js +++ b/server/index.js @@ -2,11 +2,12 @@ import express from 'express'; import compression from 'compression'; import session from 'express-session'; import QRCode from 'qrcode'; -import { randomUUID } from 'crypto'; +import { randomUUID, randomBytes } from 'crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { - initDb, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject, + initDb, dataDir, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject, countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, getShareToken, setShareToken, clearShareToken, getProjectByShareToken, @@ -50,9 +51,23 @@ const app = express(); const PORT = process.env.PORT || 3001; const isProd = process.env.NODE_ENV === 'production'; -if (isProd && !process.env.SESSION_SECRET) { - console.error('SESSION_SECRET is not set. Set it to a long random string before running in production.'); - process.exit(1); +// 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). @@ -98,7 +113,7 @@ 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', + secret: sessionSecret(), resave: false, saveUninitialized: false, rolling: true,