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'); // Import QR code service (only for local generation, no upload) const { generateQRCode } = require('./services/qrcode'); // 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}`; data.geodata = `${lat};${lng}`; } } // 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}`); } } // Auto-parse settings sheet ID if URL is provided let SETTINGS_SHEET_ID = null; if (process.env.NOCODB_SETTINGS_SHEET) { // Check if it's a URL or just an ID if (process.env.NOCODB_SETTINGS_SHEET.startsWith('http')) { const { projectId, tableId } = parseNocoDBUrl(process.env.NOCODB_SETTINGS_SHEET); if (projectId && tableId) { SETTINGS_SHEET_ID = tableId; console.log(`Auto-parsed settings sheet ID from URL: ${SETTINGS_SHEET_ID}`); } else { console.error('Could not parse settings sheet URL'); } } else { // Assume it's already just the ID SETTINGS_SHEET_ID = process.env.NOCODB_SETTINGS_SHEET; console.log(`Using settings sheet ID: ${SETTINGS_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(/^\./, ''); if (host.includes(cookieDomain)) { config.domain = process.env.COOKIE_DOMAIN; config.secure = true; // Enable secure cookies for production } } 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", "https://cdn.jsdelivr.net"], imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://unpkg.com"], 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'); } } }; // Admin middleware const requireAdmin = (req, res, next) => { if (req.session && req.session.authenticated && req.session.isAdmin) { next(); } else { if (req.xhr || req.headers.accept?.indexOf('json') > -1) { res.status(403).json({ success: false, error: 'Admin access 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 including admin status req.session.authenticated = true; req.session.userEmail = email; req.session.userName = authorizedUser.Name || email; req.session.isAdmin = authorizedUser.Admin === true || authorizedUser.Admin === 1; // 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}, Admin: ${req.session.isAdmin}`); res.json({ success: true, message: 'Login successful', user: { email: email, name: req.session.userName, isAdmin: req.session.isAdmin } }); }); } 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, isAdmin: req.session.isAdmin || false } : 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' }); }); }); // Admin routes // Serve admin page (protected) app.get('/admin.html', requireAdmin, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin.html')); }); // Add admin API endpoint to update start location app.post('/api/admin/start-location', requireAdmin, async (req, res) => { try { const { latitude, longitude, zoom } = req.body; // Validate input if (!latitude || !longitude) { return res.status(400).json({ success: false, error: 'Latitude and longitude are required' }); } const lat = parseFloat(latitude); const lng = parseFloat(longitude); const mapZoom = parseInt(zoom) || 11; if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { return res.status(400).json({ success: false, error: 'Invalid coordinates' }); } if (!SETTINGS_SHEET_ID) { return res.status(500).json({ success: false, error: 'Settings sheet not configured' }); } // Get current settings to preserve walk sheet config let currentConfig = {}; try { const getUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; const currentResponse = await axios.get(getUrl, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN }, params: { sort: '-created_at', limit: 1 } }); if (currentResponse.data?.list?.length > 0) { currentConfig = currentResponse.data.list[0]; } } catch (e) { logger.warn('Could not fetch current settings:', e.message); } // Create new settings row with updated location const settingData = { // System fields created_at: new Date().toISOString(), created_by: req.session.userEmail, // Location fields 'Geo-Location': `${lat};${lng}`, latitude: lat, longitude: lng, zoom: mapZoom, // Preserve walk sheet fields walk_sheet_title: currentConfig.walk_sheet_title || 'Campaign Walk Sheet', walk_sheet_subtitle: currentConfig.walk_sheet_subtitle || 'Door-to-Door Canvassing Form', walk_sheet_footer: currentConfig.walk_sheet_footer || 'Thank you for your support!', qr_code_1_url: currentConfig.qr_code_1_url || '', qr_code_1_label: currentConfig.qr_code_1_label || '', qr_code_2_url: currentConfig.qr_code_2_url || '', qr_code_2_label: currentConfig.qr_code_2_label || '', qr_code_3_url: currentConfig.qr_code_3_url || '', qr_code_3_label: currentConfig.qr_code_3_label || '' }; const createUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; const createResponse = await axios.post(createUrl, settingData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } }); logger.info('Created new settings row with start location'); res.json({ success: true, message: 'Start location saved successfully', location: { latitude: lat, longitude: lng, zoom: mapZoom }, settingsId: createResponse.data.id || createResponse.data.Id || createResponse.data.ID }); } catch (error) { logger.error('Error updating start location:', error); res.status(500).json({ success: false, error: error.message || 'Failed to update start location' }); } }); // Get current start location (fetch most recent) app.get('/api/admin/start-location', requireAdmin, async (req, res) => { try { // First try to get from database if (SETTINGS_SHEET_ID) { const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; const response = await axios.get(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN }, params: { sort: '-created_at', // Get most recent limit: 1 } }); const settings = response.data.list || []; if (settings.length > 0) { const setting = settings[0]; // Try to extract coordinates let lat, lng, zoom; if (setting['Geo-Location']) { const parts = setting['Geo-Location'].split(';'); if (parts.length === 2) { lat = parseFloat(parts[0]); lng = parseFloat(parts[1]); } } else if (setting.latitude && setting.longitude) { lat = parseFloat(setting.latitude); lng = parseFloat(setting.longitude); } zoom = parseInt(setting.zoom) || 11; if (lat && lng && !isNaN(lat) && !isNaN(lng)) { return res.json({ success: true, location: { latitude: lat, longitude: lng, zoom: zoom }, source: 'database', settingsId: setting.id || setting.Id || setting.ID, lastUpdated: setting.created_at }); } } } // Fallback to environment variables res.json({ success: true, location: { latitude: 53.5461, longitude: -113.4938, zoom: 11 }, source: 'defaults' }); } catch (error) { logger.error('Error fetching start location:', error); // Return defaults on error res.json({ success: true, location: { latitude: 53.5461, longitude: -113.4938, zoom: 11 }, source: 'defaults' }); } }); // Update the public config endpoint similarly app.get('/api/config/start-location', async (req, res) => { try { // Try to get from database first if (SETTINGS_SHEET_ID) { const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; logger.info(`Fetching start location from settings sheet: ${SETTINGS_SHEET_ID}`); const response = await axios.get(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN }, params: { sort: '-created_at', // Get most recent limit: 1 } }); const settings = response.data.list || []; if (settings.length > 0) { const setting = settings[0]; logger.info('Found settings row:', { id: setting.id || setting.Id || setting.ID, hasGeoLocation: !!setting['Geo-Location'], hasLatLng: !!(setting.latitude && setting.longitude) }); // Try to extract coordinates let lat, lng, zoom; if (setting['Geo-Location']) { const parts = setting['Geo-Location'].split(';'); if (parts.length === 2) { lat = parseFloat(parts[0]); lng = parseFloat(parts[1]); } } else if (setting.latitude && setting.longitude) { lat = parseFloat(setting.latitude); lng = parseFloat(setting.longitude); } zoom = parseInt(setting.zoom) || 11; if (lat && lng && !isNaN(lat) && !isNaN(lng)) { logger.info(`Returning location from database: ${lat}, ${lng}, zoom: ${zoom}`); return res.json({ latitude: lat, longitude: lng, zoom: zoom }); } } else { logger.info('No settings found in database'); } } else { logger.info('Settings sheet not configured, using defaults'); } } catch (error) { logger.error('Error fetching config start location:', error); } // Return defaults const defaultLat = parseFloat(process.env.DEFAULT_LAT) || 53.5461; const defaultLng = parseFloat(process.env.DEFAULT_LNG) || -113.4938; const defaultZoom = parseInt(process.env.DEFAULT_ZOOM) || 11; logger.info(`Using default start location: ${defaultLat}, ${defaultLng}, zoom: ${defaultZoom}`); res.json({ latitude: defaultLat, longitude: defaultLng, zoom: defaultZoom }); }); // Get walk sheet configuration (load most recent) app.get('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => { try { // Default configuration const defaultConfig = { walk_sheet_title: 'Campaign Walk Sheet', walk_sheet_subtitle: 'Door-to-Door Canvassing Form', walk_sheet_footer: 'Thank you for your support!', qr_code_1_url: '', qr_code_1_label: '', qr_code_2_url: '', qr_code_2_label: '', qr_code_3_url: '', qr_code_3_label: '' }; if (!SETTINGS_SHEET_ID) { logger.warn('SETTINGS_SHEET_ID not configured, returning defaults'); return res.json({ success: true, config: defaultConfig, source: 'defaults', message: 'Settings sheet not configured, using defaults' }); } // Get ALL settings rows and find the most recent one with walk sheet data const response = await axios.get( `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' }, params: { sort: '-created_at', // Sort by created_at descending limit: 20 // Get more records to find one with walk sheet data } } ); logger.debug('GET Settings response structure:', JSON.stringify(response.data, null, 2)); if (!response.data?.list || response.data.list.length === 0) { logger.info('No settings found in database, returning defaults'); return res.json({ success: true, config: defaultConfig, source: 'defaults', message: 'No settings found in database' }); } // Find the first row that has walk sheet configuration (not just location data) const settingsRow = response.data.list.find(row => row.walk_sheet_title || row.walk_sheet_subtitle || row.walk_sheet_footer || row.qr_code_1_url || row.qr_code_2_url || row.qr_code_3_url ) || response.data.list[0]; // Fallback to most recent if none have walk sheet data const walkSheetConfig = { walk_sheet_title: settingsRow.walk_sheet_title || defaultConfig.walk_sheet_title, walk_sheet_subtitle: settingsRow.walk_sheet_subtitle || defaultConfig.walk_sheet_subtitle, walk_sheet_footer: settingsRow.walk_sheet_footer || defaultConfig.walk_sheet_footer, qr_code_1_url: settingsRow.qr_code_1_url || defaultConfig.qr_code_1_url, qr_code_1_label: settingsRow.qr_code_1_label || defaultConfig.qr_code_1_label, qr_code_2_url: settingsRow.qr_code_2_url || defaultConfig.qr_code_2_url, qr_code_2_label: settingsRow.qr_code_2_label || defaultConfig.qr_code_2_label, qr_code_3_url: settingsRow.qr_code_3_url || defaultConfig.qr_code_3_url, qr_code_3_label: settingsRow.qr_code_3_label || defaultConfig.qr_code_3_label }; logger.info(`Retrieved walk sheet config from database (ID: ${settingsRow.Id || settingsRow.id})`); res.json({ success: true, config: walkSheetConfig, source: 'database', settingsId: settingsRow.id || settingsRow.Id || settingsRow.ID, lastUpdated: settingsRow.created_at || settingsRow.updated_at }); } catch (error) { logger.error('Failed to get walk sheet config:', error); logger.error('Error details:', error.response?.data || error.message); // Return defaults if there's an error res.json({ success: true, config: { walk_sheet_title: 'Campaign Walk Sheet', walk_sheet_subtitle: 'Door-to-Door Canvassing Form', walk_sheet_footer: 'Thank you for your support!', qr_code_1_url: '', qr_code_1_label: '', qr_code_2_url: '', qr_code_2_label: '', qr_code_3_url: '', qr_code_3_label: '' }, source: 'defaults', message: 'Error retrieving from database, using defaults', error: error.message }); } }); // Save walk sheet configuration (always create new row) app.post('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => { try { if (!SETTINGS_SHEET_ID) { return res.status(400).json({ success: false, error: 'Settings sheet not configured. Please configure NOCODB_SETTINGS_SHEET environment variable.' }); } logger.info('Using SETTINGS_SHEET_ID:', SETTINGS_SHEET_ID); const config = req.body; logger.info('Received walk sheet config:', JSON.stringify(config, null, 2)); // Validate input if (!config || typeof config !== 'object') { return res.status(400).json({ success: false, error: 'Invalid configuration data' }); } const userEmail = req.session.userEmail; const timestamp = new Date().toISOString(); // Prepare data for saving - only include walk sheet fields const walkSheetData = { // System fields created_at: timestamp, created_by: userEmail, // Walk sheet fields with validation walk_sheet_title: (config.walk_sheet_title || '').toString().trim(), walk_sheet_subtitle: (config.walk_sheet_subtitle || '').toString().trim(), walk_sheet_footer: (config.walk_sheet_footer || '').toString().trim(), // QR Code fields with URL validation qr_code_1_url: validateUrl(config.qr_code_1_url), qr_code_1_label: (config.qr_code_1_label || '').toString().trim(), qr_code_2_url: validateUrl(config.qr_code_2_url), qr_code_2_label: (config.qr_code_2_label || '').toString().trim(), qr_code_3_url: validateUrl(config.qr_code_3_url), qr_code_3_label: (config.qr_code_3_label || '').toString().trim() }; logger.info('Prepared walk sheet data for saving:', JSON.stringify(walkSheetData, null, 2)); // Create new settings row const response = await axios.post( `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, walkSheetData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } } ); logger.info('NocoDB create response:', JSON.stringify(response.data, null, 2)); const newId = response.data.id || response.data.Id || response.data.ID; res.json({ success: true, message: 'Walk sheet configuration saved successfully', config: walkSheetData, settingsId: newId, timestamp: timestamp }); } catch (error) { logger.error('Failed to save walk sheet config:', error); logger.error('Error response:', error.response?.data); logger.error('Request URL:', error.config?.url); // Provide more detailed error information let errorMessage = 'Failed to save walk sheet configuration'; let errorDetails = null; if (error.response?.data) { errorDetails = error.response.data; if (error.response.data.message) { errorMessage = error.response.data.message; } } res.status(500).json({ success: false, error: errorMessage, details: errorDetails, timestamp: new Date().toISOString() }); } }); // Helper function to validate URLs function validateUrl(url) { if (!url || typeof url !== 'string') { return ''; } const trimmed = url.trim(); if (!trimmed) { return ''; } // Basic URL validation try { new URL(trimmed); return trimmed; } catch (e) { // If not a valid URL, check if it's a relative path or missing protocol if (trimmed.startsWith('/') || !trimmed.includes('://')) { // For relative paths or missing protocol, return as-is return trimmed; } logger.warn('Invalid URL provided:', trimmed); return ''; } } // Debug session endpoint 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, hasSettingsSheet: !!SETTINGS_SHEET_ID, projectId: process.env.NOCODB_PROJECT_ID, tableId: process.env.NOCODB_TABLE_ID, loginSheet: LOGIN_SHEET_ID, loginSheetConfigured: process.env.NOCODB_LOGIN_SHEET, settingsSheet: SETTINGS_SHEET_ID, settingsSheetConfigured: process.env.NOCODB_SETTINGS_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 || []; // Log the structure of the first location to debug ID field name if (locations.length > 0) { const sampleLocation = locations[0]; logger.info('Sample location structure:', { keys: Object.keys(sampleLocation), idFields: { 'Id': sampleLocation.Id, 'id': sampleLocation.id, 'ID': sampleLocation.ID, '_id': sampleLocation._id } }); } 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); } } 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 { const locationId = req.params.id; // Validate ID if (!locationId || locationId === 'undefined' || locationId === 'null') { return res.status(400).json({ success: false, error: 'Invalid location ID' }); } let updateData = { ...req.body }; // Remove ID from update data to avoid conflicts - use the correct field name delete updateData.ID; delete updateData.Id; delete updateData.id; delete updateData._id; // Sync geo fields updateData = syncGeoFields(updateData); updateData.last_updated_at = new Date().toISOString(); updateData.last_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}/${locationId}`; logger.info(`Updating location ${locationId} by ${req.session.userEmail}`); logger.debug('Update data:', updateData); 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); if (error.response) { logger.error('Error response:', error.response.data); } res.status(error.response?.status || 500).json({ success: false, error: 'Failed to update location', details: error.response?.data?.message || error.message }); } }); // Delete location app.delete('/api/locations/:id', strictLimiter, async (req, res) => { try { const locationId = req.params.id; // Validate ID if (!locationId || locationId === 'undefined' || locationId === 'null') { return res.status(400).json({ success: false, error: 'Invalid location ID' }); } const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${locationId}`; await axios.delete(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN } }); logger.info(`Location ${locationId} 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' }); } }); // Add a debug endpoint to check table structure app.get('/api/debug/table-structure', requireAdmin, async (req, res) => { try { const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`; const response = await axios.get(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN }, params: { limit: 1 } }); const sample = response.data.list?.[0] || {}; res.json({ success: true, fields: Object.keys(sample), sampleRecord: sample, idField: sample.ID ? 'ID' : (sample.Id ? 'Id' : (sample.id ? 'id' : 'unknown')) }); } catch (error) { logger.error('Error checking table structure:', error); res.status(500).json({ success: false, error: 'Failed to check table structure' }); } }); // QR code generation test endpoint (local only, no upload) app.get('/api/debug/test-qr', requireAdmin, async (req, res) => { try { const testUrl = req.query.url || 'https://example.com/test'; const testSize = parseInt(req.query.size) || 200; logger.info('Testing local QR code generation...'); const qrOptions = { type: 'png', width: testSize, margin: 1, color: { dark: '#000000', light: '#FFFFFF' }, errorCorrectionLevel: 'M' }; const buffer = await generateQRCode(testUrl, qrOptions); res.set({ 'Content-Type': 'image/png', 'Content-Length': buffer.length }); res.send(buffer); } catch (error) { logger.error('QR code test failed:', error); res.status(500).json({ success: false, error: error.message }); } }); // Local QR code generation endpoint app.get('/api/qr', async (req, res) => { try { const { text, size = 200 } = req.query; if (!text) { return res.status(400).json({ success: false, error: 'Text parameter is required' }); } const { generateQRCode } = require('./services/qrcode'); const qrOptions = { type: 'png', width: parseInt(size), margin: 1, color: { dark: '#000000', light: '#FFFFFF' }, errorCorrectionLevel: 'M' }; const buffer = await generateQRCode(text, qrOptions); res.set({ 'Content-Type': 'image/png', 'Content-Length': buffer.length, 'Cache-Control': 'public, max-age=3600' // Cache for 1 hour }); res.send(buffer); } catch (error) { logger.error('QR code generation error:', error); res.status(500).json({ success: false, error: 'Failed to generate QR code' }); } }); // Simple QR test page app.get('/test-qr', (req, res) => { res.send(` QR Code Test

QR Code Generation Test

Test 1: Direct API Call

Try accessing these URLs directly:

Test 2: Dynamic Generation

Test 3: Using QRCode Library (like admin panel)

`); }); // Debug walk sheet configuration endpoint app.get('/api/debug/walk-sheet-config', requireAdmin, async (req, res) => { try { const debugInfo = { settingsSheetId: SETTINGS_SHEET_ID, settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET, hasSettingsSheet: !!SETTINGS_SHEET_ID, timestamp: new Date().toISOString() }; if (!SETTINGS_SHEET_ID) { return res.json({ success: true, debug: debugInfo, message: 'Settings sheet not configured' }); } // Test connection to settings sheet const testUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; const response = await axios.get(testUrl, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' }, params: { limit: 5, sort: '-created_at' } }); const records = response.data.list || []; const sampleRecord = records[0] || {}; res.json({ success: true, debug: { ...debugInfo, connectionTest: 'success', recordCount: records.length, availableFields: Object.keys(sampleRecord), sampleRecord: sampleRecord, recentRecords: records.slice(0, 3).map(r => ({ id: r.id || r.Id || r.ID, created_at: r.created_at, walk_sheet_title: r.walk_sheet_title, hasQrCodes: !!(r.qr_code_1_url || r.qr_code_2_url || r.qr_code_3_url) })) } }); } catch (error) { logger.error('Error debugging walk sheet config:', error); res.json({ success: false, debug: { settingsSheetId: SETTINGS_SHEET_ID, settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET, hasSettingsSheet: !!SETTINGS_SHEET_ID, timestamp: new Date().toISOString(), error: error.message, errorDetails: error.response?.data } }); } }); // Test walk sheet configuration endpoint app.post('/api/debug/test-walk-sheet-save', requireAdmin, async (req, res) => { try { const testConfig = { walk_sheet_title: 'Test Walk Sheet', walk_sheet_subtitle: 'Test Subtitle', walk_sheet_footer: 'Test Footer', qr_code_1_url: 'https://example.com/test1', qr_code_1_label: 'Test QR 1', qr_code_2_url: 'https://example.com/test2', qr_code_2_label: 'Test QR 2', qr_code_3_url: 'https://example.com/test3', qr_code_3_label: 'Test QR 3' }; logger.info('Testing walk sheet configuration save...'); // Create a test request object const testReq = { body: testConfig, session: { userEmail: req.session.userEmail, authenticated: true, isAdmin: true } }; // Create a test response object let testResult = null; let testError = null; const testRes = { json: (data) => { testResult = data; }, status: (code) => ({ json: (data) => { testResult = data; testResult.statusCode = code; } }) }; // Test the save functionality if (!SETTINGS_SHEET_ID) { return res.json({ success: false, test: 'failed', error: 'Settings sheet not configured', config: testConfig }); } const userEmail = req.session.userEmail; const timestamp = new Date().toISOString(); const walkSheetData = { created_at: timestamp, created_by: userEmail, walk_sheet_title: testConfig.walk_sheet_title, walk_sheet_subtitle: testConfig.walk_sheet_subtitle, walk_sheet_footer: testConfig.walk_sheet_footer, qr_code_1_url: testConfig.qr_code_1_url, qr_code_1_label: testConfig.qr_code_1_label, qr_code_2_url: testConfig.qr_code_2_url, qr_code_2_label: testConfig.qr_code_2_label, qr_code_3_url: testConfig.qr_code_3_url, qr_code_3_label: testConfig.qr_code_3_label }; const response = await axios.post( `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, walkSheetData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } } ); res.json({ success: true, test: 'passed', message: 'Test walk sheet configuration saved successfully', testData: walkSheetData, saveResponse: response.data, settingsId: response.data.id || response.data.Id || response.data.ID }); } catch (error) { logger.error('Test walk sheet save failed:', error); res.json({ success: false, test: 'failed', error: error.message, errorDetails: error.response?.data, timestamp: new Date().toISOString() }); } }); // 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); }); });