Files
the-bible/backend/src/index.js

453 lines
14 KiB
JavaScript

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