refactor: modularize backend security and observability
This commit is contained in:
@@ -89,6 +89,13 @@ COOKIE_SECURE=true
|
|||||||
# 前端会自动从 Cookie 读取 csrf_token 并在请求头中发送
|
# 前端会自动从 Cookie 读取 csrf_token 并在请求头中发送
|
||||||
ENABLE_CSRF=true
|
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等)
|
# 反向代理配置(Nginx/Cloudflare等)
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -147,6 +154,9 @@ STORAGE_ROOT=./storage
|
|||||||
# 日志级别 (error, warn, info, debug)
|
# 日志级别 (error, warn, info, debug)
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
|
|
||||||
|
# production 默认输出 JSON;开发环境可显式开启
|
||||||
|
# LOG_FORMAT=json
|
||||||
|
|
||||||
# 是否启用调试模式
|
# 是否启用调试模式
|
||||||
# DEBUG=false
|
# DEBUG=false
|
||||||
|
|
||||||
|
|||||||
68
backend/middleware/csrf.js
Normal file
68
backend/middleware/csrf.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const { getRequestId, logger } = require('../utils/logger');
|
||||||
|
|
||||||
function expressErrorHandler(err, req, res, next) {
|
function expressErrorHandler(err, req, res, next) {
|
||||||
if (res.headersSent) return next(err);
|
if (res.headersSent) return next(err);
|
||||||
|
|
||||||
@@ -6,14 +8,18 @@ function expressErrorHandler(err, req, res, next) {
|
|||||||
? requestedStatus
|
? requestedStatus
|
||||||
: 500;
|
: 500;
|
||||||
|
|
||||||
console.error(`[未处理错误] ${req.method} ${req.originalUrl}`, {
|
const requestId = getRequestId(req);
|
||||||
message: err?.message,
|
(req.log || logger).error({
|
||||||
stack: err?.stack
|
err,
|
||||||
});
|
method: req.method,
|
||||||
|
path: req.path || req.originalUrl,
|
||||||
|
statusCode: status
|
||||||
|
}, 'unhandled request error');
|
||||||
|
|
||||||
return res.status(status).json({
|
return res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: status >= 500 ? '服务器内部错误' : (err?.message || '请求处理失败')
|
message: status >= 500 ? '服务器内部错误' : (err?.message || '请求处理失败'),
|
||||||
|
...(requestId ? { requestId } : {})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
149
backend/middleware/rate-limit.js
Normal file
149
backend/middleware/rate-limit.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
49
backend/middleware/security.js
Normal file
49
backend/middleware/security.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
202
backend/package-lock.json
generated
202
backend/package-lock.json
generated
@@ -18,15 +18,18 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-async-errors": "3.1.1",
|
||||||
|
"express-rate-limit": "8.6.1",
|
||||||
"express-validator": "^7.3.0",
|
"express-validator": "^7.3.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"pino": "10.3.1",
|
||||||
"svg-captcha": "^1.4.0"
|
"svg-captcha": "^1.4.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20 <25"
|
"node": ">=20.19 <25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-crypto/crc32": {
|
"node_modules/@aws-crypto/crc32": {
|
||||||
@@ -973,6 +976,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@smithy/abort-controller": {
|
||||||
"version": "4.2.8",
|
"version": "4.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz",
|
||||||
@@ -1780,6 +1789,15 @@
|
|||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/b4a": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
|
||||||
@@ -2404,6 +2422,57 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express-validator": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
||||||
@@ -2660,6 +2729,15 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -3028,6 +3106,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -3091,6 +3178,43 @@
|
|||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"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==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -3170,6 +3310,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -3264,6 +3410,15 @@
|
|||||||
"url": "https://github.com/sponsors/yqnn"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -3284,6 +3439,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -3470,6 +3634,24 @@
|
|||||||
"simple-concat": "^1.0.0"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -3605,6 +3787,24 @@
|
|||||||
"b4a": "^1.6.4"
|
"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": {
|
"node_modules/tiny-inflate": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "玩玩云 - 云存储管理平台后端服务",
|
"description": "玩玩云 - 云存储管理平台后端服务",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20 <25"
|
"node": ">=20.19 <25"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
@@ -33,11 +33,14 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-async-errors": "3.1.1",
|
||||||
|
"express-rate-limit": "8.6.1",
|
||||||
"express-validator": "^7.3.0",
|
"express-validator": "^7.3.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"pino": "10.3.1",
|
||||||
"svg-captcha": "^1.4.0"
|
"svg-captcha": "^1.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
35
backend/routes/system.routes.js
Normal file
35
backend/routes/system.routes.js
Normal file
@@ -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 };
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
// 加载环境变量(必须在最开始)
|
// 加载环境变量(必须在最开始)
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const { installConsoleBridge, logger, requestContextMiddleware } = require('./utils/logger');
|
||||||
|
installConsoleBridge();
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
require('express-async-errors');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
const svgCaptcha = require('svg-captcha');
|
const svgCaptcha = require('svg-captcha');
|
||||||
@@ -117,7 +121,45 @@ const {
|
|||||||
resolveDownloadTrafficPolicyUpdates
|
resolveDownloadTrafficPolicyUpdates
|
||||||
} = require('./utils/download-quota');
|
} = require('./utils/download-quota');
|
||||||
const { expressErrorHandler } = require('./middleware/error-handler');
|
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 { 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 app = express();
|
||||||
const PORT = process.env.PORT || 40001;
|
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_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0));
|
||||||
const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || '';
|
const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || '';
|
||||||
const FRONTEND_ROOT_DIR = path.resolve(__dirname, '../frontend');
|
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_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads');
|
||||||
const DESKTOP_INSTALLER_FILE_PATTERN = /^(wanwan-cloud-desktop|玩玩云)_.*_x64-setup\.exe$/i;
|
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_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_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
|
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_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_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 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({
|
const DOWNLOAD_SECURITY_DEFAULTS = Object.freeze({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
same_ip_same_file: {
|
same_ip_same_file: {
|
||||||
@@ -194,55 +235,6 @@ const DEFAULT_CAPTCHA_SECRETS = [
|
|||||||
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
|
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
|
||||||
const sameSiteMode = isSecureCookie ? 'none' : 'lax';
|
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) {
|
function resolveDesktopInstallerLocalPath(installerUrl) {
|
||||||
const raw = String(installerUrl || '').trim();
|
const raw = String(installerUrl || '').trim();
|
||||||
if (!raw) return null;
|
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 开关策略:
|
// CSRF 开关策略:
|
||||||
// - 显式配置 ENABLE_CSRF 时按配置值
|
// - 显式配置 ENABLE_CSRF 时按配置值
|
||||||
// - 未配置时,生产环境默认开启
|
// - 未配置时,生产环境默认开启
|
||||||
@@ -650,85 +550,34 @@ const ENABLE_CSRF = process.env.ENABLE_CSRF !== undefined
|
|||||||
? process.env.ENABLE_CSRF === 'true'
|
? process.env.ENABLE_CSRF === 'true'
|
||||||
: process.env.NODE_ENV === 'production';
|
: 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) {
|
if (ENABLE_CSRF) {
|
||||||
console.log('[安全] CSRF 保护已启用');
|
logger.info('CSRF protection enabled');
|
||||||
app.use(csrfProtection);
|
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
|
// 应用XSS过滤到所有POST/PUT请求的body
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
|
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
|
||||||
@@ -768,12 +617,6 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 请求日志
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取正确的协议(基于可信代理链)
|
// 获取正确的协议(基于可信代理链)
|
||||||
// 安全说明:req.protocol 由 Express 根据 trust proxy 配置计算,
|
// 安全说明:req.protocol 由 Express 根据 trust proxy 配置计算,
|
||||||
// 只有可信代理的 X-Forwarded-Proto 才会被采信
|
// 只有可信代理的 X-Forwarded-Proto 才会被采信
|
||||||
@@ -796,23 +639,6 @@ function getBusyDownloadMessage() {
|
|||||||
return '当前网络繁忙,请稍后再试';
|
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) {
|
function clampIntegerSetting(rawValue, fallback, min, max) {
|
||||||
const value = Number(rawValue);
|
const value = Number(rawValue);
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
@@ -1610,116 +1436,6 @@ async function readS3BodyToBuffer(body) {
|
|||||||
return Buffer.alloc(0);
|
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 = '') {
|
function extractLogLinesFromBuffer(buffer, logKey = '') {
|
||||||
let content = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || '');
|
let content = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || '');
|
||||||
if ((logKey || '').toLowerCase().endsWith('.gz')) {
|
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分钟)
|
// 创建登录限流器(5次失败/15分钟,封锁30分钟)
|
||||||
const loginLimiter = new RateLimiter({
|
const loginLimiter = new RateLimiter({
|
||||||
maxAttempts: 5,
|
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) {
|
function getClientIp(req) {
|
||||||
return normalizeClientIp(
|
return normalizeClientIp(
|
||||||
req?.ip
|
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 = {}) {
|
function buildDeviceSessionContext(req, payload = {}) {
|
||||||
const userAgent = sanitizeDeviceText(req.get('user-agent') || req.headers?.['user-agent'] || '', 1024);
|
const userAgent = sanitizeDeviceText(req.get('user-agent') || req.headers?.['user-agent'] || '', 1024);
|
||||||
const clientType = resolveClientType(payload.client_type || req.headers?.['x-client-type'], userAgent);
|
const clientType = resolveClientType(payload.client_type || req.headers?.['x-client-type'], userAgent);
|
||||||
@@ -3603,21 +3004,11 @@ function verifyCaptcha(req, res, captcha, logPrefix = '验证码验证') {
|
|||||||
|
|
||||||
// ===== 公开API =====
|
// ===== 公开API =====
|
||||||
|
|
||||||
// 健康检查
|
const healthService = createHealthService({
|
||||||
app.get('/api/health', (req, res) => {
|
db,
|
||||||
res.json({ success: true, message: 'Server is running' });
|
diskPath: path.resolve(__dirname, 'data')
|
||||||
});
|
|
||||||
|
|
||||||
// 获取公开的系统配置(不需要登录)
|
|
||||||
app.get('/api/config', (req, res) => {
|
|
||||||
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
config: {
|
|
||||||
max_upload_size: maxUploadSize
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
app.use('/api', createSystemRouter({ SettingsDB, healthService }));
|
||||||
|
|
||||||
// 桌面客户端更新信息(无需登录)
|
// 桌面客户端更新信息(无需登录)
|
||||||
app.get('/api/client/desktop-update', (req, res) => {
|
app.get('/api/client/desktop-update', (req, res) => {
|
||||||
@@ -3688,13 +3079,7 @@ app.get('/api/csrf-token', (req, res) => {
|
|||||||
// 如果没有 token,生成一个新的
|
// 如果没有 token,生成一个新的
|
||||||
if (!csrfToken) {
|
if (!csrfToken) {
|
||||||
csrfToken = generateCsrfToken();
|
csrfToken = generateCsrfToken();
|
||||||
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
|
setCsrfCookie(res, csrfToken, SHOULD_USE_SECURE_COOKIES);
|
||||||
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
|
||||||
httpOnly: false,
|
|
||||||
secure: isSecureEnv,
|
|
||||||
sameSite: isSecureEnv ? 'strict' : 'lax',
|
|
||||||
maxAge: 24 * 60 * 60 * 1000
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
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) => {
|
app.get('/api/share/:code/theme', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
59
backend/services/health-service.js
Normal file
59
backend/services/health-service.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
113
backend/tests/architecture-tests.js
Normal file
113
backend/tests/architecture-tests.js
Normal file
@@ -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);
|
||||||
155
backend/tests/domain-utils-tests.js
Normal file
155
backend/tests/domain-utils-tests.js
Normal file
@@ -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);
|
||||||
180
backend/tests/middleware-tests.js
Normal file
180
backend/tests/middleware-tests.js
Normal file
@@ -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);
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
process.env.LOG_LEVEL = 'silent';
|
||||||
|
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const {
|
const {
|
||||||
parseDateTimeValue,
|
parseDateTimeValue,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const path = require('path');
|
|||||||
const testFiles = [
|
const testFiles = [
|
||||||
'boundary-tests.js',
|
'boundary-tests.js',
|
||||||
'production-utils-tests.js',
|
'production-utils-tests.js',
|
||||||
|
'domain-utils-tests.js',
|
||||||
|
'middleware-tests.js',
|
||||||
|
'architecture-tests.js',
|
||||||
'archive-tests.js',
|
'archive-tests.js',
|
||||||
'network-concurrent-tests.js',
|
'network-concurrent-tests.js',
|
||||||
'state-consistency-tests.js'
|
'state-consistency-tests.js'
|
||||||
|
|||||||
26
backend/utils/coerce.js
Normal file
26
backend/utils/coerce.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
68
backend/utils/device.js
Normal file
68
backend/utils/device.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
137
backend/utils/logger.js
Normal file
137
backend/utils/logger.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
115
backend/utils/oss-log-parser.js
Normal file
115
backend/utils/oss-log-parser.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
34
backend/utils/path-safety.js
Normal file
34
backend/utils/path-safety.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
54
backend/utils/share.js
Normal file
54
backend/utils/share.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
43
backend/utils/url.js
Normal file
43
backend/utils/url.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
45
backend/utils/version.js
Normal file
45
backend/utils/version.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user