- Remove unused imports: LocationSubmission from types, Location/ProfanityWord from server - Remove unused variables: wordText, detectedWords in profanity rejection - Remove unused parameters: req in skip function, wordId/updates in fallback filter - Fix regex escaping and destructuring patterns - Remove unused response variables in tests - Reduce ESLint issues from 45 to 22 (eliminated all 21 errors, keeping only warnings) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
257 lines
No EOL
10 KiB
TypeScript
257 lines
No EOL
10 KiB
TypeScript
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<void> => {
|
|
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<void> => {
|
|
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;
|
|
}; |