fixed cut overlays and solved the docker duplication thing

This commit is contained in:
admin 2025-08-06 16:04:41 -06:00
parent f4327c3c40
commit 0d3a273e22
9 changed files with 248 additions and 3274 deletions

View File

@ -1,7 +1,7 @@
FROM node:18-alpine FROM node:18-alpine
# Install wget and dumb-init for proper signal handling # Install wget for health checks
RUN apk add --no-cache wget dumb-init RUN apk add --no-cache wget
WORKDIR /app WORKDIR /app
@ -33,6 +33,5 @@ USER nodejs
EXPOSE 3000 EXPOSE 3000
# Use dumb-init to handle signals properly and prevent zombie processes # Use exec form to ensure PID 1 is node process
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@ -5,7 +5,6 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [ "keywords": [

View File

@ -54,8 +54,8 @@
cursor: pointer; cursor: pointer;
} }
/* Ensure circle markers are visible */ /* Ensure circle markers are visible - but NOT cut polygons */
path.leaflet-interactive { path.leaflet-interactive:not(.cut-polygon) {
stroke: #fff; stroke: #fff;
stroke-opacity: 1; stroke-opacity: 1;
stroke-width: 2; stroke-width: 2;
@ -107,7 +107,15 @@ path.leaflet-interactive {
/* Cut polygons - allow dynamic opacity (higher specificity to override) */ /* Cut polygons - allow dynamic opacity (higher specificity to override) */
.leaflet-container path.leaflet-interactive.cut-polygon { .leaflet-container path.leaflet-interactive.cut-polygon {
stroke-width: 2px !important; stroke-width: 2px !important;
/* Allow JavaScript to control fill-opacity - remove !important */ /* Allow JavaScript to control fill-opacity - explicitly do NOT override it */
stroke: currentColor !important;
stroke-opacity: 0.8 !important;
}
/* Additional specificity rule for cut polygons to ensure JS opacity takes precedence */
path.leaflet-interactive.cut-polygon {
/* Do not set fill-opacity here - let JavaScript control it */
stroke-width: 2px;
} }
/* Marker being moved */ /* Marker being moved */

View File

@ -37,6 +37,10 @@
border-radius: 50%; border-radius: 50%;
background: rgba(255, 68, 68, 0.3); background: rgba(255, 68, 68, 0.3);
animation: pulse-ring 2s ease-out infinite; animation: pulse-ring 2s ease-out infinite;
/* Position the pulse at the tip of the marker */
top: 24px;
left: 0;
transform-origin: center center;
} }
@keyframes bounce-marker { @keyframes bounce-marker {

View File

@ -184,91 +184,122 @@ export class CutManager {
} }
} }
/** // ...existing code...
* Display a cut on the map (enhanced to support multiple cuts)
*/
/**
* Display a cut on the map (enhanced to support multiple cuts)
*/
displayCut(cutData, autoDisplayed = false) {
if (!this.map) {
console.error('Map not initialized');
return false;
}
// Normalize field names for consistent access displayCut(cutData, autoDisplayed = false) {
const normalizedCut = { if (!this.map) {
...cutData, console.error('Map not initialized');
id: cutData.id || cutData.Id || cutData.ID, return false;
name: cutData.name || cutData.Name,
description: cutData.description || cutData.Description,
color: cutData.color || cutData.Color,
opacity: cutData.opacity || cutData.Opacity,
category: cutData.category || cutData.Category,
geojson: cutData.geojson || cutData.GeoJSON || cutData['GeoJSON Data'],
is_public: cutData.is_public || cutData['Public Visibility'],
is_official: cutData.is_official || cutData['Official Cut'],
autoDisplayed: autoDisplayed // Track if this was auto-displayed
};
// Check if already displayed
if (this.cutLayers.has(normalizedCut.id)) {
console.log(`Cut already displayed: ${normalizedCut.name}`);
return true;
}
if (!normalizedCut.geojson) {
console.error('Cut has no GeoJSON data');
return false;
}
try {
const geojsonData = typeof normalizedCut.geojson === 'string' ?
JSON.parse(normalizedCut.geojson) : normalizedCut.geojson;
const cutLayer = L.geoJSON(geojsonData, {
style: {
color: normalizedCut.color || '#3388ff',
fillColor: normalizedCut.color || '#3388ff',
fillOpacity: parseFloat(normalizedCut.opacity) || 0.3,
weight: 2,
opacity: 1,
className: 'cut-polygon'
}
});
// Add popup with cut info
cutLayer.bindPopup(`
<div class="cut-popup">
<h3>${normalizedCut.name}</h3>
${normalizedCut.description ? `<p>${normalizedCut.description}</p>` : ''}
${normalizedCut.category ? `<p><strong>Category:</strong> ${normalizedCut.category}</p>` : ''}
${normalizedCut.is_official ? '<span class="badge official">Official Cut</span>' : ''}
</div>
`);
cutLayer.addTo(this.map);
// Store in both tracking systems
this.cutLayers.set(normalizedCut.id, cutLayer);
this.displayedCuts.set(normalizedCut.id, normalizedCut);
// Update current cut reference (for legacy compatibility)
this.currentCut = normalizedCut;
this.currentCutLayer = cutLayer;
console.log(`Displayed cut: ${normalizedCut.name} (ID: ${normalizedCut.id})`);
return true;
} catch (error) {
console.error('Error displaying cut:', error);
return false;
}
} }
/** // Normalize field names for consistent access
* Hide the currently displayed cut (legacy method - now hides all cuts) const normalizedCut = {
*/ ...cutData,
id: cutData.id || cutData.Id || cutData.ID,
name: cutData.name || cutData.Name,
description: cutData.description || cutData.Description,
color: cutData.color || cutData.Color,
opacity: cutData.opacity || cutData.Opacity,
category: cutData.category || cutData.Category,
geojson: cutData.geojson || cutData.GeoJSON || cutData['GeoJSON Data'],
is_public: cutData.is_public || cutData['Public Visibility'],
is_official: cutData.is_official || cutData['Official Cut'],
autoDisplayed: autoDisplayed // Track if this was auto-displayed
};
// Check if already displayed
if (this.cutLayers.has(normalizedCut.id)) {
console.log(`Cut already displayed: ${normalizedCut.name}`);
return true;
}
if (!normalizedCut.geojson) {
console.error('Cut has no GeoJSON data');
return false;
}
try {
const geojsonData = typeof normalizedCut.geojson === 'string' ?
JSON.parse(normalizedCut.geojson) : normalizedCut.geojson;
// Parse opacity value - ensure it's a number between 0 and 1
let opacityValue = parseFloat(normalizedCut.opacity);
// Validate opacity is within range
if (isNaN(opacityValue) || opacityValue < 0 || opacityValue > 1) {
opacityValue = 0.3; // Default fallback
console.log(`Invalid opacity value (${normalizedCut.opacity}), using default: ${opacityValue}`);
}
const cutLayer = L.geoJSON(geojsonData, {
style: {
color: normalizedCut.color || '#3388ff',
fillColor: normalizedCut.color || '#3388ff',
fillOpacity: opacityValue,
weight: 2,
opacity: 0.8, // Stroke opacity - keeping this slightly transparent for better visibility
className: 'cut-polygon'
},
// Add onEachFeature to apply styles to each individual feature
onEachFeature: function (feature, layer) {
// Apply styles directly to the layer to ensure they override CSS
if (layer.setStyle) {
layer.setStyle({
fillOpacity: opacityValue,
color: normalizedCut.color || '#3388ff',
fillColor: normalizedCut.color || '#3388ff',
weight: 2,
opacity: 0.8
});
}
// Add cut-polygon class to the path element
if (layer._path) {
layer._path.classList.add('cut-polygon');
}
}
});
// Add popup with cut info
cutLayer.bindPopup(`
<div class="cut-popup">
<h3>${normalizedCut.name}</h3>
${normalizedCut.description ? `<p>${normalizedCut.description}</p>` : ''}
${normalizedCut.category ? `<p><strong>Category:</strong> ${normalizedCut.category}</p>` : ''}
${normalizedCut.is_official ? '<span class="badge official">Official Cut</span>' : ''}
</div>
`);
cutLayer.addTo(this.map);
// Ensure cut-polygon class is applied to all path elements after adding to map
cutLayer.eachLayer(function(layer) {
if (layer._path) {
layer._path.classList.add('cut-polygon');
// Force the fill-opacity style to ensure it overrides CSS
layer._path.style.fillOpacity = opacityValue;
}
});
// Store in both tracking systems
this.cutLayers.set(normalizedCut.id, cutLayer);
this.displayedCuts.set(normalizedCut.id, normalizedCut);
// Update current cut reference (for legacy compatibility)
this.currentCut = normalizedCut;
this.currentCutLayer = cutLayer;
console.log(`Displayed cut: ${normalizedCut.name} (ID: ${normalizedCut.id}) with opacity: ${opacityValue} (raw: ${normalizedCut.opacity})`);
return true;
} catch (error) {
console.error('Error displaying cut:', error);
return false;
}
}
// ...existing code...
hideCut() { hideCut() {
this.hideAllCuts(); this.hideAllCuts();
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,16 @@
// 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 // Prevent duplicate execution
if (require.main !== module) { if (require.main !== module) {
console.log('Server.js being imported, not executed directly'); console.log('[INIT] Server.js being imported, not executed directly - EXITING');
return; return;
} }
@ -12,10 +22,6 @@ const cookieParser = require('cookie-parser');
const crypto = require('crypto'); const crypto = require('crypto');
const fetch = require('node-fetch'); 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 PID:${process.pid} instance ${serverInstanceId} loading at ${new Date().toISOString()}`);
// Import configuration and utilities // Import configuration and utilities
const config = require('./config'); const config = require('./config');
const logger = require('./utils/logger'); const logger = require('./utils/logger');
@ -24,8 +30,14 @@ const { apiLimiter } = require('./middleware/rateLimiter');
const { cacheBusting } = require('./utils/cacheBusting'); const { cacheBusting } = require('./utils/cacheBusting');
const { initializeEmailService } = require('./services/email'); const { initializeEmailService } = require('./services/email');
// Initialize Express app // Initialize Express app - only create once
if (global.__expressApp) {
console.log('[INIT] Express app already created - EXITING');
return;
}
const app = express(); const app = express();
global.__expressApp = app;
// Trust proxy for Cloudflare // Trust proxy for Cloudflare
app.set('trust proxy', true); app.set('trust proxy', true);
@ -33,8 +45,8 @@ app.set('trust proxy', true);
// Cookie parser // Cookie parser
app.use(cookieParser()); app.use(cookieParser());
// Session configuration // Session configuration - only initialize once
app.use(session({ const sessionMiddleware = session({
secret: config.session.secret, secret: config.session.secret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
@ -44,36 +56,34 @@ app.use(session({
// Use a custom session ID generator to avoid conflicts // Use a custom session ID generator to avoid conflicts
return crypto.randomBytes(16).toString('hex'); return crypto.randomBytes(16).toString('hex');
} }
})); });
app.use(sessionMiddleware);
// Build dynamic CSP configuration // Build dynamic CSP configuration
const buildConnectSrc = () => { const buildConnectSrc = () => {
const sources = ["'self'"]; const sources = ["'self'"];
// Add MkDocs URLs from config // Add NocoDB API URL
if (config.mkdocs?.url) { if (config.nocodb.apiUrl) {
sources.push(config.mkdocs.url); try {
const nocodbUrl = new URL(config.nocodb.apiUrl);
sources.push(`${nocodbUrl.protocol}//${nocodbUrl.host}`);
} catch (e) {
// Invalid URL, skip
}
} }
// Add localhost ports from environment // Add Edmonton Open Data Portal
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'); sources.push('https://data.edmonton.ca');
// Add Stadia Maps for better tile coverage // Add Nominatim for geocoding
sources.push('https://tiles.stadiamaps.com'); sources.push('https://nominatim.openstreetmap.org');
// Add production domains if in production // Add localhost for development
if (config.isProduction || process.env.NODE_ENV === 'production') { if (!config.isProduction) {
// Add the main domain from environment sources.push('http://localhost:*');
const mainDomain = process.env.DOMAIN || 'cmlite.org'; sources.push('ws://localhost:*');
sources.push(`https://${mainDomain}`);
sources.push('https://cmlite.org'); // Fallback
} }
return sources; return sources;
@ -95,14 +105,26 @@ app.use(helmet({
// CORS configuration // CORS configuration
app.use(cors({ app.use(cors({
origin: function(origin, callback) { origin: function(origin, callback) {
// Allow requests with no origin (like mobile apps or curl requests) // Allow requests with no origin (like Postman or server-to-server)
if (!origin) return callback(null, true); if (!origin) return callback(null, true);
const allowedOrigins = config.cors.allowedOrigins; // In production, be more restrictive
if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) { if (config.isProduction) {
callback(null, true); 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 { } else {
callback(new Error('Not allowed by CORS')); // In development, allow localhost
callback(null, true);
} }
}, },
credentials: true, credentials: true,
@ -136,25 +158,24 @@ app.get('/api/version', (req, res) => {
// Proxy endpoint for MkDocs search // Proxy endpoint for MkDocs search
app.get('/api/docs-search', async (req, res) => { app.get('/api/docs-search', async (req, res) => {
try { try {
const mkdocsUrl = config.mkdocs?.url || `http://localhost:${process.env.MKDOCS_SITE_SERVER_PORT || '4002'}`; const docsUrl = config.isProduction ?
logger.info(`Fetching search index from: ${mkdocsUrl}/search/search_index.json`); `https://docs.${config.domain}/search/search_index.json` :
'http://localhost:8000/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 response = await fetch(docsUrl);
const data = await response.json(); const data = await response.json();
res.json(data); res.json(data);
} catch (error) { } catch (error) {
logger.error('Error fetching search index:', error); logger.error('Failed to fetch docs search index:', error);
res.status(500).json({ error: 'Failed to fetch search index' }); res.status(500).json({ error: 'Failed to fetch search index' });
} }
}); });
// Initialize email service // Initialize email service - only once
initializeEmailService(); if (!global.__emailInitialized) {
initializeEmailService();
global.__emailInitialized = true;
}
// Import and setup routes // Import and setup routes
require('./routes')(app); require('./routes')(app);
@ -162,63 +183,70 @@ require('./routes')(app);
// Error handling middleware // Error handling middleware
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
logger.error('Unhandled error:', err); logger.error('Unhandled error:', err);
res.status(500).json({
// Don't leak error details in production error: 'Internal server error',
const message = config.isProduction ? message: config.isProduction ? 'An error occurred' : err.message
'Internal server error' :
err.message || 'Internal server error';
res.status(err.status || 500).json({
success: false,
error: message
}); });
}); });
// Start server // Add a simple health check endpoint early in the middleware stack (before other middlewares)
const server = app.listen(config.port, () => { app.get('/health', (req, res) => {
logger.info(` 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 BNKops Map Server
Status: Running Status: Running
Port: ${config.port} Port: ${config.port}
Environment: ${config.nodeEnv} Environment: ${config.isProduction ? 'production' : 'development'}
Project ID: ${config.nocodb.projectId} Project ID: ${config.nocodb.projectId}
Table ID: ${config.nocodb.tableId} Table ID: ${config.nocodb.tableId}
Login Sheet: ${config.nocodb.loginSheetId || 'Not Configured'} Login Sheet: ${config.nocodb.loginSheetId}
PID: ${process.pid} PID: ${process.pid}
Time: ${new Date().toISOString()} 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', () => { // Graceful shutdown
logger.info('SIGINT signal received: closing HTTP server'); process.on('SIGTERM', () => {
server.close(() => { logger.info('SIGTERM signal received: closing HTTP server');
logger.info('HTTP server closed'); server.close(() => {
process.exit(0); logger.info('HTTP server closed');
process.exit(0);
});
}); });
});
// Handle uncaught exceptions process.on('SIGINT', () => {
process.on('uncaughtException', (err) => { logger.info('SIGINT signal received: closing HTTP server');
logger.error('Uncaught exception:', err); server.close(() => {
process.exit(1); logger.info('HTTP server closed');
}); process.exit(0);
});
});
// Handle unhandled promise rejections // Handle uncaught exceptions
process.on('unhandledRejection', (reason, promise) => { process.on('uncaughtException', (err) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason); logger.error('Uncaught Exception:', err);
process.exit(1); 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; module.exports = app;

View File

@ -1,20 +1,10 @@
const winston = require('winston'); const winston = require('winston');
const config = require('../config'); const config = require('../config');
// Debug: Check if logger is being created multiple times // Create the logger only once
const instanceId = Math.random().toString(36).substr(2, 9);
console.log(`[DEBUG] Creating logger instance ${instanceId} at ${new Date().toISOString()}`);
// Ensure we only create one logger instance
if (global.appLogger) {
console.log(`[DEBUG] Reusing existing logger instance`);
module.exports = global.appLogger;
return;
}
const logger = winston.createLogger({ const logger = winston.createLogger({
level: config.isProduction ? 'info' : 'debug', level: config.isProduction ? 'info' : 'debug',
defaultMeta: { service: 'bnkops-map', instanceId }, defaultMeta: { service: 'bnkops-map' },
transports: [ transports: [
new winston.transports.Console({ new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(
@ -50,7 +40,4 @@ if (config.isProduction) {
})); }));
} }
// Store logger globally to prevent multiple instances
global.appLogger = logger;
module.exports = logger; module.exports = logger;