import express, { Request, Response, Router } from 'express'; import rateLimit from 'express-rate-limit'; import Location from '../models/Location'; import ProfanityFilterService from '../services/ProfanityFilterService'; // Define interfaces for request bodies interface LocationPostRequest extends Request { body: { address: string; latitude?: number; longitude?: number; description?: string; }; } export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => { const router = express.Router(); // Rate limiting for location submissions to prevent abuse const submitLocationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes limit: 10, // Limit each IP to 10 location submissions per 15 minutes message: { error: 'Too many location reports submitted', message: 'You can submit up to 10 location reports every 15 minutes. Please wait before submitting more.', retryAfter: '15 minutes' }, standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers // Skip rate limiting in test environment skip: () => process.env.NODE_ENV === 'test' }); /** * @swagger * /api/locations: * get: * tags: * - Public API * summary: Get active ice condition reports * description: | * Retrieves all active ice condition reports. Reports are considered active if they are: * - Less than 48 hours old, OR * - Marked as persistent by an administrator * responses: * 200: * description: Active locations retrieved successfully * content: * application/json: * schema: * type: array * items: * $ref: '#/components/schemas/Location' * examples: * active_locations: * summary: Example active locations * value: * - id: 123 * address: "Main St & Oak Ave, Grand Rapids, MI" * latitude: 42.9634 * longitude: -85.6681 * description: "Black ice present, multiple vehicles stuck" * persistent: false * created_at: "2025-01-15T10:30:00.000Z" * - id: 124 * address: "I-96 & US-131, Grand Rapids, MI" * latitude: 42.9584 * longitude: -85.6706 * description: "Icy on-ramp conditions" * persistent: true * created_at: "2025-01-14T08:15:00.000Z" * 500: * description: Internal server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ router.get('/', async (req: Request, res: Response): Promise => { console.log('Fetching active locations'); try { const locations = await locationModel.getActive(); console.log(`Fetched ${locations.length} active locations (including persistent)`); res.json(locations); } catch (err) { console.error('Error fetching locations:', err); res.status(500).json({ error: 'Internal server error' }); } }); /** * @swagger * /api/locations: * post: * tags: * - Public API * summary: Report a new ice condition * description: | * Submit a new ice condition report. The system will: * - Validate the address is provided * - Check description for profanity and reject if found * - Geocode the address if coordinates not provided * - Store the report for 48 hours (unless made persistent by admin) * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/LocationInput' * examples: * basic_report: * summary: Basic ice report with address only * value: * address: "Main St & Oak Ave, Grand Rapids, MI" * description: "Black ice spotted, drive carefully" * detailed_report: * summary: Detailed report with coordinates * value: * address: "I-96 westbound at Exit 85" * latitude: 42.9634 * longitude: -85.6681 * description: "Multiple vehicles stuck, road salt needed" * responses: * 200: * description: Location report created successfully * content: * application/json: * schema: * $ref: '#/components/schemas/Location' * example: * id: 125 * address: "Main St & Oak Ave, Grand Rapids, MI" * latitude: 42.9634 * longitude: -85.6681 * description: "Black ice spotted, drive carefully" * persistent: false * created_at: "2025-01-15T12:00:00.000Z" * 400: * description: Bad request - invalid input or profanity detected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * examples: * missing_address: * summary: Missing required address field * value: * error: "Address is required" * profanity_detected: * summary: Profanity detected in description * value: * error: "Submission rejected" * message: "Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional." * details: * severity: "medium" * wordCount: 1 * detectedCategories: ["general"] * 500: * description: Internal server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise => { const { address, latitude, longitude, description } = req.body; console.log(`Attempt to add new location: ${address}`); // Input validation for security if (!address) { console.warn('Failed to add location: Address is required'); res.status(400).json({ error: 'Address is required' }); return; } if (typeof address !== 'string' || address.length > 500) { console.warn(`Failed to add location: Invalid address length (${address?.length || 0})`); res.status(400).json({ error: 'Address must be a string with maximum 500 characters' }); return; } if (description && (typeof description !== 'string' || description.length > 1000)) { console.warn(`Failed to add location: Invalid description length (${description?.length || 0})`); res.status(400).json({ error: 'Description must be a string with maximum 1000 characters' }); return; } // Validate latitude if provided if (latitude !== undefined && (typeof latitude !== 'number' || latitude < -90 || latitude > 90)) { console.warn(`Failed to add location: Invalid latitude (${latitude})`); res.status(400).json({ error: 'Latitude must be a number between -90 and 90' }); return; } // Validate longitude if provided if (longitude !== undefined && (typeof longitude !== 'number' || longitude < -180 || longitude > 180)) { console.warn(`Failed to add location: Invalid longitude (${longitude})`); res.status(400).json({ error: 'Longitude must be a number between -180 and 180' }); return; } // Log suspicious activity if (address.length > 200 || (description && description.length > 500)) { console.warn(`Unusually long submission from IP: ${req.ip} - Address: ${address.length} chars, Description: ${description?.length || 0} chars`); } // Check for profanity in description and reject if any is found if (description && profanityFilter) { try { const analysis = profanityFilter.analyzeProfanity(description); if (analysis.hasProfanity) { console.warn(`Submission rejected due to inappropriate language (${analysis.count} word${analysis.count > 1 ? 's' : ''}, severity: ${analysis.severity}) - Original: "${req.body.description}"`); // Reject any submission with profanity res.status(400).json({ error: 'Submission rejected', message: 'Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.\n\nExample: "Multiple vehicles stuck, black ice present" or "Road very slippery, saw 3 accidents"', details: { severity: analysis.severity, wordCount: analysis.count, detectedCategories: [...new Set(analysis.matches.map((m: any) => m.category))] } }); return; } } catch (filterError) { console.error('Error checking profanity:', filterError); // Continue with original description if filter fails } } try { const newLocation = await locationModel.create({ address, latitude, longitude, description }); console.log(`Location added successfully: ${address}`); res.json({ ...newLocation, created_at: new Date().toISOString() }); } catch (err) { console.error('Error inserting location:', err); res.status(500).json({ error: 'Internal server error' }); } }); // DELETE functionality has been moved to admin-only routes for security. // Use /api/admin/locations/:id (with authentication) for location deletion. return router; };