const express = require('express'); 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 BibleSearchEngine = require('./search'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(helmet({ contentSecurityPolicy: false, crossOriginOpenerPolicy: false, crossOriginEmbedderPolicy: false, originAgentCluster: false })); app.use(cors({ origin: process.env.FRONTEND_URL || true, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'Cookie'], exposedHeaders: ['Set-Cookie'] })); app.use(express.json()); // Configure authentication configureAuth(app); // Serve static files from the React build app.use(express.static(path.join(__dirname, '../../frontend/build'))); // Serve logos folder as static files app.use('/logos', express.static(path.join(__dirname, '../../frontend/logos'))); // Bible data directories const ESV_DATA_DIR = path.join(__dirname, '../../ESV'); // ESV local files const NKJV_DATA_DIR = path.join(__dirname, '../../NKJV'); // NKJV local files const NLT_DATA_DIR = path.join(__dirname, '../../NLT'); // NLT local files const CSB_DATA_DIR = path.join(__dirname, '../../CSB'); // CSB local files // Initialize search engines for each version let esvSearchEngine = null; let nkjvSearchEngine = null; let nltSearchEngine = null; let csbSearchEngine = null; try { if (ESV_DATA_DIR) { esvSearchEngine = new BibleSearchEngine(ESV_DATA_DIR); } } catch (error) { console.log('ESV search engine failed to initialize (data directory may not exist):', error.message); } try { nkjvSearchEngine = new BibleSearchEngine(NKJV_DATA_DIR); } catch (error) { console.log('NKJV search engine failed to initialize:', error.message); } try { nltSearchEngine = new BibleSearchEngine(NLT_DATA_DIR); } catch (error) { console.log('NLT search engine failed to initialize:', error.message); } try { csbSearchEngine = new BibleSearchEngine(CSB_DATA_DIR); } catch (error) { console.log('CSB search engine failed to initialize:', error.message); } // Helper function to get data directory for version function getDataDir(version) { if (version === 'esv' && esvSearchEngine) return ESV_DATA_DIR; if (version === 'nkjv') return NKJV_DATA_DIR; if (version === 'nlt' && nltSearchEngine) return NLT_DATA_DIR; if (version === 'csb' && csbSearchEngine) return CSB_DATA_DIR; return esvSearchEngine ? ESV_DATA_DIR : NKJV_DATA_DIR; // default to available version } // Helper function to read markdown files async function readMarkdownFile(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); // Remove BOM if present and normalize encoding issues return content.replace(/^\uFEFF/, ''); // Remove UTF-8 BOM } catch (error) { throw new Error(`Failed to read file: ${filePath}`); } } // Helper function to get all books for a version async function getBooks(version = 'esv') { try { const dataDir = getDataDir(version); console.log(`Attempting to read books from: ${dataDir} for version: ${version}`); const items = await fs.readdir(dataDir); console.log(`Found ${items.length} items in ${dataDir}`); const bookDirs = []; for (const item of items) { const itemPath = path.join(dataDir, item); const stat = await fs.stat(itemPath); if (stat.isDirectory()) { console.log(`Checking directory: ${item}`); // Check if directory contains markdown files try { const files = await fs.readdir(itemPath); if (files.some(file => file.endsWith('.md'))) { bookDirs.push(item); console.log(`Added book: ${item} (${files.length} files)`); } } catch (error) { // Skip directories we can't read console.log(`Could not read files in ${item}: ${error.message}`); continue; } } } console.log(`Found ${bookDirs.length} books for version ${version}:`, bookDirs.slice(0, 5)); return bookDirs; } catch (error) { console.error(`Failed to read bible data directory for version ${version}:`, error.message); throw new Error(`Failed to read bible data directory for version ${version}`); } } // Routes app.get('/health', (req, res) => { res.json({ status: 'OK', message: 'The Bible API is running' }); }); app.get('/versions', (req, res) => { const availableVersions = []; if (esvSearchEngine) availableVersions.push({ id: 'esv', name: 'ESV - English Standard Version' }); availableVersions.push({ id: 'nkjv', name: 'NKJV - New King James Version' }); if (nltSearchEngine) availableVersions.push({ id: 'nlt', name: 'NLT - New Living Translation' }); if (csbSearchEngine) availableVersions.push({ id: 'csb', name: 'CSB - Christian Standard Bible' }); res.json({ versions: availableVersions }); }); app.get('/books', async (req, res) => { try { const { version = 'esv' } = req.query; const books = await getBooks(version); res.json({ books, version }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/books/:book', async (req, res) => { try { const { book } = req.params; const { version = 'esv' } = req.query; const dataDir = getDataDir(version); const bookDir = path.join(dataDir, book); // Check if book directory exists const stat = await fs.stat(bookDir); if (!stat.isDirectory()) { return res.status(404).json({ error: `Book '${book}' not found in version '${version}'` }); } // Get all chapter files const files = await fs.readdir(bookDir); const chapterFiles = files.filter(file => file.endsWith('.md')).sort(); if (chapterFiles.length === 0) { return res.status(404).json({ error: `No chapters found for book '${book}' in version '${version}'` }); } // Extract chapter numbers from filenames (e.g., "Chapter_01.md" -> "1") const chapters = chapterFiles.map(file => { const match = file.match(/Chapter_(\d+)\.md$/); return match ? parseInt(match[1], 10).toString() : null; }).filter(Boolean); res.json({ chapters, version }); } catch (error) { res.status(404).json({ error: `Book '${req.params.book}' not found in version '${req.query.version || 'esv'}'` }); } }); app.get('/books/:book/:chapter', async (req, res) => { try { const { book, chapter } = req.params; const { version = 'esv' } = req.query; // Check if chapter already includes "Chapter_" prefix (from frontend) let chapterFileName; if (chapter.startsWith('Chapter_')) { chapterFileName = `${chapter}.md`; } else { // Legacy support: if just a number, format with leading zero const paddedChapter = chapter.padStart(2, '0'); chapterFileName = `Chapter_${paddedChapter}.md`; } const dataDir = getDataDir(version); const chapterPath = path.join(dataDir, book, chapterFileName); const content = await readMarkdownFile(chapterPath); res.type('text/markdown').send(content); } catch (error) { res.status(404).json({ error: `Chapter ${req.params.chapter} not found in book '${req.params.book}' for version '${req.query.version || 'esv'}'` }); } }); // Search routes app.get('/api/search', async (req, res) => { try { const { q: query, book, limit, context, version = 'esv' } = req.query; if (!query || query.trim().length < 2) { return res.status(400).json({ error: 'Query must be at least 2 characters long' }); } const options = { bookFilter: book || null, limit: parseInt(limit) || 50, includeContext: context !== 'false', contextSize: 2 }; let results = []; let searchVersion = version; if (version === 'all') { // Search across all available versions const allResults = []; const searchEngines = [ { engine: esvSearchEngine, version: 'esv' }, { engine: nkjvSearchEngine, version: 'nkjv' }, { engine: nltSearchEngine, version: 'nlt' }, { engine: csbSearchEngine, version: 'csb' } ].filter(item => item.engine); // Only include engines that are available for (const { engine, version: engineVersion } of searchEngines) { try { const versionResults = await engine.search(query, { ...options, limit: Math.ceil(options.limit / searchEngines.length) }); // Add version info to each result const resultsWithVersion = versionResults.map(result => ({ ...result, searchVersion: engineVersion })); allResults.push(...resultsWithVersion); } catch (error) { console.log(`Search failed for ${engineVersion}:`, error.message); } } // Sort by relevance and limit total results results = allResults .sort((a, b) => b.relevance - a.relevance) .slice(0, options.limit); searchVersion = 'all'; } else { // Search in specific version let searchEngine; if (version === 'esv' && esvSearchEngine) { searchEngine = esvSearchEngine; } else if (version === 'nlt' && nltSearchEngine) { searchEngine = nltSearchEngine; } else if (version === 'csb' && csbSearchEngine) { searchEngine = csbSearchEngine; } else { searchEngine = nkjvSearchEngine; // default fallback } results = await searchEngine.search(query, options); } res.json({ query, results, total: results.length, hasMore: results.length === options.limit, version: searchVersion }); } catch (error) { console.error('Search error:', error); res.status(500).json({ error: 'Search failed. Please try again.' }); } }); app.get('/api/search/suggestions', async (req, res) => { try { const { q: query, limit, version = 'esv' } = req.query; if (!query || query.trim().length < 2) { return res.json({ suggestions: [] }); } // Get the appropriate search engine for the version let searchEngine; if (version === 'esv' && esvSearchEngine) { searchEngine = esvSearchEngine; } else if (version === 'nlt' && nltSearchEngine) { searchEngine = nltSearchEngine; } else if (version === 'csb' && csbSearchEngine) { searchEngine = csbSearchEngine; } else { searchEngine = nkjvSearchEngine; // default fallback } const suggestions = await searchEngine.getSearchSuggestions(query, parseInt(limit) || 10); res.json({ suggestions, version }); } catch (error) { console.error('Search suggestions error:', error); res.status(500).json({ error: 'Failed to get search suggestions' }); } }); // 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, version } = req.body; // Book is required, but chapter is optional (for book-level favorites) if (!book) { return res.status(400).json({ error: 'Book is required' }); } const favorite = { book, chapter: chapter || null, verse_start: verse_start || null, verse_end: verse_end || null, version: version || 'esv', 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) { return res.status(400).json({ error: 'Book is required' }); } favoritesOps.isFavorited( req.user.id, book, chapter || null, 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')); }); // Error handling middleware app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ error: 'Something went wrong!' }); }); // Start server app.listen(PORT, () => { console.log(`The Bible API server running on port ${PORT}`); if (esvSearchEngine) { console.log(`ESV data directory: ${ESV_DATA_DIR}`); } else { console.log(`ESV data not available - mdbible not found`); } console.log(`NKJV data directory: ${NKJV_DATA_DIR}`); if (nltSearchEngine) { console.log(`NLT data directory: ${NLT_DATA_DIR}`); } if (csbSearchEngine) { console.log(`CSB data directory: ${CSB_DATA_DIR}`); } });