freealberta/map/app/server.js

217 lines
7.0 KiB
JavaScript

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');
// Debug: Check if server.js is being loaded multiple times
const serverInstanceId = Math.random().toString(36).substr(2, 9);
console.log(`[DEBUG] Server.js instance ${serverInstanceId} loading at ${new Date().toISOString()}`);
// 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;