// At the very top of the file, before any requires const startTime = Date.now(); // Use a more robust check for duplicate execution if (global.__serverInitialized) { console.log(`[INIT] Server already initialized - EXITING`); return; } global.__serverInitialized = true; // Prevent duplicate execution if (require.main !== module) { console.log('[INIT] Server.js being imported, not executed directly - EXITING'); return; } 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'); const listmonkService = require('./services/listmonk'); // Initialize Express app - only create once if (global.__expressApp) { console.log('[INIT] Express app already created - EXITING'); return; } const app = express(); global.__expressApp = app; // Trust proxy for Cloudflare app.set('trust proxy', true); // Cookie parser app.use(cookieParser()); // Session configuration - only initialize once const sessionMiddleware = 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'); } }); app.use(sessionMiddleware); // Build dynamic CSP configuration const buildConnectSrc = () => { const sources = ["'self'"]; // Add NocoDB API URL if (config.nocodb.apiUrl) { try { const nocodbUrl = new URL(config.nocodb.apiUrl); sources.push(`${nocodbUrl.protocol}//${nocodbUrl.host}`); } catch (e) { // Invalid URL, skip } } // Add Edmonton Open Data Portal sources.push('https://data.edmonton.ca'); // Add Nominatim for geocoding sources.push('https://nominatim.openstreetmap.org'); // Add localhost for development if (!config.isProduction) { sources.push('http://localhost:*'); sources.push('ws://localhost:*'); } 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 Postman or server-to-server) if (!origin) return callback(null, true); // In production, be more restrictive if (config.isProduction) { const allowedOrigins = [ `https://${config.domain}`, `https://map.${config.domain}`, config.mkdocs.url, // Use configured MkDocs URL instead of hardcoded subdomain `https://admin.${config.domain}` ]; if (allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } } else { // In development, allow localhost callback(null, true); } }, 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')); // Cache busting version endpoint (before rate limiting) app.get('/api/version', (req, res) => { res.json({ version: cacheBusting.getVersion(), timestamp: new Date().toISOString() }); }); // Apply rate limiting to API routes app.use('/api/', apiLimiter); // Proxy endpoint for MkDocs search app.get('/api/docs-search', async (req, res) => { try { const docsUrl = config.isProduction ? `${config.mkdocs.searchUrl}/search/search_index.json` : 'http://localhost:8000/search/search_index.json'; const response = await fetch(docsUrl); const data = await response.json(); res.json(data); } catch (error) { logger.error('Failed to fetch docs search index:', error); res.status(500).json({ error: 'Failed to fetch search index' }); } }); // Initialize email service - only once if (!global.__emailInitialized) { initializeEmailService(); global.__emailInitialized = true; } // Initialize Listmonk service if (!global.__listmonkInitialized) { global.__listmonkInitialized = true; // Initialize Listmonk in background (don't block server startup) setImmediate(async () => { try { if (listmonkService.syncEnabled) { logger.info('🔄 Initializing Listmonk integration...'); const initialized = await listmonkService.initializeLists(); if (initialized) { logger.info('✅ Listmonk integration initialized successfully'); // Optional initial sync (only if explicitly enabled) if (process.env.LISTMONK_INITIAL_SYNC === 'true') { logger.info('🔄 Performing initial Listmonk sync...'); // Use setTimeout to further delay initial sync setTimeout(async () => { try { const nocodbService = require('./services/nocodb'); // Sync existing locations try { const locationData = await nocodbService.getLocations(); const locations = locationData?.list || []; console.log('🔍 Initial sync - fetched locations:', locations?.length || 0); if (locations && locations.length > 0) { const locationResults = await listmonkService.bulkSync(locations, 'location'); logger.info(`📍 Initial location sync: ${locationResults.success} succeeded, ${locationResults.failed} failed`); } else { logger.warn('No locations found for initial sync'); } } catch (locError) { logger.warn('Initial location sync failed:', { message: locError.message, stack: locError.stack, error: locError.toString() }); } // Sync existing users try { const userData = await nocodbService.getAllPaginated(config.nocodb.loginSheetId); const users = userData?.list || []; console.log('🔍 Initial sync - fetched users:', users?.length || 0); if (users && users.length > 0) { const userResults = await listmonkService.bulkSync(users, 'user'); logger.info(`👤 Initial user sync: ${userResults.success} succeeded, ${userResults.failed} failed`); } else { logger.warn('No users found for initial sync'); } } catch (userError) { logger.warn('Initial user sync failed:', { message: userError.message, stack: userError.stack, error: userError.toString() }); } logger.info('✅ Initial Listmonk sync completed'); } catch (syncError) { logger.error('Initial sync failed:', syncError.message); } }, 5000); // Wait 5 seconds after startup } } else { logger.error('❌ Listmonk integration failed to initialize'); logger.error(`Last error: ${listmonkService.lastError}`); } } else { logger.info('📧 Listmonk sync is disabled via configuration'); } } catch (error) { logger.error('Listmonk initialization error:', error.message); // Don't crash the app, just disable sync listmonkService.syncEnabled = false; } }); } // Import and setup routes require('./routes')(app); // Error handling middleware app.use((err, req, res, next) => { logger.error('Unhandled error:', err); res.status(500).json({ error: 'Internal server error', message: config.isProduction ? 'An error occurred' : err.message }); }); // Add a simple health check endpoint early in the middleware stack (before other middlewares) app.get('/health', (req, res) => { res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString(), uptime: process.uptime() }); }); // Only start server if not already started if (!global.__serverStarted) { global.__serverStarted = true; const server = app.listen(config.port, () => { logger.info(` ╔════════════════════════════════════════╗ ║ BNKops Map Server ║ ╠════════════════════════════════════════╣ ║ Status: Running ║ ║ Port: ${config.port} ║ ║ Environment: ${config.isProduction ? 'production' : 'development'} ║ ║ Project ID: ${config.nocodb.projectId} ║ ║ Table ID: ${config.nocodb.tableId} ║ ║ Login Sheet: ${config.nocodb.loginSheetId} ║ ║ PID: ${process.pid} ║ ║ 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;