Merge pull request #12 from derekslenk/security/fix-public-delete-vulnerability
🚨 SECURITY: Fix critical vulnerabilities in location endpoints
This commit is contained in:
commit
44873b5b27
22 changed files with 2934 additions and 618 deletions
51
CLAUDE.md
51
CLAUDE.md
|
@ -58,15 +58,30 @@ 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
|
||||
# Run all tests (128+ tests with TypeScript)
|
||||
npm test
|
||||
|
||||
# Run tests with coverage report
|
||||
# Run tests with coverage report (76% overall coverage)
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
**Test Coverage:**
|
||||
- **Unit Tests:** Location/ProfanityWord models, DatabaseService, ProfanityFilterService
|
||||
- **Integration Tests:** Public API routes, Admin API routes with authentication
|
||||
- **Security Tests:** Rate limiting, input validation, authentication flows
|
||||
- **Coverage:** 76% statements, 63% branches, 78% lines
|
||||
|
||||
### Environment Setup
|
||||
Before running the application, you must configure environment variables:
|
||||
```bash
|
||||
|
@ -140,18 +155,27 @@ Multiple map implementations for flexibility:
|
|||
|
||||
### API Endpoints
|
||||
|
||||
Public endpoints:
|
||||
**Public endpoints:**
|
||||
- `GET /api/config`: Returns MapBox token for frontend geocoding
|
||||
- `GET /api/locations`: Active locations (< 48 hours old or persistent)
|
||||
- `POST /api/locations`: Submit new location report (with profanity filtering)
|
||||
- `POST /api/locations`: Submit new location report (rate limited: 10/15min per IP)
|
||||
- **Input Validation:** Address ≤500 chars, Description ≤1000 chars
|
||||
- **Profanity Filtering:** Automatic content moderation with rejection
|
||||
- **Security:** Rate limiting prevents spam and DoS attacks
|
||||
|
||||
Admin endpoints (require Bearer token):
|
||||
**Admin endpoints (require Bearer token):**
|
||||
- `POST /api/admin/login`: Authenticate and receive token
|
||||
- `GET /api/admin/locations`: All locations including expired
|
||||
- `PUT /api/admin/locations/:id`: Update location details
|
||||
- `PATCH /api/admin/locations/:id/persistent`: Toggle persistent status
|
||||
- `DELETE /api/admin/locations/:id`: Delete location
|
||||
- Profanity management: `/api/admin/profanity-words` (GET, POST, PUT, DELETE)
|
||||
- `DELETE /api/admin/locations/:id`: Delete location (admin-only)
|
||||
- **Profanity management:** `/api/admin/profanity-words` (GET, POST, PUT, DELETE)
|
||||
|
||||
**Security Features:**
|
||||
- **Rate Limiting:** Express-rate-limit middleware on public endpoints
|
||||
- **Authentication:** Bearer token authentication for admin routes
|
||||
- **Input Validation:** Strict length limits and type checking
|
||||
- **Audit Logging:** Suspicious activity detection and logging
|
||||
|
||||
### SCSS Organization
|
||||
SCSS files are in `src/scss/`:
|
||||
|
@ -164,12 +188,13 @@ SCSS files are in `src/scss/`:
|
|||
### Key Design Patterns
|
||||
|
||||
1. **TypeScript-First Architecture**: Full type safety with strict type checking
|
||||
2. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
|
||||
3. **Dual Database Design**: Separate databases for application data and content moderation
|
||||
4. **Type-Safe Database Operations**: All database interactions use typed models
|
||||
5. **Graceful Degradation**: Fallback geocoding providers and error handling
|
||||
6. **Automated Maintenance**: Cron-based cleanup of expired reports
|
||||
7. **Security**: Server-side content filtering, environment-based configuration
|
||||
2. **Security-by-Design**: Rate limiting, input validation, and authentication built into core routes
|
||||
3. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
|
||||
4. **Dual Database Design**: Separate databases for application data and content moderation
|
||||
5. **Type-Safe Database Operations**: All database interactions use typed models
|
||||
6. **Comprehensive Testing**: 125+ tests covering units, integration, and security scenarios
|
||||
7. **Graceful Degradation**: Fallback geocoding providers and error handling
|
||||
8. **Automated Maintenance**: Cron-based cleanup of expired reports
|
||||
|
||||
### Deployment
|
||||
- Automated deployment script for Debian 12 ARM64 in `scripts/deploy.sh`
|
||||
|
|
68
README.md
68
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
|
||||
|
@ -114,16 +135,32 @@ See `docs/deployment.md` for detailed manual deployment instructions.
|
|||
|
||||
## API Endpoints
|
||||
|
||||
### Public Endpoints
|
||||
- `GET /api/locations` - Get active location reports
|
||||
- `POST /api/locations` - Submit new location report
|
||||
- `POST /api/locations` - Submit new location report (rate limited: 10/15min per IP)
|
||||
- `GET /api/config` - Get API configuration
|
||||
|
||||
### Admin Endpoints (Authentication Required)
|
||||
- `GET /admin` - Admin panel (password protected)
|
||||
- `GET /api/admin/locations` - Get all location reports
|
||||
- `PUT /api/admin/locations/:id` - Update location report
|
||||
- `PATCH /api/admin/locations/:id/persistent` - Toggle persistent status
|
||||
- `DELETE /api/admin/locations/:id` - Delete location report
|
||||
- `GET /api/admin/profanity-words` - Manage profanity filter
|
||||
- `POST /api/admin/profanity-words` - Add custom profanity word
|
||||
- `PUT /api/admin/profanity-words/:id` - Update profanity word
|
||||
- `DELETE /api/admin/profanity-words/:id` - Delete profanity word
|
||||
|
||||
### API Documentation
|
||||
Interactive API documentation available at `/api-docs` when running the server.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend:** Node.js, Express.js, SQLite
|
||||
- **Backend:** Node.js, Express.js, SQLite, TypeScript
|
||||
- **Frontend:** Vanilla JavaScript, Leaflet.js
|
||||
- **Geocoding:** MapBox API (with Nominatim fallback)
|
||||
- **Security:** Rate limiting, input validation, authentication
|
||||
- **Testing:** Jest, TypeScript, 128+ tests with 76% coverage
|
||||
- **Reverse Proxy:** Caddy (automatic HTTPS)
|
||||
- **Database:** SQLite (lightweight, serverless)
|
||||
|
||||
|
@ -137,10 +174,21 @@ See `docs/deployment.md` for detailed manual deployment instructions.
|
|||
|
||||
## Security
|
||||
|
||||
- API keys are stored in environment variables
|
||||
- Admin routes are password protected
|
||||
- Database queries use parameterized statements
|
||||
- HTTPS enforced in production
|
||||
- **Authentication:** Admin routes protected with bearer token authentication
|
||||
- **Rate Limiting:** Public endpoints limited to prevent abuse (10 requests/15min per IP)
|
||||
- **Input Validation:** Strict length limits and type checking on all user inputs
|
||||
- **Data Protection:** API keys stored in environment variables only
|
||||
- **Database Security:** Parameterized queries prevent SQL injection
|
||||
- **Content Filtering:** Built-in profanity filter with custom word management
|
||||
- **HTTPS:** Enforced in production via Caddy reverse proxy
|
||||
- **Audit Logging:** Suspicious activity and abuse attempts are logged
|
||||
|
||||
### 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
|
||||
|
||||
|
|
305
docs/api.md
Normal file
305
docs/api.md
Normal file
|
@ -0,0 +1,305 @@
|
|||
# Great Lakes Ice Report - API Documentation
|
||||
|
||||
This document provides an overview of the API endpoints. For interactive documentation with examples, visit `/api-docs` when the server is running.
|
||||
|
||||
## Base URL
|
||||
|
||||
- **Development**: `http://localhost:3000`
|
||||
- **Production**: `https://your-domain.com`
|
||||
|
||||
## Authentication
|
||||
|
||||
Admin endpoints require Bearer token authentication:
|
||||
```
|
||||
Authorization: Bearer YOUR_TOKEN_HERE
|
||||
```
|
||||
|
||||
Get a token by authenticating with the admin password at `POST /api/admin/login`.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- **General API**: 30 requests per minute per IP
|
||||
- **Location Submissions**: 10 requests per 15 minutes per IP
|
||||
|
||||
## Public Endpoints
|
||||
|
||||
### Get Configuration
|
||||
```http
|
||||
GET /api/config
|
||||
```
|
||||
|
||||
Returns MapBox API configuration for frontend geocoding.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"mapboxAccessToken": "pk.your_token_here",
|
||||
"hasMapbox": true
|
||||
}
|
||||
```
|
||||
|
||||
### Get Active Locations
|
||||
```http
|
||||
GET /api/locations
|
||||
```
|
||||
|
||||
Returns all active location reports (less than 48 hours old or marked persistent).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 123,
|
||||
"address": "Main St & Oak Ave, Grand Rapids, MI",
|
||||
"latitude": 42.9634,
|
||||
"longitude": -85.6681,
|
||||
"description": "Black ice present, multiple vehicles stuck",
|
||||
"persistent": false,
|
||||
"created_at": "2025-01-15T10:30:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Submit Location Report
|
||||
```http
|
||||
POST /api/locations
|
||||
```
|
||||
|
||||
Submit a new ice condition report with automatic profanity filtering.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"address": "Main St & Oak Ave, Grand Rapids, MI",
|
||||
"latitude": 42.9634,
|
||||
"longitude": -85.6681,
|
||||
"description": "Black ice spotted, drive carefully"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `address`: Required, max 500 characters
|
||||
- `latitude`: Optional, valid number
|
||||
- `longitude`: Optional, valid number
|
||||
- `description`: Optional, max 1000 characters
|
||||
|
||||
**Response (Success):**
|
||||
```json
|
||||
{
|
||||
"id": 125,
|
||||
"address": "Main St & Oak Ave, Grand Rapids, MI",
|
||||
"latitude": 42.9634,
|
||||
"longitude": -85.6681,
|
||||
"description": "Black ice spotted, drive carefully",
|
||||
"persistent": false,
|
||||
"created_at": "2025-01-15T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Profanity Detected):**
|
||||
```json
|
||||
{
|
||||
"error": "Submission rejected",
|
||||
"message": "Your description contains inappropriate language...",
|
||||
"details": {
|
||||
"severity": "medium",
|
||||
"wordCount": 1,
|
||||
"detectedCategories": ["general"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Endpoints
|
||||
|
||||
All admin endpoints require authentication via `Authorization: Bearer <token>`.
|
||||
|
||||
### Admin Login
|
||||
```http
|
||||
POST /api/admin/login
|
||||
```
|
||||
|
||||
Authenticate with admin password to receive access token.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"password": "your_admin_password"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"token": "your_bearer_token_here",
|
||||
"message": "Login successful"
|
||||
}
|
||||
```
|
||||
|
||||
### Get All Locations
|
||||
```http
|
||||
GET /api/admin/locations
|
||||
```
|
||||
|
||||
Returns all location reports including expired ones.
|
||||
|
||||
### Update Location
|
||||
```http
|
||||
PUT /api/admin/locations/:id
|
||||
```
|
||||
|
||||
Update location details.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"address": "Updated Address",
|
||||
"latitude": 42.9634,
|
||||
"longitude": -85.6681,
|
||||
"description": "Updated description"
|
||||
}
|
||||
```
|
||||
|
||||
### Toggle Persistent Status
|
||||
```http
|
||||
PATCH /api/admin/locations/:id/persistent
|
||||
```
|
||||
|
||||
Mark location as persistent (won't auto-expire) or remove persistent status.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"persistent": true
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Location
|
||||
```http
|
||||
DELETE /api/admin/locations/:id
|
||||
```
|
||||
|
||||
Permanently delete a location report.
|
||||
|
||||
### Profanity Word Management
|
||||
|
||||
#### Get All Profanity Words
|
||||
```http
|
||||
GET /api/admin/profanity-words
|
||||
```
|
||||
|
||||
#### Add Profanity Word
|
||||
```http
|
||||
POST /api/admin/profanity-words
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"word": "badword",
|
||||
"severity": "medium",
|
||||
"category": "custom"
|
||||
}
|
||||
```
|
||||
|
||||
#### Update Profanity Word
|
||||
```http
|
||||
PUT /api/admin/profanity-words/:id
|
||||
```
|
||||
|
||||
#### Delete Profanity Word
|
||||
```http
|
||||
DELETE /api/admin/profanity-words/:id
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
### Rate Limiting
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
```json
|
||||
{
|
||||
"error": "Address must be a string with maximum 500 characters"
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Errors
|
||||
```json
|
||||
{
|
||||
"error": "Access denied"
|
||||
}
|
||||
```
|
||||
|
||||
### Not Found
|
||||
```json
|
||||
{
|
||||
"error": "Location not found"
|
||||
}
|
||||
```
|
||||
|
||||
### Server Errors
|
||||
```json
|
||||
{
|
||||
"error": "Internal server error"
|
||||
}
|
||||
```
|
||||
|
||||
## Interactive Documentation
|
||||
|
||||
For complete interactive API documentation with live testing capabilities, visit `/api-docs` when running the server. This provides:
|
||||
|
||||
- Complete endpoint specifications
|
||||
- Request/response schemas
|
||||
- Interactive testing interface
|
||||
- Authentication flows
|
||||
- Example requests and responses
|
||||
|
||||
## Security Features
|
||||
|
||||
- **Rate Limiting**: Prevents abuse and DoS attacks
|
||||
- **Input Validation**: Strict length and type checking
|
||||
- **Profanity Filtering**: Automatic content moderation
|
||||
- **Authentication**: Secure admin access with bearer tokens
|
||||
- **HTTPS**: SSL/TLS encryption in production
|
||||
- **Audit Logging**: Suspicious activity detection
|
||||
|
||||
## Client Libraries
|
||||
|
||||
The API follows REST conventions and can be consumed by any HTTP client. Examples:
|
||||
|
||||
### JavaScript/Node.js
|
||||
```javascript
|
||||
// Submit location report
|
||||
const response = await fetch('/api/locations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
address: 'Main St & Oak Ave',
|
||||
description: 'Icy conditions'
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### curl
|
||||
```bash
|
||||
# Get active locations
|
||||
curl https://your-domain.com/api/locations
|
||||
|
||||
# Submit location report
|
||||
curl -X POST https://your-domain.com/api/locations \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"address":"Main St & Oak Ave","description":"Icy conditions"}'
|
||||
|
||||
# Admin login
|
||||
curl -X POST https://your-domain.com/api/admin/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password":"your_password"}'
|
||||
```
|
347
docs/deployment.md
Normal file
347
docs/deployment.md
Normal file
|
@ -0,0 +1,347 @@
|
|||
# Great Lakes Ice Report - Deployment Guide
|
||||
|
||||
This guide covers both automated and manual deployment options for the Great Lakes Ice Report application.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Server**: Debian 12 ARM64 (or compatible Linux distribution)
|
||||
- **Node.js**: Version 18 or higher
|
||||
- **Domain**: DNS pointing to your server
|
||||
- **Ports**: 80 and 443 open for web traffic
|
||||
|
||||
## Automated Deployment (Recommended)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Run the deployment script on your server:**
|
||||
```bash
|
||||
curl -sSL https://ice-puremichigan-lol.s3.amazonaws.com/scripts/deploy.sh | bash
|
||||
```
|
||||
|
||||
2. **Clone and setup the application:**
|
||||
```bash
|
||||
git clone git@git.deco.sh:deco/ice.git /opt/ice
|
||||
cd /opt/ice
|
||||
npm install # This automatically builds CSS via postinstall
|
||||
```
|
||||
|
||||
3. **Configure environment variables:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # Add your API keys
|
||||
```
|
||||
|
||||
4. **Start services:**
|
||||
```bash
|
||||
sudo systemctl enable ice
|
||||
sudo systemctl start ice
|
||||
sudo systemctl enable caddy
|
||||
sudo systemctl start caddy
|
||||
```
|
||||
|
||||
### What the Automated Script Does
|
||||
|
||||
The deployment script automatically:
|
||||
|
||||
1. **System Updates**: Updates package repositories and system packages
|
||||
2. **Node.js Installation**: Installs Node.js 20.x with build tools
|
||||
3. **Go Installation**: Installs Go (required for building Caddy with plugins)
|
||||
4. **Custom Caddy Build**: Builds Caddy with rate limiting plugin using xcaddy
|
||||
5. **Service Configuration**: Creates systemd services for both the app and Caddy
|
||||
6. **Security Setup**: Configures users, permissions, and security settings
|
||||
|
||||
## Manual Deployment
|
||||
|
||||
### 1. System Preparation
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Node.js 20.x
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt install -y nodejs build-essential
|
||||
|
||||
# Install Git (if not already installed)
|
||||
sudo apt install -y git
|
||||
```
|
||||
|
||||
### 2. Install Go (for Custom Caddy)
|
||||
|
||||
```bash
|
||||
# Download and install Go
|
||||
wget -q https://go.dev/dl/go1.21.5.linux-arm64.tar.gz
|
||||
sudo rm -rf /usr/local/go
|
||||
sudo tar -C /usr/local -xzf go1.21.5.linux-arm64.tar.gz
|
||||
|
||||
# Add to PATH
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
### 3. Build Custom Caddy with Rate Limiting
|
||||
|
||||
```bash
|
||||
# Install xcaddy
|
||||
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
|
||||
export PATH=$PATH:$(go env GOPATH)/bin
|
||||
|
||||
# Build Caddy with rate limiting plugin
|
||||
xcaddy build --with github.com/mholt/caddy-ratelimit
|
||||
|
||||
# Install Caddy
|
||||
sudo mv caddy /usr/local/bin/caddy
|
||||
sudo chmod +x /usr/local/bin/caddy
|
||||
```
|
||||
|
||||
### 4. Create Users and Directories
|
||||
|
||||
```bash
|
||||
# Create Caddy user
|
||||
sudo groupadd --system caddy
|
||||
sudo useradd --system --gid caddy --create-home --home-dir /var/lib/caddy --shell /usr/sbin/nologin caddy
|
||||
|
||||
# Create directories
|
||||
sudo mkdir -p /etc/caddy /var/log/caddy
|
||||
sudo chown -R caddy:caddy /var/log/caddy
|
||||
|
||||
# Create app user
|
||||
sudo groupadd --system great-lakes-ice-report
|
||||
sudo useradd --system --gid great-lakes-ice-report --create-home --home-dir /opt/great-lakes-ice-report --shell /usr/sbin/nologin great-lakes-ice-report
|
||||
```
|
||||
|
||||
### 5. Deploy Application
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
sudo git clone https://github.com/your-username/ice.git /opt/great-lakes-ice-report
|
||||
cd /opt/great-lakes-ice-report
|
||||
|
||||
# Install dependencies and build
|
||||
sudo npm install
|
||||
sudo npm run build
|
||||
|
||||
# Set ownership
|
||||
sudo chown -R great-lakes-ice-report:great-lakes-ice-report /opt/great-lakes-ice-report
|
||||
```
|
||||
|
||||
### 6. Configure Environment
|
||||
|
||||
```bash
|
||||
# Copy environment template
|
||||
sudo cp .env.example .env
|
||||
|
||||
# Edit environment file
|
||||
sudo nano .env
|
||||
```
|
||||
|
||||
**Required environment variables:**
|
||||
```bash
|
||||
# MapBox API token (get free token at https://account.mapbox.com/access-tokens/)
|
||||
MAPBOX_ACCESS_TOKEN=pk.your_mapbox_token_here
|
||||
|
||||
# Admin panel password
|
||||
ADMIN_PASSWORD=your_secure_password
|
||||
|
||||
# Server port (default: 3000)
|
||||
PORT=3000
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
### 7. Configure Systemd Services
|
||||
|
||||
**Create app service:**
|
||||
```bash
|
||||
sudo cp scripts/great-lakes-ice-report.service /etc/systemd/system/
|
||||
```
|
||||
|
||||
**Create Caddy service:**
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/caddy.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Caddy
|
||||
Documentation=https://caddyserver.com/docs/
|
||||
After=network.target network-online.target
|
||||
Requires=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
User=caddy
|
||||
Group=caddy
|
||||
ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
|
||||
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
|
||||
TimeoutStopSec=5s
|
||||
LimitNOFILE=1048576
|
||||
LimitNPROC=1048576
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
```
|
||||
|
||||
### 8. Configure Caddy
|
||||
|
||||
```bash
|
||||
# Copy Caddyfile
|
||||
sudo cp scripts/Caddyfile /etc/caddy/
|
||||
|
||||
# Edit domain name in Caddyfile
|
||||
sudo nano /etc/caddy/Caddyfile
|
||||
# Change 'ice.puremichigan.lol' to your domain
|
||||
```
|
||||
|
||||
### 9. Start Services
|
||||
|
||||
```bash
|
||||
# Reload systemd
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Enable and start services
|
||||
sudo systemctl enable great-lakes-ice-report
|
||||
sudo systemctl start great-lakes-ice-report
|
||||
|
||||
sudo systemctl enable caddy
|
||||
sudo systemctl start caddy
|
||||
```
|
||||
|
||||
## Security Configuration
|
||||
|
||||
### Firewall Setup
|
||||
|
||||
```bash
|
||||
# Install UFW if not present
|
||||
sudo apt install -y ufw
|
||||
|
||||
# Configure firewall
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
sudo ufw allow ssh
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw --force enable
|
||||
```
|
||||
|
||||
### SSL Certificates
|
||||
|
||||
Caddy automatically obtains SSL certificates from Let's Encrypt. No manual configuration required.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
The application includes multiple layers of rate limiting:
|
||||
|
||||
1. **Application Level**: 10 requests per 15 minutes per IP for location submissions
|
||||
2. **Caddy Level**:
|
||||
- 30 requests per minute for general API endpoints
|
||||
- 5 requests per minute for location submissions
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Service Status
|
||||
|
||||
```bash
|
||||
# Check application status
|
||||
sudo systemctl status great-lakes-ice-report
|
||||
|
||||
# Check Caddy status
|
||||
sudo systemctl status caddy
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u great-lakes-ice-report -f
|
||||
sudo journalctl -u caddy -f
|
||||
```
|
||||
|
||||
### Log Files
|
||||
|
||||
- **Application logs**: `sudo journalctl -u great-lakes-ice-report`
|
||||
- **Caddy access logs**: `/var/log/caddy/great-lakes-ice-report.log`
|
||||
- **System logs**: `/var/log/syslog`
|
||||
|
||||
### Database Maintenance
|
||||
|
||||
The application automatically:
|
||||
- Cleans up expired location reports (older than 48 hours)
|
||||
- Maintains profanity filter database
|
||||
- Uses SQLite with automatic cleanup
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
# Navigate to app directory
|
||||
cd /opt/great-lakes-ice-report
|
||||
|
||||
# Pull latest changes
|
||||
sudo git pull
|
||||
|
||||
# Install new dependencies
|
||||
sudo npm install
|
||||
|
||||
# Rebuild application
|
||||
sudo npm run build
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart great-lakes-ice-report
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Service won't start**:
|
||||
```bash
|
||||
sudo journalctl -u great-lakes-ice-report --no-pager
|
||||
```
|
||||
|
||||
2. **SSL certificate issues**:
|
||||
```bash
|
||||
sudo journalctl -u caddy --no-pager
|
||||
```
|
||||
|
||||
3. **Database permissions**:
|
||||
```bash
|
||||
sudo chown -R great-lakes-ice-report:great-lakes-ice-report /opt/great-lakes-ice-report
|
||||
```
|
||||
|
||||
4. **Port conflicts**:
|
||||
```bash
|
||||
sudo netstat -tlnp | grep :3000
|
||||
sudo netstat -tlnp | grep :80
|
||||
sudo netstat -tlnp | grep :443
|
||||
```
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
For high-traffic deployments:
|
||||
|
||||
1. **Increase Node.js memory limit**:
|
||||
```bash
|
||||
# Edit service file
|
||||
sudo nano /etc/systemd/system/great-lakes-ice-report.service
|
||||
# Add: Environment=NODE_OPTIONS="--max-old-space-size=4096"
|
||||
```
|
||||
|
||||
2. **Configure log rotation**:
|
||||
```bash
|
||||
sudo nano /etc/logrotate.d/great-lakes-ice-report
|
||||
```
|
||||
|
||||
3. **Monitor resource usage**:
|
||||
```bash
|
||||
htop
|
||||
sudo iotop
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For deployment issues:
|
||||
- Check system logs: `sudo journalctl -f`
|
||||
- Verify environment variables: `sudo -u great-lakes-ice-report env`
|
||||
- Test application directly: `cd /opt/great-lakes-ice-report && sudo -u great-lakes-ice-report node server.js`
|
||||
- Review this documentation and configuration files
|
||||
|
||||
The application includes comprehensive logging and monitoring to help diagnose issues quickly.
|
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
|
||||
}
|
||||
}
|
||||
];
|
1400
package-lock.json
generated
1400
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -17,12 +17,15 @@
|
|||
"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": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
|
@ -38,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",
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -1,4 +1,5 @@
|
|||
import express, { Request, Response, Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import Location from '../models/Location';
|
||||
import ProfanityFilterService from '../services/ProfanityFilterService';
|
||||
import { LocationSubmission } from '../types';
|
||||
|
@ -13,16 +14,26 @@ interface LocationPostRequest extends Request {
|
|||
};
|
||||
}
|
||||
|
||||
interface LocationDeleteRequest extends Request {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/locations:
|
||||
* get:
|
||||
|
@ -67,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:
|
||||
|
@ -154,82 +165,97 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
|
|||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.post('/', 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}`);
|
||||
|
||||
if (!address) {
|
||||
console.warn('Failed to add location: Address is required');
|
||||
res.status(400).json({ error: 'Address is required' });
|
||||
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}"`);
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise<void> => {
|
||||
const { address, latitude, longitude, description } = req.body;
|
||||
console.log(`Attempt to add new location: ${address}`);
|
||||
|
||||
// Legacy delete route (keeping for backwards compatibility)
|
||||
router.delete('/:id', 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' });
|
||||
}
|
||||
});
|
||||
// Input validation for security
|
||||
if (!address) {
|
||||
console.warn('Failed to add location: Address is required');
|
||||
res.status(400).json({ error: 'Address is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
return router;
|
||||
if (typeof address !== 'string' || address.length > 500) {
|
||||
console.warn(`Failed to add location: Invalid address length (${address?.length || 0})`);
|
||||
res.status(400).json({ error: 'Address must be a string with maximum 500 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (description && (typeof description !== 'string' || description.length > 1000)) {
|
||||
console.warn(`Failed to add location: Invalid description length (${description?.length || 0})`);
|
||||
res.status(400).json({ error: 'Description must be a string with maximum 1000 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate latitude if provided
|
||||
if (latitude !== undefined && (typeof latitude !== 'number' || latitude < -90 || latitude > 90)) {
|
||||
console.warn(`Failed to add location: Invalid latitude (${latitude})`);
|
||||
res.status(400).json({ error: 'Latitude must be a number between -90 and 90' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate longitude if provided
|
||||
if (longitude !== undefined && (typeof longitude !== 'number' || longitude < -180 || longitude > 180)) {
|
||||
console.warn(`Failed to add location: Invalid longitude (${longitude})`);
|
||||
res.status(400).json({ error: 'Longitude must be a number between -180 and 180' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Log suspicious activity
|
||||
if (address.length > 200 || (description && description.length > 500)) {
|
||||
console.warn(`Unusually long submission from IP: ${req.ip} - Address: ${address.length} chars, Description: ${description?.length || 0} chars`);
|
||||
}
|
||||
|
||||
// Check for profanity in description and reject if any is found
|
||||
if (description && profanityFilter) {
|
||||
try {
|
||||
const analysis = profanityFilter.analyzeProfanity(description);
|
||||
if (analysis.hasProfanity) {
|
||||
console.warn(`Submission rejected due to inappropriate language (${analysis.count} word${analysis.count > 1 ? 's' : ''}, severity: ${analysis.severity}) - Original: "${req.body.description}"`);
|
||||
|
||||
// Reject any submission with profanity
|
||||
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
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
328
src/server.ts
328
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 || '',
|
||||
|
||||
// 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);
|
||||
});
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}`)
|
||||
|
|
|
@ -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'))
|
||||
|
@ -313,15 +313,42 @@ describe('Public API Routes', () => {
|
|||
});
|
||||
|
||||
describe('Request validation', () => {
|
||||
it('should handle very long addresses', async () => {
|
||||
const longAddress = 'A'.repeat(1000);
|
||||
it('should reject overly long addresses for security', async () => {
|
||||
const longAddress = 'A'.repeat(1000); // Over 500 character limit
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/locations')
|
||||
.send({ address: longAddress })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toBe('Address must be a string with maximum 500 characters');
|
||||
});
|
||||
|
||||
it('should accept addresses within the limit', async () => {
|
||||
const validLongAddress = 'A'.repeat(400); // Under 500 character limit
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/locations')
|
||||
.send({ address: validLongAddress })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.address).toBe(longAddress);
|
||||
expect(response.body.address).toBe(validLongAddress);
|
||||
});
|
||||
|
||||
it('should reject overly long descriptions for security', async () => {
|
||||
const longDescription = 'B'.repeat(1500); // Over 1000 character limit
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/locations')
|
||||
.send({
|
||||
address: 'Test Address',
|
||||
description: longDescription
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toBe('Description must be a string with maximum 1000 characters');
|
||||
});
|
||||
|
||||
it('should handle special characters in address', async () => {
|
||||
|
@ -345,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
|
@ -70,14 +70,9 @@ export const createTestProfanityDatabase = (): Promise<Database> => {
|
|||
});
|
||||
};
|
||||
|
||||
// Cleanup function for tests
|
||||
// Cleanup function for tests (in-memory databases don't need file cleanup)
|
||||
export const cleanupTestDatabases = () => {
|
||||
// Clean up any test database files
|
||||
[TEST_DB_PATH, TEST_PROFANITY_DB_PATH].forEach(dbPath => {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.unlinkSync(dbPath);
|
||||
}
|
||||
});
|
||||
// Using in-memory databases (:memory:) - no file cleanup needed
|
||||
};
|
||||
|
||||
// Global test cleanup
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue