Add OpenID Connect authentication and SQLite database with user preferences and favorites system

This commit is contained in:
Ryderjj89
2025-09-13 16:50:26 -04:00
parent 9beff1e610
commit a804430536
5 changed files with 631 additions and 71 deletions

226
README.md
View File

@@ -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. A Docker-based ESV Bible application with markdown content, featuring a React frontend and Node.js backend.
## 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
```
## Features ## Features
- Complete ESV Bible text in Markdown format from [lguenth/mdbible](https://github.com/lguenth/mdbible) - **Complete ESV Bible** in markdown format
- Organized by book and chapter for easy navigation - **Clean URLs** like `/book/Genesis/chapter/1`
- Docker containerized for easy deployment - **Mobile responsive** design with adaptive navigation
- Modern React frontend with responsive design - **Dark mode** support with persistent preferences
- RESTful API for accessing Bible content - **Font size controls** (Small, Medium, Large)
- Persistent volume storage for Bible data - **Chapter navigation** with Previous/Next buttons
- Optimized for remote hosting - **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 ### Basic Setup (No Authentication)
2. Run `docker-compose up` (uses pre-built image from Docker Hub)
3. The ESV Bible data is included in the Docker image
## 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 ### Docker Compose Setup
- `GET /books` - List all available books
- `GET /books/:book` - Get complete book (all chapters combined)
- `GET /books/:book/:chapter` - Get specific chapter
### Example Usage 1. **Run with docker-compose**
```bash
docker-compose up -d
```
```bash 2. **Access the application**
# List all books - Open http://localhost:3000 in your browser
curl http://localhost:3000/books
# Get the book of Genesis ## Authentication Setup (Optional)
curl http://localhost:3000/books/Genesis
# Get Genesis chapter 1 To enable user authentication and features like favorites and synced preferences, configure OpenID Connect:
curl http://localhost:3000/books/Genesis/1
### 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 ## Development
### Backend Development ### Prerequisites
```bash - Node.js 16+
cd backend - Docker (optional)
npm install
npm run dev
```
### Frontend Development ### Local Development
```bash
cd frontend
npm install
npm start
```
## 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: 2. **Start development servers**
```bash ```bash
docker-compose up # 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 ## 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.

View File

@@ -11,9 +11,14 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.0",
"cors": "^2.8.5", "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": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"

156
backend/src/auth.js Normal file
View File

@@ -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
};

200
backend/src/database.js Normal file
View File

@@ -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
};

View File

@@ -3,6 +3,8 @@ const cors = require('cors');
const helmet = require('helmet'); const helmet = require('helmet');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { configureAuth, requireAuth, optionalAuth } = require('./auth');
const { preferencesOps, favoritesOps } = require('./database');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -14,9 +16,15 @@ app.use(helmet({
crossOriginEmbedderPolicy: false, crossOriginEmbedderPolicy: false,
originAgentCluster: false originAgentCluster: false
})); }));
app.use(cors()); app.use(cors({
origin: process.env.FRONTEND_URL || true,
credentials: true
}));
app.use(express.json()); app.use(express.json());
// Configure authentication
configureAuth(app);
// Serve static files from the React build // Serve static files from the React build
app.use(express.static(path.join(__dirname, '../../frontend/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 // Catch-all handler: send back React's index.html for client-side routing
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../../frontend/build/index.html')); res.sendFile(path.join(__dirname, '../../frontend/build/index.html'));