ice/src/server.ts
Claude Code 5a8bcb7fff Fix ESLint issues in TypeScript codebase
- Add browser globals for frontend TypeScript files in ESLint config
- Fix unused parameter in MapImageService.generateErrorImage()
- Remove auto-fixable formatting issues (trailing spaces, indentation)

All ESLint errors now resolved, only warnings for 'any' types remain.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 20:05:27 -04:00

575 lines
No EOL
21 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 { 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<string, string>) =>
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<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));
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<void> => {
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, '&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 || t('table.status.noDetails'))}</td>
<td>${location.created_at ? formatDate(location.created_at) : t('common.loading')}</td>
<td style="text-align: center;">${location.created_at ? formatTimeRemaining(location.created_at, !!location.persistent) : t('common.loading')}</td>
</tr>
`).join('');
// Generate language selector form for non-JS users
const languageSelectorForm = `
<form method="get" action="/table" style="display: inline-block; margin-left: 10px;">
<select name="locale" onchange="this.form.submit();" style="padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px;">
<option value="en" ${locale === 'en' ? 'selected' : ''}>English</option>
<option value="es-MX" ${locale === 'es-MX' ? 'selected' : ''}>Español (México)</option>
</select>
<noscript>
<input type="submit" value="${t('common.submit')}" style="margin-left: 5px; padding: 4px 8px;">
</noscript>
</form>
`;
const html = `
<!DOCTYPE html>
<html lang="${locale}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${t('common.appName')} - ${t('navigation.tableView')}</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<div class="header-content">
<div class="header-text">
<h1><a href="/" style="text-decoration: none; color: inherit;">❄️</a> ${t('common.appName')}</h1>
<p>${t('meta.subtitle')}</p>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
${languageSelectorForm}
</div>
</div>
</header>
<div class="content">
<div class="form-section">
<h2>${t('form.reportConditions')}</h2>
<form method="POST" action="/submit-report">
<input type="hidden" name="locale" value="${locale}">
<div class="form-group">
<label for="address">${t('form.addressLabel')}</label>
<input type="text" id="address" name="address" required
placeholder="${t('form.addressPlaceholder')}">
<small class="input-help">${t('form.addressExamples')}</small>
</div>
<div class="form-group">
<label for="description">${t('form.detailsLabel')}</label>
<textarea id="description" name="description" rows="3"
placeholder="${t('form.detailsPlaceholder')}"></textarea>
<small class="input-help">${t('form.detailsHelp')}</small>
</div>
<button type="submit">${t('form.reportLocation')}</button>
</form>
</div>
<div class="map-section">
<div class="reports-header">
<h2>${t('map.currentReports')} (${locations.length} ${locations.length === 1 ? t('map.activeReports') : t('map.activeReportsPlural')})</h2>
<p><a href="/">${t('navigation.backToMap')}</a></p>
</div>
${locations.length > 0 ? `
<div style="text-align: center; margin: 24px 0;">
<h3>${t('map.staticMapOverview')}</h3>
<img src="/map-image.png?width=800&height=400"
alt="${t('map.staticMapOverview')}"
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;">
${t('map.markerLegend')}
</p>
</div>
` : ''}
<div class="table-container">
<table class="reports-table">
<thead>
<tr>
<th>#</th>
<th>${t('table.headers.location')}</th>
<th>${t('table.headers.details')}</th>
<th>${t('table.headers.reported')}</th>
<th>${t('table.headers.timeRemaining')}</th>
</tr>
</thead>
<tbody>
${tableRows || `<tr><td colspan="5">${t('table.noReports')}</td></tr>`}
</tbody>
</table>
</div>
</div>
</div>
<footer>
<p><strong>${t('footer.safetyNotice')}</strong> <a href="https://www.aclu.org/know-your-rights/immigrants-rights" target="_blank" rel="noopener noreferrer">${t('footer.knowRights')}</a>.</p>
<div class="disclaimer">
<small>${t('footer.disclaimer')} <a href="/privacy">${t('common.privacyPolicy')}</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, 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(`
<html lang="${locale}">
<head><title>${t('common.error')}</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>${t('common.error')}</h1>
<p>${t('form.addressRequired')}</p>
<p><a href="/table?locale=${locale}">${t('navigation.goBack')}</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 lang="${locale}">
<head><title>${t('form.submissionRejected')}</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>${t('form.submissionRejected')}</h1>
<p>${t('errors.inappropriateLanguage')}</p>
<p><a href="/table?locale=${locale}">${t('navigation.goBack')}</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 lang="${locale}">
<head><title>${t('form.reportSubmitted')}</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>✅ ${t('form.reportSubmitted')}</h1>
<p>${t('form.reportSubmittedMsg')}</p>
<p><a href="/table?locale=${locale}">${t('navigation.viewReports')}</a></p>
</div>
</body>
</html>
`);
} catch (err) {
console.error('Error creating location:', err);
res.status(500).send(`
<html lang="${locale}">
<head><title>${t('common.error')}</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>${t('common.error')}</h1>
<p>${t('form.submitError')}</p>
<p><a href="/table?locale=${locale}">${t('navigation.goBack')}</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);
});