freealberta/map/app/server.js

252 lines
7.8 KiB
JavaScript

// 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');
// 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}`,
`https://docs.${config.domain}`,
`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'));
// 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 docsUrl = config.isProduction ?
`https://docs.${config.domain}/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;
}
// 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;