diff --git a/.gitea/workflows/backend-tests.yml b/.gitea/workflows/backend-tests.yml new file mode 100644 index 0000000..682f065 --- /dev/null +++ b/.gitea/workflows/backend-tests.yml @@ -0,0 +1,29 @@ +name: Backend tests + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: backend/package-lock.json + - name: Install dependencies + run: npm ci + - name: Audit production dependencies + run: npm audit --omit=dev + - name: Run backend tests + run: npm test diff --git a/.gitignore b/.gitignore index 580703d..c1b82c2 100644 --- a/.gitignore +++ b/.gitignore @@ -89,9 +89,7 @@ package-lock.json.bak # Claude配置 .claude/ -# 测试脚本和报告 -backend/test-*.js -backend/verify-*.js +# 测试报告和本地验证产物 backend/verify-*.sh backend/test-results-*.json backend/*最终*.js diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/INSTALL_GUIDE.md b/INSTALL_GUIDE.md index 5064022..2495433 100644 --- a/INSTALL_GUIDE.md +++ b/INSTALL_GUIDE.md @@ -81,8 +81,8 @@ sudo chown -R $USER:$USER /var/www/wanwanyun ```bash cd /var/www/wanwanyun/backend -# 安装依赖 -npm install --production +# 严格按锁文件安装生产依赖 +npm ci --omit=dev # 创建数据目录 mkdir -p data storage @@ -133,6 +133,8 @@ sudo systemctl reload nginx ### 7. 配置系统服务 +> 后端当前使用进程内限流器与用量缓存,只能运行一个实例。不要使用 PM2 cluster 模式或同时启动多个 systemd 实例。 + 创建 systemd 服务文件: ```bash @@ -242,7 +244,7 @@ sudo tail -f /var/log/nginx/error.log ```bash cd /var/www/wanwanyun sudo git pull -cd backend && npm install --production +cd backend && npm ci --omit=dev sudo systemctl restart wanwanyun ``` diff --git a/README.md b/README.md index 67d0c9b..071549e 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,10 @@ ### 环境要求 - **操作系统**: Linux (Ubuntu 18.04+ / Debian 10+ / CentOS 7+) +- **Node.js**: 20.x LTS(支持 20-24,推荐与 `.nvmrc` 保持一致) - **内存**: 最低 1GB RAM(推荐 2GB+) - **磁盘空间**: 至少 2GB 可用空间 +- **后端实例数**: 当前限流与用量缓存为进程内状态,PM2 必须保持单实例 `fork` 模式 ### 方式1: 一键部署(推荐)⭐ diff --git a/backend/Dockerfile b/backend/Dockerfile index e5862e2..195b5ce 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,8 +8,8 @@ RUN apk add --no-cache python3 make g++ wget # 复制 package 文件 COPY package*.json ./ -# 安装依赖 -RUN npm install --production +# 严格按锁文件安装生产依赖 +RUN npm ci --omit=dev # 复制应用代码 COPY . . diff --git a/backend/middleware/error-handler.js b/backend/middleware/error-handler.js new file mode 100644 index 0000000..f376fcd --- /dev/null +++ b/backend/middleware/error-handler.js @@ -0,0 +1,20 @@ +function expressErrorHandler(err, req, res, next) { + if (res.headersSent) return next(err); + + const requestedStatus = Number(err?.statusCode || err?.status); + const status = Number.isInteger(requestedStatus) && requestedStatus >= 400 && requestedStatus <= 599 + ? requestedStatus + : 500; + + console.error(`[未处理错误] ${req.method} ${req.originalUrl}`, { + message: err?.message, + stack: err?.stack + }); + + return res.status(status).json({ + success: false, + message: status >= 500 ? '服务器内部错误' : (err?.message || '请求处理失败') + }); +} + +module.exports = { expressErrorHandler }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 98c5b40..67cff41 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.985.0", "@aws-sdk/s3-request-presigner": "^3.985.0", - "archiver": "^7.0.1", + "archiver": "^8.0.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^11.8.1", "cookie-parser": "^1.4.7", @@ -25,8 +25,8 @@ "nodemailer": "^9.0.3", "svg-captcha": "^1.4.0" }, - "devDependencies": { - "nodemon": "^3.0.1" + "engines": { + "node": ">=20 <25" } }, "node_modules/@aws-crypto/crc32": { @@ -961,23 +961,6 @@ "node": ">=18.0.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@nodable/entities": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", @@ -990,16 +973,6 @@ ], "license": "MIT" }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@smithy/abort-controller": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", @@ -1757,44 +1730,6 @@ "node": ">= 0.6" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/anynum": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", @@ -1814,39 +1749,23 @@ "license": "MIT" }, "node_modules/archiver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", - "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", "license": "MIT", "dependencies": { - "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", - "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", - "license": "MIT", - "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", + "is-stream": "^4.0.0", "lazystream": "^1.0.0", - "lodash": "^4.17.15", "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" }, "engines": { - "node": ">= 14" + "node": ">=18" } }, "node_modules/array-flatten": { @@ -1875,12 +1794,6 @@ } } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, "node_modules/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", @@ -1935,19 +1848,6 @@ "prebuild-install": "^7.1.1" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -1983,9 +1883,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -2013,25 +1913,24 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/buffer": { @@ -2114,78 +2013,28 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/compress-commons": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", - "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", "license": "MIT", "dependencies": { "crc-32": "^1.2.0", - "crc32-stream": "^6.0.0", - "is-stream": "^2.0.1", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" + "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -2296,30 +2145,16 @@ } }, "node_modules/crc32-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", - "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", "license": "MIT", "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" + "node": ">=18" } }, "node_modules/debug": { @@ -2409,12 +2244,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2430,12 +2259,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -2643,19 +2466,6 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -2674,22 +2484,6 @@ "node": ">= 0.8" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2714,21 +2508,6 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2781,39 +2560,6 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2826,22 +2572,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2918,13 +2648,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2946,68 +2669,13 @@ "node": ">= 0.10" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3019,27 +2687,6 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -3179,12 +2826,6 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3267,15 +2908,15 @@ } }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3290,15 +2931,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -3366,84 +2998,6 @@ "node": ">=6.0.0" } }, - "node_modules/nodemon": { - "version": "3.1.11", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", - "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/nodemon/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/nodemon/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/nodemon/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/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3507,12 +3061,6 @@ "ot": "bin/ot" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3537,50 +3085,12 @@ "node": ">=14.0.0" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -3635,13 +3145,6 @@ "node": ">= 0.10" } }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -3747,37 +3250,18 @@ } }, "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", "license": "Apache-2.0", "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" + "minimatch": "^10.2.2" }, "engines": { - "node": ">=10" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" + "node": ">=18" }, - "engines": { - "node": ">=8.10.0" + "funding": { + "url": "https://github.com/sponsors/yqnn" } }, "node_modules/safe-buffer": { @@ -3869,27 +3353,6 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -3962,18 +3425,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -4019,19 +3470,6 @@ "simple-concat": "^1.0.0" } }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4069,102 +3507,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -4189,19 +3531,6 @@ "anynum": "^1.0.0" } }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/svg-captcha": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/svg-captcha/-/svg-captcha-1.4.0.tgz", @@ -4282,19 +3611,6 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4304,16 +3620,6 @@ "node": ">=0.6" } }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4351,13 +3657,6 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4400,112 +3699,6 @@ "node": ">= 0.8" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -4528,17 +3721,17 @@ } }, "node_modules/zip-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", - "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", "license": "MIT", "dependencies": { - "archiver-utils": "^5.0.0", - "compress-commons": "^6.0.2", + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" + "node": ">=18" } } } diff --git a/backend/package.json b/backend/package.json index fde3d11..ab532f5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,9 +3,12 @@ "version": "3.1.0", "description": "玩玩云 - 云存储管理平台后端服务", "main": "server.js", + "engines": { + "node": ">=20 <25" + }, "scripts": { "start": "node server.js", - "dev": "nodemon server.js", + "dev": "node --watch server.js", "test": "npm run test:unit && npm run test:integration && node test_download_quota_defaults.js", "test:unit": "node tests/run-all-tests.js", "test:integration": "node tests/full-audit-regression.js" @@ -23,7 +26,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.985.0", "@aws-sdk/s3-request-presigner": "^3.985.0", - "archiver": "^7.0.1", + "archiver": "^8.0.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^11.8.1", "cookie-parser": "^1.4.7", @@ -36,8 +39,5 @@ "multer": "^2.2.0", "nodemailer": "^9.0.3", "svg-captcha": "^1.4.0" - }, - "devDependencies": { - "nodemon": "^3.0.1" } } diff --git a/backend/server.js b/backend/server.js index 7c608ee..ce6181c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,7 +11,6 @@ const path = require('path'); const fs = require('fs'); const zlib = require('zlib'); const { body, validationResult } = require('express-validator'); -const archiver = require('archiver'); const crypto = require('crypto'); const { exec, execSync, execFile } = require('child_process'); const util = require('util'); @@ -95,6 +94,30 @@ const { } = require('./auth'); const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage'); const { encryptSecret, decryptSecret } = require('./utils/encryption'); +const { + sanitizeInput, + decodeHtmlEntities, + escapeHtml, + isSafePathSegment, + isFileExtensionSafe +} = require('./utils/input-security'); +const { + parseDateTimeValue, + formatDateTimeForSqlite, + getDateKeyFromDate, + getRecentDateKeys, + normalizeTimeHHmm, + isCurrentTimeInWindow +} = require('./utils/datetime'); +const { + MAX_DOWNLOAD_TRAFFIC_BYTES, + normalizeDownloadTrafficQuota, + normalizeDownloadTrafficUsed, + getDownloadTrafficState, + resolveDownloadTrafficPolicyUpdates +} = require('./utils/download-quota'); +const { expressErrorHandler } = require('./middleware/error-handler'); +const { createZipArchive } = require('./utils/archive'); const app = express(); const PORT = process.env.PORT || 40001; @@ -102,7 +125,6 @@ const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英 const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true'; const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB -const MAX_DOWNLOAD_TRAFFIC_BYTES = 10 * 1024 * 1024 * 1024 * 1024; // 10TB const DOWNLOAD_POLICY_SWEEP_INTERVAL_MS = 30 * 60 * 1000; // 30分钟 const DOWNLOAD_RESERVATION_TTL_MS = Number(process.env.DOWNLOAD_RESERVATION_TTL_MS || (30 * 60 * 1000)); // 30分钟 const DOWNLOAD_LOG_RECONCILE_INTERVAL_MS = Number(process.env.DOWNLOAD_LOG_RECONCILE_INTERVAL_MS || (5 * 60 * 1000)); // 5分钟 @@ -637,7 +659,13 @@ if (ENABLE_CSRF) { // 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置, // 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头 app.use((req, res, next) => { - if (!ENFORCE_HTTPS) return next(); + const remoteAddress = req.socket?.remoteAddress || ''; + const isLocalHealthCheck = req.path === '/api/health' && [ + '127.0.0.1', + '::1', + '::ffff:127.0.0.1' + ].includes(remoteAddress); + if (!ENFORCE_HTTPS || isLocalHealthCheck) return next(); // req.secure 由 Express 根据 trust proxy 配置计算: // - 如果 trust proxy = false,仅检查直接连接是否为 TLS @@ -657,95 +685,6 @@ app.use((req, res, next) => { next(); }); -/** - * XSS过滤函数 - 过滤用户输入中的潜在XSS攻击代码 - * 注意:不转义 / 因为它是文件路径的合法字符 - * @param {string} str - 需要过滤的输入字符串 - * @returns {string} 过滤后的安全字符串 - */ -function sanitizeInput(str) { - if (typeof str !== 'string') return str; - - // 1. 基础HTML实体转义(不包括 / 因为是路径分隔符,不包括 ` 因为是合法文件名字符) - let sanitized = str - .replace(/[&<>"']/g, (char) => { - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - return map[char]; - }); - - // 2. 过滤危险协议(javascript:, data:, vbscript:等) - sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, ''); - - // 3. 移除空字节 - sanitized = sanitized.replace(/\x00/g, ''); - - return sanitized; -} - -/** - * 将 HTML 实体解码为原始字符 - * 用于处理经过XSS过滤后的文件名/路径字段,恢复原始字符 - * 支持嵌套实体的递归解码(如 &#x60; -> ` -> `) - * @param {string} str - 包含HTML实体的字符串 - * @returns {string} 解码后的原始字符串 - */ -function decodeHtmlEntities(str) { - if (typeof str !== 'string') return str; - - // 支持常见实体和数字实体(含多次嵌套,如 &#x60;) - const entityMap = { - amp: '&', - lt: '<', - gt: '>', - quot: '"', - apos: "'", - '#x27': "'", - '#x2F': '/', - '#x60': '`' - }; - - const decodeOnce = (input) => - input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => { - if (code[0] === '#') { - const isHex = code[1]?.toLowerCase() === 'x'; - const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10); - if (!Number.isNaN(num)) { - return String.fromCharCode(num); - } - return match; - } - const mapped = entityMap[code]; - return mapped !== undefined ? mapped : match; - }); - - let output = str; - let decoded = decodeOnce(output); - // 处理嵌套实体(如 &#x60;),直到稳定 - while (decoded !== output) { - output = decoded; - decoded = decodeOnce(output); - } - return output; -} - -// HTML转义(用于模板输出) -function escapeHtml(str) { - if (typeof str !== 'string') return str; - return str.replace(/[&<>"']/g, char => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }[char])); -} - // 规范化并校验HTTP直链前缀,只允许http/https function sanitizeHttpBaseUrl(raw) { if (!raw) return null; @@ -790,56 +729,6 @@ function buildHttpDownloadUrl(rawBaseUrl, filePath) { } } -// 校验文件名/路径片段安全(禁止分隔符、控制字符、..) -function isSafePathSegment(name) { - return ( - typeof name === 'string' && - name.length > 0 && - name.length <= 255 && // 限制文件名长度 - !name.includes('..') && - !/[/\\]/.test(name) && - !/[\x00-\x1F]/.test(name) - ); -} - -// 危险文件扩展名黑名单(仅限可能被Web服务器解析执行的脚本文件) -// 注意:这是网盘应用,.exe等可执行文件允许上传(服务器不会执行) -const DANGEROUS_EXTENSIONS = [ - '.php', '.php3', '.php4', '.php5', '.phtml', '.phar', // PHP - '.jsp', '.jspx', '.jsw', '.jsv', '.jspf', // Java Server Pages - '.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', // ASP.NET - '.htaccess', '.htpasswd' // Apache配置(可能改变服务器行为) -]; - -// 检查文件扩展名是否安全 -function isFileExtensionSafe(filename) { - if (!filename || typeof filename !== 'string') return false; - - const ext = path.extname(filename).toLowerCase(); - const nameLower = filename.toLowerCase(); - - // 检查危险扩展名 - if (DANGEROUS_EXTENSIONS.includes(ext)) { - return false; - } - - // 特殊处理:检查以危险名称开头的文件(如 .htaccess, .htpasswd) - // 因为 path.extname('.htaccess') 返回空字符串 - const dangerousFilenames = ['.htaccess', '.htpasswd']; - if (dangerousFilenames.includes(nameLower)) { - return false; - } - - // 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行) - for (const dangerExt of DANGEROUS_EXTENSIONS) { - if (nameLower.includes(dangerExt + '.')) { - return false; - } - } - - return true; -} - // 应用XSS过滤到所有POST/PUT请求的body app.use((req, res, next) => { if ((req.method === 'POST' || req.method === 'PUT') && req.body) { @@ -903,40 +792,6 @@ function normalizeOssQuota(rawQuota) { return parsedQuota; } -function normalizeDownloadTrafficQuota(rawQuota) { - const parsedQuota = Number(rawQuota); - if (!Number.isFinite(parsedQuota)) { - return 0; // 0 表示禁止下载 - } - if (parsedQuota < 0) { - return -1; // -1 表示不限流量 - } - return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota)); -} - -function normalizeDownloadTrafficUsed(rawUsed, quota = 0) { - const parsedUsed = Number(rawUsed); - const normalizedUsed = Number.isFinite(parsedUsed) && parsedUsed > 0 - ? Math.floor(parsedUsed) - : 0; - if (quota >= 0) { - return Math.min(normalizedUsed, quota); - } - return normalizedUsed; -} - -function getDownloadTrafficState(user) { - const quota = normalizeDownloadTrafficQuota(user?.download_traffic_quota); - const used = normalizeDownloadTrafficUsed(user?.download_traffic_used, quota); - const isUnlimited = quota < 0; - return { - quota, - used, - isUnlimited, - remaining: isUnlimited ? Number.POSITIVE_INFINITY : Math.max(0, quota - used) - }; -} - function getBusyDownloadMessage() { return '当前网络繁忙,请稍后再试'; } @@ -1468,37 +1323,6 @@ function sendPlainTextError(res, statusCode, message) { return res.status(statusCode).type('text/plain; charset=utf-8').send(message); } -function parseDateTimeValue(value) { - if (!value || typeof value !== 'string') { - return null; - } - - const directDate = new Date(value); - if (!Number.isNaN(directDate.getTime())) { - return directDate; - } - - // 兼容 SQLite 常见 DATETIME 格式: YYYY-MM-DD HH:mm:ss - const normalized = value.replace(' ', 'T'); - const normalizedDate = new Date(normalized); - if (!Number.isNaN(normalizedDate.getTime())) { - return normalizedDate; - } - - return null; -} - -function formatDateTimeForSqlite(date = new Date()) { - const target = date instanceof Date ? date : new Date(date); - const year = target.getFullYear(); - const month = String(target.getMonth() + 1).padStart(2, '0'); - const day = String(target.getDate()).padStart(2, '0'); - const hours = String(target.getHours()).padStart(2, '0'); - const minutes = String(target.getMinutes()).padStart(2, '0'); - const seconds = String(target.getSeconds()).padStart(2, '0'); - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; -} - function createOssUploadReservationToken() { return crypto.randomBytes(24).toString('hex'); } @@ -1519,132 +1343,6 @@ function encodeS3CopySource(bucket, key) { return `${encodedBucket}/${encodedKey}`; } -function getDateKeyFromDate(date = new Date()) { - const target = date instanceof Date ? date : new Date(date); - if (Number.isNaN(target.getTime())) { - return null; - } - const year = target.getFullYear(); - const month = String(target.getMonth() + 1).padStart(2, '0'); - const day = String(target.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -function getRecentDateKeys(days = 30, now = new Date()) { - const safeDays = Math.max(1, Math.floor(Number(days) || 30)); - const keys = []; - for (let i = safeDays - 1; i >= 0; i -= 1) { - const date = new Date(now.getTime()); - date.setDate(date.getDate() - i); - const key = getDateKeyFromDate(date); - if (key) { - keys.push(key); - } - } - return keys; -} - -function getNextDownloadResetTime(lastResetAt, resetCycle) { - const baseDate = parseDateTimeValue(lastResetAt); - if (!baseDate) { - return null; - } - - const next = new Date(baseDate.getTime()); - if (resetCycle === 'daily') { - next.setDate(next.getDate() + 1); - return next; - } - - if (resetCycle === 'weekly') { - next.setDate(next.getDate() + 7); - return next; - } - - if (resetCycle === 'monthly') { - next.setMonth(next.getMonth() + 1); - return next; - } - - return null; -} - -function resolveDownloadTrafficPolicyUpdates(user, now = new Date()) { - if (!user) { - return { - updates: {}, - hasUpdates: false, - expired: false, - resetApplied: false - }; - } - - const updates = {}; - let hasUpdates = false; - let expired = false; - let resetApplied = false; - - const normalizedQuota = normalizeDownloadTrafficQuota(user.download_traffic_quota); - const normalizedUsed = normalizeDownloadTrafficUsed(user.download_traffic_used, normalizedQuota); - if (normalizedQuota !== Number(user.download_traffic_quota || 0)) { - updates.download_traffic_quota = normalizedQuota; - hasUpdates = true; - } - if (normalizedUsed !== Number(user.download_traffic_used || 0)) { - updates.download_traffic_used = normalizedUsed; - hasUpdates = true; - } - - const resetCycle = ['none', 'daily', 'weekly', 'monthly'].includes(user.download_traffic_reset_cycle) - ? user.download_traffic_reset_cycle - : 'none'; - if (resetCycle !== (user.download_traffic_reset_cycle || 'none')) { - updates.download_traffic_reset_cycle = resetCycle; - hasUpdates = true; - } - - const expiresAt = parseDateTimeValue(user.download_traffic_quota_expires_at); - if (normalizedQuota <= 0 && user.download_traffic_quota_expires_at) { - updates.download_traffic_quota_expires_at = null; - hasUpdates = true; - } else if (normalizedQuota > 0 && expiresAt && now >= expiresAt) { - // 到期后自动恢复为不限并重置已用量 - updates.download_traffic_quota = 0; - updates.download_traffic_used = 0; - updates.download_traffic_quota_expires_at = null; - updates.download_traffic_reset_cycle = 'none'; - updates.download_traffic_last_reset_at = null; - hasUpdates = true; - expired = true; - } - - if (!expired && resetCycle !== 'none') { - const lastResetAt = user.download_traffic_last_reset_at; - if (!lastResetAt) { - updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now); - hasUpdates = true; - } else { - const nextResetAt = getNextDownloadResetTime(lastResetAt, resetCycle); - if (nextResetAt && now >= nextResetAt) { - updates.download_traffic_used = 0; - updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now); - hasUpdates = true; - resetApplied = true; - } - } - } else if (resetCycle === 'none' && user.download_traffic_last_reset_at) { - updates.download_traffic_last_reset_at = null; - hasUpdates = true; - } - - return { - updates, - hasUpdates, - expired, - resetApplied - }; -} - const enforceDownloadTrafficPolicyTransaction = db.transaction((userId, trigger = 'runtime') => { let user = UserDB.findById(userId); if (!user) { @@ -3262,43 +2960,6 @@ function dedupeOnlineDeviceRows(rows = [], currentSessionId = '') { return Array.from(deduped.values()); } -function normalizeTimeHHmm(value) { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - const match = trimmed.match(/^(\d{2}):(\d{2})$/); - if (!match) return null; - const hours = Number(match[1]); - const minutes = Number(match[2]); - if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null; - if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; - return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; -} - -function toMinutesOfDay(hhmm) { - const normalized = normalizeTimeHHmm(hhmm); - if (!normalized) return null; - const [hh, mm] = normalized.split(':').map(Number); - return hh * 60 + mm; -} - -function isCurrentTimeInWindow(startTime, endTime, now = new Date()) { - const start = toMinutesOfDay(startTime); - const end = toMinutesOfDay(endTime); - if (start === null || end === null) { - return true; - } - - const nowMinutes = now.getHours() * 60 + now.getMinutes(); - if (start === end) { - return true; - } - if (start < end) { - return nowMinutes >= start && nowMinutes < end; - } - // 跨天窗口:如 22:00 - 06:00 - return nowMinutes >= start || nowMinutes < end; -} - function getSharePolicySummary(share) { const maxDownloads = Number(share?.max_downloads); const whitelist = parseShareIpWhitelist(share?.ip_whitelist || ''); @@ -7916,7 +7577,7 @@ app.get('/api/upload/download-tool', authMiddleware, async (req, res) => { // 创建文件写入流 const output = fs.createWriteStream(tempZipPath); - const archive = archiver('zip', { + const archive = await createZipArchive({ store: true // 使用STORE模式,不压缩,速度最快 }); @@ -11791,6 +11452,35 @@ app.get("/s/:code", (req, res) => { res.redirect(frontendUrl); }); +// Keep this after every route so Express can normalize synchronous and next(err) failures. +app.use(expressErrorHandler); + +let server = null; +let fatalShutdownStarted = false; + +process.on('unhandledRejection', (reason) => { + console.error('[unhandledRejection]', reason); +}); + +process.on('uncaughtException', (error) => { + console.error('[uncaughtException]', error); + if (fatalShutdownStarted) return; + fatalShutdownStarted = true; + + const forceExitTimer = setTimeout(() => process.exit(1), 10_000); + forceExitTimer.unref(); + const exitAfterClose = () => { + clearTimeout(forceExitTimer); + process.exit(1); + }; + + if (server?.listening) { + server.close(exitAfterClose); + } else { + exitAfterClose(); + } +}); + // 启动时清理旧临时文件 cleanupOldTempFiles(); const desktopCleanupOnStartup = cleanupDesktopInstallerPackages(getDesktopUpdateConfig().installerUrl); @@ -11799,7 +11489,7 @@ if (desktopCleanupOnStartup.executed && desktopCleanupOnStartup.removed > 0) { } // 启动服务器 -app.listen(PORT, '0.0.0.0', () => { +server = app.listen(PORT, '0.0.0.0', () => { console.log(`\n========================================`); console.log(`玩玩云已启动`); console.log(`服务器地址: http://localhost:${PORT}`); diff --git a/backend/tests/archive-tests.js b/backend/tests/archive-tests.js new file mode 100644 index 0000000..b367d6f --- /dev/null +++ b/backend/tests/archive-tests.js @@ -0,0 +1,31 @@ +const assert = require('assert'); +const { PassThrough } = require('stream'); +const { finished } = require('stream/promises'); +const { createZipArchive } = require('../utils/archive'); + +async function run() { + const archive = await createZipArchive({ store: true }); + const output = new PassThrough(); + const chunks = []; + output.on('data', (chunk) => chunks.push(chunk)); + archive.pipe(output); + archive.append(Buffer.from('wanwanyun archive smoke test'), { name: 'smoke.txt' }); + + await archive.finalize(); + await finished(output); + + const zip = Buffer.concat(chunks); + assert.ok(zip.length > 30, 'ZIP output should not be empty'); + assert.strictEqual(zip.subarray(0, 2).toString('ascii'), 'PK'); + assert.ok(zip.includes(Buffer.from('smoke.txt')), 'ZIP should contain the requested entry'); + + console.log('通过: 1'); + console.log('失败: 0'); +} + +run().catch((error) => { + console.error(error.stack || error.message); + console.log('通过: 0'); + console.log('失败: 1'); + process.exit(1); +}); diff --git a/backend/tests/boundary-tests.js b/backend/tests/boundary-tests.js index df14aa4..71488ac 100644 --- a/backend/tests/boundary-tests.js +++ b/backend/tests/boundary-tests.js @@ -12,6 +12,12 @@ const assert = require('assert'); const path = require('path'); const fs = require('fs'); +const { + sanitizeInput, + decodeHtmlEntities, + isSafePathSegment, + isFileExtensionSafe +} = require('../utils/input-security'); // 主函数包装器(支持 async/await) async function runTests() { @@ -58,28 +64,6 @@ console.log('\n========== 1. 输入边界测试 ==========\n'); function testSanitizeInput() { console.log('--- 测试 XSS 过滤函数 sanitizeInput ---'); - // 从 server.js 复制的 sanitizeInput 函数 - function sanitizeInput(str) { - if (typeof str !== 'string') return str; - - let sanitized = str - .replace(/[&<>"']/g, (char) => { - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - return map[char]; - }); - - sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, ''); - sanitized = sanitized.replace(/\x00/g, ''); - - return sanitized; - } - // 空字符串测试 test('空字符串输入应该返回空字符串', () => { assert.strictEqual(sanitizeInput(''), ''); @@ -263,17 +247,6 @@ console.log('\n========== 2. 文件操作边界测试 ==========\n'); function testPathSecurity() { console.log('--- 测试路径安全校验 ---'); - function isSafePathSegment(name) { - return ( - typeof name === 'string' && - name.length > 0 && - name.length <= 255 && - !name.includes('..') && - !/[/\\]/.test(name) && - !/[\x00-\x1F]/.test(name) - ); - } - test('空文件名应该被拒绝', () => { assert.strictEqual(isSafePathSegment(''), false); }); @@ -312,32 +285,6 @@ testPathSecurity(); function testFileExtensionSecurity() { console.log('\n--- 测试文件扩展名安全 ---'); - const DANGEROUS_EXTENSIONS = [ - '.php', '.php3', '.php4', '.php5', '.phtml', '.phar', - '.jsp', '.jspx', '.jsw', '.jsv', '.jspf', - '.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', - '.htaccess', '.htpasswd' - ]; - - function isFileExtensionSafe(filename) { - if (!filename || typeof filename !== 'string') return false; - - const ext = path.extname(filename).toLowerCase(); - - if (DANGEROUS_EXTENSIONS.includes(ext)) { - return false; - } - - const nameLower = filename.toLowerCase(); - for (const dangerExt of DANGEROUS_EXTENSIONS) { - if (nameLower.includes(dangerExt + '.')) { - return false; - } - } - - return true; - } - test('PHP 文件应该被拒绝', () => { assert.strictEqual(isFileExtensionSafe('test.php'), false); assert.strictEqual(isFileExtensionSafe('shell.phtml'), false); @@ -360,36 +307,8 @@ function testFileExtensionSecurity() { }); test('.htaccess 和 .htpasswd 文件应该被拒绝', () => { - // 更新测试以匹配修复后的 isFileExtensionSafe 函数 - // 现在会检查 dangerousFilenames 列表 - const dangerousFilenames = ['.htaccess', '.htpasswd']; - - function isFileExtensionSafeFixed(filename) { - if (!filename || typeof filename !== 'string') return false; - - const ext = path.extname(filename).toLowerCase(); - const nameLower = filename.toLowerCase(); - - if (DANGEROUS_EXTENSIONS.includes(ext)) { - return false; - } - - // 特殊处理:检查以危险名称开头的文件 - if (dangerousFilenames.includes(nameLower)) { - return false; - } - - for (const dangerExt of DANGEROUS_EXTENSIONS) { - if (nameLower.includes(dangerExt + '.')) { - return false; - } - } - - return true; - } - - assert.strictEqual(isFileExtensionSafeFixed('.htaccess'), false); - assert.strictEqual(isFileExtensionSafeFixed('.htpasswd'), false); + assert.strictEqual(isFileExtensionSafe('.htaccess'), false); + assert.strictEqual(isFileExtensionSafe('.htpasswd'), false); }); test('正常文件应该被接受', () => { @@ -771,43 +690,6 @@ console.log('\n========== 7. HTML 实体解码测试 ==========\n'); function testHtmlEntityDecoding() { console.log('--- 测试 HTML 实体解码 ---'); - function decodeHtmlEntities(str) { - if (typeof str !== 'string') return str; - - const entityMap = { - amp: '&', - lt: '<', - gt: '>', - quot: '"', - apos: "'", - '#x27': "'", - '#x2F': '/', - '#x60': '`' - }; - - const decodeOnce = (input) => - input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => { - if (code[0] === '#') { - const isHex = code[1]?.toLowerCase() === 'x'; - const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10); - if (!Number.isNaN(num)) { - return String.fromCharCode(num); - } - return match; - } - const mapped = entityMap[code]; - return mapped !== undefined ? mapped : match; - }); - - let output = str; - let decoded = decodeOnce(output); - while (decoded !== output) { - output = decoded; - decoded = decodeOnce(output); - } - return output; - } - test('基本 HTML 实体应该被解码', () => { assert.strictEqual(decodeHtmlEntities('<'), '<'); assert.strictEqual(decodeHtmlEntities('>'), '>'); diff --git a/backend/tests/full-audit-regression.js b/backend/tests/full-audit-regression.js index 4471c38..afa5c4d 100644 --- a/backend/tests/full-audit-regression.js +++ b/backend/tests/full-audit-regression.js @@ -199,6 +199,17 @@ async function run() { assert.ok(jar.get('csrf_token')); }); + test('malformed JSON receives the global JSON error response', async () => { + const malformed = await request(baseUrl, new CookieJar(), 'POST', '/api/login', { + csrf: false, + headers: { 'Content-Type': 'application/json' }, + body: '{"invalid":}' + }); + assert.strictEqual(malformed.status, 400); + assert.strictEqual(malformed.data.success, false); + assert.strictEqual(typeof malformed.data.message, 'string'); + }); + test('auth endpoints login with real cookies and enforce CSRF after authentication', async () => { const login = await request(baseUrl, jar, 'POST', '/api/login', { json: { username: 'admin', password: adminPassword } diff --git a/backend/tests/production-utils-tests.js b/backend/tests/production-utils-tests.js new file mode 100644 index 0000000..4bc1253 --- /dev/null +++ b/backend/tests/production-utils-tests.js @@ -0,0 +1,244 @@ +const assert = require('assert'); +const { + parseDateTimeValue, + formatDateTimeForSqlite, + getDateKeyFromDate, + getRecentDateKeys, + getNextDownloadResetTime, + normalizeTimeHHmm, + isCurrentTimeInWindow +} = require('../utils/datetime'); +const { + MAX_DOWNLOAD_TRAFFIC_BYTES, + normalizeDownloadTrafficQuota, + normalizeDownloadTrafficUsed, + getDownloadTrafficState, + resolveDownloadTrafficPolicyUpdates +} = require('../utils/download-quota'); +const { expressErrorHandler } = require('../middleware/error-handler'); + +const results = { passed: 0, failed: 0 }; + +function test(name, fn) { + try { + fn(); + results.passed += 1; + console.log(` [PASS] ${name}`); + } catch (error) { + results.failed += 1; + console.error(` [FAIL] ${name}: ${error.message}`); + } +} + +function createResponse(headersSent = false) { + return { + headersSent, + statusCode: null, + payload: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.payload = payload; + return this; + } + }; +} + +console.log('\n========== 生产工具模块测试 ==========\n'); + +test('SQLite 日期时间可以被解析', () => { + const parsed = parseDateTimeValue('2026-07-27 12:34:56'); + assert.ok(parsed instanceof Date); + assert.strictEqual(parsed.getFullYear(), 2026); + assert.strictEqual(parsed.getMonth(), 6); + assert.strictEqual(parsed.getDate(), 27); +}); + +test('非法日期时间返回 null', () => { + assert.strictEqual(parseDateTimeValue('not-a-date'), null); + assert.strictEqual(parseDateTimeValue(null), null); +}); + +test('日期时间按 SQLite 格式输出', () => { + const value = new Date(2026, 6, 27, 3, 4, 5); + assert.strictEqual(formatDateTimeForSqlite(value), '2026-07-27 03:04:05'); +}); + +test('日期键校验有效和无效日期', () => { + assert.strictEqual(getDateKeyFromDate(new Date(2026, 0, 2)), '2026-01-02'); + assert.strictEqual(getDateKeyFromDate('invalid'), null); +}); + +test('最近日期键按时间顺序生成', () => { + const keys = getRecentDateKeys(3, new Date(2026, 0, 2, 12, 0, 0)); + assert.deepStrictEqual(keys, ['2025-12-31', '2026-01-01', '2026-01-02']); +}); + +test('下载配额重置时间支持日周月周期', () => { + assert.strictEqual( + formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'daily')), + '2026-01-02 00:00:00' + ); + assert.strictEqual( + formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'weekly')), + '2026-01-08 00:00:00' + ); + assert.strictEqual( + formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'monthly')), + '2026-02-01 00:00:00' + ); + assert.strictEqual(getNextDownloadResetTime('2026-01-01 00:00:00', 'none'), null); +}); + +test('访问时间格式被严格规范化', () => { + assert.strictEqual(normalizeTimeHHmm(' 09:05 '), '09:05'); + assert.strictEqual(normalizeTimeHHmm('9:05'), null); + assert.strictEqual(normalizeTimeHHmm('24:00'), null); +}); + +test('同日访问时间窗口正确判断', () => { + assert.strictEqual(isCurrentTimeInWindow('09:00', '18:00', new Date(2026, 0, 1, 12, 0)), true); + assert.strictEqual(isCurrentTimeInWindow('09:00', '18:00', new Date(2026, 0, 1, 18, 0)), false); +}); + +test('跨日访问时间窗口正确判断', () => { + assert.strictEqual(isCurrentTimeInWindow('22:00', '06:00', new Date(2026, 0, 1, 23, 0)), true); + assert.strictEqual(isCurrentTimeInWindow('22:00', '06:00', new Date(2026, 0, 1, 12, 0)), false); +}); + +test('非法下载配额被禁止而负数统一为不限', () => { + assert.strictEqual(normalizeDownloadTrafficQuota('invalid'), 0); + assert.strictEqual(normalizeDownloadTrafficQuota(-99), -1); +}); + +test('下载配额被取整并限制在 10TB', () => { + assert.strictEqual(normalizeDownloadTrafficQuota(10.9), 10); + assert.strictEqual(normalizeDownloadTrafficQuota(MAX_DOWNLOAD_TRAFFIC_BYTES + 1), MAX_DOWNLOAD_TRAFFIC_BYTES); +}); + +test('已用流量按有限配额封顶', () => { + assert.strictEqual(normalizeDownloadTrafficUsed(150, 100), 100); + assert.strictEqual(normalizeDownloadTrafficUsed(150, -1), 150); + assert.strictEqual(normalizeDownloadTrafficUsed(-1, 100), 0); +}); + +test('不限流量状态保留无限剩余额度', () => { + const state = getDownloadTrafficState({ download_traffic_quota: -1, download_traffic_used: 123 }); + assert.strictEqual(state.isUnlimited, true); + assert.strictEqual(state.used, 123); + assert.strictEqual(state.remaining, Number.POSITIVE_INFINITY); +}); + +test('空用户不会产生配额更新', () => { + assert.deepStrictEqual(resolveDownloadTrafficPolicyUpdates(null), { + updates: {}, + hasUpdates: false, + expired: false, + resetApplied: false + }); +}); + +test('非法重置周期会被规范化', () => { + const result = resolveDownloadTrafficPolicyUpdates({ + download_traffic_quota: 100, + download_traffic_used: 0, + download_traffic_reset_cycle: 'yearly' + }); + assert.strictEqual(result.updates.download_traffic_reset_cycle, 'none'); +}); + +test('周期配额首次运行时记录重置时间', () => { + const now = new Date(2026, 6, 27, 12, 0, 0); + const result = resolveDownloadTrafficPolicyUpdates({ + download_traffic_quota: 100, + download_traffic_used: 10, + download_traffic_reset_cycle: 'daily', + download_traffic_last_reset_at: null + }, now); + assert.strictEqual(result.updates.download_traffic_last_reset_at, '2026-07-27 12:00:00'); +}); + +test('到达周期时已用流量归零', () => { + const result = resolveDownloadTrafficPolicyUpdates({ + download_traffic_quota: 100, + download_traffic_used: 90, + download_traffic_reset_cycle: 'daily', + download_traffic_last_reset_at: '2026-07-26 12:00:00' + }, new Date(2026, 6, 27, 12, 0, 0)); + assert.strictEqual(result.resetApplied, true); + assert.strictEqual(result.updates.download_traffic_used, 0); +}); + +test('限时配额到期后恢复为不限流量', () => { + const result = resolveDownloadTrafficPolicyUpdates({ + download_traffic_quota: 100, + download_traffic_used: 90, + download_traffic_quota_expires_at: '2026-07-26 12:00:00', + download_traffic_reset_cycle: 'daily', + download_traffic_last_reset_at: '2026-07-26 12:00:00' + }, new Date(2026, 6, 27, 12, 0, 0)); + assert.strictEqual(result.expired, true); + assert.strictEqual(result.updates.download_traffic_quota, -1); + assert.strictEqual(result.updates.download_traffic_used, 0); + assert.strictEqual(result.updates.download_traffic_quota_expires_at, null); +}); + +test('Express 错误处理器保留四参数签名', () => { + assert.strictEqual(expressErrorHandler.length, 4); +}); + +test('Express 错误处理器返回客户端错误 JSON', () => { + const response = createResponse(); + const originalConsoleError = console.error; + console.error = () => {}; + try { + expressErrorHandler( + Object.assign(new Error('请求 JSON 无效'), { status: 400 }), + { method: 'POST', originalUrl: '/api/login' }, + response, + () => assert.fail('不应调用 next') + ); + } finally { + console.error = originalConsoleError; + } + assert.strictEqual(response.statusCode, 400); + assert.deepStrictEqual(response.payload, { success: false, message: '请求 JSON 无效' }); +}); + +test('Express 错误处理器隐藏服务端异常详情', () => { + const response = createResponse(); + const originalConsoleError = console.error; + console.error = () => {}; + try { + expressErrorHandler( + new Error('sensitive detail'), + { method: 'GET', originalUrl: '/api/example' }, + response, + () => assert.fail('不应调用 next') + ); + } finally { + console.error = originalConsoleError; + } + assert.strictEqual(response.statusCode, 500); + assert.deepStrictEqual(response.payload, { success: false, message: '服务器内部错误' }); +}); + +test('响应头已发送时错误处理器继续交给 Express', () => { + const response = createResponse(true); + const error = new Error('stream failed'); + let forwarded = null; + expressErrorHandler(error, {}, response, (received) => { + forwarded = received; + }); + assert.strictEqual(forwarded, error); +}); + +console.log('\n========================================'); +console.log('测试总结'); +console.log('========================================'); +console.log(`通过: ${results.passed}`); +console.log(`失败: ${results.failed}`); + +process.exit(results.failed > 0 ? 1 : 0); diff --git a/backend/tests/run-all-tests.js b/backend/tests/run-all-tests.js index dd9dcaf..0de186a 100644 --- a/backend/tests/run-all-tests.js +++ b/backend/tests/run-all-tests.js @@ -7,6 +7,8 @@ const path = require('path'); const testFiles = [ 'boundary-tests.js', + 'production-utils-tests.js', + 'archive-tests.js', 'network-concurrent-tests.js', 'state-consistency-tests.js' ]; @@ -42,7 +44,9 @@ function runTest(file) { const failMatch = output.match(/失败:\s*(\d+)/); const passed = passMatch ? parseInt(passMatch[1]) : 0; - const failed = failMatch ? parseInt(failMatch[1]) : 0; + const parsedFailed = failMatch ? parseInt(failMatch[1]) : 0; + // A syntax error or early crash may not print the normal summary. + const failed = code !== 0 && parsedFailed === 0 ? 1 : parsedFailed; results.files.push({ file, @@ -91,7 +95,8 @@ async function runAllTests() { console.log(`总计: 通过 ${results.total.passed}, 失败 ${results.total.failed}`); console.log(''); - if (results.total.failed > 0) { + const hasProcessFailure = results.files.some(file => file.exitCode !== 0); + if (results.total.failed > 0 || hasProcessFailure) { console.log('存在失败的测试,请检查输出以了解详情。'); process.exit(1); } else { diff --git a/backend/utils/archive.js b/backend/utils/archive.js new file mode 100644 index 0000000..b855cbb --- /dev/null +++ b/backend/utils/archive.js @@ -0,0 +1,9 @@ +let archiverModulePromise = null; + +async function createZipArchive(options = {}) { + archiverModulePromise ||= import('archiver'); + const { ZipArchive } = await archiverModulePromise; + return new ZipArchive(options); +} + +module.exports = { createZipArchive }; diff --git a/backend/utils/datetime.js b/backend/utils/datetime.js new file mode 100644 index 0000000..da6b8cf --- /dev/null +++ b/backend/utils/datetime.js @@ -0,0 +1,92 @@ +function parseDateTimeValue(value) { + if (!value || typeof value !== 'string') return null; + + const directDate = new Date(value); + if (!Number.isNaN(directDate.getTime())) return directDate; + + const normalizedDate = new Date(value.replace(' ', 'T')); + return Number.isNaN(normalizedDate.getTime()) ? null : normalizedDate; +} + +function formatDateTimeForSqlite(date = new Date()) { + const target = date instanceof Date ? date : new Date(date); + const year = target.getFullYear(); + const month = String(target.getMonth() + 1).padStart(2, '0'); + const day = String(target.getDate()).padStart(2, '0'); + const hours = String(target.getHours()).padStart(2, '0'); + const minutes = String(target.getMinutes()).padStart(2, '0'); + const seconds = String(target.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +function getDateKeyFromDate(date = new Date()) { + const target = date instanceof Date ? date : new Date(date); + if (Number.isNaN(target.getTime())) return null; + const year = target.getFullYear(); + const month = String(target.getMonth() + 1).padStart(2, '0'); + const day = String(target.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function getRecentDateKeys(days = 30, now = new Date()) { + const safeDays = Math.max(1, Math.floor(Number(days) || 30)); + const keys = []; + for (let i = safeDays - 1; i >= 0; i -= 1) { + const date = new Date(now.getTime()); + date.setDate(date.getDate() - i); + const key = getDateKeyFromDate(date); + if (key) keys.push(key); + } + return keys; +} + +function getNextDownloadResetTime(lastResetAt, resetCycle) { + const baseDate = parseDateTimeValue(lastResetAt); + if (!baseDate) return null; + + const next = new Date(baseDate.getTime()); + if (resetCycle === 'daily') next.setDate(next.getDate() + 1); + else if (resetCycle === 'weekly') next.setDate(next.getDate() + 7); + else if (resetCycle === 'monthly') next.setMonth(next.getMonth() + 1); + else return null; + return next; +} + +function normalizeTimeHHmm(value) { + if (typeof value !== 'string') return null; + const match = value.trim().match(/^(\d{2}):(\d{2})$/); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +} + +function toMinutesOfDay(hhmm) { + const normalized = normalizeTimeHHmm(hhmm); + if (!normalized) return null; + const [hours, minutes] = normalized.split(':').map(Number); + return hours * 60 + minutes; +} + +function isCurrentTimeInWindow(startTime, endTime, now = new Date()) { + const start = toMinutesOfDay(startTime); + const end = toMinutesOfDay(endTime); + if (start === null || end === null) return true; + + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + if (start === end) return true; + if (start < end) return nowMinutes >= start && nowMinutes < end; + return nowMinutes >= start || nowMinutes < end; +} + +module.exports = { + parseDateTimeValue, + formatDateTimeForSqlite, + getDateKeyFromDate, + getRecentDateKeys, + getNextDownloadResetTime, + normalizeTimeHHmm, + toMinutesOfDay, + isCurrentTimeInWindow +}; diff --git a/backend/utils/download-quota.js b/backend/utils/download-quota.js new file mode 100644 index 0000000..05d416f --- /dev/null +++ b/backend/utils/download-quota.js @@ -0,0 +1,107 @@ +const { + parseDateTimeValue, + formatDateTimeForSqlite, + getNextDownloadResetTime +} = require('./datetime'); + +const MAX_DOWNLOAD_TRAFFIC_BYTES = 10 * 1024 * 1024 * 1024 * 1024; + +function normalizeDownloadTrafficQuota(rawQuota) { + const parsedQuota = Number(rawQuota); + if (!Number.isFinite(parsedQuota)) return 0; + if (parsedQuota < 0) return -1; + return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota)); +} + +function normalizeDownloadTrafficUsed(rawUsed, quota = 0) { + const parsedUsed = Number(rawUsed); + const normalizedUsed = Number.isFinite(parsedUsed) && parsedUsed > 0 + ? Math.floor(parsedUsed) + : 0; + return quota >= 0 ? Math.min(normalizedUsed, quota) : normalizedUsed; +} + +function getDownloadTrafficState(user) { + const quota = normalizeDownloadTrafficQuota(user?.download_traffic_quota); + const used = normalizeDownloadTrafficUsed(user?.download_traffic_used, quota); + const isUnlimited = quota < 0; + return { + quota, + used, + isUnlimited, + remaining: isUnlimited ? Number.POSITIVE_INFINITY : Math.max(0, quota - used) + }; +} + +function resolveDownloadTrafficPolicyUpdates(user, now = new Date()) { + if (!user) { + return { updates: {}, hasUpdates: false, expired: false, resetApplied: false }; + } + + const updates = {}; + let hasUpdates = false; + let expired = false; + let resetApplied = false; + + const normalizedQuota = normalizeDownloadTrafficQuota(user.download_traffic_quota); + const normalizedUsed = normalizeDownloadTrafficUsed(user.download_traffic_used, normalizedQuota); + if (normalizedQuota !== Number(user.download_traffic_quota || 0)) { + updates.download_traffic_quota = normalizedQuota; + hasUpdates = true; + } + if (normalizedUsed !== Number(user.download_traffic_used || 0)) { + updates.download_traffic_used = normalizedUsed; + hasUpdates = true; + } + + const resetCycle = ['none', 'daily', 'weekly', 'monthly'].includes(user.download_traffic_reset_cycle) + ? user.download_traffic_reset_cycle + : 'none'; + if (resetCycle !== (user.download_traffic_reset_cycle || 'none')) { + updates.download_traffic_reset_cycle = resetCycle; + hasUpdates = true; + } + + const expiresAt = parseDateTimeValue(user.download_traffic_quota_expires_at); + if (normalizedQuota <= 0 && user.download_traffic_quota_expires_at) { + updates.download_traffic_quota_expires_at = null; + hasUpdates = true; + } else if (normalizedQuota > 0 && expiresAt && now >= expiresAt) { + updates.download_traffic_quota = -1; + updates.download_traffic_used = 0; + updates.download_traffic_quota_expires_at = null; + updates.download_traffic_reset_cycle = 'none'; + updates.download_traffic_last_reset_at = null; + hasUpdates = true; + expired = true; + } + + if (!expired && resetCycle !== 'none') { + const lastResetAt = user.download_traffic_last_reset_at; + if (!lastResetAt) { + updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now); + hasUpdates = true; + } else { + const nextResetAt = getNextDownloadResetTime(lastResetAt, resetCycle); + if (nextResetAt && now >= nextResetAt) { + updates.download_traffic_used = 0; + updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now); + hasUpdates = true; + resetApplied = true; + } + } + } else if (resetCycle === 'none' && user.download_traffic_last_reset_at) { + updates.download_traffic_last_reset_at = null; + hasUpdates = true; + } + + return { updates, hasUpdates, expired, resetApplied }; +} + +module.exports = { + MAX_DOWNLOAD_TRAFFIC_BYTES, + normalizeDownloadTrafficQuota, + normalizeDownloadTrafficUsed, + getDownloadTrafficState, + resolveDownloadTrafficPolicyUpdates +}; diff --git a/backend/utils/input-security.js b/backend/utils/input-security.js new file mode 100644 index 0000000..79dcf0e --- /dev/null +++ b/backend/utils/input-security.js @@ -0,0 +1,98 @@ +const path = require('path'); + +const DANGEROUS_EXTENSIONS = [ + '.php', '.php3', '.php4', '.php5', '.phtml', '.phar', + '.jsp', '.jspx', '.jsw', '.jsv', '.jspf', + '.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', + '.htaccess', '.htpasswd' +]; + +function sanitizeInput(str) { + if (typeof str !== 'string') return str; + + let sanitized = str.replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char])); + + sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, ''); + return sanitized.replace(/\x00/g, ''); +} + +function decodeHtmlEntities(str) { + if (typeof str !== 'string') return str; + + const entityMap = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + '#x27': "'", + '#x2F': '/', + '#x60': '`' + }; + + const decodeOnce = (input) => + input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => { + if (code[0] === '#') { + const isHex = code[1]?.toLowerCase() === 'x'; + const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10); + return Number.isNaN(num) ? match : String.fromCharCode(num); + } + const mapped = entityMap[code]; + return mapped !== undefined ? mapped : match; + }); + + let output = str; + let decoded = decodeOnce(output); + while (decoded !== output) { + output = decoded; + decoded = decodeOnce(output); + } + return output; +} + +function escapeHtml(str) { + if (typeof str !== 'string') return str; + return str.replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char])); +} + +function isSafePathSegment(name) { + return ( + typeof name === 'string' && + name.length > 0 && + name.length <= 255 && + !name.includes('..') && + !/[/\\]/.test(name) && + !/[\x00-\x1F]/.test(name) + ); +} + +function isFileExtensionSafe(filename) { + if (!filename || typeof filename !== 'string') return false; + + const ext = path.extname(filename).toLowerCase(); + const nameLower = filename.toLowerCase(); + if (DANGEROUS_EXTENSIONS.includes(ext)) return false; + if (['.htaccess', '.htpasswd'].includes(nameLower)) return false; + + return !DANGEROUS_EXTENSIONS.some((dangerExt) => nameLower.includes(`${dangerExt}.`)); +} + +module.exports = { + sanitizeInput, + decodeHtmlEntities, + escapeHtml, + isSafePathSegment, + isFileExtensionSafe +}; diff --git a/install.sh b/install.sh index 2511db7..7e21beb 100644 --- a/install.sh +++ b/install.sh @@ -2091,15 +2091,15 @@ install_backend_dependencies() { print_info "正在安装依赖包(包含数据库native模块,可能需要几分钟)..." - # 安装依赖,捕获错误 - if PYTHON=python3 npm install --production; then + # 严格按锁文件安装依赖,捕获错误 + if PYTHON=python3 npm ci --omit=dev; then print_success "后端依赖安装完成" else print_error "依赖安装失败" echo "" print_warning "可能的解决方案:" echo " 1. 检查网络连接" - echo " 2. 手动执行: cd ${PROJECT_DIR}/backend && npm install --production" + echo " 2. 手动执行: cd ${PROJECT_DIR}/backend && npm ci --omit=dev" echo " 3. 查看详细错误日志: ~/.npm/_logs/" echo "" @@ -2875,7 +2875,7 @@ start_backend_service() { cd "${PROJECT_DIR}/backend" - # 使用PM2启动 + # 限流器与用量缓存是进程内状态,必须保持单实例 fork 模式。 pm2 start server.js --name ${PROJECT_NAME}-backend pm2 save @@ -3333,7 +3333,7 @@ confirm_update() { echo "" echo "【将要更新】" echo " ✓ 从仓库拉取最新代码" - echo " ✓ 更新后端依赖(npm install)" + echo " ✓ 更新后端依赖(npm ci)" echo " ✓ 重启后端服务" echo "" echo "【将会保留】" @@ -3516,7 +3516,7 @@ update_install_dependencies() { print_info "正在重新安装依赖(可能需要几分钟)..." - if PYTHON=python3 npm install --production; then + if PYTHON=python3 npm ci --omit=dev; then print_success "依赖更新完成" else print_error "依赖更新失败" @@ -4173,6 +4173,7 @@ repair_restart_services() { else print_warning "后端服务未运行,尝试启动..." cd "${PROJECT_DIR}/backend" + # 限流器与用量缓存是进程内状态,必须保持单实例 fork 模式。 pm2 start server.js --name ${PROJECT_NAME}-backend pm2 save print_success "后端服务已启动"