Merge pull request #9 from derekslenk/feature/typescript-conversion
Add comprehensive TypeScript support and conversion
This commit is contained in:
commit
13c0b8b457
14 changed files with 1993 additions and 25 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
@ -30,3 +30,12 @@ Thumbs.db
|
|||
# Generated files
|
||||
public/style.css
|
||||
public/style.css.map
|
||||
|
||||
# TypeScript build outputs
|
||||
dist/
|
||||
|
||||
# TypeScript compiled files in src (should only be in dist/)
|
||||
src/**/*.js
|
||||
src/**/*.js.map
|
||||
src/**/*.d.ts
|
||||
src/**/*.d.ts.map
|
||||
|
|
57
CLAUDE.md
57
CLAUDE.md
|
@ -9,18 +9,31 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start the server (production mode)
|
||||
# Start the server (production mode - TypeScript)
|
||||
npm start
|
||||
|
||||
# Start with auto-reload (development mode)
|
||||
npm run dev
|
||||
|
||||
# Development with CSS watching (recommended for frontend work)
|
||||
npm run dev-with-css
|
||||
# Development mode options:
|
||||
npm run dev # TypeScript development with auto-reload
|
||||
npm run dev:js # Legacy JavaScript development mode
|
||||
npm run dev-with-css:ts # TypeScript + CSS watching (recommended)
|
||||
npm run dev-with-css # Legacy JS + CSS watching
|
||||
```
|
||||
|
||||
The application runs on port 3000 by default. Visit http://localhost:3000 to view the website.
|
||||
|
||||
### TypeScript Development
|
||||
The backend is written in TypeScript and compiles to `dist/` directory.
|
||||
```bash
|
||||
# Build TypeScript (production)
|
||||
npm run build:ts
|
||||
|
||||
# Build everything (TypeScript + CSS)
|
||||
npm run build
|
||||
|
||||
# Development with TypeScript watching
|
||||
npm run dev:ts
|
||||
```
|
||||
|
||||
### CSS Development
|
||||
CSS is generated from SCSS and should NOT be committed to git.
|
||||
```bash
|
||||
|
@ -57,19 +70,27 @@ Required environment variables:
|
|||
|
||||
## Architecture Overview
|
||||
|
||||
### Backend (Node.js/Express)
|
||||
- **server.js**: Main Express server with modular route architecture
|
||||
### Backend (Node.js/Express + TypeScript)
|
||||
- **src/server.ts**: Main Express server with modular route architecture (compiles to `dist/server.js`)
|
||||
- Uses two SQLite databases: `icewatch.db` (locations) and `profanity.db` (content moderation)
|
||||
- Automatic cleanup of reports older than 48 hours via node-cron
|
||||
- Bearer token authentication for admin endpoints
|
||||
- Environment variable configuration via dotenv
|
||||
- Full TypeScript with strict type checking
|
||||
|
||||
### Route Architecture
|
||||
Routes are organized as factory functions accepting dependencies:
|
||||
Routes are organized as factory functions accepting dependencies with full TypeScript typing:
|
||||
|
||||
- **routes/config.js**: Public API configuration endpoints
|
||||
- **routes/locations.js**: Location submission and retrieval with profanity filtering
|
||||
- **routes/admin.js**: Admin panel functionality with authentication middleware
|
||||
- **src/routes/config.ts**: Public API configuration endpoints
|
||||
- **src/routes/locations.ts**: Location submission and retrieval with profanity filtering
|
||||
- **src/routes/admin.ts**: Admin panel functionality with authentication middleware
|
||||
|
||||
### Models & Services (TypeScript)
|
||||
- **src/models/Location.ts**: Type-safe database operations for location data
|
||||
- **src/models/ProfanityWord.ts**: Type-safe database operations for profanity words
|
||||
- **src/services/DatabaseService.ts**: Centralized database connection management
|
||||
- **src/services/ProfanityFilterService.ts**: Content moderation with type safety
|
||||
- **src/types/index.ts**: Shared TypeScript interfaces and type definitions
|
||||
|
||||
### Database Schema
|
||||
**Main Database (`icewatch.db`)**:
|
||||
|
@ -131,11 +152,13 @@ SCSS files are in `src/scss/`:
|
|||
|
||||
### Key Design Patterns
|
||||
|
||||
1. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
|
||||
2. **Dual Database Design**: Separate databases for application data and content moderation
|
||||
3. **Graceful Degradation**: Fallback geocoding providers and error handling
|
||||
4. **Automated Maintenance**: Cron-based cleanup of expired reports
|
||||
5. **Security**: Server-side content filtering, environment-based configuration
|
||||
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
|
||||
|
||||
### Deployment
|
||||
- Automated deployment script for Debian 12 ARM64 in `scripts/deploy.sh`
|
||||
|
|
319
package-lock.json
generated
319
package-lock.json
generated
|
@ -17,11 +17,18 @@
|
|||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/sqlite3": "^3.1.11",
|
||||
"concurrently": "^9.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"nodemon": "^3.1.10",
|
||||
"sass": "^1.89.2",
|
||||
"supertest": "^6.3.4"
|
||||
"supertest": "^6.3.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
|
@ -621,6 +628,30 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@gar/promisify": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
|
||||
|
@ -1396,6 +1427,34 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
@ -1441,6 +1500,62 @@
|
|||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
|
||||
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz",
|
||||
"integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/graceful-fs": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||
|
@ -1451,6 +1566,13 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
|
@ -1478,6 +1600,13 @@
|
|||
"@types/istanbul-lib-report": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz",
|
||||
|
@ -1488,6 +1617,60 @@
|
|||
"undici-types": "~7.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
|
||||
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sqlite3": {
|
||||
"version": "3.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.11.tgz",
|
||||
"integrity": "sha512-KYF+QgxAnnAh7DWPdNDroxkDI3/MspH1NMx6m/N/6fT1G6+jvsw4/ZePt8R8cr7ta58aboeTfYFBDxTJ5yv15w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
|
@ -1532,6 +1715,32 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
|
@ -1675,6 +1884,13 @@
|
|||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
|
@ -2483,6 +2699,13 @@
|
|||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
@ -2622,6 +2845,16 @@
|
|||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-sequences": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||
|
@ -4584,6 +4817,13 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/make-fetch-happen": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz",
|
||||
|
@ -6458,6 +6698,50 @@
|
|||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
|
@ -6513,6 +6797,20 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
|
@ -6611,6 +6909,13 @@
|
|||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
|
@ -6754,6 +7059,16 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
23
package.json
23
package.json
|
@ -2,15 +2,19 @@
|
|||
"name": "great-lakes-ice-report",
|
||||
"version": "1.0.0",
|
||||
"description": "Great Lakes Ice Report - Community-driven winter road conditions tracker for Michigan",
|
||||
"main": "server.js",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"start": "npm run build && node server.js",
|
||||
"dev": "npm run build-css && nodemon server.js",
|
||||
"start": "npm run build && node dist/server.js",
|
||||
"dev": "npm run build-css && npm run dev:ts",
|
||||
"dev:ts": "concurrently \"tsc --watch\" \"nodemon dist/server.js\"",
|
||||
"dev:js": "npm run build-css && nodemon server.js",
|
||||
"build-css": "sass src/scss/main.scss public/style.css --style=compressed",
|
||||
"build-css:dev": "sass src/scss/main.scss public/style.css --style=expanded --source-map",
|
||||
"watch-css": "sass src/scss/main.scss public/style.css --watch --style=expanded --source-map",
|
||||
"dev-with-css": "concurrently \"npm run watch-css\" \"npm run dev\"",
|
||||
"build": "npm run build-css",
|
||||
"dev-with-css:ts": "concurrently \"npm run watch-css\" \"npm run dev:ts\"",
|
||||
"build": "npm run build:ts && npm run build-css",
|
||||
"build:ts": "tsc",
|
||||
"test": "jest --runInBand --forceExit",
|
||||
"test:coverage": "jest --coverage",
|
||||
"postinstall": "npm run build-css"
|
||||
|
@ -23,11 +27,18 @@
|
|||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/sqlite3": "^3.1.11",
|
||||
"concurrently": "^9.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"nodemon": "^3.1.10",
|
||||
"sass": "^1.89.2",
|
||||
"supertest": "^6.3.4"
|
||||
"supertest": "^6.3.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ice",
|
||||
|
|
162
src/models/Location.ts
Normal file
162
src/models/Location.ts
Normal file
|
@ -0,0 +1,162 @@
|
|||
import { Database } from 'sqlite3';
|
||||
import { Location as LocationInterface } from '../types';
|
||||
|
||||
export interface LocationCreateInput {
|
||||
address: string;
|
||||
latitude?: number | undefined;
|
||||
longitude?: number | undefined;
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
export interface LocationUpdateInput {
|
||||
address?: string | undefined;
|
||||
latitude?: number | undefined;
|
||||
longitude?: number | undefined;
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
export interface DatabaseResult {
|
||||
changes: number;
|
||||
}
|
||||
|
||||
export interface LocationCreatedResult {
|
||||
id: number;
|
||||
address: string;
|
||||
latitude?: number | undefined;
|
||||
longitude?: number | undefined;
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
class Location {
|
||||
private db: Database;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async getActive(hoursThreshold: number = 48): Promise<LocationInterface[]> {
|
||||
const cutoffTime = new Date(Date.now() - hoursThreshold * 60 * 60 * 1000).toISOString();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(
|
||||
'SELECT * FROM locations WHERE created_at > ? OR persistent = 1 ORDER BY created_at DESC',
|
||||
[cutoffTime],
|
||||
(err: Error | null, rows: LocationInterface[]) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getAll(): Promise<LocationInterface[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(
|
||||
'SELECT id, address, description, latitude, longitude, persistent, created_at FROM locations ORDER BY created_at DESC',
|
||||
[],
|
||||
(err: Error | null, rows: LocationInterface[]) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async create(location: LocationCreateInput): Promise<LocationCreatedResult> {
|
||||
const { address, latitude, longitude, description } = location;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'INSERT INTO locations (address, latitude, longitude, description) VALUES (?, ?, ?, ?)',
|
||||
[address, latitude, longitude, description],
|
||||
function(this: { lastID: number }, err: Error | null) {
|
||||
if (err) return reject(err);
|
||||
resolve({ id: this.lastID, ...location });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, location: LocationUpdateInput): Promise<DatabaseResult> {
|
||||
const { address, latitude, longitude, description } = location;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'UPDATE locations SET address = ?, latitude = ?, longitude = ?, description = ? WHERE id = ?',
|
||||
[address, latitude, longitude, description, id],
|
||||
function(this: { changes: number }, err: Error | null) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async togglePersistent(id: number, persistent: boolean): Promise<DatabaseResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'UPDATE locations SET persistent = ? WHERE id = ?',
|
||||
[persistent ? 1 : 0, id],
|
||||
function(this: { changes: number }, err: Error | null) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<DatabaseResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'DELETE FROM locations WHERE id = ?',
|
||||
[id],
|
||||
function(this: { changes: number }, err: Error | null) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async cleanupExpired(hoursThreshold: number = 48): Promise<DatabaseResult> {
|
||||
const cutoffTime = new Date(Date.now() - hoursThreshold * 60 * 60 * 1000).toISOString();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'DELETE FROM locations WHERE created_at < ? AND persistent = 0',
|
||||
[cutoffTime],
|
||||
function(this: { changes: number }, err: Error | null) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async initializeTable(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.serialize(() => {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
address TEXT NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err: Error | null) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
this.db.run(`
|
||||
ALTER TABLE locations ADD COLUMN persistent INTEGER DEFAULT 0
|
||||
`, (err: Error | null) => {
|
||||
if (err && !err.message.includes('duplicate column name')) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Location;
|
137
src/models/ProfanityWord.ts
Normal file
137
src/models/ProfanityWord.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
import { Database } from 'sqlite3';
|
||||
import { ProfanityWord as ProfanityWordInterface } from '../types';
|
||||
|
||||
export interface ProfanityWordCreateInput {
|
||||
word: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
category: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface ProfanityWordUpdateInput {
|
||||
word: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface ProfanityWordLoadResult {
|
||||
word: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface DatabaseResult {
|
||||
changes: number;
|
||||
}
|
||||
|
||||
export interface ProfanityWordCreatedResult extends ProfanityWordInterface {
|
||||
id: number;
|
||||
}
|
||||
|
||||
class ProfanityWord {
|
||||
private db: Database;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async getAll(): Promise<ProfanityWordInterface[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(
|
||||
'SELECT id, word, severity, category, created_at, created_by FROM profanity_words ORDER BY created_at DESC',
|
||||
[],
|
||||
(err: Error | null, rows: ProfanityWordInterface[]) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async loadWords(): Promise<ProfanityWordLoadResult[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(
|
||||
'SELECT word, severity, category FROM profanity_words',
|
||||
[],
|
||||
(err: Error | null, rows: ProfanityWordLoadResult[]) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
word: string,
|
||||
severity: 'low' | 'medium' | 'high',
|
||||
category: string,
|
||||
createdBy: string = 'admin'
|
||||
): Promise<ProfanityWordCreatedResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'INSERT INTO profanity_words (word, severity, category, created_by) VALUES (?, ?, ?, ?)',
|
||||
[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,
|
||||
category
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
word: string,
|
||||
severity: 'low' | 'medium' | 'high',
|
||||
category: string
|
||||
): Promise<DatabaseResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'UPDATE profanity_words SET word = ?, severity = ?, category = ? WHERE id = ?',
|
||||
[word.toLowerCase(), severity, category, id],
|
||||
function(this: { changes: number }, err: Error | null) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<DatabaseResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'DELETE FROM profanity_words WHERE id = ?',
|
||||
[id],
|
||||
function(this: { changes: number }, err: Error | null) {
|
||||
if (err) return reject(err);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async initializeTable(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS profanity_words (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
word TEXT NOT NULL UNIQUE,
|
||||
severity TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT DEFAULT 'system'
|
||||
)
|
||||
`, (err: Error | null) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfanityWord;
|
313
src/routes/admin.ts
Normal file
313
src/routes/admin.ts
Normal file
|
@ -0,0 +1,313 @@
|
|||
import express, { Request, Response, Router, NextFunction } from 'express';
|
||||
import Location from '../models/Location';
|
||||
import ProfanityWord from '../models/ProfanityWord';
|
||||
import ProfanityFilterService from '../services/ProfanityFilterService';
|
||||
|
||||
// Define interfaces for request bodies and parameters
|
||||
interface AdminLoginRequest extends Request {
|
||||
body: {
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocationUpdateRequest extends Request {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
body: {
|
||||
address: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocationPersistentRequest extends Request {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
body: {
|
||||
persistent: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocationDeleteRequest extends Request {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProfanityWordCreateRequest extends Request {
|
||||
body: {
|
||||
word: string;
|
||||
severity?: 'low' | 'medium' | 'high';
|
||||
category?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProfanityWordUpdateRequest extends Request {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
body: {
|
||||
word: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
category: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProfanityWordDeleteRequest extends Request {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProfanityTestRequest extends Request {
|
||||
body: {
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Type for the authentication middleware
|
||||
type AuthMiddleware = (req: Request, res: Response, next: NextFunction) => void;
|
||||
|
||||
export default (
|
||||
locationModel: Location,
|
||||
profanityWordModel: ProfanityWord,
|
||||
profanityFilter: ProfanityFilterService | any,
|
||||
authenticateAdmin: AuthMiddleware
|
||||
): Router => {
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all locations for admin (including expired ones)
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
};
|
23
src/routes/config.ts
Normal file
23
src/routes/config.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import express, { Request, Response, Router } from 'express';
|
||||
|
||||
export default (): Router => {
|
||||
const router = express.Router();
|
||||
|
||||
// Get API configuration
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
118
src/routes/locations.ts
Normal file
118
src/routes/locations.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import express, { Request, Response, Router } from 'express';
|
||||
import Location from '../models/Location';
|
||||
import ProfanityFilterService from '../services/ProfanityFilterService';
|
||||
import { LocationSubmission } from '../types';
|
||||
|
||||
// Define interfaces for request bodies
|
||||
interface LocationPostRequest extends Request {
|
||||
body: {
|
||||
address: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocationDeleteRequest extends Request {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => {
|
||||
const router = express.Router();
|
||||
|
||||
// Get all active locations (within 48 hours OR persistent)
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add a new location
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
257
src/server.ts
Normal file
257
src/server.ts
Normal file
|
@ -0,0 +1,257 @@
|
|||
import dotenv from 'dotenv';
|
||||
import express, { Request, Response, NextFunction, Application } from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import cron from 'node-cron';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: '.env.local' });
|
||||
dotenv.config();
|
||||
|
||||
// Import services and models
|
||||
import DatabaseService from './services/DatabaseService';
|
||||
import ProfanityFilterService from './services/ProfanityFilterService';
|
||||
|
||||
// Import route modules
|
||||
import configRoutes from './routes/config';
|
||||
import locationRoutes from './routes/locations';
|
||||
import adminRoutes from './routes/admin';
|
||||
|
||||
// Import types
|
||||
import { Location, ProfanityWord } from './types';
|
||||
|
||||
const app: Application = express();
|
||||
const PORT: number = parseInt(process.env.PORT || '3000', 10);
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Database and services setup
|
||||
const databaseService = new DatabaseService();
|
||||
let profanityFilter: ProfanityFilterService | FallbackFilter;
|
||||
|
||||
// Fallback filter interface for type safety
|
||||
interface FallbackFilter {
|
||||
containsProfanity(): boolean;
|
||||
analyzeProfanity(text: string): {
|
||||
hasProfanity: boolean;
|
||||
matches: any[];
|
||||
severity: string;
|
||||
count: number;
|
||||
filtered: string;
|
||||
};
|
||||
filterProfanity(text: string): string;
|
||||
addCustomWord(word: string, severity: string, category: string, createdBy?: string): Promise<any>;
|
||||
removeCustomWord(wordId: number): Promise<any>;
|
||||
updateCustomWord(wordId: number, updates: any): Promise<any>;
|
||||
getCustomWords(): Promise<any[]>;
|
||||
loadCustomWords(): Promise<void>;
|
||||
getAllWords(): any[];
|
||||
getSeverity(): string;
|
||||
getSeverityLevel(): number;
|
||||
getSeverityName(): string;
|
||||
normalizeText(text: string): string;
|
||||
buildPatterns(): any[];
|
||||
close(): void;
|
||||
_isFallback: boolean;
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
// 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!');
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Run cleanup every hour
|
||||
console.log('Scheduling hourly cleanup task');
|
||||
cron.schedule('0 * * * *', cleanupExpiredLocations);
|
||||
|
||||
// Configuration
|
||||
const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123'; // Change this!
|
||||
|
||||
// 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();
|
||||
};
|
||||
|
||||
// Setup routes after database and profanity filter are initialized
|
||||
function setupRoutes(): void {
|
||||
const locationModel = databaseService.getLocationModel();
|
||||
const profanityWordModel = databaseService.getProfanityWordModel();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Close database service
|
||||
databaseService.close();
|
||||
console.log('Database connections closed.');
|
||||
process.exit(0);
|
||||
});
|
99
src/services/DatabaseService.ts
Normal file
99
src/services/DatabaseService.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import sqlite3, { Database } from 'sqlite3';
|
||||
import path from 'path';
|
||||
import Location from '../models/Location';
|
||||
import ProfanityWord from '../models/ProfanityWord';
|
||||
|
||||
class DatabaseService {
|
||||
private mainDb: Database | null = null;
|
||||
private profanityDb: Database | null = null;
|
||||
private locationModel: Location | null = null;
|
||||
private profanityWordModel: ProfanityWord | null = null;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.initializeMainDatabase();
|
||||
await this.initializeProfanityDatabase();
|
||||
}
|
||||
|
||||
async initializeMainDatabase(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dbPath = path.join(__dirname, '../../../icewatch.db');
|
||||
this.mainDb = new sqlite3.Database(dbPath, async (err: Error | null) => {
|
||||
if (err) {
|
||||
console.error('Could not connect to main database', err);
|
||||
return reject(err);
|
||||
}
|
||||
console.log('Connected to main SQLite database.');
|
||||
|
||||
if (!this.mainDb) {
|
||||
return reject(new Error('Main database connection failed'));
|
||||
}
|
||||
|
||||
this.locationModel = new Location(this.mainDb);
|
||||
try {
|
||||
await this.locationModel.initializeTable();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async initializeProfanityDatabase(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dbPath = path.join(__dirname, '../../../profanity.db');
|
||||
this.profanityDb = new sqlite3.Database(dbPath, async (err: Error | null) => {
|
||||
if (err) {
|
||||
console.error('Could not connect to profanity database', err);
|
||||
return reject(err);
|
||||
}
|
||||
console.log('Connected to profanity SQLite database.');
|
||||
|
||||
if (!this.profanityDb) {
|
||||
return reject(new Error('Profanity database connection failed'));
|
||||
}
|
||||
|
||||
this.profanityWordModel = new ProfanityWord(this.profanityDb);
|
||||
try {
|
||||
await this.profanityWordModel.initializeTable();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getLocationModel(): Location {
|
||||
if (!this.locationModel) {
|
||||
throw new Error('Database not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.locationModel;
|
||||
}
|
||||
|
||||
getProfanityWordModel(): ProfanityWord {
|
||||
if (!this.profanityWordModel) {
|
||||
throw new Error('Database not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.profanityWordModel;
|
||||
}
|
||||
|
||||
getMainDb(): Database | null {
|
||||
return this.mainDb;
|
||||
}
|
||||
|
||||
getProfanityDb(): Database | null {
|
||||
return this.profanityDb;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.mainDb) {
|
||||
this.mainDb.close();
|
||||
}
|
||||
if (this.profanityDb) {
|
||||
this.profanityDb.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DatabaseService;
|
404
src/services/ProfanityFilterService.ts
Normal file
404
src/services/ProfanityFilterService.ts
Normal file
|
@ -0,0 +1,404 @@
|
|||
/**
|
||||
* Refactored Profanity Filter Service that uses the ProfanityWord model
|
||||
*/
|
||||
|
||||
import ProfanityWord from '../models/ProfanityWord';
|
||||
|
||||
interface CustomWord {
|
||||
word: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface ProfanityPattern {
|
||||
word: string;
|
||||
pattern: RegExp;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface ProfanityMatch {
|
||||
word: string;
|
||||
found: string;
|
||||
index: number;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface ProfanityAnalysis {
|
||||
hasProfanity: boolean;
|
||||
matches: ProfanityMatch[];
|
||||
severity: 'none' | 'low' | 'medium' | 'high';
|
||||
count: number;
|
||||
filtered: string;
|
||||
}
|
||||
|
||||
interface LeetMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface CategoryMap {
|
||||
[key: string]: string[];
|
||||
}
|
||||
|
||||
class ProfanityFilterService {
|
||||
private profanityWordModel: ProfanityWord;
|
||||
private isInitialized: boolean = false;
|
||||
private baseProfanityWords: string[];
|
||||
private leetMap: LeetMap;
|
||||
private customWords: CustomWord[] = [];
|
||||
private patterns: ProfanityPattern[] | null = null;
|
||||
|
||||
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'
|
||||
];
|
||||
|
||||
// Leetspeak and common substitutions
|
||||
this.leetMap = {
|
||||
'0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '6': 'g', '7': 't',
|
||||
'8': 'b', '9': 'g', '@': 'a', '$': 's', '!': 'i', '+': 't', '*': 'a',
|
||||
'%': 'a', '(': 'c', ')': 'c', '&': 'a', '#': 'h', '|': 'l', '\\': '/'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the filter by loading custom words
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.loadCustomWords();
|
||||
this.isInitialized = true;
|
||||
console.log('ProfanityFilterService initialization completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error during ProfanityFilterService initialization:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load custom words from database using the model
|
||||
*/
|
||||
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) {
|
||||
console.error('Error loading custom profanity words:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build regex patterns for all profanity words
|
||||
*/
|
||||
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, '\\$&');
|
||||
const pattern = escaped
|
||||
.split('')
|
||||
.map(char => {
|
||||
const leetChars = Object.entries(this.leetMap)
|
||||
.filter(([_, v]) => v === char.toLowerCase())
|
||||
.map(([k, _]) => k);
|
||||
|
||||
if (leetChars.length > 0) {
|
||||
const allChars = [char, ...leetChars].map(c =>
|
||||
c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
);
|
||||
return `[${allChars.join('')}]`;
|
||||
}
|
||||
return char;
|
||||
})
|
||||
.join('[\\s\\-\\_\\*\\.]*');
|
||||
|
||||
return {
|
||||
word: word,
|
||||
pattern: new RegExp(`\\b${pattern}\\b`, 'gi'),
|
||||
severity: this.getSeverity(word),
|
||||
category: this.getCategory(word)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level for a word
|
||||
*/
|
||||
getSeverity(word: string): 'low' | 'medium' | 'high' {
|
||||
// Check custom words first
|
||||
const customWord = this.customWords.find(w => w.word === word.toLowerCase());
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category for a word
|
||||
*/
|
||||
getCategory(word: string): string {
|
||||
// Check custom words first
|
||||
const customWord = this.customWords.find(w => w.word === word.toLowerCase());
|
||||
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'],
|
||||
sexual: ['penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum', 'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate'],
|
||||
violence: ['kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape', 'violence', 'assault', 'attack'],
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize text for checking
|
||||
*/
|
||||
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 =>
|
||||
this.leetMap[char] || char
|
||||
).join('');
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains profanity
|
||||
*/
|
||||
containsProfanity(text: string): boolean {
|
||||
if (!text || !this.patterns) return false;
|
||||
|
||||
const normalized = this.normalizeText(text);
|
||||
return this.patterns.some(({ pattern }) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze text for profanity with detailed results
|
||||
*/
|
||||
analyzeProfanity(text: string): ProfanityAnalysis {
|
||||
if (!text || !this.patterns) {
|
||||
return {
|
||||
hasProfanity: false,
|
||||
matches: [],
|
||||
severity: 'none',
|
||||
count: 0,
|
||||
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,
|
||||
found: match[0],
|
||||
index: match.index,
|
||||
severity: severity,
|
||||
category: category
|
||||
});
|
||||
|
||||
// Replace in filtered text
|
||||
const replacement = '*'.repeat(match[0].length);
|
||||
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) {
|
||||
if (matches.some(m => m.severity === 'high')) {
|
||||
overallSeverity = 'high';
|
||||
} else if (matches.some(m => m.severity === 'medium')) {
|
||||
overallSeverity = 'medium';
|
||||
} else {
|
||||
overallSeverity = 'low';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasProfanity: matches.length > 0,
|
||||
matches: matches,
|
||||
severity: overallSeverity,
|
||||
count: matches.length,
|
||||
filtered: filteredText
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter profanity from text
|
||||
*/
|
||||
filterProfanity(text: string, replacementChar: string = '*'): string {
|
||||
const analysis = this.analyzeProfanity(text);
|
||||
return analysis.filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom word using the model
|
||||
*/
|
||||
async addCustomWord(
|
||||
word: string,
|
||||
severity: 'low' | 'medium' | 'high' = 'medium',
|
||||
category: string = 'custom',
|
||||
createdBy: string = 'admin'
|
||||
): Promise<any> {
|
||||
try {
|
||||
const result = await this.profanityWordModel.create(word, severity, category, createdBy);
|
||||
await this.loadCustomWords(); // Reload to update patterns
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error('Word already exists in the filter');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a custom word using the model
|
||||
*/
|
||||
async removeCustomWord(wordId: number): Promise<{ deleted: boolean; changes: number }> {
|
||||
const result = await this.profanityWordModel.delete(wordId);
|
||||
if (result.changes === 0) {
|
||||
throw new Error('Word not found');
|
||||
}
|
||||
await this.loadCustomWords(); // Reload to update patterns
|
||||
return { deleted: true, changes: result.changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all custom words using the model
|
||||
*/
|
||||
async getCustomWords(): Promise<any[]> {
|
||||
return await this.profanityWordModel.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a custom word using the model
|
||||
*/
|
||||
async updateCustomWord(wordId: number, updates: {
|
||||
word: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
category: string;
|
||||
}): Promise<{ updated: boolean; changes: number }> {
|
||||
const { word, severity, category } = updates;
|
||||
const result = await this.profanityWordModel.update(wordId, word, severity, category);
|
||||
if (result.changes === 0) {
|
||||
throw new Error('Word not found');
|
||||
}
|
||||
await this.loadCustomWords(); // Reload to update patterns
|
||||
return { updated: true, changes: result.changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close method for cleanup
|
||||
*/
|
||||
close(): void {
|
||||
// This service doesn't maintain its own database connection
|
||||
// so no cleanup is needed
|
||||
}
|
||||
|
||||
// Utility methods for compatibility
|
||||
getAllWords(): string[] {
|
||||
return [...this.baseProfanityWords, ...this.customWords.map(w => w.word)];
|
||||
}
|
||||
|
||||
getSeverityLevel(): number {
|
||||
return 0; // Default severity level
|
||||
}
|
||||
|
||||
getSeverityName(): string {
|
||||
return 'none'; // Default severity name
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfanityFilterService;
|
64
src/types/index.ts
Normal file
64
src/types/index.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
// Global type definitions for the Ice Report application
|
||||
|
||||
export interface Location {
|
||||
id?: number;
|
||||
address: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
timestamp?: string;
|
||||
description?: string;
|
||||
persistent?: number; // SQLite boolean (0 or 1)
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ProfanityWord {
|
||||
id?: number;
|
||||
word: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
category?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface LocationSubmission {
|
||||
address: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success?: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AdminLoginRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
token: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GeocodeResult {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
mainDbPath: string;
|
||||
profanityDbPath: string;
|
||||
}
|
||||
|
||||
// Express request extensions
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: {
|
||||
id: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"exactOptionalPropertyTypes": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"types/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"public",
|
||||
"scripts"
|
||||
]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue