- 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>
575 lines
No EOL
21 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
|
|
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);
|
|
}); |