Add coordinate validation and ESLint integration

- Add explicit latitude/longitude validation in location submissions
- Implement ESLint with TypeScript support and flat config
- Auto-fix 621 formatting issues across codebase
- Add comprehensive tests for coordinate validation
- Update documentation with lint scripts and validation rules
- Maintain 128 passing tests with enhanced security

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code 2025-07-05 22:12:37 -04:00
parent 5176636f6d
commit 30fdd72cc5
20 changed files with 2171 additions and 599 deletions

View file

@ -58,9 +58,18 @@ npm run build-css:dev
npm run watch-css
```
### Code Quality
```bash
# Run ESLint to check code style and quality
npm run lint
# Auto-fix ESLint issues where possible
npm run lint:fix
```
### Testing
```bash
# Run all tests (125+ tests with TypeScript)
# Run all tests (128+ tests with TypeScript)
npm test
# Run tests with coverage report (76% overall coverage)

View file

@ -6,10 +6,11 @@ A community-driven web application for tracking winter road conditions and icy h
- 🗺️ **Interactive Map** - Real-time location tracking centered on Grand Rapids
- ⚡ **Fast Geocoding** - Lightning-fast address lookup with MapBox API
- 🔄 **Auto-Expiration** - Reports automatically removed after 24 hours
- 🔄 **Auto-Expiration** - Reports automatically removed after 48 hours
- 👨‍💼 **Admin Panel** - Manage and moderate location reports
- 📱 **Responsive Design** - Works on desktop and mobile devices
- 🔒 **Privacy-Focused** - No user tracking, community safety oriented
- 🛡️ **Enhanced Security** - Coordinate validation, rate limiting, and content filtering
## Quick Start
@ -39,11 +40,31 @@ A community-driven web application for tracking winter road conditions and icy h
4. **Start the server:**
```bash
npm start # Production mode
npm run dev # Development mode
npm run dev-with-css # Development with CSS watching
npm start # Production mode
npm run dev # Development mode
npm run dev-with-css # Development with CSS watching
```
## Development Commands
### Code Quality
```bash
# Run ESLint to check code style and quality
npm run lint
# Auto-fix ESLint issues where possible
npm run lint:fix
```
### Testing
```bash
# Run all tests (128+ tests with TypeScript)
npm test
# Run tests with coverage report (76% overall coverage)
npm run test:coverage
```
5. **Visit the application:**
```
http://localhost:3000
@ -139,7 +160,7 @@ Interactive API documentation available at `/api-docs` when running the server.
- **Frontend:** Vanilla JavaScript, Leaflet.js
- **Geocoding:** MapBox API (with Nominatim fallback)
- **Security:** Rate limiting, input validation, authentication
- **Testing:** Jest, TypeScript, 125+ tests with 76% coverage
- **Testing:** Jest, TypeScript, 128+ tests with 76% coverage
- **Reverse Proxy:** Caddy (automatic HTTPS)
- **Database:** SQLite (lightweight, serverless)
@ -165,6 +186,8 @@ Interactive API documentation available at `/api-docs` when running the server.
### Input Limits
- **Address:** Maximum 500 characters
- **Description:** Maximum 1000 characters
- **Latitude:** Must be between -90 and 90 degrees
- **Longitude:** Must be between -180 and 180 degrees
- **Submissions:** 10 per 15 minutes per IP address
## License

77
eslint.config.mjs Normal file
View file

@ -0,0 +1,77 @@
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import globals from 'globals';
export default [
{
ignores: ['node_modules/', 'dist/', 'public/*.css', '*.db', '*.scss', '*.md']
},
js.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module'
},
globals: {
...globals.node
}
},
plugins: {
'@typescript-eslint': typescript
},
rules: {
// TypeScript rules
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
// General rules
'no-console': 'off', // Allow console.log for server logging
'no-var': 'error',
'prefer-const': 'error',
'eqeqeq': 'error',
'no-unused-vars': 'off', // Use TypeScript version instead
// Style rules
'indent': ['error', 2],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'comma-dangle': ['error', 'never'],
'no-trailing-spaces': 'error'
}
},
{
files: ['**/*.js'],
languageOptions: {
globals: {
...globals.node
}
},
rules: {
'no-console': 'off',
'no-var': 'error',
'prefer-const': 'error',
'eqeqeq': 'error',
'indent': ['error', 2],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'comma-dangle': ['error', 'never'],
'no-trailing-spaces': 'error'
}
},
{
files: ['tests/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
...globals.jest
}
},
rules: {
'@typescript-eslint/no-explicit-any': 'off' // Allow any in tests for mocks
}
}
];

1384
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,8 @@
"build:ts": "tsc",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --coverage",
"lint": "eslint src/ tests/",
"lint:fix": "eslint src/ tests/ --fix",
"postinstall": "npm run build-css"
},
"dependencies": {
@ -39,7 +41,11 @@
"@types/supertest": "^6.0.3",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"concurrently": "^9.2.0",
"eslint": "^9.30.1",
"globals": "^16.3.0",
"jest": "^29.7.0",
"jest-environment-node": "^30.0.4",
"nodemon": "^3.1.10",

View file

@ -144,7 +144,7 @@ class Location {
)
`, (err: Error | null) => {
if (err) return reject(err);
this.db.run(`
ALTER TABLE locations ADD COLUMN persistent INTEGER DEFAULT 0
`, (err: Error | null) => {

View file

@ -62,9 +62,9 @@ class ProfanityWord {
}
async create(
word: string,
severity: 'low' | 'medium' | 'high',
category: string,
word: string,
severity: 'low' | 'medium' | 'high',
category: string,
createdBy: string = 'admin'
): Promise<ProfanityWordCreatedResult> {
return new Promise((resolve, reject) => {
@ -73,10 +73,10 @@ class ProfanityWord {
[word.toLowerCase(), severity, category, createdBy],
function(this: { lastID: number }, err: Error | null) {
if (err) return reject(err);
resolve({
id: this.lastID,
word: word.toLowerCase(),
severity,
resolve({
id: this.lastID,
word: word.toLowerCase(),
severity,
category
});
}
@ -85,9 +85,9 @@ class ProfanityWord {
}
async update(
id: number,
word: string,
severity: 'low' | 'medium' | 'high',
id: number,
word: string,
severity: 'low' | 'medium' | 'high',
category: string
): Promise<DatabaseResult> {
return new Promise((resolve, reject) => {

View file

@ -72,14 +72,14 @@ interface ProfanityTestRequest extends Request {
type AuthMiddleware = (req: Request, res: Response, next: NextFunction) => void;
export default (
locationModel: Location,
profanityWordModel: ProfanityWord,
profanityFilter: ProfanityFilterService | any,
authenticateAdmin: AuthMiddleware
locationModel: Location,
profanityWordModel: ProfanityWord,
profanityFilter: ProfanityFilterService | any,
authenticateAdmin: AuthMiddleware
): Router => {
const router = express.Router();
const router = express.Router();
/**
/**
* @swagger
* /api/admin/login:
* post:
@ -114,21 +114,21 @@ export default (
* example:
* error: "Invalid password"
*/
router.post('/login', (req: AdminLoginRequest, res: Response): void => {
console.log('Admin login attempt');
const { password } = req.body;
const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123';
if (password === ADMIN_PASSWORD) {
console.log('Admin login successful');
res.json({ token: ADMIN_PASSWORD, message: 'Login successful' });
} else {
console.warn('Admin login failed: invalid password');
res.status(401).json({ error: 'Invalid password' });
}
});
router.post('/login', (req: AdminLoginRequest, res: Response): void => {
console.log('Admin login attempt');
const { password } = req.body;
const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123';
/**
if (password === ADMIN_PASSWORD) {
console.log('Admin login successful');
res.json({ token: ADMIN_PASSWORD, message: 'Login successful' });
} else {
console.warn('Admin login failed: invalid password');
res.status(401).json({ error: 'Invalid password' });
}
});
/**
* @swagger
* /api/admin/locations:
* get:
@ -183,219 +183,219 @@ export default (
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/locations', authenticateAdmin, async (req: Request, res: Response): Promise<void> => {
try {
const rows = await locationModel.getAll();
// Process and clean data before sending
const locations = rows.map(row => ({
id: row.id,
address: row.address,
description: row.description || '',
latitude: row.latitude,
longitude: row.longitude,
persistent: !!row.persistent,
created_at: row.created_at,
isActive: new Date(row.created_at || '').getTime() > Date.now() - 48 * 60 * 60 * 1000
}));
res.json(locations);
} catch (err) {
console.error('Error fetching all locations:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/locations', authenticateAdmin, async (req: Request, res: Response): Promise<void> => {
try {
const rows = await locationModel.getAll();
// Update a location (admin only)
router.put('/locations/:id', authenticateAdmin, async (req: LocationUpdateRequest, res: Response): Promise<void> => {
const { id } = req.params;
const { address, latitude, longitude, description } = req.body;
if (!address) {
res.status(400).json({ error: 'Address is required' });
return;
}
try {
const result = await locationModel.update(parseInt(id, 10), {
address,
latitude,
longitude,
description
});
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location updated successfully' });
} catch (err) {
console.error('Error updating location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Process and clean data before sending
const locations = rows.map(row => ({
id: row.id,
address: row.address,
description: row.description || '',
latitude: row.latitude,
longitude: row.longitude,
persistent: !!row.persistent,
created_at: row.created_at,
isActive: new Date(row.created_at || '').getTime() > Date.now() - 48 * 60 * 60 * 1000
}));
// Toggle persistent status of a location (admin only)
router.patch('/locations/:id/persistent', authenticateAdmin, async (req: LocationPersistentRequest, res: Response): Promise<void> => {
const { id } = req.params;
const { persistent } = req.body;
if (typeof persistent !== 'boolean') {
res.status(400).json({ error: 'Persistent value must be a boolean' });
return;
}
try {
const result = await locationModel.togglePersistent(parseInt(id, 10), persistent);
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
console.log(`Location ${id} persistent status set to ${persistent}`);
res.json({ message: 'Persistent status updated successfully', persistent });
} catch (err) {
console.error('Error updating persistent status:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
res.json(locations);
} catch (err) {
console.error('Error fetching all locations:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete a location (admin authentication required)
router.delete('/locations/:id', authenticateAdmin, async (req: LocationDeleteRequest, res: Response): Promise<void> => {
const { id } = req.params;
try {
const result = await locationModel.delete(parseInt(id, 10));
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
} catch (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update a location (admin only)
router.put('/locations/:id', authenticateAdmin, async (req: LocationUpdateRequest, res: Response): Promise<void> => {
const { id } = req.params;
const { address, latitude, longitude, description } = req.body;
// Profanity Management Routes
if (!address) {
res.status(400).json({ error: 'Address is required' });
return;
}
// Get all custom profanity words (admin only)
router.get('/profanity-words', authenticateAdmin, async (req: Request, res: Response): Promise<void> => {
try {
const words = await profanityFilter.getCustomWords();
res.json(words);
} catch (error) {
console.error('Error fetching custom profanity words:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
try {
const result = await locationModel.update(parseInt(id, 10), {
address,
latitude,
longitude,
description
});
// Add a custom profanity word (admin only)
router.post('/profanity-words', authenticateAdmin, async (req: ProfanityWordCreateRequest, res: Response): Promise<void> => {
try {
const { word, severity = 'medium', category = 'custom' } = req.body;
if (!word || typeof word !== 'string' || word.trim().length === 0) {
res.status(400).json({ error: 'Word is required and must be a non-empty string' });
return;
}
if (!['low', 'medium', 'high'].includes(severity)) {
res.status(400).json({ error: 'Severity must be low, medium, or high' });
return;
}
const result = await profanityFilter.addCustomWord(word, severity, category, 'admin');
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin added custom profanity word: ${word}`);
res.json(result);
} catch (error: any) {
console.error('Error adding custom profanity word:', error);
if (error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
// Update a custom profanity word (admin only)
router.put('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordUpdateRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { word, severity, category } = req.body;
if (!word || typeof word !== 'string' || word.trim().length === 0) {
res.status(400).json({ error: 'Word is required and must be a non-empty string' });
return;
}
if (!['low', 'medium', 'high'].includes(severity)) {
res.status(400).json({ error: 'Severity must be low, medium, or high' });
return;
}
const result = await profanityFilter.updateCustomWord(parseInt(id, 10), { word, severity, category });
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin updated custom profanity word ID ${id}`);
res.json(result);
} catch (error: any) {
console.error('Error updating custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
res.json({ message: 'Location updated successfully' });
} catch (err) {
console.error('Error updating location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete a custom profanity word (admin only)
router.delete('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordDeleteRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const result = await profanityFilter.removeCustomWord(parseInt(id, 10));
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin deleted custom profanity word ID ${id}`);
res.json(result);
} catch (error: any) {
console.error('Error deleting custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Toggle persistent status of a location (admin only)
router.patch('/locations/:id/persistent', authenticateAdmin, async (req: LocationPersistentRequest, res: Response): Promise<void> => {
const { id } = req.params;
const { persistent } = req.body;
// Test profanity filter (admin only) - for testing purposes
router.post('/test-profanity', authenticateAdmin, (req: ProfanityTestRequest, res: Response): void => {
try {
const { text } = req.body;
if (!text || typeof text !== 'string') {
res.status(400).json({ error: 'Text is required for testing' });
return;
}
const analysis = profanityFilter.analyzeProfanity(text);
res.json({
original: text,
analysis: analysis,
filtered: analysis.filtered
});
} catch (error) {
console.error('Error testing profanity filter:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
if (typeof persistent !== 'boolean') {
res.status(400).json({ error: 'Persistent value must be a boolean' });
return;
}
return router;
try {
const result = await locationModel.togglePersistent(parseInt(id, 10), persistent);
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
console.log(`Location ${id} persistent status set to ${persistent}`);
res.json({ message: 'Persistent status updated successfully', persistent });
} catch (err) {
console.error('Error updating persistent status:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete a location (admin authentication required)
router.delete('/locations/:id', authenticateAdmin, async (req: LocationDeleteRequest, res: Response): Promise<void> => {
const { id } = req.params;
try {
const result = await locationModel.delete(parseInt(id, 10));
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
} catch (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Profanity Management Routes
// Get all custom profanity words (admin only)
router.get('/profanity-words', authenticateAdmin, async (req: Request, res: Response): Promise<void> => {
try {
const words = await profanityFilter.getCustomWords();
res.json(words);
} catch (error) {
console.error('Error fetching custom profanity words:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add a custom profanity word (admin only)
router.post('/profanity-words', authenticateAdmin, async (req: ProfanityWordCreateRequest, res: Response): Promise<void> => {
try {
const { word, severity = 'medium', category = 'custom' } = req.body;
if (!word || typeof word !== 'string' || word.trim().length === 0) {
res.status(400).json({ error: 'Word is required and must be a non-empty string' });
return;
}
if (!['low', 'medium', 'high'].includes(severity)) {
res.status(400).json({ error: 'Severity must be low, medium, or high' });
return;
}
const result = await profanityFilter.addCustomWord(word, severity, category, 'admin');
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin added custom profanity word: ${word}`);
res.json(result);
} catch (error: any) {
console.error('Error adding custom profanity word:', error);
if (error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Update a custom profanity word (admin only)
router.put('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordUpdateRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { word, severity, category } = req.body;
if (!word || typeof word !== 'string' || word.trim().length === 0) {
res.status(400).json({ error: 'Word is required and must be a non-empty string' });
return;
}
if (!['low', 'medium', 'high'].includes(severity)) {
res.status(400).json({ error: 'Severity must be low, medium, or high' });
return;
}
const result = await profanityFilter.updateCustomWord(parseInt(id, 10), { word, severity, category });
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin updated custom profanity word ID ${id}`);
res.json(result);
} catch (error: any) {
console.error('Error updating custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Delete a custom profanity word (admin only)
router.delete('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordDeleteRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const result = await profanityFilter.removeCustomWord(parseInt(id, 10));
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin deleted custom profanity word ID ${id}`);
res.json(result);
} catch (error: any) {
console.error('Error deleting custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Test profanity filter (admin only) - for testing purposes
router.post('/test-profanity', authenticateAdmin, (req: ProfanityTestRequest, res: Response): void => {
try {
const { text } = req.body;
if (!text || typeof text !== 'string') {
res.status(400).json({ error: 'Text is required for testing' });
return;
}
const analysis = profanityFilter.analyzeProfanity(text);
res.json({
original: text,
analysis: analysis,
filtered: analysis.filtered
});
} catch (error) {
console.error('Error testing profanity filter:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
return router;
};

View file

@ -1,9 +1,9 @@
import express, { Request, Response, Router } from 'express';
export default (): Router => {
const router = express.Router();
const router = express.Router();
/**
/**
* @swagger
* /api/config:
* get:
@ -36,20 +36,20 @@ export default (): Router => {
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/', (req: Request, res: Response): void => {
console.log('📡 API Config requested');
const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined;
console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN);
console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
res.json({
// MapBox tokens are designed to be public (they have domain restrictions)
mapboxAccessToken: MAPBOX_ACCESS_TOKEN || null,
hasMapbox: !!MAPBOX_ACCESS_TOKEN
// SECURITY: Google Maps API key is kept server-side only
});
});
router.get('/', (req: Request, res: Response): void => {
console.log('📡 API Config requested');
const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined;
return router;
console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN);
console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
res.json({
// MapBox tokens are designed to be public (they have domain restrictions)
mapboxAccessToken: MAPBOX_ACCESS_TOKEN || null,
hasMapbox: !!MAPBOX_ACCESS_TOKEN
// SECURITY: Google Maps API key is kept server-side only
});
});
return router;
};

View file

@ -16,24 +16,24 @@ interface LocationPostRequest extends Request {
export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => {
const router = express.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: (req) => process.env.NODE_ENV === 'test'
});
// 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: (req) => process.env.NODE_ENV === 'test'
});
/**
/**
* @swagger
* /api/locations:
* get:
@ -78,20 +78,20 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
* 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' });
}
});
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:
@ -165,84 +165,98 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise<void> => {
const { address, latitude, longitude } = req.body;
let { 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;
}
router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise<void> => {
const { address, latitude, longitude } = req.body;
const { description } = req.body;
console.log(`Attempt to add new location: ${address}`);
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;
}
// 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 (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;
}
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;
}
// 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
const wordText = analysis.count === 1 ? 'word' : 'words';
const detectedWords = analysis.matches.map((m: any) => m.word).join(', ');
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
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
const wordText = analysis.count === 1 ? 'word' : 'words';
const detectedWords = analysis.matches.map((m: any) => m.word).join(', ');
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;
}
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' });
}
});
} catch (filterError) {
console.error('Error checking profanity:', filterError);
// Continue with original description if filter fails
}
}
// DELETE functionality has been moved to admin-only routes for security.
// Use /api/admin/locations/:id (with authentication) for location deletion.
try {
const newLocation = await locationModel.create({
address,
latitude,
longitude,
description
});
return router;
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;
};

View file

@ -62,82 +62,82 @@ interface FallbackFilter {
// 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 (wordId: number) => ({
success: false,
error: 'Profanity filter not available - please check server configuration'
}),
updateCustomWord: async (wordId: number, updates: any) => ({
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
};
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 (wordId: number) => ({
success: false,
error: 'Profanity filter not available - please check server configuration'
}),
updateCustomWord: async (wordId: number, updates: any) => ({
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!');
}
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);
}
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
@ -149,96 +149,96 @@ const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123'; // Chan
// 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();
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'));
});
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'));
});
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'));
});
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);
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
@ -246,20 +246,20 @@ 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);
}
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);
}
// Close database service
databaseService.close();
console.log('Database connections closed.');
process.exit(0);
});

View file

@ -23,7 +23,7 @@ class DatabaseService {
return reject(err);
}
console.log('Connected to main SQLite database.');
if (!this.mainDb) {
return reject(new Error('Main database connection failed'));
}
@ -48,7 +48,7 @@ class DatabaseService {
return reject(err);
}
console.log('Connected to profanity SQLite database.');
if (!this.profanityDb) {
return reject(new Error('Profanity database connection failed'));
}

View file

@ -51,42 +51,42 @@ class ProfanityFilterService {
constructor(profanityWordModel: ProfanityWord) {
this.profanityWordModel = profanityWordModel;
// Base profanity words - comprehensive list
this.baseProfanityWords = [
// Common profanity
'damn', 'hell', 'crap', 'shit', 'fuck', 'ass', 'bitch', 'bastard',
'piss', 'whore', 'slut', 'retard', 'fag', 'gay', 'homo', 'tranny',
'dickhead', 'asshole', 'motherfucker', 'cocksucker', 'twat', 'cunt',
// Racial slurs and hate speech
'nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike',
'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey',
'kyke', 'jigaboo', 'coon', 'darkie', 'mammy', 'pickaninny',
// Sexual content
'penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum',
'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate', 'orgasm',
'blowjob', 'handjob', 'anal', 'penetration', 'erection', 'climax',
// Violence and threats
'kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape',
'violence', 'assault', 'attack', 'threat', 'harm', 'hurt', 'pain',
'stab', 'strangle', 'torture', 'execute', 'assassinate', 'slaughter',
// Drugs and substances
'weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high',
'stoned', 'drunk', 'alcohol', 'beer', 'liquor', 'vodka', 'whiskey',
'ecstasy', 'lsd', 'crack', 'dope', 'pot', 'joint', 'bong',
// Religious/cultural insults
'jesus christ', 'goddamn', 'christ almighty', 'holy shit', 'god damn',
'for christ sake', 'jesus fucking christ', 'holy fuck',
// Body parts (inappropriate context)
'testicles', 'balls', 'scrotum', 'clitoris', 'labia', 'anus',
'rectum', 'butthole', 'nipples', 'breasts',
// Misc inappropriate
'wtf', 'omfg', 'stfu', 'gtfo', 'milf', 'dilf', 'thot', 'simp',
'incel', 'chad', 'beta', 'alpha male', 'mansplain', 'karen'
@ -99,7 +99,7 @@ class ProfanityFilterService {
'%': 'a', '(': 'c', ')': 'c', '&': 'a', '#': 'h', '|': 'l', '\\': '/'
};
}
/**
* Initialize the filter by loading custom words
*/
@ -107,7 +107,7 @@ class ProfanityFilterService {
if (this.isInitialized) {
return;
}
try {
await this.loadCustomWords();
this.isInitialized = true;
@ -124,13 +124,13 @@ class ProfanityFilterService {
async loadCustomWords(): Promise<void> {
try {
const rows = await this.profanityWordModel.loadWords();
this.customWords = rows.map(row => ({
word: row.word.toLowerCase(),
severity: row.severity,
category: row.category
}));
console.log(`Loaded ${this.customWords.length} custom profanity words`);
this.patterns = this.buildPatterns(); // Rebuild patterns with custom words
} catch (err) {
@ -144,10 +144,10 @@ class ProfanityFilterService {
*/
buildPatterns(): ProfanityPattern[] {
const allWords = [...this.baseProfanityWords, ...this.customWords.map(w => w.word)];
// Sort by length (longest first) to catch longer variations before shorter ones
allWords.sort((a, b) => b.length - a.length);
// Create patterns with word boundaries and common variations
return allWords.map(word => {
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@ -157,9 +157,9 @@ class ProfanityFilterService {
const leetChars = Object.entries(this.leetMap)
.filter(([_, v]) => v === char.toLowerCase())
.map(([k, _]) => k);
if (leetChars.length > 0) {
const allChars = [char, ...leetChars].map(c =>
const allChars = [char, ...leetChars].map(c =>
c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
return `[${allChars.join('')}]`;
@ -167,7 +167,7 @@ class ProfanityFilterService {
return char;
})
.join('[\\s\\-\\_\\*\\.]*');
return {
word: word,
pattern: new RegExp(`\\b${pattern}\\b`, 'gi'),
@ -186,11 +186,11 @@ class ProfanityFilterService {
if (customWord) {
return customWord.severity;
}
// Categorize severity based on type
const highSeverity = ['nigger', 'nigga', 'cunt', 'fag', 'retard', 'kike', 'spic', 'gook', 'chink'];
const lowSeverity = ['damn', 'hell', 'crap', 'wtf', 'omfg'];
if (highSeverity.includes(word.toLowerCase())) return 'high';
if (lowSeverity.includes(word.toLowerCase())) return 'low';
return 'medium';
@ -205,7 +205,7 @@ class ProfanityFilterService {
if (customWord) {
return customWord.category;
}
// Categorize based on type
const categories: CategoryMap = {
racial: ['nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike', 'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey'],
@ -214,13 +214,13 @@ class ProfanityFilterService {
substance: ['weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high', 'stoned', 'drunk', 'alcohol'],
general: ['shit', 'fuck', 'ass', 'bitch', 'bastard', 'damn', 'hell', 'crap']
};
for (const [category, words] of Object.entries(categories)) {
if (words.includes(word.toLowerCase())) {
return category;
}
}
return 'general';
}
@ -229,18 +229,18 @@ class ProfanityFilterService {
*/
normalizeText(text: string): string {
if (!text) return '';
// Convert to lowercase and handle basic substitutions
let normalized = text.toLowerCase();
// Replace multiple spaces/special chars with single space
normalized = normalized.replace(/[\s\-\_\*\.]+/g, ' ');
// Apply leet speak conversions
normalized = normalized.split('').map(char =>
normalized = normalized.split('').map(char =>
this.leetMap[char] || char
).join('');
return normalized;
}
@ -249,7 +249,7 @@ class ProfanityFilterService {
*/
containsProfanity(text: string): boolean {
if (!text || !this.patterns) return false;
const normalized = this.normalizeText(text);
return this.patterns.some(({ pattern }) => pattern.test(normalized));
}
@ -267,15 +267,15 @@ class ProfanityFilterService {
filtered: text || ''
};
}
const normalized = this.normalizeText(text);
const matches: ProfanityMatch[] = [];
let filteredText = text;
this.patterns.forEach(({ word, pattern, severity, category }) => {
const regex = new RegExp(pattern.source, 'gi');
let match;
while ((match = regex.exec(normalized)) !== null) {
matches.push({
word: word,
@ -284,15 +284,15 @@ class ProfanityFilterService {
severity: severity,
category: category
});
// Replace in filtered text
const replacement = '*'.repeat(match[0].length);
filteredText = filteredText.substring(0, match.index) +
replacement +
filteredText = filteredText.substring(0, match.index) +
replacement +
filteredText.substring(match.index + match[0].length);
}
});
// Determine overall severity
let overallSeverity: 'none' | 'low' | 'medium' | 'high' = 'none';
if (matches.length > 0) {
@ -304,7 +304,7 @@ class ProfanityFilterService {
overallSeverity = 'low';
}
}
return {
hasProfanity: matches.length > 0,
matches: matches,
@ -326,9 +326,9 @@ class ProfanityFilterService {
* Add a custom word using the model
*/
async addCustomWord(
word: string,
severity: 'low' | 'medium' | 'high' = 'medium',
category: string = 'custom',
word: string,
severity: 'low' | 'medium' | 'high' = 'medium',
category: string = 'custom',
createdBy: string = 'admin'
): Promise<any> {
try {

View file

@ -70,7 +70,7 @@ const options: swaggerJsdoc.Options = {
},
longitude: {
type: 'number',
format: 'float',
format: 'float',
description: 'Geographic longitude coordinate',
example: -85.6681,
nullable: true
@ -116,7 +116,7 @@ const options: swaggerJsdoc.Options = {
example: 42.9634
},
longitude: {
type: 'number',
type: 'number',
format: 'float',
description: 'Geographic longitude coordinate (optional, will be geocoded if not provided)',
example: -85.6681
@ -341,7 +341,7 @@ const options: swaggerJsdoc.Options = {
}
]
},
apis: ['./src/routes/*.ts', './src/server.ts'], // Paths to files containing OpenAPI definitions
apis: ['./src/routes/*.ts', './src/server.ts'] // Paths to files containing OpenAPI definitions
};
export const swaggerSpec = swaggerJsdoc(options);

View file

@ -35,12 +35,12 @@ describe('Admin API Routes', () => {
if (!authHeader) {
return res.status(401).json({ error: 'Access denied' });
}
const token = authHeader.split(' ')[1];
if (!token || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access denied' });
}
// Simple token validation for testing
if (token === authToken) {
next();
@ -59,7 +59,7 @@ describe('Admin API Routes', () => {
const loginResponse = await request(app)
.post('/api/admin/login')
.send({ password: 'test_admin_password' });
authToken = loginResponse.body.token;
});
@ -69,7 +69,7 @@ describe('Admin API Routes', () => {
closedCount++;
if (closedCount === 2) done();
};
db.close(checkBothClosed);
profanityDb.close(checkBothClosed);
});
@ -481,7 +481,7 @@ describe('Admin API Routes', () => {
// Create a new app with broken database to simulate error
const brokenApp = express();
brokenApp.use(express.json());
// Create a broken location model that throws errors
const brokenLocationModel = {
getAll: jest.fn().mockRejectedValue(new Error('Database error'))
@ -497,7 +497,7 @@ describe('Admin API Routes', () => {
const loginResponse = await request(brokenApp)
.post('/api/admin/login')
.send({ password: 'test_admin_password' });
const brokenAuthToken = loginResponse.body.token;
const response = await request(brokenApp)
@ -554,7 +554,7 @@ describe('Admin API Routes', () => {
it('should handle expired/tampered tokens gracefully', async () => {
const tamperedToken = authToken.slice(0, -5) + 'XXXXX';
const response = await request(app)
.get('/api/admin/locations')
.set('Authorization', `Bearer ${tamperedToken}`)

View file

@ -121,7 +121,7 @@ describe('Public API Routes', () => {
// Create a new app with broken database to simulate error
const brokenApp = express();
brokenApp.use(express.json());
// Create a broken location model that throws errors
const brokenLocationModel = {
getActive: jest.fn().mockRejectedValue(new Error('Database error'))
@ -341,9 +341,9 @@ describe('Public API Routes', () => {
const response = await request(app)
.post('/api/locations')
.send({
.send({
address: 'Test Address',
description: longDescription
description: longDescription
})
.expect(400);
@ -372,5 +372,64 @@ describe('Public API Routes', () => {
expect(response.body.address).toBe(unicodeAddress);
});
it('should reject invalid latitude values', async () => {
const invalidLatitudes = [91, -91, 'invalid', null, true, []];
for (const latitude of invalidLatitudes) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: latitude,
longitude: -85.6681
})
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Latitude must be a number between -90 and 90');
}
});
it('should reject invalid longitude values', async () => {
const invalidLongitudes = [181, -181, 'invalid', null, true, []];
for (const longitude of invalidLongitudes) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: 42.9634,
longitude: longitude
})
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Longitude must be a number between -180 and 180');
}
});
it('should accept valid latitude and longitude values', async () => {
const validCoordinates = [
{ latitude: 0, longitude: 0 },
{ latitude: 90, longitude: 180 },
{ latitude: -90, longitude: -180 },
{ latitude: 42.9634, longitude: -85.6681 }
];
for (const coords of validCoordinates) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: coords.latitude,
longitude: coords.longitude
})
.expect(200);
expect(response.body.latitude).toBe(coords.latitude);
expect(response.body.longitude).toBe(coords.longitude);
}
});
});
});

View file

@ -16,7 +16,7 @@ export const createTestDatabase = (): Promise<Database> => {
reject(err);
return;
}
// Create locations table
db.run(`
CREATE TABLE IF NOT EXISTS locations (
@ -48,7 +48,7 @@ export const createTestProfanityDatabase = (): Promise<Database> => {
reject(err);
return;
}
// Create profanity_words table
db.run(`
CREATE TABLE IF NOT EXISTS profanity_words (

View file

@ -138,7 +138,7 @@ describe('Location Model', () => {
// Check that we have all locations and they're ordered by created_at DESC
expect(allLocations).toHaveLength(3);
// The query uses ORDER BY created_at DESC, so the most recent should be first
// Since they're created in the same moment, check that ordering is consistent
expect(allLocations[0]).toHaveProperty('id');

View file

@ -5,7 +5,7 @@ describe('DatabaseService', () => {
beforeEach(() => {
databaseService = new DatabaseService();
// Mock console methods to reduce test noise
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
@ -88,7 +88,7 @@ describe('DatabaseService', () => {
it('should allow multiple instances', () => {
const service1 = new DatabaseService();
const service2 = new DatabaseService();
expect(service1).toBeInstanceOf(DatabaseService);
expect(service2).toBeInstanceOf(DatabaseService);
expect(service1).not.toBe(service2);

View file

@ -27,10 +27,10 @@ describe('ProfanityFilterService', () => {
it('should load custom words during initialization', async () => {
await profanityWordModel.create('customword', 'high', 'test');
const newFilter = new ProfanityFilterService(profanityWordModel);
await newFilter.initialize();
expect(newFilter.containsProfanity('customword')).toBe(true);
});
});
@ -50,7 +50,7 @@ describe('ProfanityFilterService', () => {
// Test basic profanity detection - may or may not be case insensitive
const testWord = 'damn';
expect(profanityFilter.containsProfanity(testWord)).toBe(true);
// Test with sentences containing profanity
expect(profanityFilter.containsProfanity('This is DAMN cold')).toBe(true);
expect(profanityFilter.containsProfanity('What the HELL')).toBe(true);
@ -104,7 +104,7 @@ describe('ProfanityFilterService', () => {
it('should determine severity levels correctly', () => {
const lowResult = profanityFilter.analyzeProfanity('damn');
const mediumResult = profanityFilter.analyzeProfanity('shit');
expect(lowResult.severity).toBe('low');
expect(mediumResult.severity).toBe('medium');
});
@ -164,7 +164,7 @@ describe('ProfanityFilterService', () => {
it('should remove custom words', async () => {
const added = await profanityFilter.addCustomWord('removeme', 'low', 'test');
const result = await profanityFilter.removeCustomWord(added.id);
expect(result.deleted).toBe(true);
@ -180,7 +180,7 @@ describe('ProfanityFilterService', () => {
it('should update custom words', async () => {
const added = await profanityFilter.addCustomWord('updateme', 'low', 'test');
const result = await profanityFilter.updateCustomWord(added.id, {
word: 'updated',
severity: 'high',
@ -208,14 +208,14 @@ describe('ProfanityFilterService', () => {
describe('text normalization', () => {
it('should normalize text correctly', () => {
const normalized = profanityFilter.normalizeText('Hello World!!!');
expect(typeof normalized).toBe('string');
expect(normalized.length).toBeGreaterThan(0);
});
it('should handle special characters', () => {
const normalized = profanityFilter.normalizeText('h3ll0 w0rld');
expect(normalized).toContain('hello world');
});
@ -228,13 +228,13 @@ describe('ProfanityFilterService', () => {
describe('severity and category helpers', () => {
it('should get severity for words', () => {
const severity = profanityFilter.getSeverity('damn');
expect(['low', 'medium', 'high']).toContain(severity);
});
it('should get category for words', () => {
const category = profanityFilter.getCategory('damn');
expect(typeof category).toBe('string');
expect(category.length).toBeGreaterThan(0);
});
@ -242,7 +242,7 @@ describe('ProfanityFilterService', () => {
it('should return default values for unknown words', () => {
const severity = profanityFilter.getSeverity('unknownword');
const category = profanityFilter.getCategory('unknownword');
expect(['low', 'medium', 'high']).toContain(severity);
expect(typeof category).toBe('string');
});
@ -251,26 +251,26 @@ describe('ProfanityFilterService', () => {
describe('utility methods', () => {
it('should get all words', () => {
const words = profanityFilter.getAllWords();
expect(Array.isArray(words)).toBe(true);
expect(words.length).toBeGreaterThan(0);
});
it('should get severity level as number', () => {
const level = profanityFilter.getSeverityLevel();
expect(typeof level).toBe('number');
});
it('should get severity name', () => {
const name = profanityFilter.getSeverityName();
expect(typeof name).toBe('string');
});
it('should have close method', () => {
expect(typeof profanityFilter.close).toBe('function');
// Should not throw
profanityFilter.close();
});