ice/src/server.ts
Claude Code cb0cd30243 Fix security vulnerabilities and improve code quality
Security fixes:
- Add HTML escaping to prevent XSS in table view (address & description fields)
- Fix content-type mismatch in map image service error fallback

Code quality improvements:
- Standardize logging levels (console.info for informational messages)
- Remove unused legacy fetchMapboxStaticMap method
- Replace text error fallback with valid 1x1 transparent PNG

All 128 tests passing 

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-06 00:22:57 -04:00

519 lines
No EOL
18 KiB
TypeScript

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 route modules
import configRoutes from './routes/config';
import locationRoutes from './routes/locations';
import adminRoutes from './routes/admin';
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')));
// 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<any>;
removeCustomWord(wordId: number): Promise<any>;
updateCustomWord(wordId: number, updates: any): Promise<any>;
getCustomWords(): Promise<any[]>;
loadCustomWords(): Promise<void>;
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<any[]> => [],
loadCustomWords: async (): Promise<void> => {},
// 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<void> {
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<void> => {
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));
// 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<void> => {
console.log('Serving table view for non-JS users');
try {
const locations = await locationModel.getActive();
const formatTimeRemaining = (createdAt: string): string => {
const created = new Date(createdAt);
const now = new Date();
const diffMs = (48 * 60 * 60 * 1000) - (now.getTime() - created.getTime());
if (diffMs <= 0) return '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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
const tableRows = locations.map((location, index) => `
<tr>
<td style="text-align: center; font-weight: bold;">${index + 1}</td>
<td>${escapeHtml(location.address)}</td>
<td>${escapeHtml(location.description || 'No additional details')}</td>
<td>${location.created_at ? formatDate(location.created_at) : 'Unknown'}</td>
<td>${location.persistent ? 'Persistent' : (location.created_at ? formatTimeRemaining(location.created_at) : 'Unknown')}</td>
</tr>
`).join('');
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Great Lakes Ice Report - Table View</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<div class="header-content">
<div class="header-text">
<h1>❄️ Great Lakes Ice Report</h1>
<p>Community-reported ICEy road conditions and winter hazards</p>
</div>
</div>
</header>
<div class="content">
<div class="form-section">
<h2>Report ICEy Conditions</h2>
<form method="POST" action="/submit-report">
<div class="form-group">
<label for="address">Address or Location *</label>
<input type="text" id="address" name="address" required
placeholder="Enter address, intersection, or landmark">
<small class="input-help">Examples: "123 Main St, City" or "Main St & Oak Ave, City"</small>
</div>
<div class="form-group">
<label for="description">Additional Details (Optional)</label>
<textarea id="description" name="description" rows="3"
placeholder="Number of vehicles, time observed, etc."></textarea>
<small class="input-help">Keep descriptions appropriate and relevant to road conditions.</small>
</div>
<button type="submit">Report Location</button>
</form>
</div>
<div class="map-section">
<div class="reports-header">
<h2>Current Reports (${locations.length} active)</h2>
<p><a href="/">← Back to Interactive Map</a></p>
</div>
${locations.length > 0 ? `
<div style="text-align: center; margin: 24px 0;">
<h3>Static Map Overview</h3>
<img src="/map-image.png?width=800&height=400"
alt="Static map showing ice report locations"
style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<p style="font-size: 14px; color: #666; margin-top: 8px;">
Red markers: Regular reports | Orange markers: Persistent reports
</p>
</div>
` : ''}
<div class="table-container">
<table class="reports-table">
<thead>
<tr>
<th>#</th>
<th>Location</th>
<th>Details</th>
<th>Reported</th>
<th>Time Remaining</th>
</tr>
</thead>
<tbody>
${tableRows || '<tr><td colspan="5">No reports currently available</td></tr>'}
</tbody>
</table>
</div>
</div>
</div>
<footer>
<p><strong>Safety Notice:</strong> This is a community tool for awareness. Stay safe and verify information independently.</p>
<div class="disclaimer">
<small>Reports are automatically deleted after 48 hours. • <a href="/privacy">Privacy Policy</a></small>
</div>
</footer>
</div>
</body>
</html>
`;
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<void> => {
console.log('Handling form submission for non-JS users');
const { address, description } = req.body;
// Validate input
if (!address || typeof address !== 'string' || address.trim().length === 0) {
res.status(400).send(`
<html>
<head><title>Error</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>Error</h1>
<p>Address is required.</p>
<p><a href="/table">← Go Back</a></p>
</div>
</body>
</html>
`);
return;
}
// Check for profanity if description provided
if (description && profanityFilter) {
try {
const analysis = profanityFilter.analyzeProfanity(description);
if (analysis.hasProfanity) {
res.status(400).send(`
<html>
<head><title>Submission Rejected</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>Submission Rejected</h1>
<p>Your description contains inappropriate language and cannot be posted.</p>
<p>Please revise your description to focus on road conditions and keep it professional.</p>
<p><a href="/table">← Go Back</a></p>
</div>
</body>
</html>
`);
return;
}
} catch (filterError) {
console.error('Error checking profanity:', filterError);
}
}
try {
await locationModel.create({
address: address.trim(),
description: description?.trim() || null
});
res.send(`
<html>
<head><title>Report Submitted</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>✅ Report Submitted Successfully</h1>
<p>Your ice condition report has been added to the system.</p>
<p><a href="/table">← View All Reports</a></p>
</div>
</body>
</html>
`);
} catch (err) {
console.error('Error creating location:', err);
res.status(500).send(`
<html>
<head><title>Error</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>Error</h1>
<p>Failed to submit report. Please try again.</p>
<p><a href="/table">← Go Back</a></p>
</div>
</body>
</html>
`);
}
});
// Static map image generation route
app.get('/map-image.png', async (req: Request, res: Response): Promise<void> => {
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<void> {
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);
});