import dotenv from 'dotenv'; import express, { Request, Response, NextFunction, Application } from 'express'; import cors from 'cors'; import path from 'path'; import cron from 'node-cron'; import swaggerUi from 'swagger-ui-express'; import { swaggerSpec } from './swagger'; // Load environment variables dotenv.config({ path: '.env.local' }); dotenv.config(); // Import services and models import DatabaseService from './services/DatabaseService'; import ProfanityFilterService from './services/ProfanityFilterService'; import MapImageService from './services/MapImageService'; import { i18nService } from './i18n'; // Import route modules import configRoutes from './routes/config'; import locationRoutes from './routes/locations'; import adminRoutes from './routes/admin'; import i18nRoutes from './routes/i18n'; const app: Application = express(); const PORT: number = parseInt(process.env.PORT || '3000', 10); // Middleware app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, '../public'))); // Locale detection middleware app.use((req: Request, res: Response, next: NextFunction) => { // Detect user's preferred locale from Accept-Language header or cookie const cookieLocale = req.headers.cookie?.split(';') .find(c => c.trim().startsWith('locale='))?.split('=')[1]; const detectedLocale = cookieLocale || i18nService.detectLocale(req.get('Accept-Language')); // Add locale to request object for use in routes (req as any).locale = detectedLocale; (req as any).t = (key: string, params?: Record) => i18nService.t(key, detectedLocale, params); next(); }); // Database and services setup const databaseService = new DatabaseService(); let profanityFilter: ProfanityFilterService | FallbackFilter; const mapImageService = new MapImageService(); // Fallback filter interface for type safety interface FallbackFilter { containsProfanity(): boolean; analyzeProfanity(text: string): { hasProfanity: boolean; matches: any[]; severity: string; count: number; filtered: string; }; filterProfanity(text: string): string; addCustomWord(word: string, severity: string, category: string, createdBy?: string): Promise; removeCustomWord(wordId: number): Promise; updateCustomWord(wordId: number, updates: any): Promise; getCustomWords(): Promise; loadCustomWords(): Promise; getAllWords(): any[]; getSeverity(): string; getSeverityLevel(): number; getSeverityName(): string; normalizeText(text: string): string; buildPatterns(): any[]; close(): void; _isFallback: boolean; } // Create fallback filter function function createFallbackFilter(): FallbackFilter { return { // Core profanity checking methods containsProfanity: (): boolean => false, analyzeProfanity: (text: string) => ({ hasProfanity: false, matches: [], severity: 'none', count: 0, filtered: text || '' }), filterProfanity: (text: string): string => text || '', // Database management methods used by admin routes addCustomWord: async (word: string, severity: string, category: string, createdBy?: string) => ({ id: null, word: word || null, severity: severity || null, category: category || null, createdBy: createdBy || null, success: false, error: 'Profanity filter not available - please check server configuration' }), removeCustomWord: async () => ({ success: false, error: 'Profanity filter not available - please check server configuration' }), updateCustomWord: async () => ({ success: false, error: 'Profanity filter not available - please check server configuration' }), getCustomWords: async (): Promise => [], loadCustomWords: async (): Promise => {}, // Utility methods getAllWords: (): any[] => [], getSeverity: (): string => 'none', getSeverityLevel: (): number => 0, getSeverityName: (): string => 'none', normalizeText: (text: string): string => text || '', buildPatterns: (): any[] => [], // Cleanup method close: (): void => {}, // Special property to identify this as a fallback filter _isFallback: true }; } // Initialize profanity filter asynchronously async function initializeProfanityFilter(): Promise { try { const profanityWordModel = databaseService.getProfanityWordModel(); profanityFilter = new ProfanityFilterService(profanityWordModel); await profanityFilter.initialize(); console.log('Profanity filter initialized successfully'); } catch (error) { console.error('WARNING: Failed to initialize profanity filter:', error); console.error('Creating fallback no-op profanity filter. ALL CONTENT WILL BE ALLOWED!'); console.error('This is a security risk - please fix the profanity filter configuration.'); profanityFilter = createFallbackFilter(); console.warn('⚠️ SECURITY WARNING: Profanity filtering is DISABLED due to initialization failure!'); } } // Clean up expired locations (older than 48 hours, but not persistent ones) const cleanupExpiredLocations = async (): Promise => { console.log('Running cleanup of expired locations'); try { const locationModel = databaseService.getLocationModel(); const result = await locationModel.cleanupExpired(); console.log(`Cleaned up ${result.changes} expired locations (persistent reports preserved)`); } catch (err) { console.error('Error cleaning up expired locations:', err); } }; // Run cleanup every hour console.log('Scheduling hourly cleanup task'); cron.schedule('0 * * * *', cleanupExpiredLocations); // Configuration const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123'; // Change this! // Authentication middleware const authenticateAdmin = (req: Request, res: Response, next: NextFunction): void => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { res.status(401).json({ error: 'Unauthorized' }); return; } const token = authHeader.substring(7); if (token !== ADMIN_PASSWORD) { res.status(401).json({ error: 'Invalid credentials' }); return; } next(); }; // Setup routes after database and profanity filter are initialized function setupRoutes(): void { const locationModel = databaseService.getLocationModel(); const profanityWordModel = databaseService.getProfanityWordModel(); // API Documentation app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { customCss: '.swagger-ui .topbar { display: none }', customSiteTitle: 'Great Lakes Ice Report API Documentation' })); // API Routes app.use('/api/config', configRoutes()); app.use('/api/locations', locationRoutes(locationModel, profanityFilter)); app.use('/api/admin', adminRoutes(locationModel, profanityWordModel, profanityFilter, authenticateAdmin)); app.use('/api/i18n', i18nRoutes()); // Static page routes app.get('/', (req: Request, res: Response): void => { console.log('Serving the main page'); res.sendFile(path.join(__dirname, '../public', 'index.html')); }); // Non-JavaScript table view route app.get('/table', async (req: Request, res: Response): Promise => { console.log('Serving table view for non-JS users'); try { // Get locale from query parameter or use detected locale const requestedLocale = req.query.locale as string; const locale = requestedLocale && i18nService.isLocaleSupported(requestedLocale) ? requestedLocale : (req as any).locale; // Helper function for translations const t = (key: string) => i18nService.t(key, locale); const locations = await locationModel.getActive(); const formatTimeRemaining = (createdAt: string, isPersistent?: boolean): string => { if (isPersistent) { return t('time.persistent'); } const created = new Date(createdAt); const now = new Date(); const diffMs = (48 * 60 * 60 * 1000) - (now.getTime() - created.getTime()); if (diffMs <= 0) return t('time.expired'); const hours = Math.floor(diffMs / (1000 * 60 * 60)); const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); if (hours > 0) { return `${hours}h ${minutes}m`; } else { return `${minutes}m`; } }; const formatDate = (dateStr: string): string => { const date = new Date(dateStr); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; const escapeHtml = (text: string): string => { return text.replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; const tableRows = locations.map((location, index) => ` ${index + 1} ${escapeHtml(location.address)} ${escapeHtml(location.description || t('table.status.noDetails'))} ${location.created_at ? formatDate(location.created_at) : t('common.loading')} ${location.created_at ? formatTimeRemaining(location.created_at, !!location.persistent) : t('common.loading')} `).join(''); // Generate language selector form for non-JS users const languageSelectorForm = `
`; const html = ` ${t('common.appName')} - ${t('navigation.tableView')}

❄️ ${t('common.appName')}

${t('meta.subtitle')}

${languageSelectorForm}

${t('form.reportConditions')}

${t('form.addressExamples')}
${t('form.detailsHelp')}

${t('map.currentReports')} (${locations.length} ${locations.length === 1 ? t('map.activeReports') : t('map.activeReportsPlural')})

${t('navigation.backToMap')}

${locations.length > 0 ? `

${t('map.staticMapOverview')}

${t('map.staticMapOverview')}

${t('map.markerLegend')}

` : ''}
${tableRows || ``}
# ${t('table.headers.location')} ${t('table.headers.details')} ${t('table.headers.reported')} ${t('table.headers.timeRemaining')}
${t('table.noReports')}
`; res.send(html); } catch (err) { console.error('Error serving table view:', err); res.status(500).send('Internal server error'); } }); // Handle form submission for non-JS users app.post('/submit-report', async (req: Request, res: Response): Promise => { console.log('Handling form submission for non-JS users'); const { address, description, locale: formLocale } = req.body; // Get locale from form or use detected locale const locale = formLocale && i18nService.isLocaleSupported(formLocale) ? formLocale : (req as any).locale; // Helper function for translations const t = (key: string) => i18nService.t(key, locale); // Validate input if (!address || typeof address !== 'string' || address.trim().length === 0) { res.status(400).send(` ${t('common.error')}

