From 30fdd72cc5594fb651ac00919d854a6993a2e2c2 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 5 Jul 2025 22:12:37 -0400 Subject: [PATCH] Add coordinate validation and ESLint integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit latitude/longitude validation in location submissions - Implement ESLint with TypeScript support and flat config - Auto-fix 621 formatting issues across codebase - Add comprehensive tests for coordinate validation - Update documentation with lint scripts and validation rules - Maintain 128 passing tests with enhanced security πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 11 +- README.md | 33 +- eslint.config.mjs | 77 + package-lock.json | 1384 +++++++++++++++++ package.json | 6 + src/models/Location.ts | 2 +- src/models/ProfanityWord.ts | 20 +- src/routes/admin.ts | 450 +++--- src/routes/config.ts | 34 +- src/routes/locations.ts | 218 +-- src/server.ts | 328 ++-- src/services/DatabaseService.ts | 4 +- src/services/ProfanityFilterService.ts | 78 +- src/swagger.ts | 6 +- tests/integration/routes/admin.test.ts | 14 +- tests/integration/routes/public.test.ts | 65 +- tests/setup.ts | 4 +- tests/unit/models/Location.test.ts | 2 +- tests/unit/services/DatabaseService.test.ts | 4 +- .../services/ProfanityFilterService.test.ts | 30 +- 20 files changed, 2171 insertions(+), 599 deletions(-) create mode 100644 eslint.config.mjs diff --git a/CLAUDE.md b/CLAUDE.md index ec31daf..5e95a60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,9 +58,18 @@ npm run build-css:dev npm run watch-css ``` +### Code Quality +```bash +# Run ESLint to check code style and quality +npm run lint + +# Auto-fix ESLint issues where possible +npm run lint:fix +``` + ### Testing ```bash -# Run all tests (125+ tests with TypeScript) +# Run all tests (128+ tests with TypeScript) npm test # Run tests with coverage report (76% overall coverage) diff --git a/README.md b/README.md index 0c93a50..0430af6 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ A community-driven web application for tracking winter road conditions and icy h - πŸ—ΊοΈ **Interactive Map** - Real-time location tracking centered on Grand Rapids - ⚑ **Fast Geocoding** - Lightning-fast address lookup with MapBox API -- πŸ”„ **Auto-Expiration** - Reports automatically removed after 24 hours +- πŸ”„ **Auto-Expiration** - Reports automatically removed after 48 hours - πŸ‘¨β€πŸ’Ό **Admin Panel** - Manage and moderate location reports - πŸ“± **Responsive Design** - Works on desktop and mobile devices - πŸ”’ **Privacy-Focused** - No user tracking, community safety oriented +- πŸ›‘οΈ **Enhanced Security** - Coordinate validation, rate limiting, and content filtering ## Quick Start @@ -39,11 +40,31 @@ A community-driven web application for tracking winter road conditions and icy h 4. **Start the server:** ```bash - npm start # Production mode - npm run dev # Development mode - npm run dev-with-css # Development with CSS watching + npm start # Production mode + npm run dev # Development mode + npm run dev-with-css # Development with CSS watching ``` +## Development Commands + +### Code Quality +```bash +# Run ESLint to check code style and quality +npm run lint + +# Auto-fix ESLint issues where possible +npm run lint:fix +``` + +### Testing +```bash +# Run all tests (128+ tests with TypeScript) +npm test + +# Run tests with coverage report (76% overall coverage) +npm run test:coverage +``` + 5. **Visit the application:** ``` http://localhost:3000 @@ -139,7 +160,7 @@ Interactive API documentation available at `/api-docs` when running the server. - **Frontend:** Vanilla JavaScript, Leaflet.js - **Geocoding:** MapBox API (with Nominatim fallback) - **Security:** Rate limiting, input validation, authentication -- **Testing:** Jest, TypeScript, 125+ tests with 76% coverage +- **Testing:** Jest, TypeScript, 128+ tests with 76% coverage - **Reverse Proxy:** Caddy (automatic HTTPS) - **Database:** SQLite (lightweight, serverless) @@ -165,6 +186,8 @@ Interactive API documentation available at `/api-docs` when running the server. ### Input Limits - **Address:** Maximum 500 characters - **Description:** Maximum 1000 characters +- **Latitude:** Must be between -90 and 90 degrees +- **Longitude:** Must be between -180 and 180 degrees - **Submissions:** 10 per 15 minutes per IP address ## License diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..eb37089 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,77 @@ +import js from '@eslint/js'; +import typescript from '@typescript-eslint/eslint-plugin'; +import typescriptParser from '@typescript-eslint/parser'; +import globals from 'globals'; + +export default [ + { + ignores: ['node_modules/', 'dist/', 'public/*.css', '*.db', '*.scss', '*.md'] + }, + js.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + parser: typescriptParser, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module' + }, + globals: { + ...globals.node + } + }, + plugins: { + '@typescript-eslint': typescript + }, + rules: { + // TypeScript rules + '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/no-explicit-any': 'warn', + + // General rules + 'no-console': 'off', // Allow console.log for server logging + 'no-var': 'error', + 'prefer-const': 'error', + 'eqeqeq': 'error', + 'no-unused-vars': 'off', // Use TypeScript version instead + + // Style rules + 'indent': ['error', 2], + 'quotes': ['error', 'single'], + 'semi': ['error', 'always'], + 'comma-dangle': ['error', 'never'], + 'no-trailing-spaces': 'error' + } + }, + { + files: ['**/*.js'], + languageOptions: { + globals: { + ...globals.node + } + }, + rules: { + 'no-console': 'off', + 'no-var': 'error', + 'prefer-const': 'error', + 'eqeqeq': 'error', + 'indent': ['error', 2], + 'quotes': ['error', 'single'], + 'semi': ['error', 'always'], + 'comma-dangle': ['error', 'never'], + 'no-trailing-spaces': 'error' + } + }, + { + files: ['tests/**/*.ts'], + languageOptions: { + globals: { + ...globals.node, + ...globals.jest + } + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off' // Allow any in tests for mocks + } + } +]; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 97dd532..f7c0947 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,11 @@ "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", + "@typescript-eslint/eslint-plugin": "^8.35.1", + "@typescript-eslint/parser": "^8.35.1", "concurrently": "^9.2.0", + "eslint": "^9.30.1", + "globals": "^16.3.0", "jest": "^29.7.0", "jest-environment-node": "^30.0.4", "nodemon": "^3.1.10", @@ -723,6 +727,253 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -730,6 +981,72 @@ "license": "MIT", "optional": true }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1151,6 +1468,44 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -1666,6 +2021,13 @@ "@types/node": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", @@ -2109,6 +2471,366 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", + "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/type-utils": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.35.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", + "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", + "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.35.1", + "@typescript-eslint/types": "^8.35.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", + "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", + "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", + "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", + "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.35.1", + "@typescript-eslint/tsconfig-utils": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", + "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -2142,6 +2864,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", @@ -2220,6 +2952,23 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3215,6 +3964,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3538,6 +4294,251 @@ "node": ">=8" } }, + "node_modules/eslint": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.30.1", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -3552,6 +4553,42 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3690,6 +4727,30 @@ "express": ">= 4.11" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3697,6 +4758,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -3704,6 +4772,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3714,6 +4792,19 @@ "bser": "2.1.1" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -3798,6 +4889,27 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", @@ -4024,6 +5136,19 @@ "node": ">= 6" } }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4043,6 +5168,13 @@ "devOptional": true, "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -4261,6 +5393,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -4275,6 +5417,33 @@ "dev": true, "license": "MIT" }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -5531,6 +6700,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -5538,6 +6714,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5551,6 +6741,16 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -5571,6 +6771,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5619,6 +6833,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.mergewith": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", @@ -5733,6 +6954,16 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -6217,6 +7448,24 @@ "license": "MIT", "peer": true }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6288,6 +7537,19 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6427,6 +7689,16 @@ "node": ">=10" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -6520,6 +7792,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -6552,6 +7834,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6699,6 +8002,17 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -6716,6 +8030,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -7617,6 +8955,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-jest": { "version": "29.4.0", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", @@ -7746,6 +9097,19 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -7870,6 +9234,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7970,6 +9344,16 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index b665cc1..a28ad46 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "build:ts": "tsc", "test": "jest --runInBand --forceExit", "test:coverage": "jest --coverage", + "lint": "eslint src/ tests/", + "lint:fix": "eslint src/ tests/ --fix", "postinstall": "npm run build-css" }, "dependencies": { @@ -39,7 +41,11 @@ "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", + "@typescript-eslint/eslint-plugin": "^8.35.1", + "@typescript-eslint/parser": "^8.35.1", "concurrently": "^9.2.0", + "eslint": "^9.30.1", + "globals": "^16.3.0", "jest": "^29.7.0", "jest-environment-node": "^30.0.4", "nodemon": "^3.1.10", diff --git a/src/models/Location.ts b/src/models/Location.ts index be37bb6..62a7cad 100644 --- a/src/models/Location.ts +++ b/src/models/Location.ts @@ -144,7 +144,7 @@ class Location { ) `, (err: Error | null) => { if (err) return reject(err); - + this.db.run(` ALTER TABLE locations ADD COLUMN persistent INTEGER DEFAULT 0 `, (err: Error | null) => { diff --git a/src/models/ProfanityWord.ts b/src/models/ProfanityWord.ts index 10020d5..5309b9c 100644 --- a/src/models/ProfanityWord.ts +++ b/src/models/ProfanityWord.ts @@ -62,9 +62,9 @@ class ProfanityWord { } async create( - word: string, - severity: 'low' | 'medium' | 'high', - category: string, + word: string, + severity: 'low' | 'medium' | 'high', + category: string, createdBy: string = 'admin' ): Promise { return new Promise((resolve, reject) => { @@ -73,10 +73,10 @@ class ProfanityWord { [word.toLowerCase(), severity, category, createdBy], function(this: { lastID: number }, err: Error | null) { if (err) return reject(err); - resolve({ - id: this.lastID, - word: word.toLowerCase(), - severity, + resolve({ + id: this.lastID, + word: word.toLowerCase(), + severity, category }); } @@ -85,9 +85,9 @@ class ProfanityWord { } async update( - id: number, - word: string, - severity: 'low' | 'medium' | 'high', + id: number, + word: string, + severity: 'low' | 'medium' | 'high', category: string ): Promise { return new Promise((resolve, reject) => { diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 35239b3..2229f98 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -72,14 +72,14 @@ interface ProfanityTestRequest extends Request { type AuthMiddleware = (req: Request, res: Response, next: NextFunction) => void; export default ( - locationModel: Location, - profanityWordModel: ProfanityWord, - profanityFilter: ProfanityFilterService | any, - authenticateAdmin: AuthMiddleware + locationModel: Location, + profanityWordModel: ProfanityWord, + profanityFilter: ProfanityFilterService | any, + authenticateAdmin: AuthMiddleware ): Router => { - const router = express.Router(); + const router = express.Router(); - /** + /** * @swagger * /api/admin/login: * post: @@ -114,21 +114,21 @@ export default ( * example: * error: "Invalid password" */ - router.post('/login', (req: AdminLoginRequest, res: Response): void => { - console.log('Admin login attempt'); - const { password } = req.body; - const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123'; - - if (password === ADMIN_PASSWORD) { - console.log('Admin login successful'); - res.json({ token: ADMIN_PASSWORD, message: 'Login successful' }); - } else { - console.warn('Admin login failed: invalid password'); - res.status(401).json({ error: 'Invalid password' }); - } - }); + router.post('/login', (req: AdminLoginRequest, res: Response): void => { + console.log('Admin login attempt'); + const { password } = req.body; + const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123'; - /** + if (password === ADMIN_PASSWORD) { + console.log('Admin login successful'); + res.json({ token: ADMIN_PASSWORD, message: 'Login successful' }); + } else { + console.warn('Admin login failed: invalid password'); + res.status(401).json({ error: 'Invalid password' }); + } + }); + + /** * @swagger * /api/admin/locations: * get: @@ -183,219 +183,219 @@ export default ( * schema: * $ref: '#/components/schemas/Error' */ - router.get('/locations', authenticateAdmin, async (req: Request, res: Response): Promise => { - try { - const rows = await locationModel.getAll(); - - // Process and clean data before sending - const locations = rows.map(row => ({ - id: row.id, - address: row.address, - description: row.description || '', - latitude: row.latitude, - longitude: row.longitude, - persistent: !!row.persistent, - created_at: row.created_at, - isActive: new Date(row.created_at || '').getTime() > Date.now() - 48 * 60 * 60 * 1000 - })); - - res.json(locations); - } catch (err) { - console.error('Error fetching all locations:', err); - res.status(500).json({ error: 'Internal server error' }); - } - }); + router.get('/locations', authenticateAdmin, async (req: Request, res: Response): Promise => { + try { + const rows = await locationModel.getAll(); - // 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' }); - } - }); + // Process and clean data before sending + const locations = rows.map(row => ({ + id: row.id, + address: row.address, + description: row.description || '', + latitude: row.latitude, + longitude: row.longitude, + persistent: !!row.persistent, + created_at: row.created_at, + isActive: new Date(row.created_at || '').getTime() > Date.now() - 48 * 60 * 60 * 1000 + })); - // Toggle persistent status of a location (admin only) - router.patch('/locations/:id/persistent', authenticateAdmin, async (req: LocationPersistentRequest, res: Response): Promise => { - const { id } = req.params; - const { persistent } = req.body; - - if (typeof persistent !== 'boolean') { - res.status(400).json({ error: 'Persistent value must be a boolean' }); - return; - } - - try { - const result = await locationModel.togglePersistent(parseInt(id, 10), persistent); - - if (result.changes === 0) { - res.status(404).json({ error: 'Location not found' }); - return; - } - - console.log(`Location ${id} persistent status set to ${persistent}`); - res.json({ message: 'Persistent status updated successfully', persistent }); - } catch (err) { - console.error('Error updating persistent status:', err); - res.status(500).json({ error: 'Internal server error' }); - } - }); + res.json(locations); + } catch (err) { + console.error('Error fetching all locations:', err); + res.status(500).json({ error: 'Internal server error' }); + } + }); - // Delete a location (admin authentication required) - router.delete('/locations/:id', authenticateAdmin, async (req: LocationDeleteRequest, res: Response): Promise => { - const { id } = req.params; - - try { - const result = await locationModel.delete(parseInt(id, 10)); - - if (result.changes === 0) { - res.status(404).json({ error: 'Location not found' }); - return; - } - - res.json({ message: 'Location deleted successfully' }); - } catch (err) { - console.error('Error deleting location:', err); - res.status(500).json({ error: 'Internal server error' }); - } - }); + // Update a location (admin only) + router.put('/locations/:id', authenticateAdmin, async (req: LocationUpdateRequest, res: Response): Promise => { + const { id } = req.params; + const { address, latitude, longitude, description } = req.body; - // Profanity Management Routes + if (!address) { + res.status(400).json({ error: 'Address is required' }); + return; + } - // Get all custom profanity words (admin only) - router.get('/profanity-words', authenticateAdmin, async (req: Request, res: Response): Promise => { - try { - const words = await profanityFilter.getCustomWords(); - res.json(words); - } catch (error) { - console.error('Error fetching custom profanity words:', error); - res.status(500).json({ error: 'Internal server error' }); - } - }); + try { + const result = await locationModel.update(parseInt(id, 10), { + address, + latitude, + longitude, + description + }); - // Add a custom profanity word (admin only) - router.post('/profanity-words', authenticateAdmin, async (req: ProfanityWordCreateRequest, res: Response): Promise => { - try { - const { word, severity = 'medium', category = 'custom' } = req.body; - - if (!word || typeof word !== 'string' || word.trim().length === 0) { - res.status(400).json({ error: 'Word is required and must be a non-empty string' }); - return; - } - - if (!['low', 'medium', 'high'].includes(severity)) { - res.status(400).json({ error: 'Severity must be low, medium, or high' }); - return; - } - - const result = await profanityFilter.addCustomWord(word, severity, category, 'admin'); - await profanityFilter.loadCustomWords(); // Reload to update patterns - - console.log(`Admin added custom profanity word: ${word}`); - res.json(result); - } catch (error: any) { - console.error('Error adding custom profanity word:', error); - if (error.message.includes('already exists')) { - res.status(409).json({ error: error.message }); - } else { - res.status(500).json({ error: 'Internal server error' }); - } - } - }); + if (result.changes === 0) { + res.status(404).json({ error: 'Location not found' }); + return; + } - // Update a custom profanity word (admin only) - router.put('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordUpdateRequest, res: Response): Promise => { - try { - const { id } = req.params; - const { word, severity, category } = req.body; - - if (!word || typeof word !== 'string' || word.trim().length === 0) { - res.status(400).json({ error: 'Word is required and must be a non-empty string' }); - return; - } - - if (!['low', 'medium', 'high'].includes(severity)) { - res.status(400).json({ error: 'Severity must be low, medium, or high' }); - return; - } - - const result = await profanityFilter.updateCustomWord(parseInt(id, 10), { word, severity, category }); - await profanityFilter.loadCustomWords(); // Reload to update patterns - - console.log(`Admin updated custom profanity word ID ${id}`); - res.json(result); - } catch (error: any) { - console.error('Error updating custom profanity word:', error); - if (error.message.includes('not found')) { - res.status(404).json({ error: error.message }); - } else { - res.status(500).json({ error: 'Internal server error' }); - } - } - }); + res.json({ message: 'Location updated successfully' }); + } catch (err) { + console.error('Error updating location:', err); + res.status(500).json({ error: 'Internal server error' }); + } + }); - // Delete a custom profanity word (admin only) - router.delete('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordDeleteRequest, res: Response): Promise => { - try { - const { id } = req.params; - - const result = await profanityFilter.removeCustomWord(parseInt(id, 10)); - await profanityFilter.loadCustomWords(); // Reload to update patterns - - console.log(`Admin deleted custom profanity word ID ${id}`); - res.json(result); - } catch (error: any) { - console.error('Error deleting custom profanity word:', error); - if (error.message.includes('not found')) { - res.status(404).json({ error: error.message }); - } else { - res.status(500).json({ error: 'Internal server error' }); - } - } - }); + // Toggle persistent status of a location (admin only) + router.patch('/locations/:id/persistent', authenticateAdmin, async (req: LocationPersistentRequest, res: Response): Promise => { + const { id } = req.params; + const { persistent } = req.body; - // Test profanity filter (admin only) - for testing purposes - router.post('/test-profanity', authenticateAdmin, (req: ProfanityTestRequest, res: Response): void => { - try { - const { text } = req.body; - - if (!text || typeof text !== 'string') { - res.status(400).json({ error: 'Text is required for testing' }); - return; - } - - const analysis = profanityFilter.analyzeProfanity(text); - res.json({ - original: text, - analysis: analysis, - filtered: analysis.filtered - }); - } catch (error) { - console.error('Error testing profanity filter:', error); - res.status(500).json({ error: 'Internal server error' }); - } - }); + if (typeof persistent !== 'boolean') { + res.status(400).json({ error: 'Persistent value must be a boolean' }); + return; + } - return router; + try { + const result = await locationModel.togglePersistent(parseInt(id, 10), persistent); + + if (result.changes === 0) { + res.status(404).json({ error: 'Location not found' }); + return; + } + + console.log(`Location ${id} persistent status set to ${persistent}`); + res.json({ message: 'Persistent status updated successfully', persistent }); + } catch (err) { + console.error('Error updating persistent status:', err); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + // Delete a location (admin authentication required) + router.delete('/locations/:id', authenticateAdmin, async (req: LocationDeleteRequest, res: Response): Promise => { + 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 index e853142..ab738d6 100644 --- a/src/routes/config.ts +++ b/src/routes/config.ts @@ -1,9 +1,9 @@ import express, { Request, Response, Router } from 'express'; export default (): Router => { - const router = express.Router(); + const router = express.Router(); - /** + /** * @swagger * /api/config: * get: @@ -36,20 +36,20 @@ export default (): Router => { * schema: * $ref: '#/components/schemas/Error' */ - router.get('/', (req: Request, res: Response): void => { - console.log('πŸ“‘ API Config requested'); - const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined; - - console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN); - console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.')); - - res.json({ - // MapBox tokens are designed to be public (they have domain restrictions) - mapboxAccessToken: MAPBOX_ACCESS_TOKEN || null, - hasMapbox: !!MAPBOX_ACCESS_TOKEN - // SECURITY: Google Maps API key is kept server-side only - }); - }); + router.get('/', (req: Request, res: Response): void => { + console.log('πŸ“‘ API Config requested'); + const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined; - return router; + console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN); + console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.')); + + res.json({ + // MapBox tokens are designed to be public (they have domain restrictions) + mapboxAccessToken: MAPBOX_ACCESS_TOKEN || null, + hasMapbox: !!MAPBOX_ACCESS_TOKEN + // SECURITY: Google Maps API key is kept server-side only + }); + }); + + return router; }; \ No newline at end of file diff --git a/src/routes/locations.ts b/src/routes/locations.ts index 980d652..6bf3625 100644 --- a/src/routes/locations.ts +++ b/src/routes/locations.ts @@ -16,24 +16,24 @@ interface LocationPostRequest extends Request { export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => { - const router = express.Router(); + const router = express.Router(); - // Rate limiting for location submissions to prevent abuse - const submitLocationLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - limit: 10, // Limit each IP to 10 location submissions per 15 minutes - message: { - error: 'Too many location reports submitted', - message: 'You can submit up to 10 location reports every 15 minutes. Please wait before submitting more.', - retryAfter: '15 minutes' - }, - standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers - legacyHeaders: false, // Disable the `X-RateLimit-*` headers - // Skip rate limiting in test environment - skip: (req) => process.env.NODE_ENV === 'test' - }); + // Rate limiting for location submissions to prevent abuse + const submitLocationLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + limit: 10, // Limit each IP to 10 location submissions per 15 minutes + message: { + error: 'Too many location reports submitted', + message: 'You can submit up to 10 location reports every 15 minutes. Please wait before submitting more.', + retryAfter: '15 minutes' + }, + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + // Skip rate limiting in test environment + skip: (req) => process.env.NODE_ENV === 'test' + }); - /** + /** * @swagger * /api/locations: * get: @@ -78,20 +78,20 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService * schema: * $ref: '#/components/schemas/Error' */ - router.get('/', async (req: Request, res: Response): Promise => { - console.log('Fetching active locations'); - - try { - const locations = await locationModel.getActive(); - console.log(`Fetched ${locations.length} active locations (including persistent)`); - res.json(locations); - } catch (err) { - console.error('Error fetching locations:', err); - res.status(500).json({ error: 'Internal server error' }); - } - }); + router.get('/', async (req: Request, res: Response): Promise => { + console.log('Fetching active locations'); - /** + try { + const locations = await locationModel.getActive(); + console.log(`Fetched ${locations.length} active locations (including persistent)`); + res.json(locations); + } catch (err) { + console.error('Error fetching locations:', err); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + /** * @swagger * /api/locations: * post: @@ -165,84 +165,98 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService * schema: * $ref: '#/components/schemas/Error' */ - router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise => { - const { address, latitude, longitude } = req.body; - let { description } = req.body; - console.log(`Attempt to add new location: ${address}`); - - // Input validation for security - if (!address) { - console.warn('Failed to add location: Address is required'); - res.status(400).json({ error: 'Address is required' }); - return; - } + router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise => { + const { address, latitude, longitude } = req.body; + const { description } = req.body; + console.log(`Attempt to add new location: ${address}`); - if (typeof address !== 'string' || address.length > 500) { - console.warn(`Failed to add location: Invalid address length (${address?.length || 0})`); - res.status(400).json({ error: 'Address must be a string with maximum 500 characters' }); - return; - } + // Input validation for security + if (!address) { + console.warn('Failed to add location: Address is required'); + res.status(400).json({ error: 'Address is required' }); + return; + } - if (description && (typeof description !== 'string' || description.length > 1000)) { - console.warn(`Failed to add location: Invalid description length (${description?.length || 0})`); - res.status(400).json({ error: 'Description must be a string with maximum 1000 characters' }); - return; - } + if (typeof address !== 'string' || address.length > 500) { + console.warn(`Failed to add location: Invalid address length (${address?.length || 0})`); + res.status(400).json({ error: 'Address must be a string with maximum 500 characters' }); + return; + } - // Log suspicious activity - if (address.length > 200 || (description && description.length > 500)) { - console.warn(`Unusually long submission from IP: ${req.ip} - Address: ${address.length} chars, Description: ${description?.length || 0} chars`); - } - - // Check for profanity in description and reject if any is found - if (description && profanityFilter) { - try { - const analysis = profanityFilter.analyzeProfanity(description); - if (analysis.hasProfanity) { - console.warn(`Submission rejected due to inappropriate language (${analysis.count} word${analysis.count > 1 ? 's' : ''}, severity: ${analysis.severity}) - Original: "${req.body.description}"`); - - // Reject any submission with profanity - const wordText = analysis.count === 1 ? 'word' : 'words'; - const detectedWords = analysis.matches.map((m: any) => m.word).join(', '); - - res.status(400).json({ - error: 'Submission rejected', - message: `Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.\n\nExample: "Multiple vehicles stuck, black ice present" or "Road very slippery, saw 3 accidents"`, - details: { - severity: analysis.severity, - wordCount: analysis.count, - detectedCategories: [...new Set(analysis.matches.map((m: any) => m.category))] - } - }); - return; - } - } catch (filterError) { - console.error('Error checking profanity:', filterError); - // Continue with original description if filter fails + if (description && (typeof description !== 'string' || description.length > 1000)) { + console.warn(`Failed to add location: Invalid description length (${description?.length || 0})`); + res.status(400).json({ error: 'Description must be a string with maximum 1000 characters' }); + return; + } + + // Validate latitude if provided + if (latitude !== undefined && (typeof latitude !== 'number' || latitude < -90 || latitude > 90)) { + console.warn(`Failed to add location: Invalid latitude (${latitude})`); + res.status(400).json({ error: 'Latitude must be a number between -90 and 90' }); + return; + } + + // Validate longitude if provided + if (longitude !== undefined && (typeof longitude !== 'number' || longitude < -180 || longitude > 180)) { + console.warn(`Failed to add location: Invalid longitude (${longitude})`); + res.status(400).json({ error: 'Longitude must be a number between -180 and 180' }); + return; + } + + // Log suspicious activity + if (address.length > 200 || (description && description.length > 500)) { + console.warn(`Unusually long submission from IP: ${req.ip} - Address: ${address.length} chars, Description: ${description?.length || 0} chars`); + } + + // Check for profanity in description and reject if any is found + if (description && profanityFilter) { + try { + const analysis = profanityFilter.analyzeProfanity(description); + if (analysis.hasProfanity) { + console.warn(`Submission rejected due to inappropriate language (${analysis.count} word${analysis.count > 1 ? 's' : ''}, severity: ${analysis.severity}) - Original: "${req.body.description}"`); + + // Reject any submission with profanity + const wordText = analysis.count === 1 ? 'word' : 'words'; + const detectedWords = analysis.matches.map((m: any) => m.word).join(', '); + + res.status(400).json({ + error: 'Submission rejected', + message: 'Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.\n\nExample: "Multiple vehicles stuck, black ice present" or "Road very slippery, saw 3 accidents"', + details: { + severity: analysis.severity, + wordCount: analysis.count, + detectedCategories: [...new Set(analysis.matches.map((m: any) => m.category))] } + }); + return; } - - 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' }); - } - }); + } catch (filterError) { + console.error('Error checking profanity:', filterError); + // Continue with original description if filter fails + } + } - // DELETE functionality has been moved to admin-only routes for security. - // Use /api/admin/locations/:id (with authentication) for location deletion. + try { + const newLocation = await locationModel.create({ + address, + latitude, + longitude, + description + }); - return router; + console.log(`Location added successfully: ${address}`); + res.json({ + ...newLocation, + created_at: new Date().toISOString() + }); + } catch (err) { + console.error('Error inserting location:', err); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + // DELETE functionality has been moved to admin-only routes for security. + // Use /api/admin/locations/:id (with authentication) for location deletion. + + return router; }; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index ec4656e..73105a9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -62,82 +62,82 @@ interface FallbackFilter { // Create fallback filter function function createFallbackFilter(): FallbackFilter { - return { - // Core profanity checking methods - containsProfanity: (): boolean => false, - analyzeProfanity: (text: string) => ({ - hasProfanity: false, - matches: [], - severity: 'none', - count: 0, - filtered: text || '' - }), - filterProfanity: (text: string): string => text || '', - - // Database management methods used by admin routes - addCustomWord: async (word: string, severity: string, category: string, createdBy?: string) => ({ - id: null, - word: word || null, - severity: severity || null, - category: category || null, - createdBy: createdBy || null, - success: false, - error: 'Profanity filter not available - please check server configuration' - }), - removeCustomWord: async (wordId: number) => ({ - success: false, - error: 'Profanity filter not available - please check server configuration' - }), - updateCustomWord: async (wordId: number, updates: any) => ({ - success: false, - error: 'Profanity filter not available - please check server configuration' - }), - getCustomWords: async (): Promise => [], - 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 - }; + 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!'); - } + 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); - } + console.log('Running cleanup of expired locations'); + try { + const locationModel = databaseService.getLocationModel(); + const result = await locationModel.cleanupExpired(); + console.log(`Cleaned up ${result.changes} expired locations (persistent reports preserved)`); + } catch (err) { + console.error('Error cleaning up expired locations:', err); + } }; // Run cleanup every hour @@ -149,96 +149,96 @@ const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123'; // Chan // Authentication middleware const authenticateAdmin = (req: Request, res: Response, next: NextFunction): void => { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } - - const token = authHeader.substring(7); - if (token !== ADMIN_PASSWORD) { - res.status(401).json({ error: 'Invalid credentials' }); - return; - } - - next(); + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const token = authHeader.substring(7); + if (token !== ADMIN_PASSWORD) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + next(); }; // Setup routes after database and profanity filter are initialized function setupRoutes(): void { - const locationModel = databaseService.getLocationModel(); - const profanityWordModel = databaseService.getProfanityWordModel(); - - // API Documentation - app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { - customCss: '.swagger-ui .topbar { display: none }', - customSiteTitle: 'Great Lakes Ice Report API Documentation' - })); - - // API Routes - app.use('/api/config', configRoutes()); - app.use('/api/locations', locationRoutes(locationModel, profanityFilter)); - app.use('/api/admin', adminRoutes(locationModel, profanityWordModel, profanityFilter, authenticateAdmin)); - - // Static page routes - app.get('/', (req: Request, res: Response): void => { - console.log('Serving the main page'); - res.sendFile(path.join(__dirname, '../../public', 'index.html')); - }); - - app.get('/admin', (req: Request, res: Response): void => { - console.log('Serving the admin page'); - res.sendFile(path.join(__dirname, '../../public', 'admin.html')); - }); - - app.get('/privacy', (req: Request, res: Response): void => { - console.log('Serving the privacy policy page'); - res.sendFile(path.join(__dirname, '../../public', 'privacy.html')); - }); + const locationModel = databaseService.getLocationModel(); + const profanityWordModel = databaseService.getProfanityWordModel(); + + // API Documentation + app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'Great Lakes Ice Report API Documentation' + })); + + // API Routes + app.use('/api/config', configRoutes()); + app.use('/api/locations', locationRoutes(locationModel, profanityFilter)); + app.use('/api/admin', adminRoutes(locationModel, profanityWordModel, profanityFilter, authenticateAdmin)); + + // Static page routes + app.get('/', (req: Request, res: Response): void => { + console.log('Serving the main page'); + res.sendFile(path.join(__dirname, '../../public', 'index.html')); + }); + + app.get('/admin', (req: Request, res: Response): void => { + console.log('Serving the admin page'); + res.sendFile(path.join(__dirname, '../../public', 'admin.html')); + }); + + app.get('/privacy', (req: Request, res: Response): void => { + console.log('Serving the privacy policy page'); + res.sendFile(path.join(__dirname, '../../public', 'privacy.html')); + }); } // Async server startup function async function startServer(): Promise { - try { - // Initialize database service first - await databaseService.initialize(); - console.log('Database service initialized successfully'); - - // Initialize profanity filter - await initializeProfanityFilter(); - - // Validate profanity filter is properly initialized - if (!profanityFilter) { - console.error('CRITICAL ERROR: profanityFilter is undefined after initialization attempt.'); - console.error('Cannot start server without a functional profanity filter.'); - process.exit(1); - } - - // Initialize routes after everything is set up - setupRoutes(); - - // Start server - app.listen(PORT, (): void => { - console.log('==========================================='); - console.log('Great Lakes Ice Report server started'); - console.log(`Listening on port ${PORT}`); - console.log(`Visit http://localhost:${PORT} to view the website`); - - // Display profanity filter status - if ('_isFallback' in profanityFilter && profanityFilter._isFallback) { - console.log('🚨 PROFANITY FILTER: DISABLED (FALLBACK MODE)'); - console.log('⚠️ ALL USER CONTENT WILL BE ALLOWED!'); - } else { - console.log('βœ… PROFANITY FILTER: ACTIVE AND FUNCTIONAL'); - } - - console.log('==========================================='); - }); - - } catch (error) { - console.error('CRITICAL ERROR: Failed to start server:', error); - process.exit(1); + try { + // Initialize database service first + await databaseService.initialize(); + console.log('Database service initialized successfully'); + + // Initialize profanity filter + await initializeProfanityFilter(); + + // Validate profanity filter is properly initialized + if (!profanityFilter) { + console.error('CRITICAL ERROR: profanityFilter is undefined after initialization attempt.'); + console.error('Cannot start server without a functional profanity filter.'); + process.exit(1); } + + // Initialize routes after everything is set up + setupRoutes(); + + // Start server + app.listen(PORT, (): void => { + console.log('==========================================='); + console.log('Great Lakes Ice Report server started'); + console.log(`Listening on port ${PORT}`); + console.log(`Visit http://localhost:${PORT} to view the website`); + + // Display profanity filter status + if ('_isFallback' in profanityFilter && profanityFilter._isFallback) { + console.log('🚨 PROFANITY FILTER: DISABLED (FALLBACK MODE)'); + console.log('⚠️ ALL USER CONTENT WILL BE ALLOWED!'); + } else { + console.log('βœ… PROFANITY FILTER: ACTIVE AND FUNCTIONAL'); + } + + console.log('==========================================='); + }); + + } catch (error) { + console.error('CRITICAL ERROR: Failed to start server:', error); + process.exit(1); + } } // Start the server @@ -246,20 +246,20 @@ startServer(); // Graceful shutdown process.on('SIGINT', (): void => { - console.log('\nShutting down server...'); - - // Close profanity filter database first - if (profanityFilter && typeof profanityFilter.close === 'function') { - try { - profanityFilter.close(); - console.log('Profanity filter database closed.'); - } catch (error) { - console.error('Error closing profanity filter:', error); - } + console.log('\nShutting down server...'); + + // Close profanity filter database first + if (profanityFilter && typeof profanityFilter.close === 'function') { + try { + profanityFilter.close(); + console.log('Profanity filter database closed.'); + } catch (error) { + console.error('Error closing profanity filter:', error); } - - // Close database service - databaseService.close(); - console.log('Database connections closed.'); - process.exit(0); + } + + // Close database service + databaseService.close(); + console.log('Database connections closed.'); + process.exit(0); }); \ No newline at end of file diff --git a/src/services/DatabaseService.ts b/src/services/DatabaseService.ts index 69e38df..bfbb173 100644 --- a/src/services/DatabaseService.ts +++ b/src/services/DatabaseService.ts @@ -23,7 +23,7 @@ class DatabaseService { return reject(err); } console.log('Connected to main SQLite database.'); - + if (!this.mainDb) { return reject(new Error('Main database connection failed')); } @@ -48,7 +48,7 @@ class DatabaseService { return reject(err); } console.log('Connected to profanity SQLite database.'); - + if (!this.profanityDb) { return reject(new Error('Profanity database connection failed')); } diff --git a/src/services/ProfanityFilterService.ts b/src/services/ProfanityFilterService.ts index 3221894..5f8bb7f 100644 --- a/src/services/ProfanityFilterService.ts +++ b/src/services/ProfanityFilterService.ts @@ -51,42 +51,42 @@ class ProfanityFilterService { constructor(profanityWordModel: ProfanityWord) { this.profanityWordModel = profanityWordModel; - + // Base profanity words - comprehensive list this.baseProfanityWords = [ // Common profanity 'damn', 'hell', 'crap', 'shit', 'fuck', 'ass', 'bitch', 'bastard', 'piss', 'whore', 'slut', 'retard', 'fag', 'gay', 'homo', 'tranny', 'dickhead', 'asshole', 'motherfucker', 'cocksucker', 'twat', 'cunt', - + // Racial slurs and hate speech 'nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike', 'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey', 'kyke', 'jigaboo', 'coon', 'darkie', 'mammy', 'pickaninny', - + // Sexual content 'penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum', 'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate', 'orgasm', 'blowjob', 'handjob', 'anal', 'penetration', 'erection', 'climax', - + // Violence and threats 'kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape', 'violence', 'assault', 'attack', 'threat', 'harm', 'hurt', 'pain', 'stab', 'strangle', 'torture', 'execute', 'assassinate', 'slaughter', - + // Drugs and substances 'weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high', 'stoned', 'drunk', 'alcohol', 'beer', 'liquor', 'vodka', 'whiskey', 'ecstasy', 'lsd', 'crack', 'dope', 'pot', 'joint', 'bong', - + // Religious/cultural insults 'jesus christ', 'goddamn', 'christ almighty', 'holy shit', 'god damn', 'for christ sake', 'jesus fucking christ', 'holy fuck', - + // Body parts (inappropriate context) 'testicles', 'balls', 'scrotum', 'clitoris', 'labia', 'anus', 'rectum', 'butthole', 'nipples', 'breasts', - + // Misc inappropriate 'wtf', 'omfg', 'stfu', 'gtfo', 'milf', 'dilf', 'thot', 'simp', 'incel', 'chad', 'beta', 'alpha male', 'mansplain', 'karen' @@ -99,7 +99,7 @@ class ProfanityFilterService { '%': 'a', '(': 'c', ')': 'c', '&': 'a', '#': 'h', '|': 'l', '\\': '/' }; } - + /** * Initialize the filter by loading custom words */ @@ -107,7 +107,7 @@ class ProfanityFilterService { if (this.isInitialized) { return; } - + try { await this.loadCustomWords(); this.isInitialized = true; @@ -124,13 +124,13 @@ class ProfanityFilterService { async loadCustomWords(): Promise { try { const rows = await this.profanityWordModel.loadWords(); - + this.customWords = rows.map(row => ({ word: row.word.toLowerCase(), severity: row.severity, category: row.category })); - + console.log(`Loaded ${this.customWords.length} custom profanity words`); this.patterns = this.buildPatterns(); // Rebuild patterns with custom words } catch (err) { @@ -144,10 +144,10 @@ class ProfanityFilterService { */ buildPatterns(): ProfanityPattern[] { const allWords = [...this.baseProfanityWords, ...this.customWords.map(w => w.word)]; - + // Sort by length (longest first) to catch longer variations before shorter ones allWords.sort((a, b) => b.length - a.length); - + // Create patterns with word boundaries and common variations return allWords.map(word => { const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -157,9 +157,9 @@ class ProfanityFilterService { const leetChars = Object.entries(this.leetMap) .filter(([_, v]) => v === char.toLowerCase()) .map(([k, _]) => k); - + if (leetChars.length > 0) { - const allChars = [char, ...leetChars].map(c => + const allChars = [char, ...leetChars].map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') ); return `[${allChars.join('')}]`; @@ -167,7 +167,7 @@ class ProfanityFilterService { return char; }) .join('[\\s\\-\\_\\*\\.]*'); - + return { word: word, pattern: new RegExp(`\\b${pattern}\\b`, 'gi'), @@ -186,11 +186,11 @@ class ProfanityFilterService { if (customWord) { return customWord.severity; } - + // Categorize severity based on type const highSeverity = ['nigger', 'nigga', 'cunt', 'fag', 'retard', 'kike', 'spic', 'gook', 'chink']; const lowSeverity = ['damn', 'hell', 'crap', 'wtf', 'omfg']; - + if (highSeverity.includes(word.toLowerCase())) return 'high'; if (lowSeverity.includes(word.toLowerCase())) return 'low'; return 'medium'; @@ -205,7 +205,7 @@ class ProfanityFilterService { if (customWord) { return customWord.category; } - + // Categorize based on type const categories: CategoryMap = { racial: ['nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike', 'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey'], @@ -214,13 +214,13 @@ class ProfanityFilterService { substance: ['weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high', 'stoned', 'drunk', 'alcohol'], general: ['shit', 'fuck', 'ass', 'bitch', 'bastard', 'damn', 'hell', 'crap'] }; - + for (const [category, words] of Object.entries(categories)) { if (words.includes(word.toLowerCase())) { return category; } } - + return 'general'; } @@ -229,18 +229,18 @@ class ProfanityFilterService { */ normalizeText(text: string): string { if (!text) return ''; - + // Convert to lowercase and handle basic substitutions let normalized = text.toLowerCase(); - + // Replace multiple spaces/special chars with single space normalized = normalized.replace(/[\s\-\_\*\.]+/g, ' '); - + // Apply leet speak conversions - normalized = normalized.split('').map(char => + normalized = normalized.split('').map(char => this.leetMap[char] || char ).join(''); - + return normalized; } @@ -249,7 +249,7 @@ class ProfanityFilterService { */ containsProfanity(text: string): boolean { if (!text || !this.patterns) return false; - + const normalized = this.normalizeText(text); return this.patterns.some(({ pattern }) => pattern.test(normalized)); } @@ -267,15 +267,15 @@ class ProfanityFilterService { filtered: text || '' }; } - + const normalized = this.normalizeText(text); const matches: ProfanityMatch[] = []; let filteredText = text; - + this.patterns.forEach(({ word, pattern, severity, category }) => { const regex = new RegExp(pattern.source, 'gi'); let match; - + while ((match = regex.exec(normalized)) !== null) { matches.push({ word: word, @@ -284,15 +284,15 @@ class ProfanityFilterService { severity: severity, category: category }); - + // Replace in filtered text const replacement = '*'.repeat(match[0].length); - filteredText = filteredText.substring(0, match.index) + - replacement + + filteredText = filteredText.substring(0, match.index) + + replacement + filteredText.substring(match.index + match[0].length); } }); - + // Determine overall severity let overallSeverity: 'none' | 'low' | 'medium' | 'high' = 'none'; if (matches.length > 0) { @@ -304,7 +304,7 @@ class ProfanityFilterService { overallSeverity = 'low'; } } - + return { hasProfanity: matches.length > 0, matches: matches, @@ -326,9 +326,9 @@ class ProfanityFilterService { * Add a custom word using the model */ async addCustomWord( - word: string, - severity: 'low' | 'medium' | 'high' = 'medium', - category: string = 'custom', + word: string, + severity: 'low' | 'medium' | 'high' = 'medium', + category: string = 'custom', createdBy: string = 'admin' ): Promise { try { diff --git a/src/swagger.ts b/src/swagger.ts index 67d2605..0c6d4d5 100644 --- a/src/swagger.ts +++ b/src/swagger.ts @@ -70,7 +70,7 @@ const options: swaggerJsdoc.Options = { }, longitude: { type: 'number', - format: 'float', + format: 'float', description: 'Geographic longitude coordinate', example: -85.6681, nullable: true @@ -116,7 +116,7 @@ const options: swaggerJsdoc.Options = { example: 42.9634 }, longitude: { - type: 'number', + type: 'number', format: 'float', description: 'Geographic longitude coordinate (optional, will be geocoded if not provided)', example: -85.6681 @@ -341,7 +341,7 @@ const options: swaggerJsdoc.Options = { } ] }, - apis: ['./src/routes/*.ts', './src/server.ts'], // Paths to files containing OpenAPI definitions + apis: ['./src/routes/*.ts', './src/server.ts'] // Paths to files containing OpenAPI definitions }; export const swaggerSpec = swaggerJsdoc(options); diff --git a/tests/integration/routes/admin.test.ts b/tests/integration/routes/admin.test.ts index ef2a6e1..8a63f29 100644 --- a/tests/integration/routes/admin.test.ts +++ b/tests/integration/routes/admin.test.ts @@ -35,12 +35,12 @@ describe('Admin API Routes', () => { if (!authHeader) { return res.status(401).json({ error: 'Access denied' }); } - + const token = authHeader.split(' ')[1]; if (!token || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Access denied' }); } - + // Simple token validation for testing if (token === authToken) { next(); @@ -59,7 +59,7 @@ describe('Admin API Routes', () => { const loginResponse = await request(app) .post('/api/admin/login') .send({ password: 'test_admin_password' }); - + authToken = loginResponse.body.token; }); @@ -69,7 +69,7 @@ describe('Admin API Routes', () => { closedCount++; if (closedCount === 2) done(); }; - + db.close(checkBothClosed); profanityDb.close(checkBothClosed); }); @@ -481,7 +481,7 @@ describe('Admin API Routes', () => { // Create a new app with broken database to simulate error const brokenApp = express(); brokenApp.use(express.json()); - + // Create a broken location model that throws errors const brokenLocationModel = { getAll: jest.fn().mockRejectedValue(new Error('Database error')) @@ -497,7 +497,7 @@ describe('Admin API Routes', () => { const loginResponse = await request(brokenApp) .post('/api/admin/login') .send({ password: 'test_admin_password' }); - + const brokenAuthToken = loginResponse.body.token; const response = await request(brokenApp) @@ -554,7 +554,7 @@ describe('Admin API Routes', () => { it('should handle expired/tampered tokens gracefully', async () => { const tamperedToken = authToken.slice(0, -5) + 'XXXXX'; - + const response = await request(app) .get('/api/admin/locations') .set('Authorization', `Bearer ${tamperedToken}`) diff --git a/tests/integration/routes/public.test.ts b/tests/integration/routes/public.test.ts index 58d6faf..1083bf1 100644 --- a/tests/integration/routes/public.test.ts +++ b/tests/integration/routes/public.test.ts @@ -121,7 +121,7 @@ describe('Public API Routes', () => { // Create a new app with broken database to simulate error const brokenApp = express(); brokenApp.use(express.json()); - + // Create a broken location model that throws errors const brokenLocationModel = { getActive: jest.fn().mockRejectedValue(new Error('Database error')) @@ -341,9 +341,9 @@ describe('Public API Routes', () => { const response = await request(app) .post('/api/locations') - .send({ + .send({ address: 'Test Address', - description: longDescription + description: longDescription }) .expect(400); @@ -372,5 +372,64 @@ describe('Public API Routes', () => { expect(response.body.address).toBe(unicodeAddress); }); + + it('should reject invalid latitude values', async () => { + const invalidLatitudes = [91, -91, 'invalid', null, true, []]; + + for (const latitude of invalidLatitudes) { + const response = await request(app) + .post('/api/locations') + .send({ + address: 'Test Address', + latitude: latitude, + longitude: -85.6681 + }) + .expect(400); + + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toBe('Latitude must be a number between -90 and 90'); + } + }); + + it('should reject invalid longitude values', async () => { + const invalidLongitudes = [181, -181, 'invalid', null, true, []]; + + for (const longitude of invalidLongitudes) { + const response = await request(app) + .post('/api/locations') + .send({ + address: 'Test Address', + latitude: 42.9634, + longitude: longitude + }) + .expect(400); + + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toBe('Longitude must be a number between -180 and 180'); + } + }); + + it('should accept valid latitude and longitude values', async () => { + const validCoordinates = [ + { latitude: 0, longitude: 0 }, + { latitude: 90, longitude: 180 }, + { latitude: -90, longitude: -180 }, + { latitude: 42.9634, longitude: -85.6681 } + ]; + + for (const coords of validCoordinates) { + const response = await request(app) + .post('/api/locations') + .send({ + address: 'Test Address', + latitude: coords.latitude, + longitude: coords.longitude + }) + .expect(200); + + expect(response.body.latitude).toBe(coords.latitude); + expect(response.body.longitude).toBe(coords.longitude); + } + }); }); }); \ No newline at end of file diff --git a/tests/setup.ts b/tests/setup.ts index 3c9fcc3..d07f999 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -16,7 +16,7 @@ export const createTestDatabase = (): Promise => { reject(err); return; } - + // Create locations table db.run(` CREATE TABLE IF NOT EXISTS locations ( @@ -48,7 +48,7 @@ export const createTestProfanityDatabase = (): Promise => { reject(err); return; } - + // Create profanity_words table db.run(` CREATE TABLE IF NOT EXISTS profanity_words ( diff --git a/tests/unit/models/Location.test.ts b/tests/unit/models/Location.test.ts index 7ff9d62..66bff32 100644 --- a/tests/unit/models/Location.test.ts +++ b/tests/unit/models/Location.test.ts @@ -138,7 +138,7 @@ describe('Location Model', () => { // Check that we have all locations and they're ordered by created_at DESC expect(allLocations).toHaveLength(3); - + // The query uses ORDER BY created_at DESC, so the most recent should be first // Since they're created in the same moment, check that ordering is consistent expect(allLocations[0]).toHaveProperty('id'); diff --git a/tests/unit/services/DatabaseService.test.ts b/tests/unit/services/DatabaseService.test.ts index 7771e5a..1157977 100644 --- a/tests/unit/services/DatabaseService.test.ts +++ b/tests/unit/services/DatabaseService.test.ts @@ -5,7 +5,7 @@ describe('DatabaseService', () => { beforeEach(() => { databaseService = new DatabaseService(); - + // Mock console methods to reduce test noise jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -88,7 +88,7 @@ describe('DatabaseService', () => { it('should allow multiple instances', () => { const service1 = new DatabaseService(); const service2 = new DatabaseService(); - + expect(service1).toBeInstanceOf(DatabaseService); expect(service2).toBeInstanceOf(DatabaseService); expect(service1).not.toBe(service2); diff --git a/tests/unit/services/ProfanityFilterService.test.ts b/tests/unit/services/ProfanityFilterService.test.ts index 7a376e7..83d490d 100644 --- a/tests/unit/services/ProfanityFilterService.test.ts +++ b/tests/unit/services/ProfanityFilterService.test.ts @@ -27,10 +27,10 @@ describe('ProfanityFilterService', () => { it('should load custom words during initialization', async () => { await profanityWordModel.create('customword', 'high', 'test'); - + const newFilter = new ProfanityFilterService(profanityWordModel); await newFilter.initialize(); - + expect(newFilter.containsProfanity('customword')).toBe(true); }); }); @@ -50,7 +50,7 @@ describe('ProfanityFilterService', () => { // Test basic profanity detection - may or may not be case insensitive const testWord = 'damn'; expect(profanityFilter.containsProfanity(testWord)).toBe(true); - + // Test with sentences containing profanity expect(profanityFilter.containsProfanity('This is DAMN cold')).toBe(true); expect(profanityFilter.containsProfanity('What the HELL')).toBe(true); @@ -104,7 +104,7 @@ describe('ProfanityFilterService', () => { it('should determine severity levels correctly', () => { const lowResult = profanityFilter.analyzeProfanity('damn'); const mediumResult = profanityFilter.analyzeProfanity('shit'); - + expect(lowResult.severity).toBe('low'); expect(mediumResult.severity).toBe('medium'); }); @@ -164,7 +164,7 @@ describe('ProfanityFilterService', () => { it('should remove custom words', async () => { const added = await profanityFilter.addCustomWord('removeme', 'low', 'test'); - + const result = await profanityFilter.removeCustomWord(added.id); expect(result.deleted).toBe(true); @@ -180,7 +180,7 @@ describe('ProfanityFilterService', () => { it('should update custom words', async () => { const added = await profanityFilter.addCustomWord('updateme', 'low', 'test'); - + const result = await profanityFilter.updateCustomWord(added.id, { word: 'updated', severity: 'high', @@ -208,14 +208,14 @@ describe('ProfanityFilterService', () => { describe('text normalization', () => { it('should normalize text correctly', () => { const normalized = profanityFilter.normalizeText('Hello World!!!'); - + expect(typeof normalized).toBe('string'); expect(normalized.length).toBeGreaterThan(0); }); it('should handle special characters', () => { const normalized = profanityFilter.normalizeText('h3ll0 w0rld'); - + expect(normalized).toContain('hello world'); }); @@ -228,13 +228,13 @@ describe('ProfanityFilterService', () => { describe('severity and category helpers', () => { it('should get severity for words', () => { const severity = profanityFilter.getSeverity('damn'); - + expect(['low', 'medium', 'high']).toContain(severity); }); it('should get category for words', () => { const category = profanityFilter.getCategory('damn'); - + expect(typeof category).toBe('string'); expect(category.length).toBeGreaterThan(0); }); @@ -242,7 +242,7 @@ describe('ProfanityFilterService', () => { it('should return default values for unknown words', () => { const severity = profanityFilter.getSeverity('unknownword'); const category = profanityFilter.getCategory('unknownword'); - + expect(['low', 'medium', 'high']).toContain(severity); expect(typeof category).toBe('string'); }); @@ -251,26 +251,26 @@ describe('ProfanityFilterService', () => { describe('utility methods', () => { it('should get all words', () => { const words = profanityFilter.getAllWords(); - + expect(Array.isArray(words)).toBe(true); expect(words.length).toBeGreaterThan(0); }); it('should get severity level as number', () => { const level = profanityFilter.getSeverityLevel(); - + expect(typeof level).toBe('number'); }); it('should get severity name', () => { const name = profanityFilter.getSeverityName(); - + expect(typeof name).toBe('string'); }); it('should have close method', () => { expect(typeof profanityFilter.close).toBe('function'); - + // Should not throw profanityFilter.close(); });