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`
|
||||
|
|
62
README.md
62
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
|
||||
|
||||
|
@ -44,6 +45,26 @@ A community-driven web application for tracking winter road conditions and icy h
|
|||
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",
|
||||
|
|
|
@ -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,15 +14,25 @@ interface LocationPostRequest extends Request {
|
|||
};
|
||||
}
|
||||
|
||||
interface LocationDeleteRequest extends Request {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => {
|
||||
const router = express.Router();
|
||||
|
||||
// Rate limiting for location submissions to prevent abuse
|
||||
const submitLocationLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
limit: 10, // Limit each IP to 10 location submissions per 15 minutes
|
||||
message: {
|
||||
error: 'Too many location reports submitted',
|
||||
message: 'You can submit up to 10 location reports every 15 minutes. Please wait before submitting more.',
|
||||
retryAfter: '15 minutes'
|
||||
},
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
// Skip rate limiting in test environment
|
||||
skip: (req) => process.env.NODE_ENV === 'test'
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/locations:
|
||||
|
@ -154,17 +165,48 @@ 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;
|
||||
router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise<void> => {
|
||||
const { address, latitude, longitude, description } = req.body;
|
||||
console.log(`Attempt to add new location: ${address}`);
|
||||
|
||||
// Input validation for security
|
||||
if (!address) {
|
||||
console.warn('Failed to add location: Address is required');
|
||||
res.status(400).json({ error: 'Address is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof address !== 'string' || address.length > 500) {
|
||||
console.warn(`Failed to add location: Invalid address length (${address?.length || 0})`);
|
||||
res.status(400).json({ error: 'Address must be a string with maximum 500 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (description && (typeof description !== 'string' || description.length > 1000)) {
|
||||
console.warn(`Failed to add location: Invalid description length (${description?.length || 0})`);
|
||||
res.status(400).json({ error: 'Description must be a string with maximum 1000 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate latitude if provided
|
||||
if (latitude !== undefined && (typeof latitude !== 'number' || latitude < -90 || latitude > 90)) {
|
||||
console.warn(`Failed to add location: Invalid latitude (${latitude})`);
|
||||
res.status(400).json({ error: 'Latitude must be a number between -90 and 90' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate longitude if provided
|
||||
if (longitude !== undefined && (typeof longitude !== 'number' || longitude < -180 || longitude > 180)) {
|
||||
console.warn(`Failed to add location: Invalid longitude (${longitude})`);
|
||||
res.status(400).json({ error: 'Longitude must be a number between -180 and 180' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Log suspicious activity
|
||||
if (address.length > 200 || (description && description.length > 500)) {
|
||||
console.warn(`Unusually long submission from IP: ${req.ip} - Address: ${address.length} chars, Description: ${description?.length || 0} chars`);
|
||||
}
|
||||
|
||||
// Check for profanity in description and reject if any is found
|
||||
if (description && profanityFilter) {
|
||||
try {
|
||||
|
@ -178,7 +220,7 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
|
|||
|
||||
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"`,
|
||||
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,
|
||||
|
@ -212,24 +254,8 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
|
|||
}
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
// DELETE functionality has been moved to admin-only routes for security.
|
||||
// Use /api/admin/locations/:id (with authentication) for location deletion.
|
||||
|
||||
return router;
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue