344 lines
13 KiB
JavaScript
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; |