${t('common.error')}

${t('form.addressRequired')}

${t('navigation.goBack')}

`); return; } // Check for profanity if description provided if (description && profanityFilter) { try { const analysis = profanityFilter.analyzeProfanity(description); if (analysis.hasProfanity) { res.status(400).send(` ${t('form.submissionRejected')}

${t('form.submissionRejected')}

${t('errors.inappropriateLanguage')}

${t('navigation.goBack')}

`); return; } } catch (filterError) { console.error('Error checking profanity:', filterError); } } try { await locationModel.create({ address: address.trim(), description: description?.trim() || null }); res.send(` ${t('form.reportSubmitted')}

✅ ${t('form.reportSubmitted')}

${t('form.reportSubmittedMsg')}

${t('navigation.viewReports')}

`); } catch (err) { console.error('Error creating location:', err); res.status(500).send(` ${t('common.error')}

${t('common.error')}

${t('form.submitError')}

${t('navigation.goBack')}

`); } }); // Static map image generation route app.get('/map-image.png', async (req: Request, res: Response): Promise => { console.log('Generating static map image'); try { const locations = await locationModel.getActive(); // Parse query parameters for customization const width = parseInt(req.query.width as string) || 800; const height = parseInt(req.query.height as string) || 600; const padding = parseInt(req.query.padding as string) || 50; const imageBuffer = await mapImageService.generateMapImage(locations, { width: Math.min(Math.max(width, 400), 1200), // Clamp between 400-1200 height: Math.min(Math.max(height, 300), 900), // Clamp between 300-900 padding: Math.min(Math.max(padding, 20), 100) // Clamp between 20-100 }); res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.send(imageBuffer); } catch (err) { console.error('Error generating map image:', err); res.status(500).send('Error generating map image'); } }); app.get('/admin', (req: Request, res: Response): void => { console.log('Serving the admin page'); res.sendFile(path.join(__dirname, '../public', 'admin.html')); }); app.get('/privacy', (req: Request, res: Response): void => { console.log('Serving the privacy policy page'); res.sendFile(path.join(__dirname, '../public', 'privacy.html')); }); } // Async server startup function async function startServer(): Promise { try { // Initialize database service first await databaseService.initialize(); console.log('Database service initialized successfully'); // Initialize profanity filter await initializeProfanityFilter(); // Validate profanity filter is properly initialized if (!profanityFilter) { console.error('CRITICAL ERROR: profanityFilter is undefined after initialization attempt.'); console.error('Cannot start server without a functional profanity filter.'); process.exit(1); } // Initialize routes after everything is set up setupRoutes(); // Start server app.listen(PORT, (): void => { console.log('==========================================='); console.log('Great Lakes Ice Report server started'); console.log(`Listening on port ${PORT}`); console.log(`Visit http://localhost:${PORT} to view the website`); // Display profanity filter status if ('_isFallback' in profanityFilter && profanityFilter._isFallback) { console.log('🚨 PROFANITY FILTER: DISABLED (FALLBACK MODE)'); console.log('⚠️ ALL USER CONTENT WILL BE ALLOWED!'); } else { console.log('✅ PROFANITY FILTER: ACTIVE AND FUNCTIONAL'); } console.log('==========================================='); }); } catch (error) { console.error('CRITICAL ERROR: Failed to start server:', error); process.exit(1); } } // Start the server startServer(); // Graceful shutdown process.on('SIGINT', (): void => { console.log('\nShutting down server...'); // Close profanity filter database first if (profanityFilter && typeof profanityFilter.close === 'function') { try { profanityFilter.close(); console.log('Profanity filter database closed.'); } catch (error) { console.error('Error closing profanity filter:', error); } } // Close database service databaseService.close(); console.log('Database connections closed.'); process.exit(0); });