From dce1e3622a8c055da9e0582478d59dfc95bd1ffe Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@users.noreply.git.workyai.cn> Date: Mon, 27 Jul 2026 13:06:54 +0800 Subject: [PATCH] refactor: modularize backend security and observability --- backend/.env.example | 10 + backend/middleware/csrf.js | 68 +++ backend/middleware/error-handler.js | 16 +- backend/middleware/rate-limit.js | 149 +++++ backend/middleware/security.js | 49 ++ backend/package-lock.json | 202 ++++++- backend/package.json | 5 +- backend/routes/health.js | 52 -- backend/routes/index.js | 90 --- backend/routes/system.routes.js | 35 ++ backend/server.js | 772 +++--------------------- backend/services/health-service.js | 59 ++ backend/tests/architecture-tests.js | 113 ++++ backend/tests/domain-utils-tests.js | 155 +++++ backend/tests/middleware-tests.js | 180 ++++++ backend/tests/production-utils-tests.js | 2 + backend/tests/run-all-tests.js | 3 + backend/utils/coerce.js | 26 + backend/utils/device.js | 68 +++ backend/utils/logger.js | 137 +++++ backend/utils/oss-log-parser.js | 115 ++++ backend/utils/path-safety.js | 34 ++ backend/utils/share.js | 54 ++ backend/utils/url.js | 43 ++ backend/utils/version.js | 45 ++ 25 files changed, 1633 insertions(+), 849 deletions(-) create mode 100644 backend/middleware/csrf.js create mode 100644 backend/middleware/rate-limit.js create mode 100644 backend/middleware/security.js delete mode 100644 backend/routes/health.js delete mode 100644 backend/routes/index.js create mode 100644 backend/routes/system.routes.js create mode 100644 backend/services/health-service.js create mode 100644 backend/tests/architecture-tests.js create mode 100644 backend/tests/domain-utils-tests.js create mode 100644 backend/tests/middleware-tests.js create mode 100644 backend/utils/coerce.js create mode 100644 backend/utils/device.js create mode 100644 backend/utils/logger.js create mode 100644 backend/utils/oss-log-parser.js create mode 100644 backend/utils/path-safety.js create mode 100644 backend/utils/share.js create mode 100644 backend/utils/url.js create mode 100644 backend/utils/version.js diff --git a/backend/.env.example b/backend/.env.example index c3146ea..31d2400 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -89,6 +89,13 @@ COOKIE_SECURE=true # 前端会自动从 Cookie 读取 csrf_token 并在请求头中发送 ENABLE_CSRF=true +# 全局 API IP 限流(现有登录/分享细粒度限流之外的连接兜底) +API_RATE_LIMIT_WINDOW_MS=60000 +API_RATE_LIMIT_MAX=600 + +# 健康检查判定磁盘空间不足的阈值(默认 256MB) +HEALTH_MIN_DISK_FREE_BYTES=268435456 + # ============================================ # 反向代理配置(Nginx/Cloudflare等) # ============================================ @@ -147,6 +154,9 @@ STORAGE_ROOT=./storage # 日志级别 (error, warn, info, debug) # LOG_LEVEL=info +# production 默认输出 JSON;开发环境可显式开启 +# LOG_FORMAT=json + # 是否启用调试模式 # DEBUG=false diff --git a/backend/middleware/csrf.js b/backend/middleware/csrf.js new file mode 100644 index 0000000..37046a8 --- /dev/null +++ b/backend/middleware/csrf.js @@ -0,0 +1,68 @@ +const crypto = require('crypto'); + +const { logger } = require('../utils/logger'); + +const CSRF_COOKIE_NAME = 'csrf_token'; +const CSRF_COOKIE_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +function generateCsrfToken() { + return crypto.randomBytes(32).toString('hex'); +} + +function setCsrfCookie(res, token, secureCookies) { + res.cookie(CSRF_COOKIE_NAME, token, { + httpOnly: false, + secure: secureCookies, + sameSite: secureCookies ? 'strict' : 'lax', + maxAge: CSRF_COOKIE_MAX_AGE_MS + }); +} + +function createCsrfCookieMiddleware({ secureCookies = false } = {}) { + return (req, res, next) => { + if (!req.cookies[CSRF_COOKIE_NAME]) { + setCsrfCookie(res, generateCsrfToken(), secureCookies); + } + next(); + }; +} + +function tokensEqual(left, right) { + if (typeof left !== 'string' || typeof right !== 'string') return false; + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function csrfProtection(req, res, next) { + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + + const hasCookieAuth = Boolean(req.cookies?.token || req.cookies?.refreshToken); + if (!hasCookieAuth) return next(); + + const cookieToken = req.cookies[CSRF_COOKIE_NAME]; + const headerToken = req.headers['x-csrf-token']; + if (!tokensEqual(cookieToken, headerToken)) { + (req.log || logger).warn({ + path: req.path, + hasCookieToken: Boolean(cookieToken), + hasHeaderToken: Boolean(headerToken) + }, 'CSRF validation failed'); + return res.status(403).json({ + success: false, + message: 'CSRF 验证失败,请刷新页面后重试', + requestId: req.id + }); + } + + return next(); +} + +module.exports = { + CSRF_COOKIE_NAME, + createCsrfCookieMiddleware, + csrfProtection, + generateCsrfToken, + setCsrfCookie, + tokensEqual +}; diff --git a/backend/middleware/error-handler.js b/backend/middleware/error-handler.js index f376fcd..b8b5516 100644 --- a/backend/middleware/error-handler.js +++ b/backend/middleware/error-handler.js @@ -1,3 +1,5 @@ +const { getRequestId, logger } = require('../utils/logger'); + function expressErrorHandler(err, req, res, next) { if (res.headersSent) return next(err); @@ -6,14 +8,18 @@ function expressErrorHandler(err, req, res, next) { ? requestedStatus : 500; - console.error(`[未处理错误] ${req.method} ${req.originalUrl}`, { - message: err?.message, - stack: err?.stack - }); + const requestId = getRequestId(req); + (req.log || logger).error({ + err, + method: req.method, + path: req.path || req.originalUrl, + statusCode: status + }, 'unhandled request error'); return res.status(status).json({ success: false, - message: status >= 500 ? '服务器内部错误' : (err?.message || '请求处理失败') + message: status >= 500 ? '服务器内部错误' : (err?.message || '请求处理失败'), + ...(requestId ? { requestId } : {}) }); } diff --git a/backend/middleware/rate-limit.js b/backend/middleware/rate-limit.js new file mode 100644 index 0000000..8425c5d --- /dev/null +++ b/backend/middleware/rate-limit.js @@ -0,0 +1,149 @@ +const { rateLimit } = require('express-rate-limit'); + +const { logger } = require('../utils/logger'); + +function parsePositiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function createGlobalApiLimiter(options = {}) { + const windowMs = parsePositiveInteger(options.windowMs ?? process.env.API_RATE_LIMIT_WINDOW_MS, 60_000); + const limit = parsePositiveInteger(options.limit ?? process.env.API_RATE_LIMIT_MAX, 600); + + return rateLimit({ + windowMs, + limit, + standardHeaders: 'draft-8', + legacyHeaders: false, + skip: (req) => req.method === 'OPTIONS' || req.path === '/health', + handler: (req, res) => { + (req.log || logger).warn({ method: req.method, path: req.path }, 'global API rate limit exceeded'); + res.status(429).json({ + success: false, + message: '请求过于频繁,请稍后再试', + requestId: req.id + }); + } + }); +} + +class RateLimiter { + constructor(options = {}) { + this.maxAttempts = options.maxAttempts || 5; + this.windowMs = options.windowMs || 15 * 60 * 1000; + this.blockDuration = options.blockDuration || 30 * 60 * 1000; + this.attempts = new Map(); + this.blockedKeys = new Map(); + this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000); + this.cleanupInterval.unref?.(); + } + + getClientKey(req) { + return req.ip || req.socket?.remoteAddress || 'unknown'; + } + + isBlocked(key) { + const blockInfo = this.blockedKeys.get(key); + if (!blockInfo) return false; + + if (Date.now() > blockInfo.expiresAt) { + this.blockedKeys.delete(key); + this.attempts.delete(key); + return false; + } + return true; + } + + recordFailure(key) { + const now = Date.now(); + if (this.isBlocked(key)) { + const blockInfo = this.blockedKeys.get(key); + return { + blocked: true, + remainingAttempts: 0, + resetTime: blockInfo.expiresAt, + waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60_000), + needCaptcha: true + }; + } + + let attemptInfo = this.attempts.get(key); + if (!attemptInfo || now > attemptInfo.windowEnd) { + attemptInfo = { count: 0, windowEnd: now + this.windowMs, firstAttempt: now }; + } + + attemptInfo.count += 1; + this.attempts.set(key, attemptInfo); + if (attemptInfo.count >= this.maxAttempts) { + const blockExpiresAt = now + this.blockDuration; + this.blockedKeys.set(key, { expiresAt: blockExpiresAt, blockedAt: now }); + logger.warn({ key, attempts: attemptInfo.count, blockDuration: this.blockDuration }, 'failure limiter blocked key'); + return { + blocked: true, + remainingAttempts: 0, + resetTime: blockExpiresAt, + waitMinutes: Math.ceil(this.blockDuration / 60_000), + needCaptcha: true + }; + } + + return { + blocked: false, + remainingAttempts: this.maxAttempts - attemptInfo.count, + resetTime: attemptInfo.windowEnd, + waitMinutes: 0, + needCaptcha: attemptInfo.count >= 2 + }; + } + + getFailureCount(key) { + const attemptInfo = this.attempts.get(key); + if (!attemptInfo || Date.now() > attemptInfo.windowEnd) return 0; + return attemptInfo.count; + } + + recordSuccess(key) { + this.attempts.delete(key); + this.blockedKeys.delete(key); + } + + cleanup() { + const now = Date.now(); + let cleanedAttempts = 0; + let cleanedBlocks = 0; + + for (const [key, info] of this.attempts.entries()) { + if (now > info.windowEnd) { + this.attempts.delete(key); + cleanedAttempts += 1; + } + } + for (const [key, info] of this.blockedKeys.entries()) { + if (now > info.expiresAt) { + this.blockedKeys.delete(key); + cleanedBlocks += 1; + } + } + + if (cleanedAttempts || cleanedBlocks) { + logger.info({ cleanedAttempts, cleanedBlocks }, 'failure limiter cleanup completed'); + } + } + + getStats() { + return { + activeAttempts: this.attempts.size, + blockedKeys: this.blockedKeys.size + }; + } + + destroy() { + if (this.cleanupInterval) clearInterval(this.cleanupInterval); + } +} + +module.exports = { + RateLimiter, + createGlobalApiLimiter +}; diff --git a/backend/middleware/security.js b/backend/middleware/security.js new file mode 100644 index 0000000..eaf66b2 --- /dev/null +++ b/backend/middleware/security.js @@ -0,0 +1,49 @@ +const LOCAL_HEALTH_ADDRESSES = new Set([ + '127.0.0.1', + '::1', + '::ffff:127.0.0.1' +]); + +function applySecurityHeaders(req, res, options = {}) { + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), microphone=()'); + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' blob: https:; connect-src 'self' https: ws: wss:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self';" + ); + + if (req?.secure || (!req && options.secureByDefault)) { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + res.removeHeader('X-Powered-By'); +} + +function securityHeadersMiddleware(options = {}) { + return (req, res, next) => { + applySecurityHeaders(req, res, options); + next(); + }; +} + +function createHttpsEnforcementMiddleware({ enabled = false } = {}) { + return (req, res, next) => { + const remoteAddress = req.socket?.remoteAddress || ''; + const isLocalHealthCheck = req.path === '/api/health' && LOCAL_HEALTH_ADDRESSES.has(remoteAddress); + if (!enabled || isLocalHealthCheck || req.secure) return next(); + + return res.status(400).json({ + success: false, + message: '仅支持HTTPS访问,请使用HTTPS', + requestId: req.id + }); + }; +} + +module.exports = { + applySecurityHeaders, + createHttpsEnforcementMiddleware, + securityHeadersMiddleware +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 67cff41..56e2895 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,15 +18,18 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "3.1.1", + "express-rate-limit": "8.6.1", "express-validator": "^7.3.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.23", "multer": "^2.2.0", "nodemailer": "^9.0.3", + "pino": "10.3.1", "svg-captcha": "^1.4.0" }, "engines": { - "node": ">=20 <25" + "node": ">=20.19 <25" } }, "node_modules/@aws-crypto/crc32": { @@ -973,6 +976,12 @@ ], "license": "MIT" }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@smithy/abort-controller": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", @@ -1780,6 +1789,15 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/b4a": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", @@ -2404,6 +2422,57 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "license": "ISC", + "peerDependencies": { + "express": "^4.16.2" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/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==", + "license": "MIT" + }, "node_modules/express-validator": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", @@ -2660,6 +2729,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3028,6 +3106,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3091,6 +3178,43 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -3132,6 +3256,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3170,6 +3310,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3264,6 +3410,15 @@ "url": "https://github.com/sponsors/yqnn" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3284,6 +3439,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3470,6 +3634,24 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3605,6 +3787,24 @@ "b4a": "^1.6.4" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index ab532f5..5be8ffd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "description": "玩玩云 - 云存储管理平台后端服务", "main": "server.js", "engines": { - "node": ">=20 <25" + "node": ">=20.19 <25" }, "scripts": { "start": "node server.js", @@ -33,11 +33,14 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "3.1.1", + "express-rate-limit": "8.6.1", "express-validator": "^7.3.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.23", "multer": "^2.2.0", "nodemailer": "^9.0.3", + "pino": "10.3.1", "svg-captcha": "^1.4.0" } } diff --git a/backend/routes/health.js b/backend/routes/health.js deleted file mode 100644 index 76284e5..0000000 --- a/backend/routes/health.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * 健康检查和公共配置路由 - * 提供服务健康状态和公共配置信息 - */ - -const express = require('express'); -const router = express.Router(); -const { SettingsDB } = require('../database'); - -/** - * 健康检查端点 - * GET /api/health - */ -router.get('/health', (req, res) => { - res.json({ success: true, message: 'Server is running' }); -}); - -/** - * 获取公开的系统配置(不需要登录) - * GET /api/config - */ -router.get('/config', (req, res) => { - const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240'); - res.json({ - success: true, - config: { - max_upload_size: maxUploadSize - } - }); -}); - -/** - * 获取公开的全局主题设置(不需要登录) - * GET /api/public/theme - */ -router.get('/public/theme', (req, res) => { - try { - const globalTheme = SettingsDB.get('global_theme') || 'dark'; - res.json({ - success: true, - theme: globalTheme - }); - } catch (error) { - console.error('获取全局主题失败:', error); - res.status(500).json({ - success: false, - message: '获取主题失败' - }); - } -}); - -module.exports = router; diff --git a/backend/routes/index.js b/backend/routes/index.js deleted file mode 100644 index e572cba..0000000 --- a/backend/routes/index.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 路由模块索引 - * - * 本项目的路由目前主要定义在 server.js 中。 - * 此目录用于未来路由拆分的模块化重构。 - * - * 建议的路由模块拆分方案: - * - * 1. routes/health.js - 健康检查和公共配置 - * - GET /api/health - * - GET /api/config - * - GET /api/public/theme - * - * 2. routes/auth.js - 认证相关 - * - POST /api/login - * - POST /api/register - * - POST /api/logout - * - POST /api/refresh-token - * - POST /api/password/forgot - * - POST /api/password/reset - * - GET /api/verify-email - * - POST /api/resend-verification - * - GET /api/captcha - * - GET /api/csrf-token - * - * 3. routes/user.js - 用户相关 - * - GET /api/user/profile - * - GET /api/user/theme - * - POST /api/user/theme - * - POST /api/user/update-oss - * - POST /api/user/test-oss - * - GET /api/user/oss-usage - * - POST /api/user/change-password - * - POST /api/user/update-username - * - POST /api/user/switch-storage - * - * 4. routes/files.js - 文件操作 - * - GET /api/files - * - POST /api/files/rename - * - POST /api/files/mkdir - * - POST /api/files/folder-info - * - POST /api/files/delete - * - GET /api/files/upload-signature - * - POST /api/files/upload-complete - * - GET /api/files/download-url - * - GET /api/files/download - * - POST /api/upload - * - * 5. routes/share.js - 分享功能 - * - POST /api/share/create - * - GET /api/share/my - * - DELETE /api/share/:id - * - GET /api/share/:code/theme - * - POST /api/share/:code/verify - * - POST /api/share/:code/list - * - POST /api/share/:code/download - * - POST /api/share/:code/download-url - * - GET /api/share/:code/download-file - * - * 6. routes/admin.js - 管理员功能 - * - GET /api/admin/settings - * - POST /api/admin/settings - * - POST /api/admin/settings/test-smtp - * - GET /api/admin/health-check - * - GET /api/admin/storage-stats - * - GET /api/admin/users - * - GET /api/admin/logs - * - GET /api/admin/logs/stats - * - POST /api/admin/logs/cleanup - * - POST /api/admin/users/:id/ban - * - DELETE /api/admin/users/:id - * - POST /api/admin/users/:id/storage-permission - * - GET /api/admin/users/:id/files - * - GET /api/admin/shares - * - DELETE /api/admin/shares/:id - * - GET /api/admin/check-upload-tool - * - POST /api/admin/upload-tool - * - * 使用示例(在 server.js 中): - * ```javascript - * const healthRoutes = require('./routes/health'); - * app.use('/api', healthRoutes); - * ``` - */ - -const healthRoutes = require('./health'); - -module.exports = { - healthRoutes -}; diff --git a/backend/routes/system.routes.js b/backend/routes/system.routes.js new file mode 100644 index 0000000..cea5117 --- /dev/null +++ b/backend/routes/system.routes.js @@ -0,0 +1,35 @@ +const express = require('express'); + +function createSystemRouter({ SettingsDB, healthService }) { + const router = express.Router(); + + router.get('/health', (req, res) => { + const result = healthService.check(); + res.status(result.healthy ? 200 : 503).json({ + success: result.healthy, + status: result.status, + uptimeSeconds: Math.floor(process.uptime()), + timestamp: new Date().toISOString(), + requestId: req.id, + checks: result.checks + }); + }); + + router.get('/config', (req, res) => { + const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240', 10); + res.json({ success: true, config: { max_upload_size: maxUploadSize } }); + }); + + router.get('/public/theme', (req, res) => { + try { + const globalTheme = SettingsDB.get('global_theme') || 'dark'; + res.json({ success: true, theme: globalTheme }); + } catch { + res.json({ success: true, theme: 'dark' }); + } + }); + + return router; +} + +module.exports = { createSystemRouter }; diff --git a/backend/server.js b/backend/server.js index ce6181c..a65bf90 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,7 +1,11 @@ // 加载环境变量(必须在最开始) require('dotenv').config(); +const { installConsoleBridge, logger, requestContextMiddleware } = require('./utils/logger'); +installConsoleBridge(); + const express = require('express'); +require('express-async-errors'); const cors = require('cors'); const cookieParser = require('cookie-parser'); const svgCaptcha = require('svg-captcha'); @@ -117,7 +121,45 @@ const { resolveDownloadTrafficPolicyUpdates } = require('./utils/download-quota'); const { expressErrorHandler } = require('./middleware/error-handler'); +const { + CSRF_COOKIE_NAME, + createCsrfCookieMiddleware, + csrfProtection, + generateCsrfToken, + setCsrfCookie +} = require('./middleware/csrf'); +const { RateLimiter, createGlobalApiLimiter } = require('./middleware/rate-limit'); +const { + createHttpsEnforcementMiddleware, + securityHeadersMiddleware +} = require('./middleware/security'); const { createZipArchive } = require('./utils/archive'); +const { normalizeNonNegativeInteger, parseBooleanLike } = require('./utils/coerce'); +const { + buildDeviceName, + detectDeviceTypeFromUserAgent, + inferPlatformFromUserAgent, + normalizeClientIp, + resolveClientType, + sanitizeDeviceText +} = require('./utils/device'); +const { parseDownloadTrafficLine } = require('./utils/oss-log-parser'); +const { isPathInside, normalizeVirtualPath } = require('./utils/path-safety'); +const { + isPathWithinShare, + isShareIpAllowed, + isValidShareCode, + parseShareIpWhitelist +} = require('./utils/share'); +const { buildHttpDownloadUrl } = require('./utils/url'); +const { + compareLooseVersion, + normalizeReleaseNotes, + normalizeSha256, + normalizeVersion +} = require('./utils/version'); +const { createHealthService } = require('./services/health-service'); +const { createSystemRouter } = require('./routes/system.routes'); const app = express(); const PORT = process.env.PORT || 40001; @@ -140,9 +182,9 @@ const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SH const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0)); const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || ''; const FRONTEND_ROOT_DIR = path.resolve(__dirname, '../frontend'); +const FRONTEND_BUILD_DIR = path.join(FRONTEND_ROOT_DIR, 'dist'); const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads'); const DESKTOP_INSTALLER_FILE_PATTERN = /^(wanwan-cloud-desktop|玩玩云)_.*_x64-setup\.exe$/i; -const DESKTOP_INSTALLER_SHA256_PATTERN = /^[a-f0-9]{64}$/; const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时 const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB @@ -159,7 +201,6 @@ const OSS_UPLOAD_TEMP_PREFIX = '__wanwan_tmp_uploads'; const GLOBAL_SEARCH_DEFAULT_LIMIT = Number(process.env.GLOBAL_SEARCH_DEFAULT_LIMIT || 80); const GLOBAL_SEARCH_MAX_LIMIT = Number(process.env.GLOBAL_SEARCH_MAX_LIMIT || 200); const GLOBAL_SEARCH_MAX_SCANNED_NODES = Number(process.env.GLOBAL_SEARCH_MAX_SCANNED_NODES || 4000); -const SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/; const DOWNLOAD_SECURITY_DEFAULTS = Object.freeze({ enabled: true, same_ip_same_file: { @@ -194,55 +235,6 @@ const DEFAULT_CAPTCHA_SECRETS = [ const isSecureCookie = SHOULD_USE_SECURE_COOKIES; const sameSiteMode = isSecureCookie ? 'none' : 'lax'; -function normalizeVersion(rawVersion, fallback = '0.0.0') { - const value = String(rawVersion || '').trim(); - if (!value) return fallback; - return value; -} - -function compareLooseVersion(left, right) { - const normalize = (value) => normalizeVersion(value, '0.0.0') - .replace(/^v/i, '') - .split('.') - .map((part) => parseInt(part, 10)) - .map((num) => (Number.isFinite(num) ? num : 0)); - - const a = normalize(left); - const b = normalize(right); - const size = Math.max(a.length, b.length); - for (let i = 0; i < size; i += 1) { - const av = a[i] || 0; - const bv = b[i] || 0; - if (av > bv) return 1; - if (av < bv) return -1; - } - return 0; -} - -function normalizeReleaseNotes(rawValue) { - return String(rawValue || '') - .replace(/\\r\\n/g, '\n') - .replace(/\\n/g, '\n') - .replace(/\\r/g, '\n') - .trim(); -} - -function normalizeSha256(rawValue) { - const digest = String(rawValue || '').trim().toLowerCase(); - return DESKTOP_INSTALLER_SHA256_PATTERN.test(digest) ? digest : ''; -} - -function normalizeNonNegativeInteger(rawValue, fallback = 0) { - const value = Number(rawValue); - if (!Number.isFinite(value) || value < 0) return Math.max(0, Number(fallback) || 0); - return Math.floor(value); -} - -function isPathInside(parent, child) { - const rel = path.relative(parent, child); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); -} - function resolveDesktopInstallerLocalPath(installerUrl) { const raw = String(installerUrl || '').trim(); if (!raw) return null; @@ -551,98 +543,6 @@ const corsOptions = { } }; -function applySecurityHeaders(req, res) { - // 防止点击劫持 - res.setHeader('X-Frame-Options', 'SAMEORIGIN'); - // 防止MIME类型嗅探 - res.setHeader('X-Content-Type-Options', 'nosniff'); - // XSS保护 - res.setHeader('X-XSS-Protection', '1; mode=block'); - // HTTPS严格传输安全(仅在可信的 HTTPS 连接时设置) - // req.secure 基于 trust proxy 配置,不会被不可信代理伪造 - if ((req && req.secure) || (!req && (ENFORCE_HTTPS || SHOULD_USE_SECURE_COOKIES))) { - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - } - // 内容安全策略 - res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';"); - // 隐藏X-Powered-By - res.removeHeader('X-Powered-By'); -} - -// 中间件 -app.use(cors(corsOptions)); - -// 静态文件服务 - 提供前端页面 -const frontendPath = path.join(__dirname, '../frontend'); -console.log('[静态文件] 前端目录:', frontendPath); -app.use(express.static(frontendPath, { - setHeaders: (res) => { - applySecurityHeaders(null, res); - } -})); - -app.use(express.json({ limit: '10mb' })); // 限制请求体大小防止DoS -app.use(cookieParser()); - -// ===== CSRF 防护 ===== -// 基于 Double Submit Cookie 模式的 CSRF 保护 -// 对于修改数据的请求(POST/PUT/DELETE),验证请求头中的 X-CSRF-Token 与 Cookie 中的值匹配 - -// 生成 CSRF Token -function generateCsrfToken() { - return crypto.randomBytes(32).toString('hex'); -} - -// CSRF Token Cookie 名称 -const CSRF_COOKIE_NAME = 'csrf_token'; - -// 设置 CSRF Cookie 的中间件 -app.use((req, res, next) => { - // 如果没有 CSRF cookie,则生成一个 - if (!req.cookies[CSRF_COOKIE_NAME]) { - const csrfToken = generateCsrfToken(); - const isSecureEnv = SHOULD_USE_SECURE_COOKIES; - res.cookie(CSRF_COOKIE_NAME, csrfToken, { - httpOnly: false, // 前端需要读取此值 - secure: isSecureEnv, - sameSite: isSecureEnv ? 'strict' : 'lax', - maxAge: 24 * 60 * 60 * 1000 // 24小时 - }); - } - next(); -}); - -// CSRF 验证中间件(仅用于需要保护的路由) -function csrfProtection(req, res, next) { - // GET、HEAD、OPTIONS 请求不需要 CSRF 保护 - if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { - return next(); - } - - // 仅对基于 Cookie 的浏览器会话启用 CSRF(Bearer API 客户端不强制) - const hasCookieAuth = !!( - req.cookies?.token || - req.cookies?.refreshToken - ); - - if (!hasCookieAuth) { - return next(); - } - - const cookieToken = req.cookies[CSRF_COOKIE_NAME]; - const headerToken = req.headers['x-csrf-token']; - - if (!cookieToken || !headerToken || cookieToken !== headerToken) { - console.warn(`[CSRF] 验证失败: path=${req.path}, cookie=${!!cookieToken}, header=${!!headerToken}`); - return res.status(403).json({ - success: false, - message: 'CSRF 验证失败,请刷新页面后重试' - }); - } - - next(); -} - // CSRF 开关策略: // - 显式配置 ENABLE_CSRF 时按配置值 // - 未配置时,生产环境默认开启 @@ -650,85 +550,34 @@ const ENABLE_CSRF = process.env.ENABLE_CSRF !== undefined ? process.env.ENABLE_CSRF === 'true' : process.env.NODE_ENV === 'production'; +// 中间件顺序是安全边界:请求标识和安全头最先,限流先于请求体解析。 +app.use(requestContextMiddleware); +app.use(securityHeadersMiddleware({ + secureByDefault: ENFORCE_HTTPS || SHOULD_USE_SECURE_COOKIES +})); +app.use(cors(corsOptions)); +app.use(createHttpsEnforcementMiddleware({ enabled: ENFORCE_HTTPS })); + +const hasFrontendBuild = fs.existsSync(path.join(FRONTEND_BUILD_DIR, 'index.html')); +const frontendPath = hasFrontendBuild ? FRONTEND_BUILD_DIR : FRONTEND_ROOT_DIR; +if (!hasFrontendBuild && process.env.NODE_ENV === 'production') { + logger.warn({ frontendPath }, 'frontend build is missing; serving source files as fallback'); +} +logger.info({ frontendPath, built: hasFrontendBuild }, 'frontend static directory configured'); + +// 安装包由管理端动态发布,不进入 Vite 构建产物。 +app.use('/downloads', express.static(DESKTOP_INSTALLERS_DIR)); +app.use(express.static(frontendPath)); +app.use('/api', createGlobalApiLimiter()); +app.use(express.json({ limit: '10mb' })); +app.use(cookieParser()); +app.use(createCsrfCookieMiddleware({ secureCookies: SHOULD_USE_SECURE_COOKIES })); + if (ENABLE_CSRF) { - console.log('[安全] CSRF 保护已启用'); + logger.info('CSRF protection enabled'); app.use(csrfProtection); } -// 强制HTTPS(可通过环境变量控制,默认关闭以兼容本地环境) -// 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置, -// 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头 -app.use((req, res, 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 - // - 如果 trust proxy 已配置,会检查可信代理的 X-Forwarded-Proto - if (!req.secure) { - return res.status(400).json({ - success: false, - message: '仅支持HTTPS访问,请使用HTTPS' - }); - } - return next(); -}); - -// 安全响应头中间件 -app.use((req, res, next) => { - applySecurityHeaders(req, res); - next(); -}); - -// 规范化并校验HTTP直链前缀,只允许http/https -function sanitizeHttpBaseUrl(raw) { - if (!raw) return null; - try { - const url = new URL(raw); - if (!['http:', 'https:'].includes(url.protocol)) { - return null; - } - url.search = ''; - url.hash = ''; - // 去掉多余的结尾斜杠,保持路径稳定 - url.pathname = url.pathname.replace(/\/+$/, ''); - return url.toString(); - } catch { - return null; - } -} - -// 构建安全的下载URL,编码路径片段并拒绝非HTTP(S)前缀 -function buildHttpDownloadUrl(rawBaseUrl, filePath) { - const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl); - if (!baseUrl || !filePath) return null; - - try { - const url = new URL(baseUrl); - const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`; - const safeSegments = normalizedPath - .split('/') - .filter(Boolean) - .map(segment => encodeURIComponent(segment)); - const safePath = safeSegments.length ? '/' + safeSegments.join('/') : ''; - - const basePath = url.pathname.replace(/\/+$/, ''); - const joinedPath = `${basePath}${safePath || '/'}`; - url.pathname = joinedPath || '/'; - url.search = ''; - url.hash = ''; - return url.toString(); - } catch (err) { - console.warn('[安全] 生成下载URL失败:', err.message); - return null; - } -} - // 应用XSS过滤到所有POST/PUT请求的body app.use((req, res, next) => { if ((req.method === 'POST' || req.method === 'PUT') && req.body) { @@ -768,12 +617,6 @@ app.use((req, res, next) => { next(); }); -// 请求日志 -app.use((req, res, next) => { - console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); - next(); -}); - // 获取正确的协议(基于可信代理链) // 安全说明:req.protocol 由 Express 根据 trust proxy 配置计算, // 只有可信代理的 X-Forwarded-Proto 才会被采信 @@ -796,23 +639,6 @@ function getBusyDownloadMessage() { return '当前网络繁忙,请稍后再试'; } -function parseBooleanLike(rawValue, fallback = false) { - if (rawValue === null || rawValue === undefined || rawValue === '') { - return fallback; - } - if (typeof rawValue === 'boolean') { - return rawValue; - } - const normalized = String(rawValue).trim().toLowerCase(); - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true; - } - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false; - } - return fallback; -} - function clampIntegerSetting(rawValue, fallback, min, max) { const value = Number(rawValue); if (!Number.isFinite(value)) { @@ -1610,116 +1436,6 @@ async function readS3BodyToBuffer(body) { return Buffer.alloc(0); } -function parseDownloadTrafficLogTime(line) { - if (!line || typeof line !== 'string') return null; - const match = line.match(/\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{4})?\]/); - if (!match) return null; - - const monthMap = { - Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, - Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 - }; - const month = monthMap[match[2]]; - if (month === undefined) return null; - - const year = Number(match[3]); - const day = Number(match[1]); - const hour = Number(match[4]); - const minute = Number(match[5]); - const second = Number(match[6]); - - if ([year, day, hour, minute, second].some(v => !Number.isFinite(v))) { - return null; - } - - // 先按 UTC 构造,再应用时区偏移(若存在) - let utcMillis = Date.UTC(year, month, day, hour, minute, second); - const tzRaw = match[7]; - if (tzRaw && /^[+-]\d{4}$/.test(tzRaw)) { - const sign = tzRaw[0] === '+' ? 1 : -1; - const tzHour = Number(tzRaw.slice(1, 3)); - const tzMin = Number(tzRaw.slice(3, 5)); - const offsetMinutes = sign * (tzHour * 60 + tzMin); - utcMillis -= offsetMinutes * 60 * 1000; - } - - const parsed = new Date(utcMillis); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -function parseDownloadTrafficLine(line) { - if (!line || typeof line !== 'string') { - return null; - } - - const trimmed = line.trim(); - if (!trimmed) return null; - - // 仅处理 GET 请求(HEAD/PUT/POST 等不计下载流量) - if (!/\bGET\b/i.test(trimmed)) { - return null; - } - - let statusCode = 0; - let bytesSent = 0; - const statusMatch = trimmed.match(/"\s*(\d{3})\s+(\d+|-)\b/); - if (statusMatch) { - statusCode = Number(statusMatch[1]); - bytesSent = statusMatch[2] === '-' ? 0 : Number(statusMatch[2]); - } - - if (![200, 206].includes(statusCode) || !Number.isFinite(bytesSent) || bytesSent <= 0) { - return null; - } - - // 尝试从请求路径提取 object key - let objectKey = null; - const requestMatch = trimmed.match(/"(?:GET|HEAD)\s+([^" ]+)\s+HTTP\//i); - if (requestMatch && requestMatch[1]) { - let requestPath = requestMatch[1]; - const qIndex = requestPath.indexOf('?'); - if (qIndex >= 0) { - requestPath = requestPath.slice(0, qIndex); - } - requestPath = requestPath.replace(/^https?:\/\/[^/]+/i, ''); - requestPath = requestPath.replace(/^\/+/, ''); - try { - requestPath = decodeURIComponent(requestPath); - } catch { - // ignore decode error - } - objectKey = requestPath || null; - } - - if (!objectKey) { - const keyMatch = trimmed.match(/\buser_(\d+)\/[^\s"]+/); - if (keyMatch && keyMatch[0]) { - objectKey = keyMatch[0]; - } - } - - if (!objectKey) { - return null; - } - - const userMatch = objectKey.match(/(?:^|\/)user_(\d+)\//); - if (!userMatch) { - return null; - } - - const userId = Number(userMatch[1]); - if (!Number.isFinite(userId) || userId <= 0) { - return null; - } - - return { - userId, - bytes: Math.floor(bytesSent), - objectKey, - eventAt: parseDownloadTrafficLogTime(trimmed) || new Date() - }; -} - function extractLogLinesFromBuffer(buffer, logKey = '') { let content = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || ''); if ((logKey || '').toLowerCase().endsWith('.gz')) { @@ -2357,162 +2073,6 @@ const shareFileCache = new TTLCache(60 * 60 * 1000); // ===== 防爆破限流器 ===== -// 防爆破限流器类 -class RateLimiter { - constructor(options = {}) { - this.maxAttempts = options.maxAttempts || 5; - this.windowMs = options.windowMs || 15 * 60 * 1000; - this.blockDuration = options.blockDuration || 30 * 60 * 1000; - this.attempts = new Map(); - this.blockedKeys = new Map(); - - // 每5分钟清理一次过期记录 - this.cleanupInterval = setInterval(() => { - this.cleanup(); - }, 5 * 60 * 1000); - } - - // 获取客户端IP(基于可信代理链) - // 安全说明:req.ip 由 Express 根据 trust proxy 配置计算, - // 只有可信代理的 X-Forwarded-For 才会被采信 - getClientKey(req) { - // req.ip 会根据 trust proxy 配置: - // - trust proxy = false: 使用直接连接的 IP(socket 地址) - // - trust proxy = 1: 取 X-Forwarded-For 的最后 1 个 IP - // - trust proxy = true: 取 X-Forwarded-For 的第 1 个 IP(不推荐) - return req.ip || req.socket?.remoteAddress || 'unknown'; - } - - // 检查是否被封锁 - isBlocked(key) { - const blockInfo = this.blockedKeys.get(key); - if (!blockInfo) { - return false; - } - - // 检查封锁是否过期 - if (Date.now() > blockInfo.expiresAt) { - this.blockedKeys.delete(key); - this.attempts.delete(key); - return false; - } - - return true; - } - - // 记录失败尝试 - recordFailure(key) { - const now = Date.now(); - - // 如果已被封锁,返回封锁信息 - if (this.isBlocked(key)) { - const blockInfo = this.blockedKeys.get(key); - return { - blocked: true, - remainingAttempts: 0, - resetTime: blockInfo.expiresAt, - waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000), - needCaptcha: true - }; - } - - // 获取或创建尝试记录 - let attemptInfo = this.attempts.get(key); - if (!attemptInfo || now > attemptInfo.windowEnd) { - attemptInfo = { - count: 0, - windowEnd: now + this.windowMs, - firstAttempt: now - }; - } - - attemptInfo.count++; - this.attempts.set(key, attemptInfo); - - // 检查是否达到封锁阈值 - if (attemptInfo.count >= this.maxAttempts) { - const blockExpiresAt = now + this.blockDuration; - this.blockedKeys.set(key, { - expiresAt: blockExpiresAt, - blockedAt: now - }); - console.warn(`[防爆破] 封锁Key: ${key}, 失败次数: ${attemptInfo.count}, 封锁时长: ${Math.ceil(this.blockDuration / 60000)}分钟`); - return { - blocked: true, - remainingAttempts: 0, - resetTime: blockExpiresAt, - waitMinutes: Math.ceil(this.blockDuration / 60000), - needCaptcha: true - }; - } - - return { - blocked: false, - remainingAttempts: this.maxAttempts - attemptInfo.count, - resetTime: attemptInfo.windowEnd, - waitMinutes: 0, - needCaptcha: attemptInfo.count >= 2 // 失败2次后需要验证码 - }; - } - - // 获取失败次数 - getFailureCount(key) { - const attemptInfo = this.attempts.get(key); - if (!attemptInfo || Date.now() > attemptInfo.windowEnd) { - return 0; - } - return attemptInfo.count; - } - - // 记录成功(清除失败记录) - recordSuccess(key) { - this.attempts.delete(key); - this.blockedKeys.delete(key); - } - - // 清理过期记录 - cleanup() { - const now = Date.now(); - let cleanedAttempts = 0; - let cleanedBlocks = 0; - - // 清理过期的尝试记录 - for (const [key, info] of this.attempts.entries()) { - if (now > info.windowEnd) { - this.attempts.delete(key); - cleanedAttempts++; - } - } - - // 清理过期的封锁记录 - for (const [key, info] of this.blockedKeys.entries()) { - if (now > info.expiresAt) { - this.blockedKeys.delete(key); - cleanedBlocks++; - } - } - - if (cleanedAttempts > 0 || cleanedBlocks > 0) { - console.log(`[防爆破清理] 已清理 ${cleanedAttempts} 个过期尝试记录, ${cleanedBlocks} 个过期封锁记录`); - } - } - - // 获取统计信息 - getStats() { - return { - activeAttempts: this.attempts.size, - blockedKeys: this.blockedKeys.size - }; - } - - // 停止清理定时器 - destroy() { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - } - } -} - // 创建登录限流器(5次失败/15分钟,封锁30分钟) const loginLimiter = new RateLimiter({ maxAttempts: 5, @@ -2717,82 +2277,6 @@ function safeDeleteFile(filePath) { } -// 规范化虚拟文件路径(统一用于分享路径校验) -function normalizeVirtualPath(rawPath) { - if (typeof rawPath !== 'string') { - return null; - } - - let decoded = rawPath; - try { - decoded = decodeURIComponent(rawPath); - } catch { - // 忽略解码失败,使用原始输入继续校验 - } - - if (decoded.includes('\x00') || decoded.includes('%00')) { - return null; - } - - const unifiedPath = decoded.replace(/\\/g, '/'); - - // 严格拦截路径遍历片段(在 normalize 前先检查) - if (/(^|\/)\.\.(\/|$)/.test(unifiedPath)) { - return null; - } - - let normalized = path.posix.normalize(unifiedPath); - if (normalized === '' || normalized === '.') { - normalized = '/'; - } - - if (!normalized.startsWith('/')) { - normalized = `/${normalized}`; - } - - normalized = normalized.replace(/\/+$/g, ''); - return normalized || '/'; -} - -function isValidShareCode(code) { - return typeof code === 'string' && SHARE_CODE_REGEX.test(code); -} - -// 验证请求路径是否在分享范围内(防止越权访问) -function isPathWithinShare(requestPath, share) { - if (!requestPath || !share) { - return false; - } - - const normalizedRequest = normalizeVirtualPath(requestPath); - const normalizedShare = normalizeVirtualPath(share.share_path); - - if (!normalizedRequest || !normalizedShare) { - return false; - } - - if (share.share_type === 'file') { - // 单文件分享:只允许下载该文件 - return normalizedRequest === normalizedShare; - } - - // 目录分享:只允许下载该目录及其子目录下的文件 - const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`; - return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix); -} - -function normalizeClientIp(rawIp) { - const ip = String(rawIp || '').trim(); - if (!ip) return ''; - if (ip.startsWith('::ffff:')) { - return ip.slice(7); - } - if (ip === '::1') { - return '127.0.0.1'; - } - return ip; -} - function getClientIp(req) { return normalizeClientIp( req?.ip @@ -2802,89 +2286,6 @@ function getClientIp(req) { ); } -function parseShareIpWhitelist(rawValue) { - if (typeof rawValue !== 'string') return []; - return rawValue - .split(/[\s,;]+/) - .map(item => item.trim()) - .filter(Boolean) - .slice(0, 100); -} - -function isShareIpAllowed(clientIp, whitelist = []) { - if (!clientIp || !Array.isArray(whitelist) || whitelist.length === 0) { - return true; - } - - for (const rule of whitelist) { - const normalizedRule = String(rule || '').trim(); - if (!normalizedRule) continue; - - if (normalizedRule === clientIp) { - return true; - } - - if (normalizedRule.endsWith('*')) { - const prefix = normalizedRule.slice(0, -1); - if (prefix && clientIp.startsWith(prefix)) { - return true; - } - } - } - - return false; -} - -function detectDeviceTypeFromUserAgent(userAgent = '') { - const ua = String(userAgent || ''); - const mobilePattern = /(Mobile|Android|iPhone|iPad|iPod|Windows Phone|HarmonyOS|Mobi)/i; - return mobilePattern.test(ua) ? 'mobile' : 'desktop'; -} - -function inferPlatformFromUserAgent(userAgent = '') { - const ua = String(userAgent || ''); - if (!ua) return '未知平台'; - if (/windows/i.test(ua)) return 'Windows'; - if (/macintosh|mac os x/i.test(ua)) return 'macOS'; - if (/android/i.test(ua)) return 'Android'; - if (/iphone|ipad|ios/i.test(ua)) return 'iOS'; - if (/linux/i.test(ua)) return 'Linux'; - return '未知平台'; -} - -function normalizeClientType(value = '') { - const normalized = String(value || '').trim().toLowerCase(); - if (['web', 'desktop', 'mobile', 'api'].includes(normalized)) { - return normalized; - } - return ''; -} - -function resolveClientType(clientType, userAgent = '') { - const normalized = normalizeClientType(clientType); - if (normalized) return normalized; - const ua = String(userAgent || '').toLowerCase(); - if (ua.includes('tauri') || ua.includes('electron') || ua.includes('wanwan-cloud-desktop') || ua.includes('玩玩云')) { - return 'desktop'; - } - return detectDeviceTypeFromUserAgent(ua) === 'mobile' ? 'mobile' : 'web'; -} - -function sanitizeDeviceText(value, maxLength = 120) { - if (typeof value !== 'string') return ''; - return value.trim().slice(0, maxLength); -} - -function buildDeviceName({ clientType, deviceName, platform }) { - const preferred = sanitizeDeviceText(deviceName, 120); - if (preferred) return preferred; - const platformText = sanitizeDeviceText(platform, 80) || '未知平台'; - if (clientType === 'desktop') return `桌面客户端 · ${platformText}`; - if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`; - if (clientType === 'api') return `API 客户端 · ${platformText}`; - return `网页端浏览器 · ${platformText}`; -} - function buildDeviceSessionContext(req, payload = {}) { const userAgent = sanitizeDeviceText(req.get('user-agent') || req.headers?.['user-agent'] || '', 1024); const clientType = resolveClientType(payload.client_type || req.headers?.['x-client-type'], userAgent); @@ -3603,21 +3004,11 @@ function verifyCaptcha(req, res, captcha, logPrefix = '验证码验证') { // ===== 公开API ===== -// 健康检查 -app.get('/api/health', (req, res) => { - res.json({ success: true, message: 'Server is running' }); -}); - -// 获取公开的系统配置(不需要登录) -app.get('/api/config', (req, res) => { - const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240'); - res.json({ - success: true, - config: { - max_upload_size: maxUploadSize - } - }); +const healthService = createHealthService({ + db, + diskPath: path.resolve(__dirname, 'data') }); +app.use('/api', createSystemRouter({ SettingsDB, healthService })); // 桌面客户端更新信息(无需登录) app.get('/api/client/desktop-update', (req, res) => { @@ -3688,13 +3079,7 @@ app.get('/api/csrf-token', (req, res) => { // 如果没有 token,生成一个新的 if (!csrfToken) { csrfToken = generateCsrfToken(); - const isSecureEnv = SHOULD_USE_SECURE_COOKIES; - res.cookie(CSRF_COOKIE_NAME, csrfToken, { - httpOnly: false, - secure: isSecureEnv, - sameSite: isSecureEnv ? 'strict' : 'lax', - maxAge: 24 * 60 * 60 * 1000 - }); + setCsrfCookie(res, csrfToken, SHOULD_USE_SECURE_COOKIES); } res.json({ @@ -8303,19 +7688,6 @@ app.delete('/api/direct-link/:id', authMiddleware, (req, res) => { // ===== 分享链接访问(公开) ===== -// 获取公共主题设置(用于分享页面,无需认证) -app.get('/api/public/theme', (req, res) => { - try { - const globalTheme = SettingsDB.get('global_theme') || 'dark'; - res.json({ - success: true, - theme: globalTheme - }); - } catch (error) { - res.json({ success: true, theme: 'dark' }); // 出错默认暗色 - } -}); - // 获取分享页面主题(基于分享者偏好或全局设置) app.get('/api/share/:code/theme', (req, res) => { try { diff --git a/backend/services/health-service.js b/backend/services/health-service.js new file mode 100644 index 0000000..9e422a0 --- /dev/null +++ b/backend/services/health-service.js @@ -0,0 +1,59 @@ +const fs = require('fs'); + +const DEFAULT_MIN_FREE_BYTES = 256 * 1024 * 1024; + +function normalizeMinimumFreeBytes(value) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_MIN_FREE_BYTES; +} + +function createHealthService({ db, diskPath, minimumFreeBytes = process.env.HEALTH_MIN_DISK_FREE_BYTES }) { + const diskThreshold = normalizeMinimumFreeBytes(minimumFreeBytes); + + function checkDatabase() { + const startedAt = process.hrtime.bigint(); + try { + const row = db.prepare('SELECT 1 AS ok').get(); + return { + status: row?.ok === 1 ? 'ok' : 'error', + latencyMs: Number((Number(process.hrtime.bigint() - startedAt) / 1e6).toFixed(2)) + }; + } catch (error) { + return { status: 'error', message: error.code || error.name || 'database_error' }; + } + } + + function checkDisk() { + try { + const stats = fs.statfsSync(diskPath); + const freeBytes = Number(BigInt(stats.bavail) * BigInt(stats.bsize)); + return { + status: freeBytes >= diskThreshold ? 'ok' : 'low', + freeBytes, + minimumFreeBytes: diskThreshold + }; + } catch (error) { + return { status: 'error', message: error.code || error.name || 'disk_error' }; + } + } + + function check() { + const checks = { + database: checkDatabase(), + disk: checkDisk() + }; + const healthy = checks.database.status === 'ok' && checks.disk.status === 'ok'; + return { + healthy, + status: healthy ? 'ok' : 'degraded', + checks + }; + } + + return { check }; +} + +module.exports = { + createHealthService, + normalizeMinimumFreeBytes +}; diff --git a/backend/tests/architecture-tests.js b/backend/tests/architecture-tests.js new file mode 100644 index 0000000..5c82fca --- /dev/null +++ b/backend/tests/architecture-tests.js @@ -0,0 +1,113 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const projectRoot = path.resolve(__dirname, '..', '..'); +const serverPath = path.join(projectRoot, 'backend', 'server.js'); +const source = fs.readFileSync(serverPath, 'utf8'); +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}`); + } +} + +console.log('\n========== 架构约束测试 ==========\n'); + +test('Express async 错误补丁在所有路由之前加载', () => { + const patchIndex = source.indexOf("require('express-async-errors')"); + const routeIndex = source.search(/\bapp\.(get|post|put|delete|patch)\s*\(/); + assert.ok(patchIndex >= 0); + assert.ok(routeIndex > patchIndex); +}); + +test('全局错误中间件保持为最后一个 app.use', () => { + const errorHandlerIndex = source.indexOf('app.use(expressErrorHandler);'); + assert.ok(errorHandlerIndex >= 0); + assert.strictEqual(source.lastIndexOf('app.use('), errorHandlerIndex); +}); + +test('抽取的纯函数不在 server.js 保留第二份定义', () => { + const extractedFunctions = [ + 'normalizeVersion', + 'normalizeSha256', + 'normalizeVirtualPath', + 'buildHttpDownloadUrl', + 'parseDownloadTrafficLine', + 'isShareIpAllowed', + 'resolveClientType', + 'parseBooleanLike' + ]; + for (const functionName of extractedFunctions) { + assert.strictEqual(new RegExp(`function\\s+${functionName}\\s*\\(`).test(source), false, functionName); + } +}); + +test('旧占位路由已清除且真实系统路由已挂载', () => { + assert.strictEqual(fs.existsSync(path.join(projectRoot, 'backend', 'routes', 'health.js')), false); + assert.strictEqual(fs.existsSync(path.join(projectRoot, 'backend', 'routes', 'index.js')), false); + assert.ok(source.includes("require('./routes/system.routes')")); + assert.ok(source.includes("app.use('/api', createSystemRouter")); +}); + +test('package.json 与 package-lock.json 顶层依赖同步', () => { + const packageJson = require('../package.json'); + const packageLock = require('../package-lock.json'); + assert.deepStrictEqual(packageLock.packages[''].dependencies, packageJson.dependencies); +}); + +test('前端 package.json 与 lock 文件依赖同步且没有手工缓存版本号', () => { + const frontendPackage = require('../../frontend/package.json'); + const frontendLock = require('../../frontend/package-lock.json'); + assert.deepStrictEqual(frontendLock.packages[''].dependencies, frontendPackage.dependencies); + assert.deepStrictEqual(frontendLock.packages[''].devDependencies, frontendPackage.devDependencies); + + const frontendDirectory = path.join(projectRoot, 'frontend'); + for (const fileName of fs.readdirSync(frontendDirectory).filter((name) => name.endsWith('.html'))) { + const html = fs.readFileSync(path.join(frontendDirectory, fileName), 'utf8'); + assert.strictEqual(/\?v=\d+/.test(html), false, fileName); + } +}); + +test('生产部署统一使用 Vite dist 且不会删除 lock 文件', () => { + const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8'); + const nginxConfig = fs.readFileSync(path.join(projectRoot, 'nginx', 'nginx.conf'), 'utf8'); + assert.ok(installer.includes('npm ci --include=dev')); + assert.ok(installer.includes('npm run build')); + assert.ok(installer.includes('frontend/dist')); + assert.strictEqual(/rm\s+-rf\s+node_modules\s+package-lock\.json/.test(installer), false); + assert.ok(nginxConfig.includes('root /usr/share/nginx/html;')); + assert.ok(nginxConfig.includes('alias /runtime/downloads/;')); +}); + +test('install.sh 生成的 Nginx server 块花括号平衡', () => { + const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8'); + const templates = [...installer.matchAll(/cat > .*?<< EOF\r?\n([\s\S]*?)\r?\nEOF/g)] + .map((match) => match[1]) + .filter((template) => template.includes('server {')); + assert.strictEqual(templates.length, 3); + for (const template of templates) { + const openingBraces = (template.match(/\{/g) || []).length; + const closingBraces = (template.match(/\}/g) || []).length; + assert.strictEqual(openingBraces, closingBraces); + } +}); + +test('PM2 部署没有启用 cluster 或多实例参数', () => { + const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8'); + assert.strictEqual(/pm2\s+start[^\n]*(?:\s-i\s|--instances)/.test(installer), false); +}); + +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/domain-utils-tests.js b/backend/tests/domain-utils-tests.js new file mode 100644 index 0000000..8821fec --- /dev/null +++ b/backend/tests/domain-utils-tests.js @@ -0,0 +1,155 @@ +const assert = require('assert'); + +const { normalizeNonNegativeInteger, parseBooleanLike } = require('../utils/coerce'); +const { + buildDeviceName, + detectDeviceTypeFromUserAgent, + inferPlatformFromUserAgent, + normalizeClientIp, + normalizeClientType, + resolveClientType, + sanitizeDeviceText +} = require('../utils/device'); +const { parseDownloadTrafficLine, parseDownloadTrafficLogTime } = require('../utils/oss-log-parser'); +const { isPathInside, normalizeVirtualPath } = require('../utils/path-safety'); +const { isPathWithinShare, isShareIpAllowed, isValidShareCode, parseShareIpWhitelist } = require('../utils/share'); +const { buildHttpDownloadUrl, sanitizeHttpBaseUrl } = require('../utils/url'); +const { + compareLooseVersion, + normalizeReleaseNotes, + normalizeSha256, + normalizeVersion +} = require('../utils/version'); + +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}`); + } +} + +console.log('\n========== 领域纯函数测试 ==========\n'); + +test('版本号规范化与宽松比较', () => { + assert.strictEqual(normalizeVersion(' v1.2.3 '), 'v1.2.3'); + assert.strictEqual(normalizeVersion('', '1.0.0'), '1.0.0'); + assert.strictEqual(compareLooseVersion('v1.10.0', '1.9.9'), 1); + assert.strictEqual(compareLooseVersion('1.2', '1.2.0'), 0); + assert.strictEqual(compareLooseVersion('1.2.0', '2.0.0'), -1); +}); + +test('发布说明转义换行被还原', () => { + assert.strictEqual(normalizeReleaseNotes('first\\r\\nsecond\\n'), 'first\nsecond'); +}); + +test('SHA256 只接受完整十六进制摘要', () => { + assert.strictEqual(normalizeSha256(' A'.repeat(64)), ''); + assert.strictEqual(normalizeSha256('A'.repeat(64)), 'a'.repeat(64)); + assert.strictEqual(normalizeSha256('a'.repeat(63)), ''); +}); + +test('非负整数与布尔配置规范化', () => { + assert.strictEqual(normalizeNonNegativeInteger(12.9), 12); + assert.strictEqual(normalizeNonNegativeInteger(-1, 4), 4); + assert.strictEqual(parseBooleanLike('YES'), true); + assert.strictEqual(parseBooleanLike('off', true), false); + assert.strictEqual(parseBooleanLike('invalid', true), true); +}); + +test('物理路径边界不接受兄弟目录', () => { + const parent = require('path').resolve('/tmp/root'); + assert.strictEqual(isPathInside(parent, require('path').join(parent, 'child')), true); + assert.strictEqual(isPathInside(parent, require('path').resolve('/tmp/root-other')), false); +}); + +test('虚拟路径统一分隔符并拒绝遍历和空字节', () => { + assert.strictEqual(normalizeVirtualPath('folder\\file.txt'), '/folder/file.txt'); + assert.strictEqual(normalizeVirtualPath('/folder/../secret'), null); + assert.strictEqual(normalizeVirtualPath('/folder/%2e%2e/secret'), null); + assert.strictEqual(normalizeVirtualPath('/folder/%2500.txt'), null); + assert.strictEqual(normalizeVirtualPath('/'), '/'); +}); + +test('HTTP 基础地址拒绝危险协议并移除查询参数', () => { + assert.strictEqual(sanitizeHttpBaseUrl('javascript:alert(1)'), null); + assert.strictEqual(sanitizeHttpBaseUrl('https://example.test/base/?token=x#hash'), 'https://example.test/base'); +}); + +test('下载地址逐段编码且不继承查询参数', () => { + assert.strictEqual( + buildHttpDownloadUrl('https://example.test/base?token=x', '/目录/a b.txt'), + 'https://example.test/base/%E7%9B%AE%E5%BD%95/a%20b.txt' + ); +}); + +test('OSS 日志时间正确应用时区', () => { + const date = parseDownloadTrafficLogTime('[27/Jul/2026:12:34:56 +0800]'); + assert.strictEqual(date.toISOString(), '2026-07-27T04:34:56.000Z'); + assert.strictEqual(parseDownloadTrafficLogTime('[31/Feb/2026:12:00:00 +0800]'), null); + assert.strictEqual(parseDownloadTrafficLogTime('[27/Jul/2026:12:00:00 +2460]'), null); +}); + +test('OSS 下载日志解析用户、对象键、字节和时间', () => { + const line = '1.2.3.4 - - [27/Jul/2026:12:34:56 +0800] "GET /user_42/folder/a%20b.txt?signature=x HTTP/1.1" 206 1024 "-" "ua"'; + const result = parseDownloadTrafficLine(line); + assert.strictEqual(result.userId, 42); + assert.strictEqual(result.bytes, 1024); + assert.strictEqual(result.objectKey, 'user_42/folder/a b.txt'); + assert.strictEqual(result.eventAt.toISOString(), '2026-07-27T04:34:56.000Z'); +}); + +test('OSS 日志只统计成功 GET 下载', () => { + const fallback = new Date('2026-01-01T00:00:00Z'); + assert.strictEqual(parseDownloadTrafficLine('"HEAD /user_1/a HTTP/1.1" 200 10', fallback), null); + assert.strictEqual(parseDownloadTrafficLine('"GET /user_1/a HTTP/1.1" 404 10', fallback), null); + assert.strictEqual(parseDownloadTrafficLine('"GET /public/a HTTP/1.1" 200 10', fallback), null); + assert.strictEqual(parseDownloadTrafficLine('"GET /user_1/a HTTP/1.1" 200 -', fallback), null); +}); + +test('分享码与分享路径严格限定', () => { + assert.strictEqual(isValidShareCode('Abc123'), true); + assert.strictEqual(isValidShareCode('../bad'), false); + assert.strictEqual(isPathWithinShare('/folder/a.txt', { share_type: 'folder', share_path: '/folder' }), true); + assert.strictEqual(isPathWithinShare('/folder-other/a.txt', { share_type: 'folder', share_path: '/folder' }), false); + assert.strictEqual(isPathWithinShare('/folder/a.txt', { share_type: 'file', share_path: '/folder/a.txt' }), true); + assert.strictEqual(isPathWithinShare('/folder/b.txt', { share_type: 'file', share_path: '/folder/a.txt' }), false); +}); + +test('IP 白名单支持分隔符、精确匹配与前缀规则', () => { + const whitelist = parseShareIpWhitelist('127.0.0.1, 10.0.*;192.168.1.5'); + assert.deepStrictEqual(whitelist, ['127.0.0.1', '10.0.*', '192.168.1.5']); + assert.strictEqual(isShareIpAllowed('10.0.2.3', whitelist), true); + assert.strictEqual(isShareIpAllowed('192.168.1.6', whitelist), false); + assert.strictEqual(isShareIpAllowed('', whitelist), false); + assert.strictEqual(isShareIpAllowed('', []), true); +}); + +test('客户端 IP 与设备类型被稳定规范化', () => { + assert.strictEqual(normalizeClientIp('::ffff:127.0.0.1'), '127.0.0.1'); + assert.strictEqual(normalizeClientIp('::1'), '127.0.0.1'); + assert.strictEqual(detectDeviceTypeFromUserAgent('Mozilla/5.0 iPhone'), 'mobile'); + assert.strictEqual(inferPlatformFromUserAgent('Mozilla/5.0 (Windows NT 10.0)'), 'Windows'); + assert.strictEqual(normalizeClientType(' DESKTOP '), 'desktop'); + assert.strictEqual(normalizeClientType('bot'), ''); + assert.strictEqual(resolveClientType('', 'wanwan-cloud-desktop/1.0'), 'desktop'); +}); + +test('设备名称限制长度并按客户端类型回退', () => { + assert.strictEqual(sanitizeDeviceText(' abc ', 2), 'ab'); + assert.strictEqual(buildDeviceName({ clientType: 'mobile', platform: 'iOS' }), '移动端浏览器 · iOS'); + assert.strictEqual(buildDeviceName({ clientType: 'web', deviceName: ' 我的设备 ', platform: 'Windows' }), '我的设备'); +}); + +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/middleware-tests.js b/backend/tests/middleware-tests.js new file mode 100644 index 0000000..2d37c92 --- /dev/null +++ b/backend/tests/middleware-tests.js @@ -0,0 +1,180 @@ +process.env.LOG_LEVEL = 'silent'; + +const assert = require('assert'); +const { EventEmitter } = require('events'); +const fs = require('fs'); + +const { + createCsrfCookieMiddleware, + csrfProtection, + generateCsrfToken, + tokensEqual +} = require('../middleware/csrf'); +const { RateLimiter } = require('../middleware/rate-limit'); +const { applySecurityHeaders, createHttpsEnforcementMiddleware } = require('../middleware/security'); +const { expressErrorHandler } = require('../middleware/error-handler'); +const { requestContextMiddleware, redactLogValue } = require('../utils/logger'); +const { createHealthService, normalizeMinimumFreeBytes } = require('../services/health-service'); + +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() { + const response = new EventEmitter(); + response.headers = {}; + response.statusCode = 200; + response.payload = null; + response.cookies = []; + response.setHeader = (name, value) => { response.headers[name] = value; }; + response.removeHeader = (name) => { delete response.headers[name]; }; + response.status = (code) => { response.statusCode = code; return response; }; + response.json = (payload) => { response.payload = payload; return response; }; + response.cookie = (name, value, options) => { response.cookies.push({ name, value, options }); }; + return response; +} + +console.log('\n========== 中间件与可观测性测试 ==========\n'); + +test('安全响应头覆盖 CSP、HSTS、Referrer 与 Permissions Policy', () => { + const response = createResponse(); + applySecurityHeaders({ secure: true }, response); + assert.ok(response.headers['Content-Security-Policy'].includes("object-src 'none'")); + assert.ok(response.headers['Strict-Transport-Security']); + assert.strictEqual(response.headers['X-Content-Type-Options'], 'nosniff'); + assert.strictEqual(response.headers['Referrer-Policy'], 'strict-origin-when-cross-origin'); + assert.ok(response.headers['Permissions-Policy']); +}); + +test('HTTPS 中间件阻止不安全请求但放行本机健康检查', () => { + const middleware = createHttpsEnforcementMiddleware({ enabled: true }); + const blockedResponse = createResponse(); + middleware({ path: '/api/config', secure: false, socket: { remoteAddress: '10.0.0.1' }, id: 'req-1' }, blockedResponse, () => assert.fail('不应放行')); + assert.strictEqual(blockedResponse.statusCode, 400); + assert.strictEqual(blockedResponse.payload.requestId, 'req-1'); + + let nextCalled = false; + middleware({ path: '/api/health', secure: false, socket: { remoteAddress: '127.0.0.1' } }, createResponse(), () => { nextCalled = true; }); + assert.strictEqual(nextCalled, true); +}); + +test('请求上下文接受安全 requestId 并回写响应头', () => { + const request = { headers: { 'x-request-id': 'trace-123' }, method: 'GET', path: '/api/config' }; + const response = createResponse(); + requestContextMiddleware(request, response, () => {}); + assert.strictEqual(request.id, 'trace-123'); + assert.strictEqual(response.headers['X-Request-ID'], 'trace-123'); + response.emit('finish'); +}); + +test('非法 requestId 被替换,日志敏感字段被脱敏', () => { + const request = { headers: { 'x-request-id': 'bad id\n' }, method: 'GET', path: '/' }; + const response = createResponse(); + requestContextMiddleware(request, response, () => {}); + assert.notStrictEqual(request.id, 'bad id\n'); + assert.match(request.id, /^[0-9a-f-]{36}$/); + const redacted = redactLogValue({ password: 'secret', note: 'token=abc', nested: { authorization: 'Bearer value' } }); + assert.strictEqual(redacted.password, '[REDACTED]'); + assert.strictEqual(redacted.note, 'token=[REDACTED]'); + assert.strictEqual(redacted.nested.authorization, '[REDACTED]'); + response.emit('finish'); +}); + +test('CSRF token 使用固定长度并支持常量时间比较', () => { + const token = generateCsrfToken(); + assert.match(token, /^[a-f0-9]{64}$/); + assert.strictEqual(tokensEqual(token, token), true); + assert.strictEqual(tokensEqual(token, `${token}0`), false); +}); + +test('CSRF Cookie 中间件只在缺少 token 时设置 Cookie', () => { + const middleware = createCsrfCookieMiddleware({ secureCookies: true }); + const response = createResponse(); + middleware({ cookies: {} }, response, () => {}); + assert.strictEqual(response.cookies.length, 1); + assert.strictEqual(response.cookies[0].options.secure, true); + assert.strictEqual(response.cookies[0].options.sameSite, 'strict'); +}); + +test('Cookie 会话的写请求必须通过 CSRF 校验', () => { + const response = createResponse(); + csrfProtection({ + method: 'POST', + path: '/api/example', + id: 'req-2', + cookies: { token: 'session', csrf_token: 'a' }, + headers: { 'x-csrf-token': 'b' } + }, response, () => assert.fail('不应放行')); + assert.strictEqual(response.statusCode, 403); + assert.strictEqual(response.payload.requestId, 'req-2'); +}); + +test('失败限流器达到阈值后封锁并可在成功后清除', () => { + const limiter = new RateLimiter({ maxAttempts: 2, windowMs: 1000, blockDuration: 1000 }); + try { + assert.strictEqual(limiter.recordFailure('key').blocked, false); + assert.strictEqual(limiter.recordFailure('key').blocked, true); + assert.strictEqual(limiter.isBlocked('key'), true); + limiter.recordSuccess('key'); + assert.strictEqual(limiter.isBlocked('key'), false); + } finally { + limiter.destroy(); + } +}); + +test('健康检查同时验证数据库与磁盘', () => { + const service = createHealthService({ + db: { prepare: () => ({ get: () => ({ ok: 1 }) }) }, + diskPath: __dirname, + minimumFreeBytes: 0 + }); + const result = service.check(); + assert.strictEqual(result.healthy, true); + assert.strictEqual(result.checks.database.status, 'ok'); + assert.strictEqual(result.checks.disk.status, 'ok'); + assert.ok(result.checks.disk.freeBytes > 0); + assert.strictEqual(fs.existsSync(__dirname), true); + assert.strictEqual(normalizeMinimumFreeBytes(-1), 256 * 1024 * 1024); +}); + +test('数据库异常会让健康状态降级', () => { + const service = createHealthService({ + db: { prepare: () => { throw Object.assign(new Error('down'), { code: 'SQLITE_DOWN' }); } }, + diskPath: __dirname, + minimumFreeBytes: 0 + }); + const result = service.check(); + assert.strictEqual(result.healthy, false); + assert.strictEqual(result.checks.database.status, 'error'); +}); + +test('错误响应携带 requestId 并隐藏服务端异常', () => { + const response = createResponse(); + expressErrorHandler(new Error('secret detail'), { + id: 'req-3', + method: 'GET', + path: '/api/example' + }, response, () => assert.fail('不应继续')); + assert.deepStrictEqual(response.payload, { + success: false, + message: '服务器内部错误', + requestId: 'req-3' + }); +}); + +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/production-utils-tests.js b/backend/tests/production-utils-tests.js index 4bc1253..5f82eaf 100644 --- a/backend/tests/production-utils-tests.js +++ b/backend/tests/production-utils-tests.js @@ -1,3 +1,5 @@ +process.env.LOG_LEVEL = 'silent'; + const assert = require('assert'); const { parseDateTimeValue, diff --git a/backend/tests/run-all-tests.js b/backend/tests/run-all-tests.js index 0de186a..c27697f 100644 --- a/backend/tests/run-all-tests.js +++ b/backend/tests/run-all-tests.js @@ -8,6 +8,9 @@ const path = require('path'); const testFiles = [ 'boundary-tests.js', 'production-utils-tests.js', + 'domain-utils-tests.js', + 'middleware-tests.js', + 'architecture-tests.js', 'archive-tests.js', 'network-concurrent-tests.js', 'state-consistency-tests.js' diff --git a/backend/utils/coerce.js b/backend/utils/coerce.js new file mode 100644 index 0000000..dc8c589 --- /dev/null +++ b/backend/utils/coerce.js @@ -0,0 +1,26 @@ +function normalizeNonNegativeInteger(rawValue, fallback = 0) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + return Math.max(0, Number(fallback) || 0); + } + return Math.floor(value); +} + +function parseBooleanLike(rawValue, fallback = false) { + if (rawValue === null || rawValue === undefined || rawValue === '') { + return fallback; + } + if (typeof rawValue === 'boolean') { + return rawValue; + } + + const normalized = String(rawValue).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +module.exports = { + normalizeNonNegativeInteger, + parseBooleanLike +}; diff --git a/backend/utils/device.js b/backend/utils/device.js new file mode 100644 index 0000000..49040f8 --- /dev/null +++ b/backend/utils/device.js @@ -0,0 +1,68 @@ +function normalizeClientIp(rawIp) { + const ip = String(rawIp || '').trim(); + if (!ip) return ''; + if (ip.startsWith('::ffff:')) return ip.slice(7); + return ip === '::1' ? '127.0.0.1' : ip; +} + +function detectDeviceTypeFromUserAgent(userAgent = '') { + const mobilePattern = /(Mobile|Android|iPhone|iPad|iPod|Windows Phone|HarmonyOS|Mobi)/i; + return mobilePattern.test(String(userAgent || '')) ? 'mobile' : 'desktop'; +} + +function inferPlatformFromUserAgent(userAgent = '') { + const value = String(userAgent || ''); + if (!value) return '未知平台'; + if (/windows/i.test(value)) return 'Windows'; + if (/macintosh|mac os x/i.test(value)) return 'macOS'; + if (/android/i.test(value)) return 'Android'; + if (/iphone|ipad|ios/i.test(value)) return 'iOS'; + if (/linux/i.test(value)) return 'Linux'; + return '未知平台'; +} + +function normalizeClientType(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return ['web', 'desktop', 'mobile', 'api'].includes(normalized) ? normalized : ''; +} + +function resolveClientType(clientType, userAgent = '') { + const normalized = normalizeClientType(clientType); + if (normalized) return normalized; + + const userAgentValue = String(userAgent || '').toLowerCase(); + if ( + userAgentValue.includes('tauri') + || userAgentValue.includes('electron') + || userAgentValue.includes('wanwan-cloud-desktop') + || userAgentValue.includes('玩玩云') + ) { + return 'desktop'; + } + return detectDeviceTypeFromUserAgent(userAgentValue) === 'mobile' ? 'mobile' : 'web'; +} + +function sanitizeDeviceText(value, maxLength = 120) { + return typeof value === 'string' ? value.trim().slice(0, maxLength) : ''; +} + +function buildDeviceName({ clientType, deviceName, platform }) { + const preferred = sanitizeDeviceText(deviceName, 120); + if (preferred) return preferred; + + const platformText = sanitizeDeviceText(platform, 80) || '未知平台'; + if (clientType === 'desktop') return `桌面客户端 · ${platformText}`; + if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`; + if (clientType === 'api') return `API 客户端 · ${platformText}`; + return `网页端浏览器 · ${platformText}`; +} + +module.exports = { + buildDeviceName, + detectDeviceTypeFromUserAgent, + inferPlatformFromUserAgent, + normalizeClientIp, + normalizeClientType, + resolveClientType, + sanitizeDeviceText +}; diff --git a/backend/utils/logger.js b/backend/utils/logger.js new file mode 100644 index 0000000..6d94a43 --- /dev/null +++ b/backend/utils/logger.js @@ -0,0 +1,137 @@ +const { AsyncLocalStorage } = require('async_hooks'); +const { randomUUID } = require('crypto'); +const pino = require('pino'); + +const requestStorage = new AsyncLocalStorage(); +const SENSITIVE_KEY_PATTERN = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|access[_-]?key)/i; +const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; + +const logger = pino({ + level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'), + base: { + service: 'wanwanyun-backend', + pid: process.pid + }, + timestamp: pino.stdTimeFunctions.isoTime, + redact: { + paths: [ + '*.authorization', + '*.cookie', + '*.password', + '*.token', + '*.refreshToken', + '*.access_key_secret', + '*.oss_access_key_secret', + '*.smtp_password', + 'req.headers.authorization', + 'req.headers.cookie' + ], + censor: '[REDACTED]' + } +}); + +function redactString(value) { + return value + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]') + .replace(/\b(password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[:=]\s*[^\s,;&]+/gi, '$1=[REDACTED]'); +} + +function redactLogValue(value, depth = 0, seen = new WeakSet()) { + if (depth > 5) return '[MAX_DEPTH]'; + if (typeof value === 'string') return redactString(value); + if (value === null || value === undefined || typeof value !== 'object') return value; + if (value instanceof Error) { + return { + type: value.name, + message: redactString(value.message || ''), + stack: redactString(value.stack || ''), + code: value.code + }; + } + if (seen.has(value)) return '[CIRCULAR]'; + seen.add(value); + + if (Array.isArray(value)) { + return value.map((item) => redactLogValue(item, depth + 1, seen)); + } + + const result = {}; + for (const [key, child] of Object.entries(value)) { + result[key] = SENSITIVE_KEY_PATTERN.test(key) + ? '[REDACTED]' + : redactLogValue(child, depth + 1, seen); + } + return result; +} + +function getRequestContext() { + return requestStorage.getStore() || {}; +} + +function getRequestId(req) { + return req?.id || getRequestContext().requestId || null; +} + +function normalizeRequestId(value) { + const candidate = Array.isArray(value) ? value[0] : value; + return typeof candidate === 'string' && REQUEST_ID_PATTERN.test(candidate) + ? candidate + : randomUUID(); +} + +function requestContextMiddleware(req, res, next) { + const requestId = normalizeRequestId(req.headers['x-request-id']); + const startedAt = process.hrtime.bigint(); + req.id = requestId; + req.log = logger.child({ requestId }); + res.setHeader('X-Request-ID', requestId); + + requestStorage.run({ requestId }, () => { + res.once('finish', () => { + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6; + const level = res.statusCode >= 500 ? 'error' : (res.statusCode >= 400 ? 'warn' : 'info'); + req.log[level]({ + method: req.method, + path: req.path, + statusCode: res.statusCode, + durationMs: Number(durationMs.toFixed(2)) + }, 'request completed'); + }); + next(); + }); +} + +let consoleBridgeInstalled = false; + +function installConsoleBridge() { + if (consoleBridgeInstalled) return; + if (process.env.NODE_ENV !== 'production' && process.env.LOG_FORMAT !== 'json') return; + consoleBridgeInstalled = true; + + const levels = { + log: 'info', + info: 'info', + warn: 'warn', + error: 'error', + debug: 'debug' + }; + + for (const [consoleMethod, level] of Object.entries(levels)) { + console[consoleMethod] = (...args) => { + const values = redactLogValue(args); + const firstMessage = typeof values[0] === 'string' ? values[0] : 'console message'; + logger[level]({ + requestId: getRequestId(), + values + }, firstMessage); + }; + } +} + +module.exports = { + getRequestId, + installConsoleBridge, + logger, + redactLogValue, + requestContextMiddleware +}; diff --git a/backend/utils/oss-log-parser.js b/backend/utils/oss-log-parser.js new file mode 100644 index 0000000..b3f36f3 --- /dev/null +++ b/backend/utils/oss-log-parser.js @@ -0,0 +1,115 @@ +const MONTHS = Object.freeze({ + Jan: 0, + Feb: 1, + Mar: 2, + Apr: 3, + May: 4, + Jun: 5, + Jul: 6, + Aug: 7, + Sep: 8, + Oct: 9, + Nov: 10, + Dec: 11 +}); + +function parseDownloadTrafficLogTime(line) { + if (!line || typeof line !== 'string') return null; + + const match = line.match(/\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{4})?\]/); + if (!match) return null; + + const month = MONTHS[match[2]]; + if (month === undefined) return null; + + const year = Number(match[3]); + const day = Number(match[1]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + if ([year, day, hour, minute, second].some((value) => !Number.isFinite(value))) return null; + + let utcMillis = Date.UTC(year, month, day, hour, minute, second); + const timezone = match[7]; + if (timezone && /^[+-]\d{4}$/.test(timezone)) { + const sign = timezone[0] === '+' ? 1 : -1; + const timezoneHours = Number(timezone.slice(1, 3)); + const timezoneMinutes = Number(timezone.slice(3, 5)); + if (timezoneHours > 23 || timezoneMinutes > 59) return null; + utcMillis -= sign * ((timezoneHours * 60) + timezoneMinutes) * 60 * 1000; + } + + const parsed = new Date(utcMillis); + if (Number.isNaN(parsed.getTime())) return null; + + // Date.UTC normalizes impossible dates; reject them instead of charging the wrong day. + const offsetMinutes = timezone + ? (timezone[0] === '+' ? 1 : -1) * ((Number(timezone.slice(1, 3)) * 60) + Number(timezone.slice(3, 5))) + : 0; + const localDate = new Date(parsed.getTime() + (offsetMinutes * 60 * 1000)); + if ( + localDate.getUTCFullYear() !== year + || localDate.getUTCMonth() !== month + || localDate.getUTCDate() !== day + || localDate.getUTCHours() !== hour + || localDate.getUTCMinutes() !== minute + || localDate.getUTCSeconds() !== second + ) { + return null; + } + + return parsed; +} + +function parseDownloadTrafficLine(line, fallbackDate = new Date()) { + if (!line || typeof line !== 'string') return null; + + const trimmed = line.trim(); + if (!trimmed || !/\bGET\b/i.test(trimmed)) return null; + + const statusMatch = trimmed.match(/"\s*(\d{3})\s+(\d+|-)\b/); + if (!statusMatch) return null; + + const statusCode = Number(statusMatch[1]); + const bytesSent = statusMatch[2] === '-' ? 0 : Number(statusMatch[2]); + if (![200, 206].includes(statusCode) || !Number.isFinite(bytesSent) || bytesSent <= 0) return null; + + let objectKey = null; + const requestMatch = trimmed.match(/"(?:GET|HEAD)\s+([^" ]+)\s+HTTP\//i); + if (requestMatch?.[1]) { + let requestPath = requestMatch[1]; + const queryIndex = requestPath.indexOf('?'); + if (queryIndex >= 0) requestPath = requestPath.slice(0, queryIndex); + requestPath = requestPath.replace(/^https?:\/\/[^/]+/i, '').replace(/^\/+/, ''); + try { + requestPath = decodeURIComponent(requestPath); + } catch { + // Keep the raw path when the log contains malformed percent encoding. + } + objectKey = requestPath || null; + } + + if (!objectKey) { + const keyMatch = trimmed.match(/\buser_(\d+)\/[^\s"]+/); + objectKey = keyMatch?.[0] || null; + } + if (!objectKey) return null; + + const userMatch = objectKey.match(/(?:^|\/)user_(\d+)\//); + if (!userMatch) return null; + + const userId = Number(userMatch[1]); + if (!Number.isSafeInteger(userId) || userId <= 0) return null; + + return { + userId, + bytes: Math.floor(bytesSent), + objectKey, + eventAt: parseDownloadTrafficLogTime(trimmed) || fallbackDate + }; +} + +module.exports = { + parseDownloadTrafficLine, + parseDownloadTrafficLogTime +}; diff --git a/backend/utils/path-safety.js b/backend/utils/path-safety.js new file mode 100644 index 0000000..3c58204 --- /dev/null +++ b/backend/utils/path-safety.js @@ -0,0 +1,34 @@ +const path = require('path'); + +function isPathInside(parent, child) { + const relativePath = path.relative(parent, child); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +function normalizeVirtualPath(rawPath) { + if (typeof rawPath !== 'string') return null; + + let decoded = rawPath; + try { + decoded = decodeURIComponent(rawPath); + } catch { + // The traversal checks below still apply to malformed encoded input. + } + + if (decoded.includes('\x00') || decoded.toLowerCase().includes('%00')) return null; + + const unifiedPath = decoded.replace(/\\/g, '/'); + if (/(^|\/)\.\.(\/|$)/.test(unifiedPath)) return null; + + let normalized = path.posix.normalize(unifiedPath); + if (normalized === '' || normalized === '.') normalized = '/'; + if (!normalized.startsWith('/')) normalized = `/${normalized}`; + + normalized = normalized.replace(/\/+$/g, ''); + return normalized || '/'; +} + +module.exports = { + isPathInside, + normalizeVirtualPath +}; diff --git a/backend/utils/share.js b/backend/utils/share.js new file mode 100644 index 0000000..6588030 --- /dev/null +++ b/backend/utils/share.js @@ -0,0 +1,54 @@ +const { normalizeVirtualPath } = require('./path-safety'); + +const SHARE_CODE_PATTERN = /^[A-Za-z0-9]{6,32}$/; + +function isValidShareCode(code) { + return typeof code === 'string' && SHARE_CODE_PATTERN.test(code); +} + +function isPathWithinShare(requestPath, share) { + if (!requestPath || !share) return false; + + const normalizedRequest = normalizeVirtualPath(requestPath); + const normalizedShare = normalizeVirtualPath(share.share_path); + if (!normalizedRequest || !normalizedShare) return false; + + if (share.share_type === 'file') return normalizedRequest === normalizedShare; + + const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`; + return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix); +} + +function parseShareIpWhitelist(rawValue) { + if (typeof rawValue !== 'string') return []; + return rawValue + .split(/[\s,;]+/) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 100); +} + +function isShareIpAllowed(clientIp, whitelist = []) { + if (!Array.isArray(whitelist) || whitelist.length === 0) return true; + if (!clientIp) return false; + + for (const rule of whitelist) { + const normalizedRule = String(rule || '').trim(); + if (!normalizedRule) continue; + if (normalizedRule === clientIp) return true; + + if (normalizedRule.endsWith('*')) { + const prefix = normalizedRule.slice(0, -1); + if (prefix && clientIp.startsWith(prefix)) return true; + } + } + + return false; +} + +module.exports = { + isPathWithinShare, + isShareIpAllowed, + isValidShareCode, + parseShareIpWhitelist +}; diff --git a/backend/utils/url.js b/backend/utils/url.js new file mode 100644 index 0000000..eba5bdd --- /dev/null +++ b/backend/utils/url.js @@ -0,0 +1,43 @@ +function sanitizeHttpBaseUrl(rawValue) { + if (!rawValue) return null; + + try { + const url = new URL(rawValue); + if (!['http:', 'https:'].includes(url.protocol)) return null; + + url.search = ''; + url.hash = ''; + url.pathname = url.pathname.replace(/\/+$/, ''); + return url.toString(); + } catch { + return null; + } +} + +function buildHttpDownloadUrl(rawBaseUrl, filePath) { + const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl); + if (!baseUrl || !filePath) return null; + + try { + const url = new URL(baseUrl); + const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`; + const safeSegments = normalizedPath + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)); + const safePath = safeSegments.length ? `/${safeSegments.join('/')}` : ''; + + const basePath = url.pathname.replace(/\/+$/, ''); + url.pathname = `${basePath}${safePath || '/'}` || '/'; + url.search = ''; + url.hash = ''; + return url.toString(); + } catch { + return null; + } +} + +module.exports = { + buildHttpDownloadUrl, + sanitizeHttpBaseUrl +}; diff --git a/backend/utils/version.js b/backend/utils/version.js new file mode 100644 index 0000000..481a412 --- /dev/null +++ b/backend/utils/version.js @@ -0,0 +1,45 @@ +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +function normalizeVersion(rawVersion, fallback = '0.0.0') { + const value = String(rawVersion || '').trim(); + return value || fallback; +} + +function compareLooseVersion(left, right) { + const normalize = (value) => normalizeVersion(value, '0.0.0') + .replace(/^v/i, '') + .split('.') + .map((part) => parseInt(part, 10)) + .map((num) => (Number.isFinite(num) ? num : 0)); + + const a = normalize(left); + const b = normalize(right); + const size = Math.max(a.length, b.length); + for (let i = 0; i < size; i += 1) { + const av = a[i] || 0; + const bv = b[i] || 0; + if (av > bv) return 1; + if (av < bv) return -1; + } + return 0; +} + +function normalizeReleaseNotes(rawValue) { + return String(rawValue || '') + .replace(/\\r\\n/g, '\n') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\n') + .trim(); +} + +function normalizeSha256(rawValue) { + const digest = String(rawValue || '').trim().toLowerCase(); + return SHA256_PATTERN.test(digest) ? digest : ''; +} + +module.exports = { + compareLooseVersion, + normalizeReleaseNotes, + normalizeSha256, + normalizeVersion +};