freealberta/map/app/server.js

344 lines
13 KiB
JavaScript

// At the very top of the file, before any requires
const startTime = Date.now();
// Load environment variables first - use the .env file in the map directory
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
// 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 OpenStreetMap tile servers for dom-to-image map capture
sources.push('https://*.tile.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:", "blob:", "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}`,
`http://localhost:${config.port}`, // Allow localhost with configured port
`http://localhost:3000` // Allow default port 3000 as well
];
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;