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'); 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}`); } } // 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; // 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: process.env.ALLOWED_ORIGINS?.split(',') || '*', credentials: true })); // Rate limiting const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100 // limit each IP to 100 requests per windowMs }); const strictLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 // limit location creation to 20 per 15 minutes }); // Middleware app.use(express.json({ limit: '10mb' })); app.use(express.static(path.join(__dirname, 'public'))); app.use('/api/', limiter); // Add geocoding routes app.use('/api/geocode', geocodingRoutes); // Health check endpoint 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 (for debugging) app.get('/api/config-check', (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, projectId: process.env.NOCODB_PROJECT_ID, tableId: process.env.NOCODB_TABLE_ID, nodeEnv: process.env.NODE_ENV }; const isConfigured = config.hasApiUrl && config.hasApiToken && config.hasProjectId && config.hasTableId; res.json({ configured: isConfigured, ...config }); }); // 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() }; 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(); 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`); 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} ║ ║ 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); }); });