const express = require('express'); const axios = require('axios'); const cors = require('cors'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const winston = require('winston'); const path = require('path'); const session = require('express-session'); const cookieParser = require('cookie-parser'); require('dotenv').config(); // Import geocoding routes const geocodingRoutes = require('./routes/geocoding'); // Parse project and table IDs from view URL function parseNocoDBUrl(url) { if (!url) return { projectId: null, tableId: null }; // Pattern to match NocoDB URLs const patterns = [ /#\/nc\/([^\/]+)\/([^\/\?#]+)/, // matches #/nc/PROJECT_ID/TABLE_ID (dashboard URLs) /\/nc\/([^\/]+)\/([^\/\?#]+)/, // matches /nc/PROJECT_ID/TABLE_ID /project\/([^\/]+)\/table\/([^\/\?#]+)/, // alternative pattern ]; for (const pattern of patterns) { const match = url.match(pattern); if (match) { return { projectId: match[1], tableId: match[2] }; } } return { projectId: null, tableId: null }; } // Add this helper function near the top of the file after the parseNocoDBUrl function function syncGeoFields(data) { // If we have latitude and longitude but no Geo-Location, create it if (data.latitude && data.longitude && !data['Geo-Location']) { const lat = parseFloat(data.latitude); const lng = parseFloat(data.longitude); if (!isNaN(lat) && !isNaN(lng)) { data['Geo-Location'] = `${lat};${lng}`; // Use semicolon format for NocoDB GeoData data.geodata = `${lat};${lng}`; // Also update geodata for compatibility } } // If we have Geo-Location but no lat/lng, parse it else if (data['Geo-Location'] && (!data.latitude || !data.longitude)) { const geoLocation = data['Geo-Location'].toString(); // Try semicolon-separated first let parts = geoLocation.split(';'); if (parts.length === 2) { const lat = parseFloat(parts[0].trim()); const lng = parseFloat(parts[1].trim()); if (!isNaN(lat) && !isNaN(lng)) { data.latitude = lat; data.longitude = lng; data.geodata = `${lat};${lng}`; return data; } } // Try comma-separated parts = geoLocation.split(','); if (parts.length === 2) { const lat = parseFloat(parts[0].trim()); const lng = parseFloat(parts[1].trim()); if (!isNaN(lat) && !isNaN(lng)) { data.latitude = lat; data.longitude = lng; data.geodata = `${lat};${lng}`; // Normalize Geo-Location to semicolon format for NocoDB GeoData data['Geo-Location'] = `${lat};${lng}`; } } } return data; } // Auto-parse IDs if view URL is provided if (process.env.NOCODB_VIEW_URL && (!process.env.NOCODB_PROJECT_ID || !process.env.NOCODB_TABLE_ID)) { const { projectId, tableId } = parseNocoDBUrl(process.env.NOCODB_VIEW_URL); if (projectId && tableId) { process.env.NOCODB_PROJECT_ID = projectId; process.env.NOCODB_TABLE_ID = tableId; console.log(`Auto-parsed from URL - Project ID: ${projectId}, Table ID: ${tableId}`); } } // Auto-parse login sheet ID if URL is provided let LOGIN_SHEET_ID = null; if (process.env.NOCODB_LOGIN_SHEET) { // Check if it's a URL or just an ID if (process.env.NOCODB_LOGIN_SHEET.startsWith('http')) { const { projectId, tableId } = parseNocoDBUrl(process.env.NOCODB_LOGIN_SHEET); if (projectId && tableId) { LOGIN_SHEET_ID = tableId; console.log(`Auto-parsed login sheet ID from URL: ${LOGIN_SHEET_ID}`); } else { console.error('Could not parse login sheet URL'); } } else { // Assume it's already just the ID LOGIN_SHEET_ID = process.env.NOCODB_LOGIN_SHEET; console.log(`Using login sheet ID: ${LOGIN_SHEET_ID}`); } } // Configure logger const logger = winston.createLogger({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [ new winston.transports.Console({ format: winston.format.simple() }) ] }); // Initialize Express app const app = express(); const PORT = process.env.PORT || 3000; // Session configuration app.use(cookieParser()); // Determine if we should use secure cookies based on environment and request const isProduction = process.env.NODE_ENV === 'production'; // Cookie configuration function const getCookieConfig = (req) => { const host = req?.get('host') || ''; const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1') || host.match(/^\d+\.\d+\.\d+\.\d+/); const config = { httpOnly: true, maxAge: 24 * 60 * 60 * 1000, // 24 hours sameSite: 'lax', secure: false, // Default to false domain: undefined // Default to no domain restriction }; // Only set domain and secure for production non-localhost access if (isProduction && !isLocalhost && process.env.COOKIE_DOMAIN) { // Check if the request is coming from a subdomain of COOKIE_DOMAIN const cookieDomain = process.env.COOKIE_DOMAIN.replace(/^\./, ''); // Remove leading dot if (host.includes(cookieDomain)) { config.domain = process.env.COOKIE_DOMAIN; config.secure = true; } } return config; }; app.use(session({ secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production', resave: false, saveUninitialized: false, cookie: getCookieConfig(), name: 'nocodb-map-session', genid: (req) => { // Use a custom session ID generator to avoid conflicts return require('crypto').randomBytes(16).toString('hex'); } })); // Security middleware app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org"], connectSrc: ["'self'"] } } })); // 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 = process.env.ALLOWED_ORIGINS?.split(',') || []; 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'] })); // Trust proxy for Cloudflare app.set('trust proxy', true); // Rate limiting with Cloudflare support const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, keyGenerator: (req) => { // Use CF-Connecting-IP header if available (Cloudflare) return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0] || req.ip; }, standardHeaders: true, legacyHeaders: false, }); const strictLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, keyGenerator: (req) => { return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0] || req.ip; } }); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: process.env.NODE_ENV === 'production' ? 10 : 50, // Increase limit slightly message: 'Too many login attempts, please try again later.', keyGenerator: (req) => { return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0] || req.ip; }, standardHeaders: true, legacyHeaders: false, }); // Middleware app.use(express.json({ limit: '10mb' })); // Authentication middleware const requireAuth = (req, res, next) => { if (req.session && req.session.authenticated) { next(); } else { if (req.xhr || req.headers.accept?.indexOf('json') > -1) { res.status(401).json({ success: false, error: 'Authentication required' }); } else { res.redirect('/login.html'); } } }; // Serve login page without authentication app.get('/login.html', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'login.html')); }); // Auth routes (no authentication required) app.post('/api/auth/login', authLimiter, async (req, res) => { try { // Log request details for debugging logger.info('Login attempt:', { email: req.body.email, ip: req.ip, cfIp: req.headers['cf-connecting-ip'], forwardedFor: req.headers['x-forwarded-for'], userAgent: req.headers['user-agent'] }); const { email } = req.body; if (!email) { return res.status(400).json({ success: false, error: 'Email is required' }); } // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return res.status(400).json({ success: false, error: 'Invalid email format' }); } // Check if login sheet is configured if (!LOGIN_SHEET_ID) { logger.error('NOCODB_LOGIN_SHEET not configured or could not be parsed'); return res.status(500).json({ success: false, error: 'Authentication system not properly configured' }); } // Fetch authorized emails from NocoDB const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${LOGIN_SHEET_ID}`; logger.info(`Checking authentication for email: ${email}`); logger.debug(`Using login sheet API: ${url}`); const response = await axios.get(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' }, params: { limit: 1000 // Adjust if you have more authorized users } }); const users = response.data.list || []; // Check if email exists in the authorized users list const authorizedUser = users.find(user => user.Email && user.Email.toLowerCase() === email.toLowerCase() ); if (authorizedUser) { // Set session req.session.authenticated = true; req.session.userEmail = email; req.session.userName = authorizedUser.Name || email; // Force session save before sending response req.session.save((err) => { if (err) { logger.error('Session save error:', err); return res.status(500).json({ success: false, error: 'Session error. Please try again.' }); } logger.info(`User authenticated: ${email}`); res.json({ success: true, message: 'Login successful', user: { email: email, name: req.session.userName } }); }); } else { logger.warn(`Authentication failed for email: ${email}`); res.status(401).json({ success: false, error: 'Email not authorized. Please contact an administrator.' }); } } catch (error) { logger.error('Login error:', error.message); res.status(500).json({ success: false, error: 'Authentication service error. Please try again later.' }); } }); app.get('/api/auth/check', (req, res) => { res.json({ authenticated: req.session?.authenticated || false, user: req.session?.authenticated ? { email: req.session.userEmail, name: req.session.userName } : null }); }); app.post('/api/auth/logout', (req, res) => { req.session.destroy((err) => { if (err) { logger.error('Logout error:', err); return res.status(500).json({ success: false, error: 'Logout failed' }); } res.json({ success: true, message: 'Logged out successfully' }); }); }); // Add this after the /api/auth/check route app.get('/api/debug/session', (req, res) => { res.json({ sessionID: req.sessionID, session: req.session, cookies: req.cookies, authenticated: req.session?.authenticated || false }); }); // Serve static files with authentication for main app app.use(express.static(path.join(__dirname, 'public'), { index: false // Don't serve index.html automatically })); // Protect main app routes app.get('/', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Add geocoding routes (protected) app.use('/api/geocode', requireAuth, geocodingRoutes); // Apply rate limiting to API routes app.use('/api/', limiter); // Health check endpoint (no auth required) app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), version: process.env.npm_package_version || '1.0.0' }); }); // Configuration validation endpoint (protected) app.get('/api/config-check', requireAuth, (req, res) => { const config = { hasApiUrl: !!process.env.NOCODB_API_URL, hasApiToken: !!process.env.NOCODB_API_TOKEN, hasProjectId: !!process.env.NOCODB_PROJECT_ID, hasTableId: !!process.env.NOCODB_TABLE_ID, hasLoginSheet: !!LOGIN_SHEET_ID, projectId: process.env.NOCODB_PROJECT_ID, tableId: process.env.NOCODB_TABLE_ID, loginSheet: LOGIN_SHEET_ID, loginSheetConfigured: process.env.NOCODB_LOGIN_SHEET, nodeEnv: process.env.NODE_ENV }; const isConfigured = config.hasApiUrl && config.hasApiToken && config.hasProjectId && config.hasTableId; res.json({ configured: isConfigured, ...config }); }); // All other API routes require authentication app.use('/api/*', requireAuth); // Get all locations from NocoDB app.get('/api/locations', async (req, res) => { try { const { limit = 1000, offset = 0, where } = req.query; const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`; const params = new URLSearchParams({ limit, offset }); if (where) { params.append('where', where); } logger.info(`Fetching locations from NocoDB: ${url}`); const response = await axios.get(`${url}?${params}`, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' }, timeout: 10000 // 10 second timeout }); // Process locations to ensure they have required fields const locations = response.data.list || []; const validLocations = locations.filter(loc => { // Apply geo field synchronization to each location loc = syncGeoFields(loc); // Check if location has valid coordinates if (loc.latitude && loc.longitude) { return true; } // Try to parse from geodata column (semicolon-separated) if (loc.geodata && typeof loc.geodata === 'string') { const parts = loc.geodata.split(';'); if (parts.length === 2) { loc.latitude = parseFloat(parts[0]); loc.longitude = parseFloat(parts[1]); return !isNaN(loc.latitude) && !isNaN(loc.longitude); } } // Try to parse from Geo-Location column (semicolon-separated first, then comma) if (loc['Geo-Location'] && typeof loc['Geo-Location'] === 'string') { // Try semicolon first (as we see in the data) let parts = loc['Geo-Location'].split(';'); if (parts.length === 2) { loc.latitude = parseFloat(parts[0].trim()); loc.longitude = parseFloat(parts[1].trim()); if (!isNaN(loc.latitude) && !isNaN(loc.longitude)) { return true; } } // Fallback to comma-separated parts = loc['Geo-Location'].split(','); if (parts.length === 2) { loc.latitude = parseFloat(parts[0].trim()); loc.longitude = parseFloat(parts[1].trim()); return !isNaN(loc.latitude) && !isNaN(loc.longitude); } } return false; }); logger.info(`Retrieved ${validLocations.length} valid locations out of ${locations.length} total`); res.json({ success: true, count: validLocations.length, total: response.data.pageInfo?.totalRows || validLocations.length, locations: validLocations }); } catch (error) { logger.error('Error fetching locations:', error.message); if (error.response) { // NocoDB API error res.status(error.response.status).json({ success: false, error: 'Failed to fetch data from NocoDB', details: error.response.data }); } else if (error.code === 'ECONNABORTED') { // Timeout res.status(504).json({ success: false, error: 'Request timeout' }); } else { // Other errors res.status(500).json({ success: false, error: 'Internal server error' }); } } }); // Get single location by ID app.get('/api/locations/:id', async (req, res) => { try { const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`; const response = await axios.get(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN } }); res.json({ success: true, location: response.data }); } catch (error) { logger.error(`Error fetching location ${req.params.id}:`, error.message); res.status(error.response?.status || 500).json({ success: false, error: 'Failed to fetch location' }); } }); // Create new location app.post('/api/locations', strictLimiter, async (req, res) => { try { let locationData = { ...req.body }; // Sync geo fields before validation locationData = syncGeoFields(locationData); const { latitude, longitude, ...additionalData } = locationData; // Validate coordinates if (!latitude || !longitude) { return res.status(400).json({ success: false, error: 'Latitude and longitude are required' }); } const lat = parseFloat(latitude); const lng = parseFloat(longitude); if (isNaN(lat) || isNaN(lng)) { return res.status(400).json({ success: false, error: 'Invalid coordinate values' }); } if (lat < -90 || lat > 90) { return res.status(400).json({ success: false, error: 'Latitude must be between -90 and 90' }); } if (lng < -180 || lng > 180) { return res.status(400).json({ success: false, error: 'Longitude must be between -180 and 180' }); } // Check bounds if configured if (process.env.BOUND_NORTH) { const bounds = { north: parseFloat(process.env.BOUND_NORTH), south: parseFloat(process.env.BOUND_SOUTH), east: parseFloat(process.env.BOUND_EAST), west: parseFloat(process.env.BOUND_WEST) }; if (lat > bounds.north || lat < bounds.south || lng > bounds.east || lng < bounds.west) { return res.status(400).json({ success: false, error: 'Location is outside allowed bounds' }); } } // Format geodata in both formats for compatibility const geodata = `${lat};${lng}`; const geoLocation = `${lat};${lng}`; // Use semicolon format for NocoDB GeoData column // Prepare data for NocoDB const finalData = { geodata, 'Geo-Location': geoLocation, latitude: lat, longitude: lng, ...additionalData, created_at: new Date().toISOString(), created_by: req.session.userEmail // Track who created the location }; const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`; logger.info('Creating new location:', { lat, lng }); const response = await axios.post(url, finalData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } }); logger.info('Location created successfully:', response.data.id); res.status(201).json({ success: true, location: response.data }); } catch (error) { logger.error('Error creating location:', error.message); if (error.response) { res.status(error.response.status).json({ success: false, error: 'Failed to save location to NocoDB', details: error.response.data }); } else { res.status(500).json({ success: false, error: 'Internal server error' }); } } }); // Update location app.put('/api/locations/:id', strictLimiter, async (req, res) => { try { let updateData = { ...req.body }; // Sync geo fields updateData = syncGeoFields(updateData); updateData.updated_at = new Date().toISOString(); updateData.updated_by = req.session.userEmail; // Track who updated const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`; const response = await axios.patch(url, updateData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } }); res.json({ success: true, location: response.data }); } catch (error) { logger.error(`Error updating location ${req.params.id}:`, error.message); res.status(error.response?.status || 500).json({ success: false, error: 'Failed to update location' }); } }); // Delete location app.delete('/api/locations/:id', strictLimiter, async (req, res) => { try { const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`; await axios.delete(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN } }); logger.info(`Location ${req.params.id} deleted by ${req.session.userEmail}`); res.json({ success: true, message: 'Location deleted successfully' }); } catch (error) { logger.error(`Error deleting location ${req.params.id}:`, error.message); res.status(error.response?.status || 500).json({ success: false, error: 'Failed to delete location' }); } }); // Error handling middleware app.use((err, req, res, next) => { logger.error('Unhandled error:', err); res.status(500).json({ success: false, error: 'Internal server error' }); }); // Start server app.listen(PORT, () => { logger.info(` ╔════════════════════════════════════════╗ ║ NocoDB Map Viewer Server ║ ╠════════════════════════════════════════╣ ║ Status: Running ║ ║ Port: ${PORT} ║ ║ Environment: ${process.env.NODE_ENV || 'development'} ║ ║ Project ID: ${process.env.NOCODB_PROJECT_ID} ║ ║ Table ID: ${process.env.NOCODB_TABLE_ID} ║ ║ Login Sheet: ${LOGIN_SHEET_ID || 'Not Configured'} ║ ║ Time: ${new Date().toISOString()} ║ ╚════════════════════════════════════════╝ `); }); // Graceful shutdown process.on('SIGTERM', () => { logger.info('SIGTERM signal received: closing HTTP server'); app.close(() => { logger.info('HTTP server closed'); process.exit(0); }); });