const express = require('express'); const helmet = require('helmet'); const cors = require('cors'); const session = require('express-session'); const cookieParser = require('cookie-parser'); const crypto = require('crypto'); const fetch = require('node-fetch'); // Import configuration and utilities const config = require('./config'); const logger = require('./utils/logger'); const { getCookieConfig } = require('./utils/helpers'); const { apiLimiter } = require('./middleware/rateLimiter'); const { cacheBusting } = require('./utils/cacheBusting'); const { initializeEmailService } = require('./services/email'); // Initialize Express app const app = express(); // Trust proxy for Cloudflare app.set('trust proxy', true); // Cookie parser app.use(cookieParser()); // Session configuration app.use(session({ secret: config.session.secret, resave: false, saveUninitialized: false, cookie: getCookieConfig(), name: 'nocodb-map-session', genid: (req) => { // Use a custom session ID generator to avoid conflicts return crypto.randomBytes(16).toString('hex'); } })); // Build dynamic CSP configuration const buildConnectSrc = () => { const sources = ["'self'"]; // Add MkDocs URLs from config if (config.mkdocs?.url) { sources.push(config.mkdocs.url); } // Add localhost ports from environment const mkdocsPort = process.env.MKDOCS_PORT || '4000'; const mkdocsSitePort = process.env.MKDOCS_SITE_SERVER_PORT || '4002'; sources.push(`http://localhost:${mkdocsPort}`); sources.push(`http://localhost:${mkdocsSitePort}`); // Add City of Edmonton Socrata API sources.push('https://data.edmonton.ca'); // Add Stadia Maps for better tile coverage sources.push('https://tiles.stadiamaps.com'); // Add production domains if in production if (config.isProduction || process.env.NODE_ENV === 'production') { // Add the main domain from environment const mainDomain = process.env.DOMAIN || 'cmlite.org'; sources.push(`https://${mainDomain}`); sources.push('https://cmlite.org'); // Fallback } return sources; }; // Security middleware app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"], imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://tiles.stadiamaps.com", "https://unpkg.com"], connectSrc: buildConnectSrc() } } })); // CORS configuration app.use(cors({ origin: function(origin, callback) { // Allow requests with no origin (like mobile apps or curl requests) if (!origin) return callback(null, true); const allowedOrigins = config.cors.allowedOrigins; if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] })); // Body parser middleware app.use(express.json({ limit: '10mb' })); // Add cache busting middleware for HTML content app.use(cacheBusting.htmlMiddleware()); // Add cache headers middleware for static files app.use(cacheBusting.staticCacheMiddleware()); // Serve static files with proper cache headers app.use(express.static('public')); // Apply rate limiting to API routes app.use('/api/', apiLimiter); // Cache busting version endpoint app.get('/api/version', (req, res) => { res.json({ version: cacheBusting.getVersion(), timestamp: new Date().toISOString() }); }); // Proxy endpoint for MkDocs search app.get('/api/docs-search', async (req, res) => { try { const mkdocsUrl = config.mkdocs?.url || `http://localhost:${process.env.MKDOCS_SITE_SERVER_PORT || '4002'}`; logger.info(`Fetching search index from: ${mkdocsUrl}/search/search_index.json`); const response = await fetch(`${mkdocsUrl}/search/search_index.json`); if (!response.ok) { throw new Error(`Failed to fetch search index: ${response.status}`); } const data = await response.json(); res.json(data); } catch (error) { logger.error('Error fetching search index:', error); res.status(500).json({ error: 'Failed to fetch search index' }); } }); // Initialize email service initializeEmailService(); // Import and setup routes require('./routes')(app); // Error handling middleware app.use((err, req, res, next) => { logger.error('Unhandled error:', err); // Don't leak error details in production const message = config.isProduction ? 'Internal server error' : err.message || 'Internal server error'; res.status(err.status || 500).json({ success: false, error: message }); }); // Start server const server = app.listen(config.port, () => { logger.info(` ╔════════════════════════════════════════╗ ║ BNKops Map Server ║ ╠════════════════════════════════════════╣ ║ Status: Running ║ ║ Port: ${config.port} ║ ║ Environment: ${config.nodeEnv} ║ ║ Project ID: ${config.nocodb.projectId} ║ ║ Table ID: ${config.nocodb.tableId} ║ ║ Login Sheet: ${config.nocodb.loginSheetId || 'Not Configured'} ║ ║ Time: ${new Date().toISOString()} ║ ╚════════════════════════════════════════╝ `); }); // Graceful shutdown process.on('SIGTERM', () => { logger.info('SIGTERM signal received: closing HTTP server'); server.close(() => { logger.info('HTTP server closed'); process.exit(0); }); }); process.on('SIGINT', () => { logger.info('SIGINT signal received: closing HTTP server'); server.close(() => { logger.info('HTTP server closed'); process.exit(0); }); }); // Handle uncaught exceptions process.on('uncaughtException', (err) => { logger.error('Uncaught exception:', err); process.exit(1); }); // Handle unhandled promise rejections process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled Rejection at:', promise, 'reason:', reason); process.exit(1); }); module.exports = app;