diff --git a/README.md b/README.md index e83de8c1..f2648532 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,185 @@ -# ESV Bible Markdown +# ESV Bible in Markdown -A Docker-based web service for serving the ESV Bible in Markdown format with chapter-by-chapter organization. - -## Project Structure - -``` -esv-bible/ -├── backend/ # Backend API server -│ ├── src/ -│ │ └── index.js # Express server -│ ├── package.json -│ └── Dockerfile -├── frontend/ # React frontend application -│ ├── src/ -│ ├── public/ -│ ├── package.json -│ └── ... -├── bible-data/ # ESV Bible markdown files (auto-downloaded) -├── docker-compose.yml -└── README.md -``` +A Docker-based ESV Bible application with markdown content, featuring a React frontend and Node.js backend. ## Features -- Complete ESV Bible text in Markdown format from [lguenth/mdbible](https://github.com/lguenth/mdbible) -- Organized by book and chapter for easy navigation -- Docker containerized for easy deployment -- Modern React frontend with responsive design -- RESTful API for accessing Bible content -- Persistent volume storage for Bible data -- Optimized for remote hosting +- **Complete ESV Bible** in markdown format +- **Clean URLs** like `/book/Genesis/chapter/1` +- **Mobile responsive** design with adaptive navigation +- **Dark mode** support with persistent preferences +- **Font size controls** (Small, Medium, Large) +- **Chapter navigation** with Previous/Next buttons +- **Times New Roman typography** for traditional Bible reading +- **Professional favicon** with book icon +- **Optional OpenID Connect authentication** for user features +- **User preferences** sync (when authenticated) +- **Favorites system** for bookmarking verses (when authenticated) -## Setup +## Quick Start -1. Clone this repository -2. Run `docker-compose up` (uses pre-built image from Docker Hub) -3. The ESV Bible data is included in the Docker image +### Basic Setup (No Authentication) -## Usage +1. **Clone the repository** + ```bash + git clone https://github.com/Ryderjj89/esv-bible.git + cd esv-bible + ``` -The service will be available at `http://localhost:3000` +2. **Build and run with Docker** + ```bash + docker build -t esv-bible . + docker run -p 3000:3000 esv-bible + ``` -### API Endpoints +3. **Access the application** + - Open http://localhost:3000 in your browser -- `GET /health` - Health check endpoint -- `GET /books` - List all available books -- `GET /books/:book` - Get complete book (all chapters combined) -- `GET /books/:book/:chapter` - Get specific chapter +### Docker Compose Setup -### Example Usage +1. **Run with docker-compose** + ```bash + docker-compose up -d + ``` -```bash -# List all books -curl http://localhost:3000/books +2. **Access the application** + - Open http://localhost:3000 in your browser -# Get the book of Genesis -curl http://localhost:3000/books/Genesis +## Authentication Setup (Optional) -# Get Genesis chapter 1 -curl http://localhost:3000/books/Genesis/1 +To enable user authentication and features like favorites and synced preferences, configure OpenID Connect: + +### Environment Variables + +Create a `.env` file in the root directory with the following variables: + +```env +# OpenID Connect Configuration (Required for authentication) +OIDC_ISSUER=https://your-oidc-provider.com +OIDC_CLIENT_ID=your-client-id +OIDC_CLIENT_SECRET=your-client-secret +OIDC_AUTH_URL=https://your-oidc-provider.com/auth +OIDC_TOKEN_URL=https://your-oidc-provider.com/token +OIDC_USERINFO_URL=https://your-oidc-provider.com/userinfo + +# Optional Configuration +OIDC_CALLBACK_URL=/auth/callback +SESSION_SECRET=your-session-secret-change-in-production +FRONTEND_URL=http://localhost:3000 +NODE_ENV=production +``` + +## Features When Authenticated + +### User Preferences +- **Font size** synced across devices +- **Dark mode** preference synced across devices +- **Persistent settings** stored in database + +### Favorites System +- **Bookmark verses** for easy reference +- **Add notes** to favorite verses +- **Organize favorites** by book and chapter +- **Quick access** to saved verses + +### API Endpoints (Authenticated) + +``` +GET /auth/user - Get current user info +POST /auth/logout - Logout user +GET /api/preferences - Get user preferences +PUT /api/preferences - Update user preferences +GET /api/favorites - Get user favorites +POST /api/favorites - Add favorite verse +DELETE /api/favorites/:id - Remove favorite +GET /api/favorites/check - Check if verse is favorited ``` ## Development -### Backend Development -```bash -cd backend -npm install -npm run dev -``` +### Prerequisites +- Node.js 16+ +- Docker (optional) -### Frontend Development -```bash -cd frontend -npm install -npm start -``` +### Local Development -## Docker Deployment +1. **Install dependencies** + ```bash + # Backend + cd backend + npm install + + # Frontend + cd ../frontend + npm install + ``` -Run with Docker Compose using pre-built Docker Hub image: -```bash -docker-compose up -``` +2. **Start development servers** + ```bash + # Backend (from backend directory) + npm run dev + + # Frontend (from frontend directory) + npm start + ``` -The application uses the `ryderjj89/esv-bible:latest` image from Docker Hub. The Bible data is included in the Docker image and stored in a persistent Docker volume named `bible_data` for efficient storage and updates. +3. **Access the application** + - Frontend: http://localhost:3000 + - Backend API: http://localhost:3001 -## Data Source +## Database -Bible content is sourced from [lguenth/mdbible](https://github.com/lguenth/mdbible/tree/main/by_chapter), which provides the ESV Bible organized by book and chapter in Markdown format. +The application uses SQLite for user data when authentication is enabled: + +- **Location**: `backend/data/bible.db` +- **Tables**: `users`, `user_preferences`, `favorites` +- **Automatic setup**: Database and tables created on first run + +## Docker Configuration + +### Dockerfile +- Multi-stage build for optimized production image +- Node.js backend with React frontend +- Persistent volume for database storage + +### docker-compose.yml +- Single service configuration +- Volume mounting for database persistence +- Environment variable support + +## URL Structure + +- **Home**: `/` - Book selection +- **Book chapters**: `/book/Genesis` - Chapter selection for Genesis +- **Chapter reading**: `/book/Genesis/chapter/1` - Genesis Chapter 1 +- **Clean URLs**: Professional book names without technical prefixes + +## Browser Support + +- **Modern browsers** with ES6+ support +- **Mobile responsive** design +- **Touch-friendly** navigation +- **Keyboard accessible** controls + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test thoroughly +5. Submit a pull request ## License -MIT +MIT License - see LICENSE file for details + +## Support + +For issues and questions: +- Create an issue on GitHub +- Check existing documentation +- Review environment variable configuration + +--- + +**Note**: Authentication is completely optional. The application works fully without any authentication setup, providing a clean Bible reading experience. Authentication only adds user-specific features like favorites and synced preferences. diff --git a/backend/package.json b/backend/package.json index e61e6f8f..d486e1bf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,9 +11,14 @@ "author": "", "license": "MIT", "dependencies": { - "express": "^4.18.2", + "express": "^4.18.0", "cors": "^2.8.5", - "helmet": "^7.1.0" + "helmet": "^6.0.0", + "sqlite3": "^5.1.6", + "express-session": "^1.17.3", + "passport": "^0.6.0", + "passport-openidconnect": "^0.1.1", + "bcrypt": "^5.1.0" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 00000000..7ad050ed --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,156 @@ +const passport = require('passport'); +const OpenIDConnectStrategy = require('passport-openidconnect').Strategy; +const { userOps } = require('./database'); + +// Configure OpenID Connect strategy +function configureAuth(app) { + // Check if OIDC is configured + const isOIDCConfigured = process.env.OIDC_ISSUER && + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET && + process.env.OIDC_AUTH_URL && + process.env.OIDC_TOKEN_URL && + process.env.OIDC_USERINFO_URL; + + if (!isOIDCConfigured) { + console.log('OpenID Connect not configured. Authentication features disabled.'); + console.log('To enable authentication, set the following environment variables:'); + console.log('- OIDC_ISSUER'); + console.log('- OIDC_CLIENT_ID'); + console.log('- OIDC_CLIENT_SECRET'); + console.log('- OIDC_AUTH_URL'); + console.log('- OIDC_TOKEN_URL'); + console.log('- OIDC_USERINFO_URL'); + console.log('- OIDC_CALLBACK_URL (optional, defaults to /auth/callback)'); + + // Add disabled auth routes + app.get('/auth/login', (req, res) => { + res.status(501).json({ error: 'Authentication not configured' }); + }); + + app.get('/auth/user', (req, res) => { + res.status(401).json({ error: 'Authentication not configured' }); + }); + + app.post('/auth/logout', (req, res) => { + res.status(501).json({ error: 'Authentication not configured' }); + }); + + return; + } + + // Session configuration + const session = require('express-session'); + + app.use(session({ + secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production', + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === 'production', + maxAge: 24 * 60 * 60 * 1000 // 24 hours + } + })); + + // Initialize Passport + app.use(passport.initialize()); + app.use(passport.session()); + + // Configure OpenID Connect strategy + passport.use('oidc', new OpenIDConnectStrategy({ + issuer: process.env.OIDC_ISSUER, + authorizationURL: process.env.OIDC_AUTH_URL, + tokenURL: process.env.OIDC_TOKEN_URL, + userInfoURL: process.env.OIDC_USERINFO_URL, + clientID: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + callbackURL: process.env.OIDC_CALLBACK_URL || '/auth/callback', + scope: 'openid email profile' + }, (issuer, sub, profile, accessToken, refreshToken, done) => { + // Extract user info from profile + const userProfile = { + sub: profile.id, + email: profile.emails?.[0]?.value, + name: profile.displayName + }; + + userOps.findOrCreateUser(userProfile, (err, user) => { + if (err) { + return done(err); + } + return done(null, user); + }); + })); + + // Serialize user for session + passport.serializeUser((user, done) => { + done(null, user.id); + }); + + // Deserialize user from session + passport.deserializeUser((id, done) => { + userOps.findById(id, (err, user) => { + done(err, user); + }); + }); + + // Auth routes + app.get('/auth/login', passport.authenticate('oidc')); + + app.get('/auth/callback', + passport.authenticate('oidc', { failureRedirect: '/login' }), + (req, res) => { + // Successful authentication, redirect to frontend + res.redirect(process.env.FRONTEND_URL || '/'); + } + ); + + app.post('/auth/logout', (req, res) => { + req.logout((err) => { + if (err) { + return res.status(500).json({ error: 'Logout failed' }); + } + req.session.destroy((err) => { + if (err) { + return res.status(500).json({ error: 'Session destruction failed' }); + } + res.json({ message: 'Logged out successfully' }); + }); + }); + }); + + // Get current user + app.get('/auth/user', (req, res) => { + if (req.isAuthenticated()) { + res.json({ + user: { + id: req.user.id, + email: req.user.email, + name: req.user.name + } + }); + } else { + res.status(401).json({ error: 'Not authenticated' }); + } + }); +} + +// Middleware to require authentication +function requireAuth(req, res, next) { + if (req.isAuthenticated()) { + return next(); + } + res.status(401).json({ error: 'Authentication required' }); +} + +// Middleware to optionally include user info +function optionalAuth(req, res, next) { + // Always proceed, but user info will be available if authenticated + next(); +} + +module.exports = { + configureAuth, + requireAuth, + optionalAuth +}; diff --git a/backend/src/database.js b/backend/src/database.js new file mode 100644 index 00000000..aa662b2c --- /dev/null +++ b/backend/src/database.js @@ -0,0 +1,200 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +// Database file path +const DB_PATH = path.join(__dirname, '../data/bible.db'); + +// Initialize database +const db = new sqlite3.Database(DB_PATH, (err) => { + if (err) { + console.error('Error opening database:', err.message); + } else { + console.log('Connected to SQLite database'); + initializeTables(); + } +}); + +// Initialize database tables +function initializeTables() { + // Users table + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + openid_sub TEXT UNIQUE NOT NULL, + email TEXT, + name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // User preferences table + db.run(` + CREATE TABLE IF NOT EXISTS user_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + font_size TEXT DEFAULT 'medium' CHECK(font_size IN ('small', 'medium', 'large')), + dark_mode BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + UNIQUE(user_id) + ) + `); + + // Favorites table + db.run(` + CREATE TABLE IF NOT EXISTS favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + book TEXT NOT NULL, + chapter TEXT NOT NULL, + verse_start INTEGER, + verse_end INTEGER, + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + UNIQUE(user_id, book, chapter, verse_start, verse_end) + ) + `); + + console.log('Database tables initialized'); +} + +// User operations +const userOps = { + // Find or create user by OpenID Connect subject + findOrCreateUser: (profile, callback) => { + const { sub, email, name } = profile; + + db.get( + 'SELECT * FROM users WHERE openid_sub = ?', + [sub], + (err, user) => { + if (err) { + return callback(err); + } + + if (user) { + // Update user info + db.run( + 'UPDATE users SET email = ?, name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [email, name, user.id], + (err) => { + if (err) return callback(err); + callback(null, user); + } + ); + } else { + // Create new user + db.run( + 'INSERT INTO users (openid_sub, email, name) VALUES (?, ?, ?)', + [sub, email, name], + function(err) { + if (err) return callback(err); + + const newUser = { + id: this.lastID, + openid_sub: sub, + email, + name + }; + + // Create default preferences + db.run( + 'INSERT INTO user_preferences (user_id) VALUES (?)', + [newUser.id], + (err) => { + if (err) console.error('Error creating default preferences:', err); + callback(null, newUser); + } + ); + } + ); + } + } + ); + }, + + // Find user by ID + findById: (id, callback) => { + db.get('SELECT * FROM users WHERE id = ?', [id], callback); + } +}; + +// Preferences operations +const preferencesOps = { + // Get user preferences + getPreferences: (userId, callback) => { + db.get( + 'SELECT * FROM user_preferences WHERE user_id = ?', + [userId], + callback + ); + }, + + // Update user preferences + updatePreferences: (userId, preferences, callback) => { + const { font_size, dark_mode } = preferences; + + db.run( + `UPDATE user_preferences + SET font_size = ?, dark_mode = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ?`, + [font_size, dark_mode ? 1 : 0, userId], + callback + ); + } +}; + +// Favorites operations +const favoritesOps = { + // Get user favorites + getFavorites: (userId, callback) => { + db.all( + 'SELECT * FROM favorites WHERE user_id = ? ORDER BY created_at DESC', + [userId], + callback + ); + }, + + // Add favorite + addFavorite: (userId, favorite, callback) => { + const { book, chapter, verse_start, verse_end, note } = favorite; + + db.run( + `INSERT INTO favorites (user_id, book, chapter, verse_start, verse_end, note) + VALUES (?, ?, ?, ?, ?, ?)`, + [userId, book, chapter, verse_start, verse_end, note], + callback + ); + }, + + // Remove favorite + removeFavorite: (userId, favoriteId, callback) => { + db.run( + 'DELETE FROM favorites WHERE id = ? AND user_id = ?', + [favoriteId, userId], + callback + ); + }, + + // Check if verse is favorited + isFavorited: (userId, book, chapter, verse_start, verse_end, callback) => { + db.get( + 'SELECT id FROM favorites WHERE user_id = ? AND book = ? AND chapter = ? AND verse_start = ? AND verse_end = ?', + [userId, book, chapter, verse_start, verse_end], + (err, row) => { + if (err) return callback(err); + callback(null, !!row); + } + ); + } +}; + +module.exports = { + db, + userOps, + preferencesOps, + favoritesOps +}; diff --git a/backend/src/index.js b/backend/src/index.js index d0e4c5d8..01ae70a0 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -3,6 +3,8 @@ const cors = require('cors'); const helmet = require('helmet'); const path = require('path'); const fs = require('fs').promises; +const { configureAuth, requireAuth, optionalAuth } = require('./auth'); +const { preferencesOps, favoritesOps } = require('./database'); const app = express(); const PORT = process.env.PORT || 3000; @@ -14,9 +16,15 @@ app.use(helmet({ crossOriginEmbedderPolicy: false, originAgentCluster: false })); -app.use(cors()); +app.use(cors({ + origin: process.env.FRONTEND_URL || true, + credentials: true +})); app.use(express.json()); +// Configure authentication +configureAuth(app); + // Serve static files from the React build app.use(express.static(path.join(__dirname, '../../frontend/build'))); @@ -120,6 +128,107 @@ app.get('/books/:book/:chapter', async (req, res) => { } }); +// User preferences routes +app.get('/api/preferences', requireAuth, (req, res) => { + preferencesOps.getPreferences(req.user.id, (err, preferences) => { + if (err) { + return res.status(500).json({ error: 'Failed to get preferences' }); + } + res.json({ + font_size: preferences?.font_size || 'medium', + dark_mode: !!preferences?.dark_mode + }); + }); +}); + +app.put('/api/preferences', requireAuth, (req, res) => { + const { font_size, dark_mode } = req.body; + + // Validate font_size + if (font_size && !['small', 'medium', 'large'].includes(font_size)) { + return res.status(400).json({ error: 'Invalid font_size' }); + } + + preferencesOps.updatePreferences(req.user.id, { font_size, dark_mode }, (err) => { + if (err) { + return res.status(500).json({ error: 'Failed to update preferences' }); + } + res.json({ message: 'Preferences updated successfully' }); + }); +}); + +// Favorites routes +app.get('/api/favorites', requireAuth, (req, res) => { + favoritesOps.getFavorites(req.user.id, (err, favorites) => { + if (err) { + return res.status(500).json({ error: 'Failed to get favorites' }); + } + res.json({ favorites }); + }); +}); + +app.post('/api/favorites', requireAuth, (req, res) => { + const { book, chapter, verse_start, verse_end, note } = req.body; + + if (!book || !chapter) { + return res.status(400).json({ error: 'Book and chapter are required' }); + } + + const favorite = { + book, + chapter, + verse_start: verse_start || null, + verse_end: verse_end || null, + note: note || null + }; + + favoritesOps.addFavorite(req.user.id, favorite, function(err) { + if (err) { + if (err.code === 'SQLITE_CONSTRAINT') { + return res.status(409).json({ error: 'Favorite already exists' }); + } + return res.status(500).json({ error: 'Failed to add favorite' }); + } + res.json({ + message: 'Favorite added successfully', + id: this.lastID + }); + }); +}); + +app.delete('/api/favorites/:id', requireAuth, (req, res) => { + const favoriteId = req.params.id; + + favoritesOps.removeFavorite(req.user.id, favoriteId, (err) => { + if (err) { + return res.status(500).json({ error: 'Failed to remove favorite' }); + } + res.json({ message: 'Favorite removed successfully' }); + }); +}); + +app.get('/api/favorites/check', requireAuth, (req, res) => { + const { book, chapter, verse_start, verse_end } = req.query; + + if (!book || !chapter) { + return res.status(400).json({ error: 'Book and chapter are required' }); + } + + favoritesOps.isFavorited( + req.user.id, + book, + chapter, + verse_start || null, + verse_end || null, + (err, isFavorited) => { + if (err) { + return res.status(500).json({ error: 'Failed to check favorite status' }); + } + res.json({ isFavorited }); + } + ); +}); + // Catch-all handler: send back React's index.html for client-side routing app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../../frontend/build/index.html'));