From c4cf921a546ce46ef7b8858dbe0207c5f1f5614b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 5 Jul 2025 21:15:29 -0400 Subject: [PATCH] Add comprehensive TypeScript support and conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert entire backend to TypeScript with strict type checking - Add comprehensive type definitions and interfaces - Create typed models for Location and ProfanityWord with database operations - Convert all services to TypeScript (DatabaseService, ProfanityFilterService) - Convert all API routes with proper request/response typing - Add TypeScript build system and development scripts - Update package.json with TypeScript dependencies and scripts - Configure tsconfig.json with strict typing and build settings - Update CLAUDE.md documentation for TypeScript development - Add .gitignore rules for TypeScript build artifacts Architecture improvements: - Full type safety throughout the application - Typed database operations and API endpoints - Proper error handling with typed exceptions - Strict optional property handling - Type-safe dependency injection for routes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .gitignore | 9 + CLAUDE.md | 57 ++-- package-lock.json | 319 ++++++++++++++++++- package.json | 23 +- src/models/Location.ts | 162 ++++++++++ src/models/ProfanityWord.ts | 137 +++++++++ src/routes/admin.ts | 313 +++++++++++++++++++ src/routes/config.ts | 23 ++ src/routes/locations.ts | 118 ++++++++ src/server.ts | 257 ++++++++++++++++ src/services/DatabaseService.ts | 99 ++++++ src/services/ProfanityFilterService.ts | 404 +++++++++++++++++++++++++ src/types/index.ts | 64 ++++ tsconfig.json | 33 ++ 14 files changed, 1993 insertions(+), 25 deletions(-) create mode 100644 src/models/Location.ts create mode 100644 src/models/ProfanityWord.ts create mode 100644 src/routes/admin.ts create mode 100644 src/routes/config.ts create mode 100644 src/routes/locations.ts create mode 100644 src/server.ts create mode 100644 src/services/DatabaseService.ts create mode 100644 src/services/ProfanityFilterService.ts create mode 100644 src/types/index.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index d91cf4c..d3abd5e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 8a607c8..e8e89e7 100644 --- a/CLAUDE.md +++ b/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` diff --git a/package-lock.json b/package-lock.json index 852b6cd..f4c2073 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a370751..8c3b456 100644 --- a/package.json +++ b/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", diff --git a/src/models/Location.ts b/src/models/Location.ts new file mode 100644 index 0000000..be37bb6 --- /dev/null +++ b/src/models/Location.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; \ No newline at end of file diff --git a/src/models/ProfanityWord.ts b/src/models/ProfanityWord.ts new file mode 100644 index 0000000..10020d5 --- /dev/null +++ b/src/models/ProfanityWord.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; \ No newline at end of file diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..88bd552 --- /dev/null +++ b/src/routes/admin.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; +}; \ No newline at end of file diff --git a/src/routes/config.ts b/src/routes/config.ts new file mode 100644 index 0000000..6032f7f --- /dev/null +++ b/src/routes/config.ts @@ -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; +}; \ No newline at end of file diff --git a/src/routes/locations.ts b/src/routes/locations.ts new file mode 100644 index 0000000..8ed8986 --- /dev/null +++ b/src/routes/locations.ts @@ -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 => { + 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 => { + 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 => { + 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; +}; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..e2c140d --- /dev/null +++ b/src/server.ts @@ -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; + removeCustomWord(wordId: number): Promise; + updateCustomWord(wordId: number, updates: any): Promise; + getCustomWords(): Promise; + loadCustomWords(): Promise; + 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 => [], + loadCustomWords: async (): Promise => {}, + + // 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 { + 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 => { + 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 { + 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); +}); \ No newline at end of file diff --git a/src/services/DatabaseService.ts b/src/services/DatabaseService.ts new file mode 100644 index 0000000..69e38df --- /dev/null +++ b/src/services/DatabaseService.ts @@ -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 { + await this.initializeMainDatabase(); + await this.initializeProfanityDatabase(); + } + + async initializeMainDatabase(): Promise { + 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 { + 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; \ No newline at end of file diff --git a/src/services/ProfanityFilterService.ts b/src/services/ProfanityFilterService.ts new file mode 100644 index 0000000..3221894 --- /dev/null +++ b/src/services/ProfanityFilterService.ts @@ -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 { + 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 { + 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 { + 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 { + 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; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..1ad1594 --- /dev/null +++ b/src/types/index.ts @@ -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 { + 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; + }; + } + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3bbb83d --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} \ No newline at end of file