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:
parent
5176636f6d
commit
30fdd72cc5
20 changed files with 2171 additions and 599 deletions
11
CLAUDE.md
11
CLAUDE.md
|
@ -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)
|
||||
|
|
33
README.md
33
README.md
|
@ -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
77
eslint.config.mjs
Normal 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
1384
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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();
|
||||
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
|
||||
}));
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
res.json(locations);
|
||||
} catch (err) {
|
||||
console.error('Error fetching all locations:', 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;
|
||||
// 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;
|
||||
}
|
||||
if (!address) {
|
||||
res.status(400).json({ error: 'Address is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await locationModel.update(parseInt(id, 10), {
|
||||
address,
|
||||
latitude,
|
||||
longitude,
|
||||
description
|
||||
});
|
||||
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;
|
||||
}
|
||||
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' });
|
||||
}
|
||||
});
|
||||
res.json({ message: 'Location updated successfully' });
|
||||
} catch (err) {
|
||||
console.error('Error updating location:', err);
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
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);
|
||||
try {
|
||||
const result = await locationModel.togglePersistent(parseInt(id, 10), persistent);
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Location not found' });
|
||||
return;
|
||||
}
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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;
|
||||
// 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));
|
||||
try {
|
||||
const result = await locationModel.delete(parseInt(id, 10));
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Location not found' });
|
||||
return;
|
||||
}
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
// 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;
|
||||
// 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 (!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;
|
||||
}
|
||||
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
|
||||
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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
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;
|
||||
// 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 (!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;
|
||||
}
|
||||
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
|
||||
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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
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;
|
||||
// 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
|
||||
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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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;
|
||||
return router;
|
||||
};
|
|
@ -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;
|
||||
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.'));
|
||||
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
|
||||
});
|
||||
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;
|
||||
return router;
|
||||
};
|
|
@ -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');
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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}`);
|
||||
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}`);
|
||||
|
||||
// Input validation for security
|
||||
if (!address) {
|
||||
console.warn('Failed to add location: Address is required');
|
||||
res.status(400).json({ error: 'Address is required' });
|
||||
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 (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 (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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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}"`);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Reject any submission with profanity
|
||||
const wordText = analysis.count === 1 ? 'word' : 'words';
|
||||
const detectedWords = analysis.matches.map((m: any) => m.word).join(', ');
|
||||
// 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`);
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const newLocation = await locationModel.create({
|
||||
address,
|
||||
latitude,
|
||||
longitude,
|
||||
description
|
||||
});
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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.
|
||||
// DELETE functionality has been moved to admin-only routes for security.
|
||||
// Use /api/admin/locations/:id (with authentication) for location deletion.
|
||||
|
||||
return router;
|
||||
return router;
|
||||
};
|
296
src/server.ts
296
src/server.ts
|
@ -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 || '',
|
||||
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> => {},
|
||||
// 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[] => [],
|
||||
// Utility methods
|
||||
getAllWords: (): any[] => [],
|
||||
getSeverity: (): string => 'none',
|
||||
getSeverityLevel: (): number => 0,
|
||||
getSeverityName: (): string => 'none',
|
||||
normalizeText: (text: string): string => text || '',
|
||||
buildPatterns: (): any[] => [],
|
||||
|
||||
// Cleanup method
|
||||
close: (): void => {},
|
||||
// Cleanup method
|
||||
close: (): void => {},
|
||||
|
||||
// Special property to identify this as a fallback filter
|
||||
_isFallback: true
|
||||
};
|
||||
// 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.');
|
||||
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!');
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
const token = authHeader.substring(7);
|
||||
if (token !== ADMIN_PASSWORD) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
next();
|
||||
};
|
||||
|
||||
// Setup routes after database and profanity filter are initialized
|
||||
function setupRoutes(): void {
|
||||
const locationModel = databaseService.getLocationModel();
|
||||
const profanityWordModel = databaseService.getProfanityWordModel();
|
||||
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 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));
|
||||
// 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'));
|
||||
});
|
||||
// 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('/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'));
|
||||
});
|
||||
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');
|
||||
try {
|
||||
// Initialize database service first
|
||||
await databaseService.initialize();
|
||||
console.log('Database service initialized successfully');
|
||||
|
||||
// Initialize profanity filter
|
||||
await initializeProfanityFilter();
|
||||
// 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);
|
||||
// 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...');
|
||||
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 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);
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue