refactor: modularize backend security and observability

This commit is contained in:
237899745
2026-07-27 13:06:54 +08:00
parent f90311e68c
commit dce1e3622a
25 changed files with 1633 additions and 849 deletions

View File

@@ -1,7 +1,11 @@
// 加载环境变量(必须在最开始)
require('dotenv').config();
const { installConsoleBridge, logger, requestContextMiddleware } = require('./utils/logger');
installConsoleBridge();
const express = require('express');
require('express-async-errors');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const svgCaptcha = require('svg-captcha');
@@ -117,7 +121,45 @@ const {
resolveDownloadTrafficPolicyUpdates
} = require('./utils/download-quota');
const { expressErrorHandler } = require('./middleware/error-handler');
const {
CSRF_COOKIE_NAME,
createCsrfCookieMiddleware,
csrfProtection,
generateCsrfToken,
setCsrfCookie
} = require('./middleware/csrf');
const { RateLimiter, createGlobalApiLimiter } = require('./middleware/rate-limit');
const {
createHttpsEnforcementMiddleware,
securityHeadersMiddleware
} = require('./middleware/security');
const { createZipArchive } = require('./utils/archive');
const { normalizeNonNegativeInteger, parseBooleanLike } = require('./utils/coerce');
const {
buildDeviceName,
detectDeviceTypeFromUserAgent,
inferPlatformFromUserAgent,
normalizeClientIp,
resolveClientType,
sanitizeDeviceText
} = require('./utils/device');
const { parseDownloadTrafficLine } = require('./utils/oss-log-parser');
const { isPathInside, normalizeVirtualPath } = require('./utils/path-safety');
const {
isPathWithinShare,
isShareIpAllowed,
isValidShareCode,
parseShareIpWhitelist
} = require('./utils/share');
const { buildHttpDownloadUrl } = require('./utils/url');
const {
compareLooseVersion,
normalizeReleaseNotes,
normalizeSha256,
normalizeVersion
} = require('./utils/version');
const { createHealthService } = require('./services/health-service');
const { createSystemRouter } = require('./routes/system.routes');
const app = express();
const PORT = process.env.PORT || 40001;
@@ -140,9 +182,9 @@ const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SH
const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0));
const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || '';
const FRONTEND_ROOT_DIR = path.resolve(__dirname, '../frontend');
const FRONTEND_BUILD_DIR = path.join(FRONTEND_ROOT_DIR, 'dist');
const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads');
const DESKTOP_INSTALLER_FILE_PATTERN = /^(wanwan-cloud-desktop|玩玩云)_.*_x64-setup\.exe$/i;
const DESKTOP_INSTALLER_SHA256_PATTERN = /^[a-f0-9]{64}$/;
const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时
const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB
const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB
@@ -159,7 +201,6 @@ const OSS_UPLOAD_TEMP_PREFIX = '__wanwan_tmp_uploads';
const GLOBAL_SEARCH_DEFAULT_LIMIT = Number(process.env.GLOBAL_SEARCH_DEFAULT_LIMIT || 80);
const GLOBAL_SEARCH_MAX_LIMIT = Number(process.env.GLOBAL_SEARCH_MAX_LIMIT || 200);
const GLOBAL_SEARCH_MAX_SCANNED_NODES = Number(process.env.GLOBAL_SEARCH_MAX_SCANNED_NODES || 4000);
const SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/;
const DOWNLOAD_SECURITY_DEFAULTS = Object.freeze({
enabled: true,
same_ip_same_file: {
@@ -194,55 +235,6 @@ const DEFAULT_CAPTCHA_SECRETS = [
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
const sameSiteMode = isSecureCookie ? 'none' : 'lax';
function normalizeVersion(rawVersion, fallback = '0.0.0') {
const value = String(rawVersion || '').trim();
if (!value) return fallback;
return value;
}
function compareLooseVersion(left, right) {
const normalize = (value) => normalizeVersion(value, '0.0.0')
.replace(/^v/i, '')
.split('.')
.map((part) => parseInt(part, 10))
.map((num) => (Number.isFinite(num) ? num : 0));
const a = normalize(left);
const b = normalize(right);
const size = Math.max(a.length, b.length);
for (let i = 0; i < size; i += 1) {
const av = a[i] || 0;
const bv = b[i] || 0;
if (av > bv) return 1;
if (av < bv) return -1;
}
return 0;
}
function normalizeReleaseNotes(rawValue) {
return String(rawValue || '')
.replace(/\\r\\n/g, '\n')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\n')
.trim();
}
function normalizeSha256(rawValue) {
const digest = String(rawValue || '').trim().toLowerCase();
return DESKTOP_INSTALLER_SHA256_PATTERN.test(digest) ? digest : '';
}
function normalizeNonNegativeInteger(rawValue, fallback = 0) {
const value = Number(rawValue);
if (!Number.isFinite(value) || value < 0) return Math.max(0, Number(fallback) || 0);
return Math.floor(value);
}
function isPathInside(parent, child) {
const rel = path.relative(parent, child);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}
function resolveDesktopInstallerLocalPath(installerUrl) {
const raw = String(installerUrl || '').trim();
if (!raw) return null;
@@ -551,98 +543,6 @@ const corsOptions = {
}
};
function applySecurityHeaders(req, res) {
// 防止点击劫持
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
// 防止MIME类型嗅探
res.setHeader('X-Content-Type-Options', 'nosniff');
// XSS保护
res.setHeader('X-XSS-Protection', '1; mode=block');
// HTTPS严格传输安全仅在可信的 HTTPS 连接时设置)
// req.secure 基于 trust proxy 配置,不会被不可信代理伪造
if ((req && req.secure) || (!req && (ENFORCE_HTTPS || SHOULD_USE_SECURE_COOKIES))) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
// 内容安全策略
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';");
// 隐藏X-Powered-By
res.removeHeader('X-Powered-By');
}
// 中间件
app.use(cors(corsOptions));
// 静态文件服务 - 提供前端页面
const frontendPath = path.join(__dirname, '../frontend');
console.log('[静态文件] 前端目录:', frontendPath);
app.use(express.static(frontendPath, {
setHeaders: (res) => {
applySecurityHeaders(null, res);
}
}));
app.use(express.json({ limit: '10mb' })); // 限制请求体大小防止DoS
app.use(cookieParser());
// ===== CSRF 防护 =====
// 基于 Double Submit Cookie 模式的 CSRF 保护
// 对于修改数据的请求POST/PUT/DELETE验证请求头中的 X-CSRF-Token 与 Cookie 中的值匹配
// 生成 CSRF Token
function generateCsrfToken() {
return crypto.randomBytes(32).toString('hex');
}
// CSRF Token Cookie 名称
const CSRF_COOKIE_NAME = 'csrf_token';
// 设置 CSRF Cookie 的中间件
app.use((req, res, next) => {
// 如果没有 CSRF cookie则生成一个
if (!req.cookies[CSRF_COOKIE_NAME]) {
const csrfToken = generateCsrfToken();
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // 前端需要读取此值
secure: isSecureEnv,
sameSite: isSecureEnv ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24小时
});
}
next();
});
// CSRF 验证中间件(仅用于需要保护的路由)
function csrfProtection(req, res, next) {
// GET、HEAD、OPTIONS 请求不需要 CSRF 保护
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// 仅对基于 Cookie 的浏览器会话启用 CSRFBearer API 客户端不强制)
const hasCookieAuth = !!(
req.cookies?.token ||
req.cookies?.refreshToken
);
if (!hasCookieAuth) {
return next();
}
const cookieToken = req.cookies[CSRF_COOKIE_NAME];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
console.warn(`[CSRF] 验证失败: path=${req.path}, cookie=${!!cookieToken}, header=${!!headerToken}`);
return res.status(403).json({
success: false,
message: 'CSRF 验证失败,请刷新页面后重试'
});
}
next();
}
// CSRF 开关策略:
// - 显式配置 ENABLE_CSRF 时按配置值
// - 未配置时,生产环境默认开启
@@ -650,85 +550,34 @@ const ENABLE_CSRF = process.env.ENABLE_CSRF !== undefined
? process.env.ENABLE_CSRF === 'true'
: process.env.NODE_ENV === 'production';
// 中间件顺序是安全边界:请求标识和安全头最先,限流先于请求体解析。
app.use(requestContextMiddleware);
app.use(securityHeadersMiddleware({
secureByDefault: ENFORCE_HTTPS || SHOULD_USE_SECURE_COOKIES
}));
app.use(cors(corsOptions));
app.use(createHttpsEnforcementMiddleware({ enabled: ENFORCE_HTTPS }));
const hasFrontendBuild = fs.existsSync(path.join(FRONTEND_BUILD_DIR, 'index.html'));
const frontendPath = hasFrontendBuild ? FRONTEND_BUILD_DIR : FRONTEND_ROOT_DIR;
if (!hasFrontendBuild && process.env.NODE_ENV === 'production') {
logger.warn({ frontendPath }, 'frontend build is missing; serving source files as fallback');
}
logger.info({ frontendPath, built: hasFrontendBuild }, 'frontend static directory configured');
// 安装包由管理端动态发布,不进入 Vite 构建产物。
app.use('/downloads', express.static(DESKTOP_INSTALLERS_DIR));
app.use(express.static(frontendPath));
app.use('/api', createGlobalApiLimiter());
app.use(express.json({ limit: '10mb' }));
app.use(cookieParser());
app.use(createCsrfCookieMiddleware({ secureCookies: SHOULD_USE_SECURE_COOKIES }));
if (ENABLE_CSRF) {
console.log('[安全] CSRF 保护已启用');
logger.info('CSRF protection enabled');
app.use(csrfProtection);
}
// 强制HTTPS可通过环境变量控制默认关闭以兼容本地环境
// 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置,
// 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头
app.use((req, res, next) => {
const remoteAddress = req.socket?.remoteAddress || '';
const isLocalHealthCheck = req.path === '/api/health' && [
'127.0.0.1',
'::1',
'::ffff:127.0.0.1'
].includes(remoteAddress);
if (!ENFORCE_HTTPS || isLocalHealthCheck) return next();
// req.secure 由 Express 根据 trust proxy 配置计算:
// - 如果 trust proxy = false仅检查直接连接是否为 TLS
// - 如果 trust proxy 已配置,会检查可信代理的 X-Forwarded-Proto
if (!req.secure) {
return res.status(400).json({
success: false,
message: '仅支持HTTPS访问请使用HTTPS'
});
}
return next();
});
// 安全响应头中间件
app.use((req, res, next) => {
applySecurityHeaders(req, res);
next();
});
// 规范化并校验HTTP直链前缀只允许http/https
function sanitizeHttpBaseUrl(raw) {
if (!raw) return null;
try {
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol)) {
return null;
}
url.search = '';
url.hash = '';
// 去掉多余的结尾斜杠,保持路径稳定
url.pathname = url.pathname.replace(/\/+$/, '');
return url.toString();
} catch {
return null;
}
}
// 构建安全的下载URL编码路径片段并拒绝非HTTP(S)前缀
function buildHttpDownloadUrl(rawBaseUrl, filePath) {
const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl);
if (!baseUrl || !filePath) return null;
try {
const url = new URL(baseUrl);
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
const safeSegments = normalizedPath
.split('/')
.filter(Boolean)
.map(segment => encodeURIComponent(segment));
const safePath = safeSegments.length ? '/' + safeSegments.join('/') : '';
const basePath = url.pathname.replace(/\/+$/, '');
const joinedPath = `${basePath}${safePath || '/'}`;
url.pathname = joinedPath || '/';
url.search = '';
url.hash = '';
return url.toString();
} catch (err) {
console.warn('[安全] 生成下载URL失败:', err.message);
return null;
}
}
// 应用XSS过滤到所有POST/PUT请求的body
app.use((req, res, next) => {
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
@@ -768,12 +617,6 @@ app.use((req, res, next) => {
next();
});
// 请求日志
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
// 获取正确的协议(基于可信代理链)
// 安全说明req.protocol 由 Express 根据 trust proxy 配置计算,
// 只有可信代理的 X-Forwarded-Proto 才会被采信
@@ -796,23 +639,6 @@ function getBusyDownloadMessage() {
return '当前网络繁忙,请稍后再试';
}
function parseBooleanLike(rawValue, fallback = false) {
if (rawValue === null || rawValue === undefined || rawValue === '') {
return fallback;
}
if (typeof rawValue === 'boolean') {
return rawValue;
}
const normalized = String(rawValue).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false;
}
return fallback;
}
function clampIntegerSetting(rawValue, fallback, min, max) {
const value = Number(rawValue);
if (!Number.isFinite(value)) {
@@ -1610,116 +1436,6 @@ async function readS3BodyToBuffer(body) {
return Buffer.alloc(0);
}
function parseDownloadTrafficLogTime(line) {
if (!line || typeof line !== 'string') return null;
const match = line.match(/\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{4})?\]/);
if (!match) return null;
const monthMap = {
Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
};
const month = monthMap[match[2]];
if (month === undefined) return null;
const year = Number(match[3]);
const day = Number(match[1]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const second = Number(match[6]);
if ([year, day, hour, minute, second].some(v => !Number.isFinite(v))) {
return null;
}
// 先按 UTC 构造,再应用时区偏移(若存在)
let utcMillis = Date.UTC(year, month, day, hour, minute, second);
const tzRaw = match[7];
if (tzRaw && /^[+-]\d{4}$/.test(tzRaw)) {
const sign = tzRaw[0] === '+' ? 1 : -1;
const tzHour = Number(tzRaw.slice(1, 3));
const tzMin = Number(tzRaw.slice(3, 5));
const offsetMinutes = sign * (tzHour * 60 + tzMin);
utcMillis -= offsetMinutes * 60 * 1000;
}
const parsed = new Date(utcMillis);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function parseDownloadTrafficLine(line) {
if (!line || typeof line !== 'string') {
return null;
}
const trimmed = line.trim();
if (!trimmed) return null;
// 仅处理 GET 请求HEAD/PUT/POST 等不计下载流量)
if (!/\bGET\b/i.test(trimmed)) {
return null;
}
let statusCode = 0;
let bytesSent = 0;
const statusMatch = trimmed.match(/"\s*(\d{3})\s+(\d+|-)\b/);
if (statusMatch) {
statusCode = Number(statusMatch[1]);
bytesSent = statusMatch[2] === '-' ? 0 : Number(statusMatch[2]);
}
if (![200, 206].includes(statusCode) || !Number.isFinite(bytesSent) || bytesSent <= 0) {
return null;
}
// 尝试从请求路径提取 object key
let objectKey = null;
const requestMatch = trimmed.match(/"(?:GET|HEAD)\s+([^" ]+)\s+HTTP\//i);
if (requestMatch && requestMatch[1]) {
let requestPath = requestMatch[1];
const qIndex = requestPath.indexOf('?');
if (qIndex >= 0) {
requestPath = requestPath.slice(0, qIndex);
}
requestPath = requestPath.replace(/^https?:\/\/[^/]+/i, '');
requestPath = requestPath.replace(/^\/+/, '');
try {
requestPath = decodeURIComponent(requestPath);
} catch {
// ignore decode error
}
objectKey = requestPath || null;
}
if (!objectKey) {
const keyMatch = trimmed.match(/\buser_(\d+)\/[^\s"]+/);
if (keyMatch && keyMatch[0]) {
objectKey = keyMatch[0];
}
}
if (!objectKey) {
return null;
}
const userMatch = objectKey.match(/(?:^|\/)user_(\d+)\//);
if (!userMatch) {
return null;
}
const userId = Number(userMatch[1]);
if (!Number.isFinite(userId) || userId <= 0) {
return null;
}
return {
userId,
bytes: Math.floor(bytesSent),
objectKey,
eventAt: parseDownloadTrafficLogTime(trimmed) || new Date()
};
}
function extractLogLinesFromBuffer(buffer, logKey = '') {
let content = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || '');
if ((logKey || '').toLowerCase().endsWith('.gz')) {
@@ -2357,162 +2073,6 @@ const shareFileCache = new TTLCache(60 * 60 * 1000);
// ===== 防爆破限流器 =====
// 防爆破限流器类
class RateLimiter {
constructor(options = {}) {
this.maxAttempts = options.maxAttempts || 5;
this.windowMs = options.windowMs || 15 * 60 * 1000;
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
this.attempts = new Map();
this.blockedKeys = new Map();
// 每5分钟清理一次过期记录
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 5 * 60 * 1000);
}
// 获取客户端IP基于可信代理链
// 安全说明req.ip 由 Express 根据 trust proxy 配置计算,
// 只有可信代理的 X-Forwarded-For 才会被采信
getClientKey(req) {
// req.ip 会根据 trust proxy 配置:
// - trust proxy = false: 使用直接连接的 IPsocket 地址)
// - trust proxy = 1: 取 X-Forwarded-For 的最后 1 个 IP
// - trust proxy = true: 取 X-Forwarded-For 的第 1 个 IP不推荐
return req.ip || req.socket?.remoteAddress || 'unknown';
}
// 检查是否被封锁
isBlocked(key) {
const blockInfo = this.blockedKeys.get(key);
if (!blockInfo) {
return false;
}
// 检查封锁是否过期
if (Date.now() > blockInfo.expiresAt) {
this.blockedKeys.delete(key);
this.attempts.delete(key);
return false;
}
return true;
}
// 记录失败尝试
recordFailure(key) {
const now = Date.now();
// 如果已被封锁,返回封锁信息
if (this.isBlocked(key)) {
const blockInfo = this.blockedKeys.get(key);
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockInfo.expiresAt,
waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000),
needCaptcha: true
};
}
// 获取或创建尝试记录
let attemptInfo = this.attempts.get(key);
if (!attemptInfo || now > attemptInfo.windowEnd) {
attemptInfo = {
count: 0,
windowEnd: now + this.windowMs,
firstAttempt: now
};
}
attemptInfo.count++;
this.attempts.set(key, attemptInfo);
// 检查是否达到封锁阈值
if (attemptInfo.count >= this.maxAttempts) {
const blockExpiresAt = now + this.blockDuration;
this.blockedKeys.set(key, {
expiresAt: blockExpiresAt,
blockedAt: now
});
console.warn(`[防爆破] 封锁Key: ${key}, 失败次数: ${attemptInfo.count}, 封锁时长: ${Math.ceil(this.blockDuration / 60000)}分钟`);
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockExpiresAt,
waitMinutes: Math.ceil(this.blockDuration / 60000),
needCaptcha: true
};
}
return {
blocked: false,
remainingAttempts: this.maxAttempts - attemptInfo.count,
resetTime: attemptInfo.windowEnd,
waitMinutes: 0,
needCaptcha: attemptInfo.count >= 2 // 失败2次后需要验证码
};
}
// 获取失败次数
getFailureCount(key) {
const attemptInfo = this.attempts.get(key);
if (!attemptInfo || Date.now() > attemptInfo.windowEnd) {
return 0;
}
return attemptInfo.count;
}
// 记录成功(清除失败记录)
recordSuccess(key) {
this.attempts.delete(key);
this.blockedKeys.delete(key);
}
// 清理过期记录
cleanup() {
const now = Date.now();
let cleanedAttempts = 0;
let cleanedBlocks = 0;
// 清理过期的尝试记录
for (const [key, info] of this.attempts.entries()) {
if (now > info.windowEnd) {
this.attempts.delete(key);
cleanedAttempts++;
}
}
// 清理过期的封锁记录
for (const [key, info] of this.blockedKeys.entries()) {
if (now > info.expiresAt) {
this.blockedKeys.delete(key);
cleanedBlocks++;
}
}
if (cleanedAttempts > 0 || cleanedBlocks > 0) {
console.log(`[防爆破清理] 已清理 ${cleanedAttempts} 个过期尝试记录, ${cleanedBlocks} 个过期封锁记录`);
}
}
// 获取统计信息
getStats() {
return {
activeAttempts: this.attempts.size,
blockedKeys: this.blockedKeys.size
};
}
// 停止清理定时器
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
}
// 创建登录限流器5次失败/15分钟封锁30分钟
const loginLimiter = new RateLimiter({
maxAttempts: 5,
@@ -2717,82 +2277,6 @@ function safeDeleteFile(filePath) {
}
// 规范化虚拟文件路径(统一用于分享路径校验)
function normalizeVirtualPath(rawPath) {
if (typeof rawPath !== 'string') {
return null;
}
let decoded = rawPath;
try {
decoded = decodeURIComponent(rawPath);
} catch {
// 忽略解码失败,使用原始输入继续校验
}
if (decoded.includes('\x00') || decoded.includes('%00')) {
return null;
}
const unifiedPath = decoded.replace(/\\/g, '/');
// 严格拦截路径遍历片段(在 normalize 前先检查)
if (/(^|\/)\.\.(\/|$)/.test(unifiedPath)) {
return null;
}
let normalized = path.posix.normalize(unifiedPath);
if (normalized === '' || normalized === '.') {
normalized = '/';
}
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/g, '');
return normalized || '/';
}
function isValidShareCode(code) {
return typeof code === 'string' && SHARE_CODE_REGEX.test(code);
}
// 验证请求路径是否在分享范围内(防止越权访问)
function isPathWithinShare(requestPath, share) {
if (!requestPath || !share) {
return false;
}
const normalizedRequest = normalizeVirtualPath(requestPath);
const normalizedShare = normalizeVirtualPath(share.share_path);
if (!normalizedRequest || !normalizedShare) {
return false;
}
if (share.share_type === 'file') {
// 单文件分享:只允许下载该文件
return normalizedRequest === normalizedShare;
}
// 目录分享:只允许下载该目录及其子目录下的文件
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`;
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
}
function normalizeClientIp(rawIp) {
const ip = String(rawIp || '').trim();
if (!ip) return '';
if (ip.startsWith('::ffff:')) {
return ip.slice(7);
}
if (ip === '::1') {
return '127.0.0.1';
}
return ip;
}
function getClientIp(req) {
return normalizeClientIp(
req?.ip
@@ -2802,89 +2286,6 @@ function getClientIp(req) {
);
}
function parseShareIpWhitelist(rawValue) {
if (typeof rawValue !== 'string') return [];
return rawValue
.split(/[\s,;]+/)
.map(item => item.trim())
.filter(Boolean)
.slice(0, 100);
}
function isShareIpAllowed(clientIp, whitelist = []) {
if (!clientIp || !Array.isArray(whitelist) || whitelist.length === 0) {
return true;
}
for (const rule of whitelist) {
const normalizedRule = String(rule || '').trim();
if (!normalizedRule) continue;
if (normalizedRule === clientIp) {
return true;
}
if (normalizedRule.endsWith('*')) {
const prefix = normalizedRule.slice(0, -1);
if (prefix && clientIp.startsWith(prefix)) {
return true;
}
}
}
return false;
}
function detectDeviceTypeFromUserAgent(userAgent = '') {
const ua = String(userAgent || '');
const mobilePattern = /(Mobile|Android|iPhone|iPad|iPod|Windows Phone|HarmonyOS|Mobi)/i;
return mobilePattern.test(ua) ? 'mobile' : 'desktop';
}
function inferPlatformFromUserAgent(userAgent = '') {
const ua = String(userAgent || '');
if (!ua) return '未知平台';
if (/windows/i.test(ua)) return 'Windows';
if (/macintosh|mac os x/i.test(ua)) return 'macOS';
if (/android/i.test(ua)) return 'Android';
if (/iphone|ipad|ios/i.test(ua)) return 'iOS';
if (/linux/i.test(ua)) return 'Linux';
return '未知平台';
}
function normalizeClientType(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (['web', 'desktop', 'mobile', 'api'].includes(normalized)) {
return normalized;
}
return '';
}
function resolveClientType(clientType, userAgent = '') {
const normalized = normalizeClientType(clientType);
if (normalized) return normalized;
const ua = String(userAgent || '').toLowerCase();
if (ua.includes('tauri') || ua.includes('electron') || ua.includes('wanwan-cloud-desktop') || ua.includes('玩玩云')) {
return 'desktop';
}
return detectDeviceTypeFromUserAgent(ua) === 'mobile' ? 'mobile' : 'web';
}
function sanitizeDeviceText(value, maxLength = 120) {
if (typeof value !== 'string') return '';
return value.trim().slice(0, maxLength);
}
function buildDeviceName({ clientType, deviceName, platform }) {
const preferred = sanitizeDeviceText(deviceName, 120);
if (preferred) return preferred;
const platformText = sanitizeDeviceText(platform, 80) || '未知平台';
if (clientType === 'desktop') return `桌面客户端 · ${platformText}`;
if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`;
if (clientType === 'api') return `API 客户端 · ${platformText}`;
return `网页端浏览器 · ${platformText}`;
}
function buildDeviceSessionContext(req, payload = {}) {
const userAgent = sanitizeDeviceText(req.get('user-agent') || req.headers?.['user-agent'] || '', 1024);
const clientType = resolveClientType(payload.client_type || req.headers?.['x-client-type'], userAgent);
@@ -3603,21 +3004,11 @@ function verifyCaptcha(req, res, captcha, logPrefix = '验证码验证') {
// ===== 公开API =====
// 健康检查
app.get('/api/health', (req, res) => {
res.json({ success: true, message: 'Server is running' });
});
// 获取公开的系统配置(不需要登录)
app.get('/api/config', (req, res) => {
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
res.json({
success: true,
config: {
max_upload_size: maxUploadSize
}
});
const healthService = createHealthService({
db,
diskPath: path.resolve(__dirname, 'data')
});
app.use('/api', createSystemRouter({ SettingsDB, healthService }));
// 桌面客户端更新信息(无需登录)
app.get('/api/client/desktop-update', (req, res) => {
@@ -3688,13 +3079,7 @@ app.get('/api/csrf-token', (req, res) => {
// 如果没有 token生成一个新的
if (!csrfToken) {
csrfToken = generateCsrfToken();
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false,
secure: isSecureEnv,
sameSite: isSecureEnv ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000
});
setCsrfCookie(res, csrfToken, SHOULD_USE_SECURE_COOKIES);
}
res.json({
@@ -8303,19 +7688,6 @@ app.delete('/api/direct-link/:id', authMiddleware, (req, res) => {
// ===== 分享链接访问(公开) =====
// 获取公共主题设置(用于分享页面,无需认证)
app.get('/api/public/theme', (req, res) => {
try {
const globalTheme = SettingsDB.get('global_theme') || 'dark';
res.json({
success: true,
theme: globalTheme
});
} catch (error) {
res.json({ success: true, theme: 'dark' }); // 出错默认暗色
}
});
// 获取分享页面主题(基于分享者偏好或全局设置)
app.get('/api/share/:code/theme', (req, res) => {
try {