// 加载环境变量(必须在最开始) require('dotenv').config(); const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser'); const session = require('express-session'); const svgCaptcha = require('svg-captcha'); const SftpClient = require('ssh2-sftp-client'); const multer = require('multer'); const nodemailer = require('nodemailer'); const path = require('path'); const fs = require('fs'); const { body, validationResult } = require('express-validator'); const archiver = require('archiver'); const { exec, execSync, execFile } = require('child_process'); const net = require('net'); const dns = require('dns').promises; const util = require('util'); const execAsync = util.promisify(exec); const execFileAsync = util.promisify(execFile); const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, SystemLogDB } = require('./database'); const { generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, isJwtSecretSecure } = require('./auth'); const app = express(); const PORT = process.env.PORT || 40001; const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英文、数字、下划线、点和短横线 const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true'; // ===== 安全配置:公开域名白名单(防止 Host Header 注入) ===== // 必须配置 PUBLIC_BASE_URL 环境变量,用于生成邮件链接和分享链接 // 例如: PUBLIC_BASE_URL=https://cloud.example.com const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || null; const ALLOWED_HOSTS = process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',').map(h => h.trim().toLowerCase()) : []; // 获取安全的基础URL(用于生成邮件链接、分享链接等) function getSecureBaseUrl(req) { // 优先使用配置的公开域名 if (PUBLIC_BASE_URL) { return PUBLIC_BASE_URL.replace(/\/+$/, ''); // 移除尾部斜杠 } // 如果没有配置,验证 Host 头是否在白名单中 const host = (req.get('host') || '').toLowerCase(); if (ALLOWED_HOSTS.length > 0 && !ALLOWED_HOSTS.includes(host)) { console.warn(`[安全警告] 检测到非白名单 Host 头: ${host}`); // 返回第一个白名单域名作为后备 const protocol = getProtocol(req); return `${protocol}://${ALLOWED_HOSTS[0]}`; } // 开发环境回退(仅在没有配置时使用) if (process.env.NODE_ENV !== 'production') { return `${getProtocol(req)}://${req.get('host')}`; } // 生产环境没有配置时,记录警告并使用请求的 Host(不推荐) console.error('[安全警告] 生产环境未配置 PUBLIC_BASE_URL,存在 Host Header 注入风险!'); return `${getProtocol(req)}://${req.get('host')}`; } // ===== 安全配置:信任代理 ===== // 默认不信任任何代理(直接暴露场景) // 配置选项: // - false: 不信任代理(默认,直接暴露) // - true: 信任所有代理(不推荐,易被伪造) // - 1/2/3: 信任前N跳代理(推荐,如 Nginx 后部署用 1) // - 'loopback': 仅信任本地回环地址 // - '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16': 信任指定IP/CIDR段 const TRUST_PROXY_RAW = process.env.TRUST_PROXY; let trustProxyValue = false; // 默认不信任 if (TRUST_PROXY_RAW !== undefined && TRUST_PROXY_RAW !== '') { if (TRUST_PROXY_RAW === 'true') { trustProxyValue = true; console.warn('[安全警告] TRUST_PROXY=true 将信任所有代理,存在 IP/协议伪造风险!建议设置为具体跳数(1)或IP段'); } else if (TRUST_PROXY_RAW === 'false') { trustProxyValue = false; } else if (/^\d+$/.test(TRUST_PROXY_RAW)) { // 数字:信任前N跳 trustProxyValue = parseInt(TRUST_PROXY_RAW, 10); } else { // 字符串:loopback 或 IP/CIDR 列表 trustProxyValue = TRUST_PROXY_RAW; } } app.set('trust proxy', trustProxyValue); console.log(`[安全] trust proxy 配置: ${JSON.stringify(trustProxyValue)}`); // 配置CORS - 严格白名单模式 const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim()) : []; // 默认为空数组,不允许任何域名 const corsOptions = { credentials: true, origin: (origin, callback) => { // 生产环境必须配置白名单 if (allowedOrigins.length === 0 && process.env.NODE_ENV === 'production') { console.error('❌ 错误: 生产环境必须配置 ALLOWED_ORIGINS 环境变量!'); callback(new Error('CORS未配置')); return; } // 开发环境如果没有配置,允许 localhost if (allowedOrigins.length === 0) { const devOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000']; if (!origin || devOrigins.some(o => origin.startsWith(o))) { callback(null, true); return; } } // 严格白名单模式:只允许白名单中的域名 // 但需要允许没有Origin头的同源请求(浏览器访问时不会发送Origin) if (!origin) { // 没有Origin头的请求通常是: // 1. 浏览器的同源请求(不触发CORS) // 2. 直接的服务器请求 // 这些都应该允许 callback(null, true); } else if (allowedOrigins.includes(origin)) { // 白名单中的域名 callback(null, true); } else { // 拒绝不在白名单中的跨域请求 console.warn(`[CORS] 拒绝来自未授权来源的请求: ${origin}`); callback(new Error('CORS策略不允许来自该来源的访问')); } } }; // 中间件 app.use(cors(corsOptions)); app.use(express.json()); app.use(cookieParser()); // 强制HTTPS(可通过环境变量控制,默认关闭以兼容本地环境) // 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置, // 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头 app.use((req, res, next) => { if (!ENFORCE_HTTPS) 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(); }); // Session配置(用于验证码) const isSecureCookie = process.env.COOKIE_SECURE === 'true'; const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码 app.use(session({ secret: process.env.SESSION_SECRET || 'your-session-secret-change-in-production', resave: false, saveUninitialized: true, // 改为true,确保验证码请求时创建session name: 'captcha.sid', // 自定义session cookie名称 cookie: { secure: isSecureCookie, httpOnly: true, sameSite: sameSiteMode, maxAge: 10 * 60 * 1000 // 10分钟 } })); // 安全响应头中间件 app.use((req, res, next) => { // 防止点击劫持 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.secure) { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } // 内容安全策略 res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"); // 隐藏X-Powered-By res.removeHeader('X-Powered-By'); next(); }); // XSS过滤中间件(用于用户输入)- 增强版 // 注意:不转义 / 因为它是文件路径的合法字符 function sanitizeInput(str) { if (typeof str !== 'string') return str; // 1. 基础HTML实体转义(不包括 / 因为是路径分隔符) let sanitized = str .replace(/[&<>"'`]/g, (char) => { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`' }; return map[char]; }); // 2. 过滤危险协议(javascript:, data:, vbscript:等) sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, ''); // 3. 移除空字节 sanitized = sanitized.replace(/\x00/g, ''); return sanitized; } // HTML转义(用于模板输出) function escapeHtml(str) { if (typeof str !== 'string') return str; return str.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); } // 规范化并校验HTTP直链前缀,只允许http/https function sanitizeHttpBaseUrl(raw) { if (!raw) return null; 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; } } // 校验文件名/路径片段安全(禁止分隔符、控制字符、..) function isSafePathSegment(name) { return ( typeof name === 'string' && name.length > 0 && name.length <= 255 && // 限制文件名长度 !name.includes('..') && !/[/\\]/.test(name) && !/[\x00-\x1F]/.test(name) ); } // 危险文件扩展名黑名单(仅限可能被Web服务器解析执行的脚本文件) // 注意:这是网盘应用,.exe等可执行文件允许上传(服务器不会执行) const DANGEROUS_EXTENSIONS = [ '.php', '.php3', '.php4', '.php5', '.phtml', '.phar', // PHP '.jsp', '.jspx', '.jsw', '.jsv', '.jspf', // Java Server Pages '.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', // ASP.NET '.htaccess', '.htpasswd' // Apache配置(可能改变服务器行为) ]; // 检查文件扩展名是否安全 function isFileExtensionSafe(filename) { if (!filename || typeof filename !== 'string') return false; const ext = path.extname(filename).toLowerCase(); // 检查危险扩展名 if (DANGEROUS_EXTENSIONS.includes(ext)) { return false; } // 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行) const nameLower = filename.toLowerCase(); for (const dangerExt of DANGEROUS_EXTENSIONS) { if (nameLower.includes(dangerExt + '.')) { return false; } } return true; } // 应用XSS过滤到所有POST/PUT请求的body app.use((req, res, next) => { if ((req.method === 'POST' || req.method === 'PUT') && req.body) { // 递归过滤所有字符串字段 function sanitizeObject(obj) { if (typeof obj === 'string') { return sanitizeInput(obj); } else if (Array.isArray(obj)) { return obj.map(item => sanitizeObject(item)); } else if (obj && typeof obj === 'object') { const sanitized = {}; for (const [key, value] of Object.entries(obj)) { sanitized[key] = sanitizeObject(value); } return sanitized; } return obj; } req.body = sanitizeObject(req.body); } next(); }); // 请求日志 app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); next(); }); // 获取正确的协议(基于可信代理链) // 安全说明:req.protocol 由 Express 根据 trust proxy 配置计算, // 只有可信代理的 X-Forwarded-Proto 才会被采信 function getProtocol(req) { // req.protocol 会根据 trust proxy 配置: // - trust proxy = false: 仅检查直接连接(TLS -> 'https', 否则 'http') // - trust proxy 已配置: 会检查可信代理的 X-Forwarded-Proto return req.protocol || (req.secure ? 'https' : 'http'); } // ===== 系统日志工具函数 ===== // 从请求中提取日志信息 function getLogInfoFromReq(req) { return { ipAddress: req.ip || req.socket?.remoteAddress || 'unknown', userAgent: req.get('User-Agent') || 'unknown', userId: req.user?.id || null, username: req.user?.username || null }; } // 记录认证日志 function logAuth(req, action, message, details = null, level = 'info') { const info = getLogInfoFromReq(req); SystemLogDB.log({ level, category: 'auth', action, message, ...info, details }); } // 记录用户管理日志 function logUser(req, action, message, details = null, level = 'info') { const info = getLogInfoFromReq(req); SystemLogDB.log({ level, category: 'user', action, message, ...info, details }); } // 记录文件操作日志 function logFile(req, action, message, details = null, level = 'info') { const info = getLogInfoFromReq(req); SystemLogDB.log({ level, category: 'file', action, message, ...info, details }); } // 记录分享操作日志 function logShare(req, action, message, details = null, level = 'info') { const info = getLogInfoFromReq(req); SystemLogDB.log({ level, category: 'share', action, message, ...info, details }); } // 记录系统操作日志 function logSystem(req, action, message, details = null, level = 'info') { const info = req ? getLogInfoFromReq(req) : {}; SystemLogDB.log({ level, category: 'system', action, message, ...info, details }); } // 记录安全事件日志 function logSecurity(req, action, message, details = null, level = 'warn') { const info = getLogInfoFromReq(req); SystemLogDB.log({ level, category: 'security', action, message, ...info, details }); } // 文件上传配置(临时存储) const upload = multer({ dest: path.join(__dirname, 'uploads'), limits: { fileSize: 5 * 1024 * 1024 * 1024 } // 5GB限制 }); // ===== TTL缓存类 ===== // 带过期时间的缓存类 class TTLCache { constructor(defaultTTL = 3600000) { // 默认1小时 this.cache = new Map(); this.defaultTTL = defaultTTL; // 每10分钟清理一次过期缓存 this.cleanupInterval = setInterval(() => { this.cleanup(); }, 10 * 60 * 1000); } set(key, value, ttl = this.defaultTTL) { const expiresAt = Date.now() + ttl; this.cache.set(key, { value, expiresAt }); } get(key) { const item = this.cache.get(key); if (!item) { return undefined; } // 检查是否过期 if (Date.now() > item.expiresAt) { this.cache.delete(key); return undefined; } return item.value; } has(key) { const item = this.cache.get(key); if (!item) { return false; } // 检查是否过期 if (Date.now() > item.expiresAt) { this.cache.delete(key); return false; } return true; } delete(key) { return this.cache.delete(key); } // 清理过期缓存 cleanup() { const now = Date.now(); let cleaned = 0; for (const [key, item] of this.cache.entries()) { if (now > item.expiresAt) { this.cache.delete(key); cleaned++; } } if (cleaned > 0) { console.log(`[缓存清理] 已清理 ${cleaned} 个过期的分享缓存`); } } // 获取缓存大小 size() { return this.cache.size; } // 停止清理定时器 destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } } } // 分享文件信息缓存(内存缓存,1小时TTL) const shareFileCache = new TTLCache(60 * 60 * 1000); // ===== 防爆破限流器 ===== // 防爆破限流器类 class RateLimiter { constructor(options = {}) { this.maxAttempts = options.maxAttempts || 5; this.windowMs = options.windowMs || 15 * 60 * 1000; this.blockDuration = options.blockDuration || 30 * 60 * 1000; this.attempts = new Map(); this.blockedKeys = new Map(); // 每5分钟清理一次过期记录 this.cleanupInterval = setInterval(() => { this.cleanup(); }, 5 * 60 * 1000); } // 获取客户端IP(基于可信代理链) // 安全说明:req.ip 由 Express 根据 trust proxy 配置计算, // 只有可信代理的 X-Forwarded-For 才会被采信 getClientKey(req) { // req.ip 会根据 trust proxy 配置: // - trust proxy = false: 使用直接连接的 IP(socket 地址) // - trust proxy = 1: 取 X-Forwarded-For 的最后 1 个 IP // - trust proxy = true: 取 X-Forwarded-For 的第 1 个 IP(不推荐) return req.ip || req.socket?.remoteAddress || 'unknown'; } // 检查是否被封锁 isBlocked(key) { const blockInfo = this.blockedKeys.get(key); if (!blockInfo) { return false; } // 检查封锁是否过期 if (Date.now() > blockInfo.expiresAt) { this.blockedKeys.delete(key); this.attempts.delete(key); return false; } return true; } // 记录失败尝试 recordFailure(key) { const now = Date.now(); // 如果已被封锁,返回封锁信息 if (this.isBlocked(key)) { const blockInfo = this.blockedKeys.get(key); return { blocked: true, remainingAttempts: 0, resetTime: blockInfo.expiresAt, waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000), needCaptcha: true }; } // 获取或创建尝试记录 let attemptInfo = this.attempts.get(key); if (!attemptInfo || now > attemptInfo.windowEnd) { attemptInfo = { count: 0, windowEnd: now + this.windowMs, firstAttempt: now }; } attemptInfo.count++; this.attempts.set(key, attemptInfo); // 检查是否达到封锁阈值 if (attemptInfo.count >= this.maxAttempts) { const blockExpiresAt = now + this.blockDuration; this.blockedKeys.set(key, { expiresAt: blockExpiresAt, blockedAt: now }); console.warn(`[防爆破] 封锁Key: ${key}, 失败次数: ${attemptInfo.count}, 封锁时长: ${Math.ceil(this.blockDuration / 60000)}分钟`); return { blocked: true, remainingAttempts: 0, resetTime: blockExpiresAt, waitMinutes: Math.ceil(this.blockDuration / 60000), needCaptcha: true }; } return { blocked: false, remainingAttempts: this.maxAttempts - attemptInfo.count, resetTime: attemptInfo.windowEnd, waitMinutes: 0, needCaptcha: attemptInfo.count >= 2 // 失败2次后需要验证码 }; } // 获取失败次数 getFailureCount(key) { const attemptInfo = this.attempts.get(key); if (!attemptInfo || Date.now() > attemptInfo.windowEnd) { return 0; } return attemptInfo.count; } // 记录成功(清除失败记录) recordSuccess(key) { this.attempts.delete(key); this.blockedKeys.delete(key); } // 清理过期记录 cleanup() { const now = Date.now(); let cleanedAttempts = 0; let cleanedBlocks = 0; // 清理过期的尝试记录 for (const [key, info] of this.attempts.entries()) { if (now > info.windowEnd) { this.attempts.delete(key); cleanedAttempts++; } } // 清理过期的封锁记录 for (const [key, info] of this.blockedKeys.entries()) { if (now > info.expiresAt) { this.blockedKeys.delete(key); cleanedBlocks++; } } if (cleanedAttempts > 0 || cleanedBlocks > 0) { console.log(`[防爆破清理] 已清理 ${cleanedAttempts} 个过期尝试记录, ${cleanedBlocks} 个过期封锁记录`); } } // 获取统计信息 getStats() { return { activeAttempts: this.attempts.size, blockedKeys: this.blockedKeys.size }; } // 停止清理定时器 destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } } } // 创建登录限流器(5次失败/15分钟,封锁30分钟) const loginLimiter = new RateLimiter({ maxAttempts: 5, windowMs: 15 * 60 * 1000, blockDuration: 30 * 60 * 1000 }); // 创建分享密码限流器(10次失败/10分钟,封锁20分钟) const shareLimiter = new RateLimiter({ maxAttempts: 10, windowMs: 10 * 60 * 1000, blockDuration: 20 * 60 * 1000 }); // 邮件发送限流(防刷) // 半小时最多3次,超过封30分钟;全天最多10次,超过封24小时 const mailLimiter30Min = new RateLimiter({ maxAttempts: 3, windowMs: 30 * 60 * 1000, blockDuration: 30 * 60 * 1000 }); const mailLimiterDay = new RateLimiter({ maxAttempts: 10, windowMs: 24 * 60 * 60 * 1000, blockDuration: 24 * 60 * 60 * 1000 }); // 创建验证码获取限流器(30次请求/10分钟,封锁30分钟) const captchaLimiter = new RateLimiter({ maxAttempts: 30, windowMs: 10 * 60 * 1000, blockDuration: 30 * 60 * 1000 }); // 创建API密钥验证限流器(防止暴力枚举API密钥,5次失败/小时,封锁24小时) const apiKeyLimiter = new RateLimiter({ maxAttempts: 5, windowMs: 60 * 60 * 1000, // 1小时窗口 blockDuration: 24 * 60 * 60 * 1000 // 封锁24小时 }); // 创建文件上传限流器(每用户每小时最多100次上传) const uploadLimiter = new RateLimiter({ maxAttempts: 100, windowMs: 60 * 60 * 1000, blockDuration: 60 * 60 * 1000 }); // 创建文件列表查询限流器(每用户每分钟最多60次) const fileListLimiter = new RateLimiter({ maxAttempts: 60, windowMs: 60 * 1000, blockDuration: 5 * 60 * 1000 }); // 验证码最小请求间隔控制 const CAPTCHA_MIN_INTERVAL = 1000; // 1秒 const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理 // 验证码防刷中间件 function captchaRateLimitMiddleware(req, res, next) { const clientKey = `captcha:${captchaLimiter.getClientKey(req)}`; const now = Date.now(); // 最小时间间隔限制 const lastRequest = captchaLastRequest.get(clientKey); if (lastRequest && (now - lastRequest) < CAPTCHA_MIN_INTERVAL) { return res.status(429).json({ success: false, message: '验证码请求过于频繁,请稍后再试' }); } captchaLastRequest.set(clientKey, now, 15 * 60 * 1000); // 窗口内总次数限流 const result = captchaLimiter.recordFailure(clientKey); if (result.blocked) { return res.status(429).json({ success: false, message: `验证码请求过多,请在 ${result.waitMinutes} 分钟后再试`, blocked: true, resetTime: result.resetTime }); } next(); } // 登录防爆破中间件 function loginRateLimitMiddleware(req, res, next) { const clientIP = loginLimiter.getClientKey(req); const { username } = req.body; const ipKey = `login:ip:${clientIP}`; // 检查IP是否被封锁 if (loginLimiter.isBlocked(ipKey)) { const result = loginLimiter.recordFailure(ipKey); console.warn(`[防爆破] 拦截登录尝试 - IP: ${clientIP}, 原因: IP被封锁`); return res.status(429).json({ success: false, message: `登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`, blocked: true, resetTime: result.resetTime }); } // 检查用户名是否被封锁 if (username) { const usernameKey = `login:username:${username}`; if (loginLimiter.isBlocked(usernameKey)) { const result = loginLimiter.recordFailure(usernameKey); console.warn(`[防爆破] 拦截登录尝试 - 用户名: ${username}, 原因: 用户名被封锁`); return res.status(429).json({ success: false, message: `该账号登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`, blocked: true, resetTime: result.resetTime }); } } // 将限流key附加到请求对象,供后续使用 req.rateLimitKeys = { ipKey, usernameKey: username ? `login:username:${username}` : null }; next(); } // 分享密码防爆破中间件 function shareRateLimitMiddleware(req, res, next) { const clientIP = shareLimiter.getClientKey(req); const { code } = req.params; const key = `share:${code}:${clientIP}`; // 检查是否被封锁 if (shareLimiter.isBlocked(key)) { const result = shareLimiter.recordFailure(key); console.warn(`[防爆破] 拦截分享密码尝试 - 分享码: ${code}, IP: ${clientIP}`); return res.status(429).json({ success: false, message: `密码尝试过多,请在 ${result.waitMinutes} 分钟后重试`, blocked: true, resetTime: result.resetTime }); } req.shareRateLimitKey = key; next(); } // ===== 工具函数 ===== // 安全删除文件(不抛出异常) function safeDeleteFile(filePath) { try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); console.log(`[清理] 已删除临时文件: ${filePath}`); return true; } } catch (error) { console.error(`[清理] 删除临时文件失败: ${filePath}`, error.message); return false; } } // 验证请求路径是否在分享范围内(防止越权访问) function isPathWithinShare(requestPath, share) { if (!requestPath || !share) { return false; } // 规范化路径(移除 ../ 等危险路径,统一分隔符) const normalizedRequest = path.normalize(requestPath).replace(/^(\.\.[\/\\])+/, '').replace(/\\/g, '/'); const normalizedShare = path.normalize(share.share_path).replace(/\\/g, '/'); if (share.share_type === 'file') { // 单文件分享:只允许下载该文件 return normalizedRequest === normalizedShare; } else { // 目录分享:只允许下载该目录及其子目录下的文件 // 确保分享路径以斜杠结尾用于前缀匹配 const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : normalizedShare + '/'; return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix); } } // 清理旧的临时文件(启动时执行一次) function cleanupOldTempFiles() { const uploadsDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadsDir)) { return; } try { const files = fs.readdirSync(uploadsDir); const now = Date.now(); const maxAge = 24 * 60 * 60 * 1000; // 24小时 let cleaned = 0; files.forEach(file => { const filePath = path.join(uploadsDir, file); try { const stats = fs.statSync(filePath); if (now - stats.mtimeMs > maxAge) { fs.unlinkSync(filePath); cleaned++; } } catch (err) { console.error(`[清理] 检查文件失败: ${filePath}`, err.message); } }); if (cleaned > 0) { console.log(`[清理] 已清理 ${cleaned} 个超过24小时的临时文件`); } } catch (error) { console.error('[清理] 清理临时文件目录失败:', error.message); } } function isPrivateIp(ip) { if (!net.isIP(ip)) return false; // IPv4 私有地址和特殊地址 if (net.isIPv4(ip)) { // 10.0.0.0/8 - 私有网络 if (ip.startsWith('10.')) return true; // 192.168.0.0/16 - 私有网络 if (ip.startsWith('192.168.')) return true; // 172.16.0.0/12 - 私有网络 (172.16.0.0 - 172.31.255.255) if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(ip)) return true; // 127.0.0.0/8 - 回环地址 if (ip.startsWith('127.')) return true; // 0.0.0.0 - 无效地址 if (ip === '0.0.0.0') return true; // 169.254.0.0/16 - 链路本地地址(包括云服务元数据 169.254.169.254) if (ip.startsWith('169.254.')) return true; // 100.64.0.0/10 - 运营商级NAT if (/^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./.test(ip)) return true; return false; } // IPv6 私有地址和特殊地址 if (net.isIPv6(ip)) { const lowerIp = ip.toLowerCase(); // ::1 - 回环地址 if (lowerIp === '::1') return true; // :: - 未指定地址 if (lowerIp === '::') return true; // fe80::/10 - 链路本地地址 if (/^fe[89ab][0-9a-f]:/i.test(lowerIp)) return true; // fc00::/7 - 唯一本地地址 (fc 和 fd 开头) if (/^f[cd][0-9a-f]{2}:/i.test(lowerIp)) return true; // ff00::/8 - 多播地址 if (lowerIp.startsWith('ff')) return true; // ::ffff:x.x.x.x - IPv4映射地址,需要检查内部的IPv4 const ipv4Mapped = lowerIp.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (ipv4Mapped) { return isPrivateIp(ipv4Mapped[1]); } return false; } return false; } async function validateSftpDestination(host, port) { if (!host || typeof host !== 'string' || host.length > 255) { throw new Error('无效的SFTP主机'); } if (host.includes('://') || host.includes('/')) { throw new Error('SFTP主机不能包含协议或路径'); } const portNum = parseInt(port, 10) || 22; if (portNum < 1 || portNum > 65535) { throw new Error('SFTP端口范围应为1-65535'); } let resolvedIp; try { const lookup = await dns.lookup(host); resolvedIp = lookup.address; } catch (err) { throw new Error('无法解析SFTP主机,请检查域名或IP'); } if (isPrivateIp(resolvedIp) && process.env.ALLOW_PRIVATE_SFTP !== 'true') { throw new Error('出于安全考虑,不允许连接内网地址'); } return { host, port: portNum, resolvedIp }; } // SFTP连接 async function connectToSFTP(config) { const sftp = new SftpClient(); await sftp.connect({ host: config.ftp_host, port: parseInt(config.ftp_port, 10) || 22, username: config.ftp_user, password: config.ftp_password, readyTimeout: parseInt(process.env.SFTP_CONNECT_TIMEOUT || '8000', 10) }); return sftp; } // 格式化文件大小 function formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; } // 生成随机Token function generateRandomToken(length = 48) { return require('crypto').randomBytes(length).toString('hex'); } // 获取SMTP配置 function getSmtpConfig() { const host = SettingsDB.get('smtp_host'); const port = SettingsDB.get('smtp_port'); const secure = SettingsDB.get('smtp_secure'); const user = SettingsDB.get('smtp_user'); const pass = SettingsDB.get('smtp_password'); const from = SettingsDB.get('smtp_from') || user; if (!host || !port || !user || !pass) { return null; } return { host, port: parseInt(port, 10) || 465, secure: secure === 'true' || secure === true || port === '465', auth: { user, pass }, from }; } // 创建邮件传输器 function createTransport() { const config = getSmtpConfig(); if (!config) return null; return nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: config.auth }); } // 发送邮件 async function sendMail(to, subject, html) { const config = getSmtpConfig(); const transporter = createTransport(); if (!config || !transporter) { throw new Error('SMTP未配置'); } const from = (config.from && config.from.trim()) ? config.from.trim() : config.auth.user; await transporter.sendMail({ from, to, subject, html }); } // 检查邮件发送限流 function checkMailRateLimit(req, type = 'mail') { // 使用 req.ip,基于 trust proxy 配置获取可信的客户端 IP const clientKey = `${type}:${req.ip || req.socket?.remoteAddress || 'unknown'}`; const res30 = mailLimiter30Min.recordFailure(clientKey); if (res30.blocked) { const err = new Error(`请求过于频繁,30分钟内最多3次,请在 ${res30.waitMinutes} 分钟后再试`); err.status = 429; throw err; } const resDay = mailLimiterDay.recordFailure(clientKey); if (resDay.blocked) { const err = new Error(`今天的次数已用完(最多10次),请稍后再试`); err.status = 429; throw err; } } // ===== 验证码验证辅助函数 ===== /** * 验证验证码 * @param {Object} req - 请求对象 * @param {string} captcha - 用户输入的验证码 * @returns {{valid: boolean, message?: string}} 验证结果 */ function verifyCaptcha(req, captcha) { if (!captcha) { return { valid: false, message: '请输入验证码' }; } const sessionCaptcha = req.session.captcha; const captchaTime = req.session.captchaTime; if (!sessionCaptcha || !captchaTime) { return { valid: false, message: '验证码已过期,请刷新验证码' }; } // 验证码有效期5分钟 if (Date.now() - captchaTime > 5 * 60 * 1000) { return { valid: false, message: '验证码已过期,请刷新验证码' }; } if (captcha.toLowerCase() !== sessionCaptcha) { return { valid: false, message: '验证码错误' }; } // 验证通过后清除session中的验证码 delete req.session.captcha; delete req.session.captchaTime; return { valid: true }; } // ===== 公开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 } }); }); // 生成验证码API app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => { try { const captcha = svgCaptcha.create({ size: 6, // 验证码长度 noise: 3, // 干扰线条数 color: true, // 使用彩色 background: '#f7f7f7', // 背景色 width: 140, height: 44, fontSize: 52, charPreset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 去掉易混淆字符,字母+数字 }); // 将验证码文本存储在session中 req.session.captcha = captcha.text.toLowerCase(); req.session.captchaTime = Date.now(); // 保存session req.session.save((err) => { if (err) { console.error('[验证码] Session保存失败:', err); } else { // 安全:不记录验证码明文到日志 console.log('[验证码] 生成成功, SessionID:', req.sessionID); } }); res.type('svg'); res.send(captcha.data); } catch (error) { console.error('生成验证码失败:', error); res.status(500).json({ success: false, message: '生成验证码失败' }); } }); // 用户注册(简化版) app.post('/api/register', [ body('username') .isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符') .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'), body('email').isEmail().withMessage('邮箱格式不正确'), body('password').isLength({ min: 6 }).withMessage('密码至少6个字符'), body('captcha').notEmpty().withMessage('请输入验证码') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { // 验证验证码 const { captcha } = req.body; const captchaResult = verifyCaptcha(req, captcha); if (!captchaResult.valid) { return res.status(400).json({ success: false, message: captchaResult.message }); } checkMailRateLimit(req, 'verify'); const { username, email, password } = req.body; // 检查用户名是否存在 if (UserDB.findByUsername(username)) { return res.status(400).json({ success: false, message: '用户名已存在' }); } // 检查邮箱是否存在 if (UserDB.findByEmail(email)) { return res.status(400).json({ success: false, message: '邮箱已被使用' }); } // 检查SMTP配置 const smtpConfig = getSmtpConfig(); if (!smtpConfig) { return res.status(400).json({ success: false, message: '管理员尚未配置SMTP,暂时无法注册' }); } const verifyToken = generateRandomToken(24); const expiresAtMs = Date.now() + 30 * 60 * 1000; // 30分钟 const safeUsernameForMail = escapeHtml(username); // 创建用户(不需要FTP配置),标记未验证 const userId = UserDB.create({ username, email, password, is_verified: 0, verification_token: verifyToken, verification_expires_at: expiresAtMs }); const verifyLink = `${getSecureBaseUrl(req)}/app.html?verifyToken=${verifyToken}`; try { await sendMail( email, '邮箱验证 - 玩玩云', `

您好,${safeUsernameForMail}:

请点击下面的链接验证您的邮箱,30分钟内有效:

${verifyLink}

如果不是您本人操作,请忽略此邮件。

` ); } catch (mailErr) { console.error('发送验证邮件失败:', mailErr); return res.status(500).json({ success: false, message: '注册成功,但发送验证邮件失败,请稍后重试或联系管理员', needVerify: true }); } // 记录注册日志 logAuth(req, 'register', `新用户注册: ${username}`, { userId, email }); res.json({ success: true, message: '注册成功,请查收邮箱完成验证', user_id: userId }); } catch (error) { console.error('注册失败:', error); logAuth(req, 'register_failed', `用户注册失败: ${req.body.username || 'unknown'}`, { error: error.message }, 'error'); res.status(500).json({ success: false, message: '注册失败: ' + error.message }); } } ); // 重新发送邮箱验证邮件 app.post('/api/resend-verification', [ body('email').optional({ checkFalsy: true }).isEmail().withMessage('邮箱格式不正确'), body('username') .optional({ checkFalsy: true }) .isLength({ min: 3 }).withMessage('用户名格式不正确') .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'), body('captcha').notEmpty().withMessage('请输入验证码') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { // 验证验证码 const { captcha } = req.body; const captchaResult = verifyCaptcha(req, captcha); if (!captchaResult.valid) { return res.status(400).json({ success: false, message: captchaResult.message }); } checkMailRateLimit(req, 'verify'); const { email, username } = req.body; const user = email ? UserDB.findByEmail(email) : UserDB.findByUsername(username); if (!user) { return res.status(400).json({ success: false, message: '用户不存在' }); } if (user.is_verified) { return res.status(400).json({ success: false, message: '该邮箱已验证,无需重复验证' }); } const smtpConfig = getSmtpConfig(); if (!smtpConfig) { return res.status(400).json({ success: false, message: 'SMTP未配置,无法发送邮件' }); } const verifyToken = generateRandomToken(24); const expiresAtMs = Date.now() + 30 * 60 * 1000; VerificationDB.setVerification(user.id, verifyToken, expiresAtMs); const verifyLink = `${getSecureBaseUrl(req)}/app.html?verifyToken=${verifyToken}`; const safeUsernameForMail = escapeHtml(user.username); await sendMail( user.email, '邮箱验证 - 玩玩云', `

您好,${safeUsernameForMail}:

请点击下面的链接验证您的邮箱,30分钟内有效:

${verifyLink}

如果不是您本人操作,请忽略此邮件。

` ); res.json({ success: true, message: '验证邮件已发送,请查收' }); } catch (error) { const status = error.status || 500; console.error('重发验证邮件失败:', error); res.status(status).json({ success: false, message: error.message || '发送失败' }); } }); // 验证邮箱 app.get('/api/verify-email', async (req, res) => { const { token } = req.query; if (!token) { return res.status(400).json({ success: false, message: '缺少token' }); } try { const user = VerificationDB.consumeVerificationToken(token); if (!user) { return res.status(400).json({ success: false, message: '无效或已过期的验证链接' }); } res.json({ success: true, message: '邮箱验证成功,请登录' }); } catch (error) { console.error('邮箱验证失败:', error); res.status(500).json({ success: false, message: '邮箱验证失败' }); } }); // 发起密码重置(邮件) app.post('/api/password/forgot', [ body('email').isEmail().withMessage('邮箱格式不正确'), body('captcha').notEmpty().withMessage('请输入验证码') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } const { email, captcha } = req.body; try { // 验证验证码 const captchaResult = verifyCaptcha(req, captcha); if (!captchaResult.valid) { return res.status(400).json({ success: false, message: captchaResult.message }); } checkMailRateLimit(req, 'pwd_forgot'); const smtpConfig = getSmtpConfig(); if (!smtpConfig) { return res.status(400).json({ success: false, message: 'SMTP未配置,无法发送邮件' }); } // 安全修复:无论邮箱是否存在,都返回相同的成功消息(防止邮箱枚举) const user = UserDB.findByEmail(email); // 只有当用户存在、已验证、未封禁时才发送邮件 if (user && user.is_verified && user.is_active && !user.is_banned) { const token = generateRandomToken(24); const expiresAtMs = Date.now() + 30 * 60 * 1000; PasswordResetTokenDB.create(user.id, token, expiresAtMs); const resetLink = `${getSecureBaseUrl(req)}/app.html?resetToken=${token}`; const safeUsernameForMail = escapeHtml(user.username); // 异步发送邮件,不等待结果(避免通过响应时间判断邮箱是否存在) sendMail( email, '密码重置 - 玩玩云', `

您好,${safeUsernameForMail}:

请点击下面的链接重置密码,30分钟内有效:

${resetLink}

如果不是您本人操作,请忽略此邮件。

` ).catch(err => { console.error('发送密码重置邮件失败:', err.message); }); } else { // 记录但不暴露邮箱是否存在 console.log('[密码重置] 邮箱不存在或账号不可用:', email); } // 无论邮箱是否存在,都返回相同的成功消息 res.json({ success: true, message: '如果该邮箱已注册,您将收到密码重置链接' }); } catch (error) { const status = error.status || 500; console.error('密码重置请求失败:', error); res.status(status).json({ success: false, message: error.message || '发送失败' }); } }); // 使用邮件Token重置密码 app.post('/api/password/reset', [ body('token').notEmpty().withMessage('缺少token'), body('new_password').isLength({ min: 6 }).withMessage('新密码至少6个字符') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } const { token, new_password } = req.body; try { const tokenRow = PasswordResetTokenDB.use(token); if (!tokenRow) { return res.status(400).json({ success: false, message: '无效或已过期的重置链接' }); } const user = UserDB.findById(tokenRow.user_id); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } if (user.is_banned || !user.is_active) { return res.status(403).json({ success: false, message: '账号不可用,无法重置密码' }); } if (!user.is_verified) { return res.status(400).json({ success: false, message: '邮箱未验证,无法重置密码' }); } // 更新密码 const hashed = require('bcryptjs').hashSync(new_password, 10); db.prepare('UPDATE users SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') .run(hashed, tokenRow.user_id); res.json({ success: true, message: '密码重置成功,请重新登录' }); } catch (error) { console.error('密码重置失败:', error); res.status(500).json({ success: false, message: '密码重置失败' }); } }); // 用户登录 app.post('/api/login', loginRateLimitMiddleware, [ body('username').notEmpty().withMessage('用户名不能为空'), body('password').notEmpty().withMessage('密码不能为空') ], (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } const { username, password, captcha } = req.body; try { // 检查是否需要验证码 const ipKey = req.rateLimitKeys?.ipKey; const usernameKey = req.rateLimitKeys?.usernameKey; const ipFailures = ipKey ? loginLimiter.getFailureCount(ipKey) : 0; const usernameFailures = usernameKey ? loginLimiter.getFailureCount(usernameKey) : 0; const needCaptcha = ipFailures >= 2 || usernameFailures >= 2; // 如果需要验证码,则验证验证码 if (needCaptcha) { console.log('[登录验证] 需要验证码, IP失败次数:', ipFailures, '用户名失败次数:', usernameFailures); if (!captcha) { return res.status(400).json({ success: false, message: '请输入验证码', needCaptcha: true }); } // 验证验证码 const sessionCaptcha = req.session.captcha; const captchaTime = req.session.captchaTime; // 安全:不记录验证码明文 console.log('[登录验证] 正在验证验证码...'); if (!sessionCaptcha || !captchaTime) { console.log('[登录验证] 验证码不存在于Session中'); return res.status(400).json({ success: false, message: '验证码已过期,请刷新验证码', needCaptcha: true }); } // 验证码有效期5分钟 if (Date.now() - captchaTime > 5 * 60 * 1000) { console.log('[登录验证] 验证码已超过5分钟'); return res.status(400).json({ success: false, message: '验证码已过期,请刷新验证码', needCaptcha: true }); } if (captcha.toLowerCase() !== sessionCaptcha) { console.log('[登录验证] 验证码不匹配'); return res.status(400).json({ success: false, message: '验证码错误', needCaptcha: true }); } console.log('[登录验证] 验证码验证通过'); // 验证通过后清除session中的验证码 delete req.session.captcha; delete req.session.captchaTime; } const user = UserDB.findByUsername(username); if (!user) { // 记录失败尝试 if (req.rateLimitKeys) { const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey); if (req.rateLimitKeys.usernameKey) { loginLimiter.recordFailure(req.rateLimitKeys.usernameKey); } return res.status(401).json({ success: false, message: '用户名或密码错误', needCaptcha: result.needCaptcha }); } return res.status(401).json({ success: false, message: '用户名或密码错误' }); } if (user.is_banned) { return res.status(403).json({ success: false, message: '账号已被封禁' }); } if (!user.is_verified) { return res.status(403).json({ success: false, message: '邮箱未验证,请查收邮件或重新发送验证邮件', needVerify: true, email: user.email }); } if (!UserDB.verifyPassword(password, user.password)) { // 记录登录失败安全日志 logSecurity(req, 'login_failed', `登录失败(密码错误): ${username}`, { userId: user.id }); // 记录失败尝试 if (req.rateLimitKeys) { const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey); if (req.rateLimitKeys.usernameKey) { loginLimiter.recordFailure(req.rateLimitKeys.usernameKey); } return res.status(401).json({ success: false, message: '用户名或密码错误', needCaptcha: result.needCaptcha }); } return res.status(401).json({ success: false, message: '用户名或密码错误' }); } const token = generateToken(user); const refreshToken = generateRefreshToken(user); // 清除失败记录 if (req.rateLimitKeys) { loginLimiter.recordSuccess(req.rateLimitKeys.ipKey); if (req.rateLimitKeys.usernameKey) { loginLimiter.recordSuccess(req.rateLimitKeys.usernameKey); } } // 增强Cookie安全设置 const isSecureEnv = process.env.COOKIE_SECURE === 'true'; res.cookie('token', token, { httpOnly: true, secure: isSecureEnv, // HTTPS环境使用strict,HTTP环境使用lax(开发环境兼容) sameSite: isSecureEnv ? 'strict' : 'lax', maxAge: 2 * 60 * 60 * 1000, // 2小时(与access token有效期一致) path: '/' // 限制Cookie作用域 }); // 记录登录成功日志 logAuth(req, 'login', `用户登录成功: ${user.username}`, { userId: user.id, isAdmin: user.is_admin }); res.json({ success: true, message: '登录成功', token, refreshToken, // 返回refresh token expiresIn: 2 * 60 * 60 * 1000, // 告知前端access token有效期(毫秒) user: { id: user.id, username: user.username, email: user.email, is_admin: user.is_admin, has_ftp_config: user.has_ftp_config, // 存储相关字段 storage_permission: user.storage_permission || 'sftp_only', current_storage_type: user.current_storage_type || 'sftp', local_storage_quota: user.local_storage_quota || 1073741824, local_storage_used: user.local_storage_used || 0 } }); } catch (error) { console.error('登录失败:', error); logAuth(req, 'login_error', `登录异常: ${req.body.username || 'unknown'}`, { error: error.message }, 'error'); res.status(500).json({ success: false, message: '登录失败: ' + error.message }); } } ); // 刷新Access Token app.post('/api/refresh-token', (req, res) => { const { refreshToken } = req.body; if (!refreshToken) { return res.status(400).json({ success: false, message: '缺少刷新令牌' }); } const result = refreshAccessToken(refreshToken); if (!result.success) { return res.status(401).json({ success: false, message: result.message }); } // 更新Cookie中的token const isSecureEnv = process.env.COOKIE_SECURE === 'true'; res.cookie('token', result.token, { httpOnly: true, secure: isSecureEnv, sameSite: isSecureEnv ? 'strict' : 'lax', maxAge: 2 * 60 * 60 * 1000, // 2小时 path: '/' }); res.json({ success: true, token: result.token, expiresIn: 2 * 60 * 60 * 1000 }); }); // ===== 需要认证的API ===== // 获取当前用户信息 app.get('/api/user/profile', authMiddleware, (req, res) => { // 不返回密码明文 const { ftp_password, password, ...safeUser } = req.user; safeUser.http_download_base_url = sanitizeHttpBaseUrl(safeUser.http_download_base_url); res.json({ success: true, user: safeUser }); }); // 获取用户主题偏好(包含全局默认主题) app.get('/api/user/theme', authMiddleware, (req, res) => { try { const globalTheme = SettingsDB.get('global_theme') || 'dark'; const userTheme = req.user.theme_preference; // null表示跟随全局 res.json({ success: true, theme: { global: globalTheme, user: userTheme, effective: userTheme || globalTheme // 用户设置优先,否则使用全局 } }); } catch (error) { res.status(500).json({ success: false, message: '获取主题失败' }); } }); // 设置用户主题偏好 app.post('/api/user/theme', authMiddleware, (req, res) => { try { const { theme } = req.body; const validThemes = ['dark', 'light', null]; // null表示跟随全局 if (!validThemes.includes(theme)) { return res.status(400).json({ success: false, message: '无效的主题设置,可选: dark, light, null(跟随全局)' }); } UserDB.update(req.user.id, { theme_preference: theme }); const globalTheme = SettingsDB.get('global_theme') || 'dark'; res.json({ success: true, message: '主题偏好已更新', theme: { global: globalTheme, user: theme, effective: theme || globalTheme } }); } catch (error) { console.error('更新主题失败:', error); res.status(500).json({ success: false, message: '更新主题失败' }); } }); // 更新FTP配置 app.post('/api/user/update-ftp', authMiddleware, [ body('ftp_host').notEmpty().withMessage('FTP主机不能为空'), body('ftp_port').isInt({ min: 1, max: 65535 }).withMessage('FTP端口范围1-65535'), body('ftp_user').notEmpty().withMessage('FTP用户名不能为空'), body('http_download_base_url') .optional({ checkFalsy: true }) .isURL({ protocols: ['http', 'https'], require_protocol: true, require_tld: false }) .withMessage('HTTP直链地址必须以 http/https 开头') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { ftp_host, ftp_port, ftp_user, ftp_password, http_download_base_url } = req.body; // 调试日志:查看接收到的配置(掩码密码) console.log("[DEBUG] 收到SFTP配置:", { ftp_host, ftp_port, ftp_user, ftp_password: ftp_password ? "***" : "(empty)", http_download_base_url }); const safeHttpBaseUrl = sanitizeHttpBaseUrl(http_download_base_url); if (http_download_base_url && !safeHttpBaseUrl) { return res.status(400).json({ success: false, message: 'HTTP直链地址必须是合法的http/https地址,且不能包含查询或片段' }); } // 如果用户已配置FTP且密码为空,使用现有密码 let actualPassword = ftp_password; if (!ftp_password && req.user.has_ftp_config && req.user.ftp_password) { actualPassword = req.user.ftp_password; } else if (!ftp_password) { return res.status(400).json({ success: false, message: 'FTP密码不能为空' }); } // 主机校验与防SSRF const { port: safePort } = await validateSftpDestination(ftp_host, ftp_port); // 验证FTP连接 try { const sftp = await connectToSFTP({ ftp_host, ftp_port: safePort, ftp_user, ftp_password: actualPassword }); await sftp.end(); } catch (error) { return res.status(400).json({ success: false, message: 'SFTP连接失败,请检查配置: ' + error.message }); } // 更新用户配置 UserDB.update(req.user.id, { ftp_host, ftp_port: safePort, ftp_user, ftp_password: actualPassword, http_download_base_url: safeHttpBaseUrl || null, has_ftp_config: 1 }); res.json({ success: true, message: 'SFTP配置已更新' }); } catch (error) { console.error('更新配置失败:', error); res.status(500).json({ success: false, message: '更新配置失败: ' + error.message }); } } ); // 获取SFTP存储空间使用情况 app.get('/api/user/sftp-usage', authMiddleware, async (req, res) => { let sftp = null; try { // 检查用户是否配置了SFTP if (!req.user.has_ftp_config) { return res.status(400).json({ success: false, message: '未配置SFTP服务器' }); } // 连接SFTP sftp = await connectToSFTP(req.user); // 递归计算目录大小的函数 async function calculateDirSize(dirPath) { let totalSize = 0; let fileCount = 0; let dirCount = 0; try { const list = await sftp.list(dirPath); for (const item of list) { // 跳过 . 和 .. 目录 if (item.name === '.' || item.name === '..') continue; const itemPath = dirPath === '/' ? `/${item.name}` : `${dirPath}/${item.name}`; if (item.type === 'd') { // 是目录,递归计算 dirCount++; const subResult = await calculateDirSize(itemPath); totalSize += subResult.totalSize; fileCount += subResult.fileCount; dirCount += subResult.dirCount; } else { // 是文件,累加大小 fileCount++; totalSize += item.size || 0; } } } catch (err) { // 跳过无法访问的目录 console.warn(`[SFTP统计] 无法访问目录 ${dirPath}: ${err.message}`); } return { totalSize, fileCount, dirCount }; } // 从根目录开始计算 const result = await calculateDirSize('/'); res.json({ success: true, usage: { totalSize: result.totalSize, totalSizeFormatted: formatFileSize(result.totalSize), fileCount: result.fileCount, dirCount: result.dirCount } }); } catch (error) { console.error('[SFTP统计] 获取失败:', error); res.status(500).json({ success: false, message: '获取SFTP空间使用情况失败: ' + error.message }); } finally { if (sftp) { try { await sftp.end(); } catch (e) { // 忽略关闭错误 } } } }); // 修改管理员账号信息(仅管理员可修改用户名) app.post('/api/admin/update-profile', authMiddleware, adminMiddleware, [ body('username') .isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符') .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { username } = req.body; // 检查用户名是否被占用(排除自己) if (username !== req.user.username) { const existingUser = UserDB.findByUsername(username); if (existingUser && existingUser.id !== req.user.id) { return res.status(400).json({ success: false, message: '用户名已被使用' }); } // 更新用户名 UserDB.update(req.user.id, { username }); // 获取更新后的用户信息 const updatedUser = UserDB.findById(req.user.id); // 生成新的token(因为用户名变了) const newToken = generateToken(updatedUser); res.json({ success: true, message: '用户名已更新', token: newToken, user: { id: updatedUser.id, username: updatedUser.username, email: updatedUser.email, is_admin: updatedUser.is_admin } }); } else { res.json({ success: true, message: '没有需要更新的信息' }); } } catch (error) { console.error('更新账号信息失败:', error); res.status(500).json({ success: false, message: '更新失败: ' + error.message }); } } ); // 修改当前用户密码(需要验证当前密码) app.post('/api/user/change-password', authMiddleware, [ body('current_password').notEmpty().withMessage('当前密码不能为空'), body('new_password').isLength({ min: 6 }).withMessage('新密码至少6个字符') ], (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { current_password, new_password } = req.body; // 获取当前用户信息 const user = UserDB.findById(req.user.id); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } // 验证当前密码 if (!UserDB.verifyPassword(current_password, user.password)) { return res.status(401).json({ success: false, message: '当前密码错误' }); } // 更新密码 UserDB.update(req.user.id, { password: new_password }); res.json({ success: true, message: '密码修改成功' }); } catch (error) { console.error('修改密码失败:', error); res.status(500).json({ success: false, message: '修改密码失败: ' + error.message }); } } ); // 修改当前用户名 app.post('/api/user/update-username', authMiddleware, [ body('username') .isLength({ min: 3 }).withMessage('用户名至少3个字符') .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线') ], (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { username } = req.body; // 检查用户名是否已存在 const existingUser = UserDB.findByUsername(username); if (existingUser && existingUser.id !== req.user.id) { return res.status(400).json({ success: false, message: '用户名已存在' }); } // 更新用户名 UserDB.update(req.user.id, { username }); res.json({ success: true, message: '用户名修改成功' }); } catch (error) { console.error('修改用户名失败:', error); res.status(500).json({ success: false, message: '修改用户名失败: ' + error.message }); } } ); // 切换存储方式 app.post('/api/user/switch-storage', authMiddleware, [ body('storage_type').isIn(['local', 'sftp']).withMessage('无效的存储类型') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { storage_type } = req.body; // 检查权限 if (req.user.storage_permission === 'local_only' && storage_type !== 'local') { return res.status(403).json({ success: false, message: '您只能使用本地存储' }); } if (req.user.storage_permission === 'sftp_only' && storage_type !== 'sftp') { return res.status(403).json({ success: false, message: '您只能使用SFTP存储' }); } // 检查SFTP配置 if (storage_type === 'sftp' && !req.user.has_ftp_config) { return res.status(400).json({ success: false, message: '请先配置SFTP服务器' }); } // 更新存储类型 UserDB.update(req.user.id, { current_storage_type: storage_type }); res.json({ success: true, message: '存储方式已切换', storage_type }); } catch (error) { console.error('切换存储失败:', error); res.status(500).json({ success: false, message: '切换存储失败: ' + error.message }); } } ); // 获取文件列表(添加速率限制) app.get('/api/files', authMiddleware, async (req, res) => { // 速率限制检查 const rateLimitKey = `file_list:${req.user.id}`; const rateLimitResult = fileListLimiter.recordFailure(rateLimitKey); if (rateLimitResult.blocked) { return res.status(429).json({ success: false, message: `请求过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试` }); } const dirPath = req.query.path || '/'; let storage; try { // 使用统一存储接口 const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); const list = await storage.list(dirPath); const storageType = req.user.current_storage_type || 'sftp'; const sanitizedHttpBase = sanitizeHttpBaseUrl(req.user.http_download_base_url); const formattedList = list.map(item => { // 构建完整的文件路径用于下载 const httpDownloadUrl = (storageType === 'sftp' && sanitizedHttpBase && item.type !== 'd') ? buildHttpDownloadUrl(sanitizedHttpBase, dirPath === '/' ? `/${item.name}` : `${dirPath}/${item.name}`) : null; return { name: item.name, type: item.type === 'd' ? 'directory' : 'file', size: item.size, sizeFormatted: formatFileSize(item.size), modifiedAt: new Date(item.modifyTime), isDirectory: item.type === 'd', httpDownloadUrl: httpDownloadUrl }; }); formattedList.sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }); res.json({ success: true, path: dirPath, items: formattedList, storageType: storageType, storagePermission: req.user.storage_permission || 'sftp_only' }); } catch (error) { console.error('获取文件列表失败:', error); res.status(500).json({ success: false, message: '获取文件列表失败: ' + error.message }); } finally { if (storage) await storage.end(); } }); // 重命名文件 app.post('/api/files/rename', authMiddleware, async (req, res) => { const { oldName, newName, path } = req.body; let storage; if (!oldName || !newName) { return res.status(400).json({ success: false, message: '缺少文件名参数' }); } try { // 使用统一存储接口 const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); const oldPath = path === '/' ? `/${oldName}` : `${path}/${oldName}`; const newPath = path === '/' ? `/${newName}` : `${path}/${newName}`; await storage.rename(oldPath, newPath); res.json({ success: true, message: '文件重命名成功' }); } catch (error) { console.error('重命名文件失败:', error); res.status(500).json({ success: false, message: '重命名文件失败: ' + error.message }); } finally { if (storage) await storage.end(); } }); // 创建文件夹 app.post('/api/files/mkdir', authMiddleware, async (req, res) => { const { path, folderName } = req.body; let storage; // 参数验证 if (!folderName || folderName.trim() === '') { return res.status(400).json({ success: false, message: '文件夹名称不能为空' }); } // 文件名安全检查 - 防止路径遍历攻击 if (folderName.includes('/') || folderName.includes('\\') || folderName.includes('..') || folderName.includes(':')) { return res.status(400).json({ success: false, message: '文件夹名称不能包含特殊字符 (/ \\ .. :)' }); } // 只允许本地存储创建文件夹 if (req.user.current_storage_type !== 'local') { return res.status(400).json({ success: false, message: '只有本地存储支持创建文件夹' }); } try { const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); // 构造文件夹路径 const basePath = path || '/'; const folderPath = basePath === '/' ? `/${folderName}` : `${basePath}/${folderName}`; const fullPath = storage.getFullPath(folderPath); // 检查是否已存在 if (fs.existsSync(fullPath)) { return res.status(400).json({ success: false, message: '文件夹已存在' }); } // 创建文件夹 (不使用recursive,只创建当前层级) fs.mkdirSync(fullPath, { mode: 0o755 }); console.log(`[创建文件夹成功] 用户${req.user.id}: ${folderPath}`); res.json({ success: true, message: '文件夹创建成功' }); } catch (error) { console.error('[创建文件夹失败]', error); res.status(500).json({ success: false, message: '创建文件夹失败: ' + error.message }); } finally { if (storage) await storage.end(); } }); // 获取文件夹详情(大小统计) app.post('/api/files/folder-info', authMiddleware, async (req, res) => { const { path: dirPath, folderName } = req.body; let storage; if (!folderName) { return res.status(400).json({ success: false, message: '缺少文件夹名称参数' }); } // 只支持本地存储 if (req.user.current_storage_type !== 'local') { return res.status(400).json({ success: false, message: '只有本地存储支持此功能' }); } try { const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); // 构造文件夹路径 const basePath = dirPath || '/'; const folderPath = basePath === '/' ? `/${folderName}` : `${basePath}/${folderName}`; const fullPath = storage.getFullPath(folderPath); // 检查是否存在且是文件夹 if (!fs.existsSync(fullPath)) { return res.status(404).json({ success: false, message: '文件夹不存在' }); } const stats = fs.statSync(fullPath); if (!stats.isDirectory()) { return res.status(400).json({ success: false, message: '指定路径不是文件夹' }); } // 计算文件夹大小 const folderSize = storage.calculateFolderSize(fullPath); // 计算文件数量 function countFiles(countDirPath) { let fileCount = 0; let folderCount = 0; const items = fs.readdirSync(countDirPath, { withFileTypes: true }); for (const item of items) { const itemPath = path.join(countDirPath, item.name); if (item.isDirectory()) { folderCount++; const subCounts = countFiles(itemPath); fileCount += subCounts.fileCount; folderCount += subCounts.folderCount; } else { fileCount++; } } return { fileCount, folderCount }; } const counts = countFiles(fullPath); res.json({ success: true, data: { name: folderName, path: folderPath, size: folderSize, fileCount: counts.fileCount, folderCount: counts.folderCount } }); } catch (error) { console.error('[获取文件夹详情失败]', error); res.status(500).json({ success: false, message: '获取文件夹详情失败: ' + error.message }); } finally { if (storage) await storage.end(); } }); // 删除文件 app.post('/api/files/delete', authMiddleware, async (req, res) => { const { fileName, path } = req.body; let storage; if (!fileName) { return res.status(400).json({ success: false, message: '缺少文件名参数' }); } try { // 使用统一存储接口 const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); const filePath = path === '/' ? `/${fileName}` : `${path}/${fileName}`; await storage.delete(filePath); res.json({ success: true, message: '删除成功' }); } catch (error) { console.error('删除文件失败:', error); res.status(500).json({ success: false, message: '删除文件失败: ' + error.message }); } finally { if (storage) await storage.end(); } }); // 上传文件(添加速率限制) app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) => { // 速率限制检查 const rateLimitKey = `upload:${req.user.id}`; const rateLimitResult = uploadLimiter.recordFailure(rateLimitKey); if (rateLimitResult.blocked) { // 清理已上传的临时文件 if (req.file && fs.existsSync(req.file.path)) { safeDeleteFile(req.file.path); } return res.status(429).json({ success: false, message: `上传过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试` }); } if (!req.file) { return res.status(400).json({ success: false, message: '没有上传文件' }); } // 检查文件大小限制 const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240'); if (req.file.size > maxUploadSize) { // 删除已上传的临时文件 if (fs.existsSync(req.file.path)) { safeDeleteFile(req.file.path); } return res.status(413).json({ success: false, message: '文件超过上传限制', maxSize: maxUploadSize, fileSize: req.file.size }); } const remotePath = req.body.path || '/'; // 修复中文文件名:multer将UTF-8转为了Latin1,需要转回来 const originalFilename = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); // 文件名安全校验 if (!isSafePathSegment(originalFilename)) { safeDeleteFile(req.file.path); return res.status(400).json({ success: false, message: '文件名包含非法字符' }); } // 文件扩展名安全检查(防止上传危险文件) if (!isFileExtensionSafe(originalFilename)) { console.warn(`[安全] 拒绝上传危险文件: ${originalFilename}, 用户: ${req.user.username}`); safeDeleteFile(req.file.path); return res.status(400).json({ success: false, message: '不允许上传此类型的文件(安全限制)' }); } // 路径安全校验 const normalizedPath = path.posix.normalize(remotePath || '/'); if (normalizedPath.includes('..')) { return res.status(400).json({ success: false, message: '上传路径非法' }); } const safePath = normalizedPath === '.' ? '/' : normalizedPath; const remoteFilePath = safePath === '/' ? `/${originalFilename}` : `${safePath}/${originalFilename}`; let storage; try { // 使用统一存储接口 const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); // storage.put() 内部已经实现了临时文件+重命名逻辑 await storage.put(req.file.path, remoteFilePath); console.log(`[上传] 文件上传成功: ${remoteFilePath}`); // 删除本地临时文件 safeDeleteFile(req.file.path); res.json({ success: true, message: '文件上传成功', filename: originalFilename, path: remoteFilePath }); } catch (error) { console.error('文件上传失败:', error); // 删除临时文件 if (fs.existsSync(req.file.path)) { safeDeleteFile(req.file.path); } res.status(500).json({ success: false, message: '文件上传失败: ' + error.message }); } finally { if (storage) await storage.end(); } }); // 下载文件 app.get('/api/files/download', authMiddleware, async (req, res) => { const filePath = req.query.path; let storage; if (!filePath) { return res.status(400).json({ success: false, message: '缺少文件路径参数' }); } try { // 使用统一存储接口 const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); // 获取文件名 const fileName = filePath.split('/').pop(); // 先获取文件信息(获取文件大小) const fileStats = await storage.stat(filePath); const fileSize = fileStats.size; console.log('[下载] 文件: ' + fileName + ', 大小: ' + fileSize + ' 字节'); // 设置响应头(包含文件大小,浏览器可显示下载进度) res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Length', fileSize); res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(fileName) + '"; filename*=UTF-8\'\'' + encodeURIComponent(fileName)); // 创建文件流并传输(流式下载,服务器不保存临时文件) const stream = storage.createReadStream(filePath); stream.on('error', (error) => { console.error('文件流错误:', error); if (!res.headersSent) { res.status(500).json({ success: false, message: '文件下载失败: ' + error.message }); } // 发生错误时关闭存储连接 if (storage) { storage.end().catch(err => console.error('关闭存储连接失败:', err)); } }); // 在传输完成后关闭存储连接 stream.on('close', () => { console.log('[下载] 文件传输完成,关闭存储连接'); if (storage) { storage.end().catch(err => console.error('关闭存储连接失败:', err)); } }); stream.pipe(res); } catch (error) { console.error('下载文件失败:', error); // 如果stream还未创建或发生错误,关闭storage连接 if (storage) { storage.end().catch(err => console.error('关闭存储连接失败:', err)); } if (!res.headersSent) { res.status(500).json({ success: false, message: '下载文件失败: ' + error.message }); } } }); // 生成上传工具(生成新密钥并创建配置文件) app.post('/api/upload/generate-tool', authMiddleware, async (req, res) => { try { // 生成新的API密钥(32位随机字符串) const crypto = require('crypto'); const newApiKey = crypto.randomBytes(16).toString('hex'); // 更新用户的upload_api_key UserDB.update(req.user.id, { upload_api_key: newApiKey }); // 创建配置文件内容 const config = { username: req.user.username, api_key: newApiKey, api_base_url: getSecureBaseUrl(req) }; res.json({ success: true, message: '上传工具已生成', config: config }); } catch (error) { console.error('生成上传工具失败:', error); res.status(500).json({ success: false, message: '生成上传工具失败: ' + error.message }); } }); // 下载上传工具(zip包含exe+config.json+README.txt) app.get('/api/upload/download-tool', authMiddleware, async (req, res) => { let tempZipPath = null; try { console.log(`[上传工具] 用户 ${req.user.username} 请求下载上传工具`); // 生成新的API密钥 const crypto = require('crypto'); const newApiKey = crypto.randomBytes(16).toString('hex'); // 更新用户的upload_api_key UserDB.update(req.user.id, { upload_api_key: newApiKey }); // 创建配置文件内容 const config = { username: req.user.username, api_key: newApiKey, api_base_url: getSecureBaseUrl(req) }; console.log("[上传工具配置]", JSON.stringify(config, null, 2)); // 检查exe文件是否存在 const toolDir = path.join(__dirname, '..', 'upload-tool'); const exePath = path.join(toolDir, 'dist', '玩玩云上传工具.exe'); const readmePath = path.join(toolDir, 'README.txt'); if (!fs.existsSync(exePath)) { console.error('[上传工具] exe文件不存在:', exePath); return res.status(500).json({ success: false, message: '上传工具尚未打包,请联系管理员运行 upload-tool/build.bat' }); } // 创建临时zip文件路径 const uploadsDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } tempZipPath = path.join(uploadsDir, `tool_${req.user.username}_${Date.now()}.zip`); console.log('[上传工具] 开始创建zip包到临时文件:', tempZipPath); // 创建文件写入流 const output = fs.createWriteStream(tempZipPath); const archive = archiver('zip', { store: true // 使用STORE模式,不压缩,速度最快 }); // 等待zip文件创建完成 await new Promise((resolve, reject) => { output.on('close', () => { console.log(`[上传工具] zip创建完成,大小: ${archive.pointer()} 字节`); resolve(); }); archive.on('error', (err) => { console.error('[上传工具] archiver错误:', err); reject(err); }); // 连接archive到文件流 archive.pipe(output); // 添加exe文件 console.log('[上传工具] 添加exe文件...'); archive.file(exePath, { name: '玩玩云上传工具.exe' }); // 添加config.json console.log('[上传工具] 添加config.json...'); archive.append(JSON.stringify(config, null, 2), { name: 'config.json' }); // 添加README.txt if (fs.existsSync(readmePath)) { console.log('[上传工具] 添加README.txt...'); archive.file(readmePath, { name: 'README.txt' }); } // 完成打包 console.log('[上传工具] 执行finalize...'); archive.finalize(); }); // 获取文件大小 const stats = fs.statSync(tempZipPath); const fileSize = stats.size; console.log(`[上传工具] 准备发送文件,大小: ${fileSize} 字节`); // 设置响应头(包含Content-Length) const filename = `玩玩云上传工具_${req.user.username}.zip`; res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Length', fileSize); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`); // 创建文件读取流并发送 const fileStream = fs.createReadStream(tempZipPath); fileStream.on('end', () => { console.log(`[上传工具] 用户 ${req.user.username} 下载完成`); // 删除临时文件 if (tempZipPath && fs.existsSync(tempZipPath)) { fs.unlinkSync(tempZipPath); console.log('[上传工具] 临时文件已删除'); } }); fileStream.on('error', (err) => { console.error('[上传工具] 文件流错误:', err); // 删除临时文件 if (tempZipPath && fs.existsSync(tempZipPath)) { fs.unlinkSync(tempZipPath); } }); fileStream.pipe(res); } catch (error) { console.error('[上传工具] 异常:', error); // 删除临时文件 if (tempZipPath && fs.existsSync(tempZipPath)) { fs.unlinkSync(tempZipPath); console.log('[上传工具] 临时文件已删除(异常)'); } if (!res.headersSent) { res.status(500).json({ success: false, message: '下载失败: ' + error.message }); } } }); // 通过API密钥获取SFTP配置(供Python工具调用) // 添加速率限制防止暴力枚举 app.post('/api/upload/get-config', async (req, res) => { // 获取客户端IP用于速率限制 const clientIP = apiKeyLimiter.getClientKey(req); const rateLimitKey = `api_key:${clientIP}`; // 检查是否被封锁 if (apiKeyLimiter.isBlocked(rateLimitKey)) { const result = apiKeyLimiter.recordFailure(rateLimitKey); console.warn(`[安全] API密钥暴力枚举检测 - IP: ${clientIP}`); return res.status(429).json({ success: false, message: `请求过于频繁,请在 ${result.waitMinutes} 分钟后重试`, blocked: true }); } try { const { api_key } = req.body; if (!api_key) { return res.status(400).json({ success: false, message: 'API密钥不能为空' }); } // 查找拥有此API密钥的用户 const user = db.prepare('SELECT * FROM users WHERE upload_api_key = ?').get(api_key); if (!user) { // 记录失败尝试 const result = apiKeyLimiter.recordFailure(rateLimitKey); console.warn(`[安全] API密钥验证失败 - IP: ${clientIP}, 剩余尝试: ${result.remainingAttempts}`); return res.status(401).json({ success: false, message: 'API密钥无效或已过期' }); } // 验证成功,清除失败记录 apiKeyLimiter.recordSuccess(rateLimitKey); if (user.is_banned) { return res.status(403).json({ success: false, message: '账号已被封禁' }); } if (!user.has_ftp_config) { return res.status(400).json({ success: false, message: '用户未配置SFTP服务器' }); } // 返回SFTP配置(注意:密码通过此API返回给上传工具使用) // 上传工具需要密码才能连接SFTP,这是设计上的需要 // 安全措施:1. 速率限制防止暴力枚举 2. API密钥是32位随机字符串 res.json({ success: true, sftp_config: { host: user.ftp_host, port: user.ftp_port, username: user.ftp_user, password: user.ftp_password } }); } catch (error) { console.error('获取SFTP配置失败:', error); res.status(500).json({ success: false, message: '获取SFTP配置失败: ' + error.message }); } }); // 创建分享链接 app.post('/api/share/create', authMiddleware, (req, res) => { try { const { share_type, file_path, file_name, password, expiry_days } = req.body; console.log("[DEBUG] 创建分享请求:", { share_type, file_path, file_name, password: password ? "***" : null, expiry_days }); if (share_type === 'file' && !file_path) { return res.status(400).json({ success: false, message: '文件路径不能为空' }); } const result = ShareDB.create(req.user.id, { share_type: share_type || 'file', file_path: file_path || '', file_name: file_name || '', password: password || null, expiry_days: expiry_days || null }); // 更新分享的存储类型 const { db } = require('./database'); db.prepare('UPDATE shares SET storage_type = ? WHERE id = ?') .run(req.user.current_storage_type || 'sftp', result.id); const shareUrl = `${getSecureBaseUrl(req)}/s/${result.share_code}`; res.json({ success: true, message: '分享链接创建成功', share_code: result.share_code, share_url: shareUrl, share_type: result.share_type, expires_at: result.expires_at, }); } catch (error) { console.error('创建分享链接失败:', error); res.status(500).json({ success: false, message: '创建分享链接失败: ' + error.message }); } }); // 获取我的分享列表 app.get('/api/share/my', authMiddleware, (req, res) => { try { const shares = ShareDB.getUserShares(req.user.id); res.json({ success: true, shares: shares.map(share => ({ ...share, share_url: `${getSecureBaseUrl(req)}/s/${share.share_code}` })) }); } catch (error) { console.error('获取分享列表失败:', error); res.status(500).json({ success: false, message: '获取分享列表失败: ' + error.message }); } }); // 删除分享(增强IDOR防护) app.delete('/api/share/:id', authMiddleware, (req, res) => { try { const shareId = parseInt(req.params.id, 10); // 验证ID格式 if (isNaN(shareId) || shareId <= 0) { return res.status(400).json({ success: false, message: '无效的分享ID' }); } // 先获取分享信息以获得share_code const share = ShareDB.findById(shareId); if (!share) { return res.status(404).json({ success: false, message: '分享不存在' }); } // 严格的权限检查 if (share.user_id !== req.user.id) { // 记录可疑的越权尝试 console.warn(`[安全] IDOR尝试 - 用户 ${req.user.id}(${req.user.username}) 试图删除用户 ${share.user_id} 的分享 ${shareId}`); return res.status(403).json({ success: false, message: '无权限删除此分享' }); } // 删除缓存 if (shareFileCache.has(share.share_code)) { shareFileCache.delete(share.share_code); console.log(`[缓存清除] 分享码: ${share.share_code}`); } // 删除数据库记录 ShareDB.delete(shareId, req.user.id); res.json({ success: true, message: '分享已删除' }); } catch (error) { console.error('删除分享失败:', error); res.status(500).json({ success: false, message: '删除分享失败: ' + error.message }); } }); // ===== 分享链接访问(公开) ===== // 获取公共主题设置(用于分享页面,无需认证) 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 { const { code } = req.params; const share = ShareDB.findByCode(code); const globalTheme = SettingsDB.get('global_theme') || 'dark'; if (!share) { return res.json({ success: true, theme: globalTheme }); } // 优先使用分享者的主题偏好,否则使用全局主题 const effectiveTheme = share.theme_preference || globalTheme; res.json({ success: true, theme: effectiveTheme }); } catch (error) { res.json({ success: true, theme: 'dark' }); } }); // 访问分享链接 - 验证密码(支持本地存储和SFTP) app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) => { const { code } = req.params; const { password } = req.body; let storage; try { // ===== 调试日志: 分享验证开始 ===== console.log('[分享验证]', { timestamp: new Date().toISOString(), shareCode: code, hasPassword: !!password, requestIP: req.ip }); const share = ShareDB.findByCode(code); // 调试日志: findByCode 结果 console.log('[分享验证] findByCode结果:', { found: !!share, expires_at: share?.expires_at || null, current_time: new Date().toISOString(), is_expired: share?.expires_at ? new Date(share.expires_at) <= new Date() : false }); if (!share) { return res.status(404).json({ success: false, message: '分享不存在' }); } // 如果设置了密码,验证密码 if (share.share_password) { if (!password) { return res.status(401).json({ success: false, message: '需要密码', needPassword: true }); } if (!ShareDB.verifyPassword(password, share.share_password)) { // 记录密码错误 if (req.shareRateLimitKey) { shareLimiter.recordFailure(req.shareRateLimitKey); } return res.status(401).json({ success: false, message: '密码错误' }); } } // 清除失败记录(密码验证成功) if (req.shareRateLimitKey) { shareLimiter.recordSuccess(req.shareRateLimitKey); } // 增加查看次数 ShareDB.incrementViewCount(code); const sanitizedShareHttpBase = sanitizeHttpBaseUrl(share.http_download_base_url); // 构建返回数据 const responseData = { success: true, share: { share_path: share.share_path, share_type: share.share_type, username: share.username, created_at: share.created_at, expires_at: share.expires_at // 添加到期时间 } }; // 如果是单文件分享,查询存储获取文件信息(带缓存) if (share.share_type === 'file') { const filePath = share.share_path; const lastSlashIndex = filePath.lastIndexOf('/'); const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : '/'; const fileName = lastSlashIndex >= 0 ? filePath.substring(lastSlashIndex + 1) : filePath; // 检查缓存 if (shareFileCache.has(code)) { console.log(`[缓存命中] 分享码: ${code}`); responseData.file = shareFileCache.get(code); } else { // 缓存未命中,查询存储 try { // 获取分享者的用户信息 const shareOwner = UserDB.findById(share.user_id); if (!shareOwner) { throw new Error('分享者不存在'); } const storageType = shareOwner.current_storage_type || 'sftp'; console.log(`[缓存未命中] 分享码: ${code},存储类型: ${storageType}`); // 使用统一存储接口 const { StorageInterface } = require('./storage'); const userForStorage = { ...shareOwner, current_storage_type: storageType }; const storageInterface = new StorageInterface(userForStorage); storage = await storageInterface.connect(); const list = await storage.list(dirPath); const fileInfo = list.find(item => item.name === fileName); // 检查文件是否存在 if (!fileInfo) { shareFileCache.delete(code); throw new Error("分享的文件已被删除或不存在"); } if (fileInfo) { const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`; // SFTP存储才提供HTTP下载URL,本地存储使用API下载 const httpDownloadUrl = (storageType === 'sftp') ? buildHttpDownloadUrl(sanitizedShareHttpBase, normalizedFilePath) : null; const fileData = { name: fileName, type: 'file', isDirectory: false, httpDownloadUrl: httpDownloadUrl, size: fileInfo.size, sizeFormatted: formatFileSize(fileInfo.size), modifiedAt: new Date(fileInfo.modifyTime) }; // 存入缓存 shareFileCache.set(code, fileData); console.log(`[缓存存储] 分享码: ${code},文件: ${fileName}`); responseData.file = fileData; } } catch (storageError) { console.error('获取文件信息失败:', storageError); // 如果是文件不存在的错误,重新抛出 if (storageError.message && storageError.message.includes("分享的文件已被删除或不存在")) { throw storageError; } // 存储失败时仍返回基本信息,只是没有大小 const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`; const storageType = share.storage_type || 'sftp'; const httpDownloadUrl = (storageType === 'sftp') ? buildHttpDownloadUrl(sanitizedShareHttpBase, normalizedFilePath) : null; responseData.file = { name: fileName, type: 'file', isDirectory: false, httpDownloadUrl: httpDownloadUrl, size: 0, sizeFormatted: '-' }; } } } res.json(responseData); } catch (error) { console.error('验证分享失败:', error); res.status(500).json({ success: false, message: '验证失败: ' + error.message }); } finally { if (storage) await storage.end(); } }); // 获取分享的文件列表(支持本地存储和SFTP) app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => { const { code } = req.params; const { password, path: subPath } = req.body; let storage; try { // ===== 调试日志: 获取分享文件列表 ===== console.log('[获取文件列表]', { timestamp: new Date().toISOString(), shareCode: code, subPath: subPath, hasPassword: !!password, requestIP: req.ip }); const share = ShareDB.findByCode(code); // 调试日志: findByCode 结果 console.log('[获取文件列表] findByCode结果:', { found: !!share, expires_at: share?.expires_at || null, current_time: new Date().toISOString(), is_expired: share?.expires_at ? new Date(share.expires_at) <= new Date() : false }); if (!share) { return res.status(404).json({ success: false, message: '分享不存在' }); } // 验证密码 if (share.share_password && !ShareDB.verifyPassword(password, share.share_password)) { // 记录密码错误 if (req.shareRateLimitKey) { shareLimiter.recordFailure(req.shareRateLimitKey); } return res.status(401).json({ success: false, message: '密码错误' }); } // 清除失败记录(密码验证成功或无密码) if (req.shareRateLimitKey && share.share_password) { shareLimiter.recordSuccess(req.shareRateLimitKey); } // 获取分享者的用户信息 const shareOwner = UserDB.findById(share.user_id); if (!shareOwner) { return res.status(404).json({ success: false, message: '分享者不存在' }); } // 构造安全的请求路径,防止越权遍历 const baseSharePath = (share.share_path || '/').replace(/\\/g, '/'); const requestedPath = subPath ? path.posix.normalize(`${baseSharePath}/${subPath}`) : baseSharePath; // 校验请求路径是否在分享范围内 if (!isPathWithinShare(requestedPath, share)) { return res.status(403).json({ success: false, message: '无权访问该路径' }); } // 使用统一存储接口,根据分享的storage_type选择存储后端 const { StorageInterface } = require('./storage'); const storageType = share.storage_type || 'sftp'; console.log(`[分享列表] 存储类型: ${storageType}, 分享路径: ${share.share_path}`); // 临时构造用户对象以使用存储接口 const userForStorage = { ...shareOwner, current_storage_type: storageType }; const storageInterface = new StorageInterface(userForStorage); storage = await storageInterface.connect(); const sanitizedShareHttpBase = sanitizeHttpBaseUrl(share.http_download_base_url); let formattedList = []; // 如果是单文件分享 if (share.share_type === 'file') { // share_path 就是文件路径 const filePath = share.share_path; // 提取父目录和文件名 const lastSlashIndex = filePath.lastIndexOf('/'); const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : '/'; const fileName = lastSlashIndex >= 0 ? filePath.substring(lastSlashIndex + 1) : filePath; // 列出父目录 const list = await storage.list(dirPath); // 只返回这个文件 const fileInfo = list.find(item => item.name === fileName); if (fileInfo) { // 确保文件路径以斜杠开头 const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`; // SFTP存储才提供HTTP下载URL,本地存储使用API下载 const httpDownloadUrl = (storageType === 'sftp') ? buildHttpDownloadUrl(sanitizedShareHttpBase, normalizedFilePath) : null; formattedList = [{ name: fileInfo.name, type: 'file', size: fileInfo.size, sizeFormatted: formatFileSize(fileInfo.size), modifiedAt: new Date(fileInfo.modifyTime), isDirectory: false, httpDownloadUrl: httpDownloadUrl }]; } } // 如果是目录分享(分享所有文件) else { const list = await storage.list(requestedPath); formattedList = list.map(item => { // 构建完整的文件路径用于下载 let httpDownloadUrl = null; if (storageType === 'sftp' && sanitizedShareHttpBase && item.type !== 'd') { const normalizedPath = requestedPath.startsWith('/') ? requestedPath : `/${requestedPath}`; const filePath = normalizedPath === '/' ? `/${item.name}` : `${normalizedPath}/${item.name}`; httpDownloadUrl = buildHttpDownloadUrl(sanitizedShareHttpBase, filePath); } return { name: item.name, type: item.type === 'd' ? 'directory' : 'file', size: item.size, sizeFormatted: formatFileSize(item.size), modifiedAt: new Date(item.modifyTime), isDirectory: item.type === 'd', httpDownloadUrl: httpDownloadUrl }; }); formattedList.sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }); } res.json({ success: true, path: share.share_path, items: formattedList }); } catch (error) { console.error('获取分享文件列表失败:', error); res.status(500).json({ success: false, message: '获取文件列表失败: ' + error.message }); } finally { if (storage) await storage.end(); } }); // 记录下载次数 app.post('/api/share/:code/download', (req, res) => { const { code } = req.params; try { const share = ShareDB.findByCode(code); if (!share) { return res.status(404).json({ success: false, message: '分享不存在' }); } // 增加下载次数 ShareDB.incrementDownloadCount(code); res.json({ success: true, message: '下载统计已记录' }); } catch (error) { console.error('记录下载失败:', error); res.status(500).json({ success: false, message: '记录下载失败: ' + error.message }); } }); // 分享文件下载(支持本地存储和SFTP,公开API,需要分享码和密码验证) app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, res) => { const { code } = req.params; const { path: filePath, password } = req.query; let storage; if (!filePath) { return res.status(400).json({ success: false, message: '缺少文件路径参数' }); } try { const share = ShareDB.findByCode(code); if (!share) { return res.status(404).json({ success: false, message: '分享不存在' }); } // 验证密码(如果需要) if (share.share_password) { if (!password || !ShareDB.verifyPassword(password, share.share_password)) { // 只在密码错误时记录失败 if (req.shareRateLimitKey) { shareLimiter.recordFailure(req.shareRateLimitKey); } return res.status(401).json({ success: false, message: '密码错误或未提供密码' }); } // 密码验证成功,清除失败记录 if (req.shareRateLimitKey) { shareLimiter.recordSuccess(req.shareRateLimitKey); } } // ✅ 安全验证:检查请求路径是否在分享范围内(防止越权访问) if (!isPathWithinShare(filePath, share)) { console.warn(`[安全] 检测到越权访问尝试 - 分享码: ${code}, 请求路径: ${filePath}, 分享路径: ${share.share_path}`); return res.status(403).json({ success: false, message: '无权访问该文件' }); } // 获取分享者的用户信息 const shareOwner = UserDB.findById(share.user_id); if (!shareOwner) { return res.status(404).json({ success: false, message: '分享者不存在' }); } // 使用统一存储接口,根据分享的storage_type选择存储后端 const { StorageInterface } = require('./storage'); const storageType = shareOwner.current_storage_type || 'sftp'; console.log(`[分享下载] 存储类型: ${storageType} (分享者当前), 文件路径: ${filePath}`); // 临时构造用户对象以使用存储接口 const userForStorage = { ...shareOwner, current_storage_type: storageType }; const storageInterface = new StorageInterface(userForStorage); storage = await storageInterface.connect(); // 获取文件名 const fileName = filePath.split('/').pop(); // 获取文件信息(获取文件大小) const fileStats = await storage.stat(filePath); const fileSize = fileStats.size; console.log(`[分享下载] 文件: ${fileName}, 大小: ${fileSize} 字节`); // 增加下载次数 ShareDB.incrementDownloadCount(code); // 设置响应头(包含文件大小,浏览器可显示下载进度) res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Length', fileSize); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`); // 创建文件流并传输(流式下载,服务器不保存临时文件) const stream = storage.createReadStream(filePath); stream.on('error', (error) => { console.error('文件流错误:', error); if (!res.headersSent) { res.status(500).json({ success: false, message: '文件下载失败: ' + error.message }); } // 发生错误时关闭存储连接 if (storage) { storage.end().catch(err => console.error('关闭存储连接失败:', err)); } }); // 在传输完成后关闭存储连接 stream.on('close', () => { console.log('[分享下载] 文件传输完成,关闭存储连接'); if (storage) { storage.end().catch(err => console.error('关闭存储连接失败:', err)); } }); stream.pipe(res); } catch (error) { console.error('分享下载文件失败:', error); if (!res.headersSent) { res.status(500).json({ success: false, message: '下载文件失败: ' + error.message }); } // 如果发生错误,关闭存储连接 if (storage) { storage.end().catch(err => console.error('关闭存储连接失败:', err)); } } }); // ===== 管理员API ===== // 获取系统设置 app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { try { const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240'); const smtpHost = SettingsDB.get('smtp_host'); const smtpPort = SettingsDB.get('smtp_port'); const smtpSecure = SettingsDB.get('smtp_secure') === 'true'; const smtpUser = SettingsDB.get('smtp_user'); const smtpFrom = SettingsDB.get('smtp_from') || smtpUser; const smtpHasPassword = !!SettingsDB.get('smtp_password'); const globalTheme = SettingsDB.get('global_theme') || 'dark'; res.json({ success: true, settings: { max_upload_size: maxUploadSize, global_theme: globalTheme, smtp: { host: smtpHost || '', port: smtpPort ? parseInt(smtpPort, 10) : 465, secure: smtpSecure, user: smtpUser || '', from: smtpFrom || '', has_password: smtpHasPassword } } }); } catch (error) { console.error('获取系统设置失败:', error); res.status(500).json({ success: false, message: '获取系统设置失败: ' + error.message }); } }); // 更新系统设置 app.post('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { try { const { max_upload_size, smtp, global_theme } = req.body; if (max_upload_size !== undefined) { const size = parseInt(max_upload_size); if (isNaN(size) || size < 0) { return res.status(400).json({ success: false, message: '无效的文件大小' }); } SettingsDB.set('max_upload_size', size.toString()); } // 更新全局主题 if (global_theme !== undefined) { const validThemes = ['dark', 'light']; if (!validThemes.includes(global_theme)) { return res.status(400).json({ success: false, message: '无效的主题设置' }); } SettingsDB.set('global_theme', global_theme); } if (smtp) { if (!smtp.host || !smtp.port || !smtp.user) { return res.status(400).json({ success: false, message: 'SMTP配置不完整' }); } SettingsDB.set('smtp_host', smtp.host); SettingsDB.set('smtp_port', smtp.port); SettingsDB.set('smtp_secure', smtp.secure ? 'true' : 'false'); SettingsDB.set('smtp_user', smtp.user); SettingsDB.set('smtp_from', smtp.from || smtp.user); if (smtp.password) { SettingsDB.set('smtp_password', smtp.password); } } res.json({ success: true, message: '系统设置已更新' }); } catch (error) { console.error('更新系统设置失败:', error); res.status(500).json({ success: false, message: '更新系统设置失败: ' + error.message }); } }); // 测试SMTP app.post('/api/admin/settings/test-smtp', authMiddleware, adminMiddleware, async (req, res) => { const { to } = req.body; try { const smtpConfig = getSmtpConfig(); if (!smtpConfig) { return res.status(400).json({ success: false, message: 'SMTP未配置' }); } const target = to || req.user.email || smtpConfig.user; await sendMail( target, 'SMTP测试 - 玩玩云', `

您好,这是一封测试邮件,说明SMTP配置可用。

时间:${new Date().toISOString()}

` ); res.json({ success: true, message: `测试邮件已发送至 ${target}` }); } catch (error) { console.error('测试SMTP失败:', error); res.status(500).json({ success: false, message: '测试邮件发送失败: ' + (error.response?.message || error.message) }); } }); // 系统健康检测API app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req, res) => { try { const checks = []; let overallStatus = 'healthy'; // healthy, warning, critical // 1. JWT密钥安全检查 const jwtSecure = isJwtSecretSecure(); checks.push({ name: 'JWT密钥', category: 'security', status: jwtSecure ? 'pass' : 'fail', message: jwtSecure ? 'JWT密钥已正确配置(随机生成)' : 'JWT密钥使用默认值或长度不足,存在安全风险!', suggestion: jwtSecure ? null : '请在.env中设置随机生成的JWT_SECRET,至少32字符' }); if (!jwtSecure) overallStatus = 'critical'; // 2. CORS配置检查 const allowedOrigins = process.env.ALLOWED_ORIGINS; const corsConfigured = allowedOrigins && allowedOrigins.trim().length > 0; checks.push({ name: 'CORS跨域配置', category: 'security', status: corsConfigured ? 'pass' : 'warning', message: corsConfigured ? `已配置允许的域名: ${allowedOrigins}` : 'CORS未配置,允许所有来源(仅适合开发环境)', suggestion: corsConfigured ? null : '生产环境建议配置ALLOWED_ORIGINS环境变量' }); if (!corsConfigured && overallStatus === 'healthy') overallStatus = 'warning'; // 3. HTTPS/Cookie安全配置 const enforceHttps = process.env.ENFORCE_HTTPS === 'true'; const cookieSecure = process.env.COOKIE_SECURE === 'true'; const httpsConfigured = enforceHttps && cookieSecure; checks.push({ name: 'HTTPS安全配置', category: 'security', status: httpsConfigured ? 'pass' : 'warning', message: httpsConfigured ? 'HTTPS强制开启,Cookie安全标志已设置' : `ENFORCE_HTTPS=${enforceHttps}, COOKIE_SECURE=${cookieSecure}`, suggestion: httpsConfigured ? null : '生产环境建议开启ENFORCE_HTTPS和COOKIE_SECURE' }); // 4. 管理员密码强度检查(检查是否为默认值) const adminUsername = process.env.ADMIN_USERNAME; const adminConfigured = adminUsername && adminUsername !== 'admin'; checks.push({ name: '管理员账号配置', category: 'security', status: adminConfigured ? 'pass' : 'warning', message: adminConfigured ? '管理员用户名已自定义' : '管理员使用默认用户名"admin"', suggestion: adminConfigured ? null : '建议使用自定义管理员用户名' }); // 5. SMTP邮件配置检查 const smtpHost = SettingsDB.get('smtp_host') || process.env.SMTP_HOST; const smtpUser = SettingsDB.get('smtp_user') || process.env.SMTP_USER; const smtpPassword = SettingsDB.get('smtp_password') || process.env.SMTP_PASSWORD; const smtpConfigured = smtpHost && smtpUser && smtpPassword; checks.push({ name: 'SMTP邮件服务', category: 'service', status: smtpConfigured ? 'pass' : 'warning', message: smtpConfigured ? `已配置: ${smtpHost}` : '未配置SMTP,邮箱验证和密码重置功能不可用', suggestion: smtpConfigured ? null : '配置SMTP以启用邮箱验证功能' }); // 6. 数据库连接检查 let dbStatus = 'pass'; let dbMessage = '数据库连接正常'; try { const testQuery = db.prepare('SELECT 1').get(); if (!testQuery) throw new Error('查询返回空'); } catch (dbError) { dbStatus = 'fail'; dbMessage = '数据库连接异常: ' + dbError.message; overallStatus = 'critical'; } checks.push({ name: '数据库连接', category: 'service', status: dbStatus, message: dbMessage, suggestion: dbStatus === 'fail' ? '检查数据库文件权限和路径配置' : null }); // 7. 存储目录检查 const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); let storageStatus = 'pass'; let storageMessage = `存储目录正常: ${storageRoot}`; try { if (!fs.existsSync(storageRoot)) { fs.mkdirSync(storageRoot, { recursive: true }); storageMessage = `存储目录已创建: ${storageRoot}`; } // 检查写入权限 const testFile = path.join(storageRoot, '.health-check-test'); fs.writeFileSync(testFile, 'test'); fs.unlinkSync(testFile); } catch (storageError) { storageStatus = 'fail'; storageMessage = '存储目录不可写: ' + storageError.message; overallStatus = 'critical'; } checks.push({ name: '存储目录', category: 'service', status: storageStatus, message: storageMessage, suggestion: storageStatus === 'fail' ? '检查存储目录权限,确保Node进程有写入权限' : null }); // 8. 限流器状态 const rateLimiterActive = typeof loginLimiter !== 'undefined' && loginLimiter !== null; checks.push({ name: '登录防爆破', category: 'security', status: rateLimiterActive ? 'pass' : 'warning', message: rateLimiterActive ? '限流器已启用(5次/15分钟,封锁30分钟)' : '限流器未正常初始化', suggestion: null }); // 9. 信任代理配置(安全检查) const trustProxy = app.get('trust proxy'); let trustProxyStatus = 'pass'; let trustProxyMessage = ''; let trustProxySuggestion = null; if (trustProxy === true) { // trust proxy = true 是不安全的配置 trustProxyStatus = 'fail'; trustProxyMessage = 'trust proxy = true(信任所有代理),客户端可伪造 IP/协议!'; trustProxySuggestion = '建议设置 TRUST_PROXY=1(单层代理)或具体的代理 IP 段'; if (overallStatus !== 'critical') overallStatus = 'critical'; } else if (trustProxy === false || !trustProxy) { trustProxyStatus = 'info'; trustProxyMessage = '未启用 trust proxy(直接暴露模式)'; trustProxySuggestion = '如在 Nginx/CDN 后部署,需配置 TRUST_PROXY=1'; } else if (typeof trustProxy === 'number') { trustProxyStatus = 'pass'; trustProxyMessage = `trust proxy = ${trustProxy}(信任前 ${trustProxy} 跳代理)`; } else { trustProxyStatus = 'pass'; trustProxyMessage = `trust proxy = "${trustProxy}"(信任指定代理)`; } checks.push({ name: '信任代理配置', category: 'security', status: trustProxyStatus, message: trustProxyMessage, suggestion: trustProxySuggestion }); // 10. Node环境 const nodeEnv = process.env.NODE_ENV || 'development'; checks.push({ name: '运行环境', category: 'config', status: nodeEnv === 'production' ? 'pass' : 'info', message: `当前环境: ${nodeEnv}`, suggestion: nodeEnv !== 'production' ? '生产部署建议设置NODE_ENV=production' : null }); // 统计 const summary = { total: checks.length, pass: checks.filter(c => c.status === 'pass').length, warning: checks.filter(c => c.status === 'warning').length, fail: checks.filter(c => c.status === 'fail').length, info: checks.filter(c => c.status === 'info').length }; res.json({ success: true, overallStatus, summary, checks, timestamp: new Date().toISOString(), version: process.env.npm_package_version || '1.1.0' }); } catch (error) { console.error('健康检测失败:', error); res.status(500).json({ success: false, message: '健康检测失败: ' + error.message }); } }); // 获取服务器存储统计信息 app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, res) => { try { // 获取本地存储目录(与 storage.js 保持一致) const localStorageDir = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); // 确保存储目录存在 if (!fs.existsSync(localStorageDir)) { fs.mkdirSync(localStorageDir, { recursive: true }); } // 获取磁盘信息(使用df命令) let totalDisk = 0; let usedDisk = 0; let availableDisk = 0; try { // 获取本地存储目录所在分区的磁盘信息(避免使用shell) const { stdout: dfOutput } = await execFileAsync('df', ['-B', '1', localStorageDir], { encoding: 'utf8' }); // 取最后一行数据 const lines = dfOutput.trim().split('\n'); const parts = lines[lines.length - 1].trim().split(/\s+/); if (parts.length >= 4) { totalDisk = parseInt(parts[1], 10) || 0; // 总大小 usedDisk = parseInt(parts[2], 10) || 0; // 已使用 availableDisk = parseInt(parts[3], 10) || 0; // 可用 } } catch (dfError) { console.error('获取磁盘信息失败:', dfError.message); // 如果df命令失败,尝试使用Windows的wmic命令 try { // 获取本地存储目录所在的驱动器号 const driveLetter = localStorageDir.charAt(0); const normalizedDrive = driveLetter.toUpperCase(); if (!/^[A-Z]$/.test(normalizedDrive)) { throw new Error('Invalid drive letter'); } const { stdout: wmicOutput } = await execFileAsync( 'wmic', ['logicaldisk', 'where', `DeviceID='${normalizedDrive}:'`, 'get', 'Size,FreeSpace', '/value'], { encoding: 'utf8' } ); const freeMatch = wmicOutput.match(/FreeSpace=(\d+)/); const sizeMatch = wmicOutput.match(/Size=(\d+)/); if (sizeMatch && freeMatch) { totalDisk = parseInt(sizeMatch[1]) || 0; availableDisk = parseInt(freeMatch[1]) || 0; usedDisk = totalDisk - availableDisk; } } catch (wmicError) { console.error('获取Windows磁盘信息失败:', wmicError.message); } } // 从数据库获取所有用户的本地存储配额和使用情况 const users = UserDB.getAll(); let totalUserQuotas = 0; let totalUserUsed = 0; users.forEach(user => { // 只统计使用本地存储的用户(local_only 或 user_choice) const storagePermission = user.storage_permission || 'sftp_only'; if (storagePermission === 'local_only' || storagePermission === 'user_choice') { totalUserQuotas += user.local_storage_quota || 0; totalUserUsed += user.local_storage_used || 0; } }); res.json({ success: true, stats: { totalDisk, // 磁盘总容量 usedDisk, // 磁盘已使用 availableDisk, // 磁盘可用空间 totalUserQuotas, // 用户配额总和 totalUserUsed, // 用户实际使用总和 totalUsers: users.length // 用户总数 } }); // 获取所有用户 } catch (error) { console.error('获取存储统计失败:', error); res.status(500).json({ success: false, message: '获取存储统计失败: ' + error.message }); }}); app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { try { const users = UserDB.getAll(); res.json({ success: true, users: users.map(u => ({ id: u.id, username: u.username, email: u.email, is_admin: u.is_admin, is_active: u.is_active, is_verified: u.is_verified, is_banned: u.is_banned, has_ftp_config: u.has_ftp_config, created_at: u.created_at, // 新增:存储相关字段 storage_permission: u.storage_permission || 'sftp_only', current_storage_type: u.current_storage_type || 'sftp', local_storage_quota: u.local_storage_quota || 1073741824, local_storage_used: u.local_storage_used || 0 })) }); } catch (error) { console.error('获取用户列表失败:', error); res.status(500).json({ success: false, message: '获取用户列表失败: ' + error.message }); } }); // 获取系统日志 app.get('/api/admin/logs', authMiddleware, adminMiddleware, (req, res) => { try { const { page = 1, pageSize = 50, level, category, userId, startDate, endDate, keyword } = req.query; const result = SystemLogDB.query({ page: parseInt(page), pageSize: Math.min(parseInt(pageSize) || 50, 200), // 限制最大每页200条 level: level || null, category: category || null, userId: userId ? parseInt(userId) : null, startDate: startDate || null, endDate: endDate || null, keyword: keyword || null }); res.json({ success: true, ...result }); } catch (error) { console.error('获取系统日志失败:', error); res.status(500).json({ success: false, message: '获取系统日志失败: ' + error.message }); } }); // 获取日志统计 app.get('/api/admin/logs/stats', authMiddleware, adminMiddleware, (req, res) => { try { const categoryStats = SystemLogDB.getStatsByCategory(); const dateStats = SystemLogDB.getStatsByDate(7); res.json({ success: true, stats: { byCategory: categoryStats, byDate: dateStats } }); } catch (error) { console.error('获取日志统计失败:', error); res.status(500).json({ success: false, message: '获取日志统计失败: ' + error.message }); } }); // 清理旧日志 app.post('/api/admin/logs/cleanup', authMiddleware, adminMiddleware, (req, res) => { try { const { keepDays = 90 } = req.body; const days = Math.max(7, Math.min(parseInt(keepDays) || 90, 365)); // 最少保留7天,最多365天 const deletedCount = SystemLogDB.cleanup(days); // 记录清理操作 logSystem(req, 'logs_cleanup', `管理员清理了 ${deletedCount} 条日志(保留 ${days} 天)`, { deletedCount, keepDays: days }); res.json({ success: true, message: `已清理 ${deletedCount} 条日志`, deletedCount }); } catch (error) { console.error('清理日志失败:', error); res.status(500).json({ success: false, message: '清理日志失败: ' + error.message }); } }); // 封禁/解封用户 app.post('/api/admin/users/:id/ban', authMiddleware, adminMiddleware, (req, res) => { try { const { id } = req.params; const { banned } = req.body; UserDB.setBanStatus(id, banned); res.json({ success: true, message: banned ? '用户已封禁' : '用户已解封' }); } catch (error) { console.error('操作失败:', error); res.status(500).json({ success: false, message: '操作失败: ' + error.message }); } }); // 删除用户(级联删除文件和分享) app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req, res) => { try { const { id } = req.params; if (parseInt(id) === req.user.id) { return res.status(400).json({ success: false, message: '不能删除自己的账号' }); } // 获取用户信息 const user = UserDB.findById(id); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } const deletionLog = { userId: id, username: user.username, deletedFiles: [], deletedShares: 0, warnings: [] }; // 1. 删除本地存储文件(如果用户使用了本地存储) const storagePermission = user.storage_permission || 'sftp_only'; if (storagePermission === 'local_only' || storagePermission === 'user_choice') { const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); const userStorageDir = path.join(storageRoot, `user_${id}`); if (fs.existsSync(userStorageDir)) { try { // 递归删除用户目录 const deletedSize = getUserDirectorySize(userStorageDir); fs.rmSync(userStorageDir, { recursive: true, force: true }); deletionLog.deletedFiles.push({ type: 'local', path: userStorageDir, size: deletedSize }); console.log(`[删除用户] 已删除本地存储目录: ${userStorageDir}`); } catch (error) { console.error(`[删除用户] 删除本地存储失败:`, error); deletionLog.warnings.push(`删除本地存储失败: ${error.message}`); } } } // 2. SFTP存储文件 - 只记录警告,不实际删除(安全考虑) if (user.has_ftp_config && (storagePermission === 'sftp_only' || storagePermission === 'user_choice')) { deletionLog.warnings.push( `用户配置了SFTP存储 (${user.ftp_host}:${user.ftp_port}),SFTP文件未自动删除,请手动处理` ); } // 3. 删除用户的所有分享记录 try { const userShares = ShareDB.getUserShares(id); deletionLog.deletedShares = userShares.length; userShares.forEach(share => { ShareDB.delete(share.id); // 清除分享缓存 if (shareFileCache.has(share.share_code)) { shareFileCache.delete(share.share_code); } }); console.log(`[删除用户] 已删除 ${deletionLog.deletedShares} 条分享记录`); } catch (error) { console.error(`[删除用户] 删除分享记录失败:`, error); deletionLog.warnings.push(`删除分享记录失败: ${error.message}`); } // 4. 删除用户记录 UserDB.delete(id); // 构建响应消息 let message = `用户 ${user.username} 已删除`; if (deletionLog.deletedFiles.length > 0) { const totalSize = deletionLog.deletedFiles.reduce((sum, f) => sum + f.size, 0); message += `,已清理本地文件 ${formatFileSize(totalSize)}`; } if (deletionLog.deletedShares > 0) { message += `,已删除 ${deletionLog.deletedShares} 条分享`; } res.json({ success: true, message, details: deletionLog }); } catch (error) { console.error('删除用户失败:', error); res.status(500).json({ success: false, message: '删除用户失败: ' + error.message }); } }); // 辅助函数:计算目录大小 function getUserDirectorySize(dirPath) { let totalSize = 0; function calculateSize(currentPath) { try { const stats = fs.statSync(currentPath); if (stats.isDirectory()) { const files = fs.readdirSync(currentPath); files.forEach(file => { calculateSize(path.join(currentPath, file)); }); } else { totalSize += stats.size; } } catch (error) { console.error(`计算大小失败: ${currentPath}`, error); } } calculateSize(dirPath); return totalSize; } // 设置用户存储权限(管理员) app.post('/api/admin/users/:id/storage-permission', authMiddleware, adminMiddleware, [ body('storage_permission').isIn(['local_only', 'sftp_only', 'user_choice']).withMessage('无效的存储权限') ], (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { id } = req.params; const { storage_permission, local_storage_quota } = req.body; const updates = { storage_permission }; // 如果提供了配额,更新配额(单位:字节) if (local_storage_quota !== undefined) { updates.local_storage_quota = parseInt(local_storage_quota); } // 根据权限设置自动调整存储类型 const user = UserDB.findById(id); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } if (storage_permission === 'local_only') { updates.current_storage_type = 'local'; } else if (storage_permission === 'sftp_only') { // 只有配置了SFTP才切换到SFTP if (user.has_ftp_config) { updates.current_storage_type = 'sftp'; } } // user_choice 不自动切换,保持用户当前选择 UserDB.update(id, updates); res.json({ success: true, message: '存储权限已更新' }); } catch (error) { console.error('设置存储权限失败:', error); res.status(500).json({ success: false, message: '设置存储权限失败: ' + error.message }); } } ); // ===== 管理员文件审查功能 ===== // 查看用户文件列表(管理员,只读) app.get('/api/admin/users/:id/files', authMiddleware, adminMiddleware, async (req, res) => { const { id } = req.params; const dirPath = req.query.path || '/'; let sftp; try { const user = UserDB.findById(id); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } if (!user.has_ftp_config) { return res.status(400).json({ success: false, message: '该用户未配置SFTP服务器' }); } sftp = await connectToSFTP(user); const list = await sftp.list(dirPath); const formattedList = list.map(item => ({ name: item.name, type: item.type === 'd' ? 'directory' : 'file', size: item.size, sizeFormatted: formatFileSize(item.size), modifiedAt: new Date(item.modifyTime), isDirectory: item.type === 'd' })); formattedList.sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }); res.json({ success: true, username: user.username, path: dirPath, items: formattedList }); } catch (error) { console.error('管理员查看用户文件失败:', error); res.status(500).json({ success: false, message: '获取文件列表失败: ' + error.message }); } finally { if (sftp) await sftp.end(); } }); // 获取所有分享(管理员) app.get('/api/admin/shares', authMiddleware, adminMiddleware, (req, res) => { try { const shares = ShareDB.getAll(); res.json({ success: true, shares }); } catch (error) { console.error('获取分享列表失败:', error); res.status(500).json({ success: false, message: '获取分享列表失败: ' + error.message }); } }); // 删除分享(管理员) app.delete('/api/admin/shares/:id', authMiddleware, adminMiddleware, (req, res) => { try { // 先获取分享信息以获得share_code const share = ShareDB.findById(req.params.id); if (share) { // 删除缓存 if (shareFileCache.has(share.share_code)) { shareFileCache.delete(share.share_code); console.log(`[缓存清除] 分享码: ${share.share_code} (管理员操作)`); } // 删除数据库记录 ShareDB.delete(req.params.id); res.json({ success: true, message: '分享已删除' }); } else { res.status(404).json({ success: false, message: '分享不存在' }); } } catch (error) { console.error('删除分享失败:', error); res.status(500).json({ success: false, message: '删除分享失败: ' + error.message }); } }); // ============================================ // 管理员:上传工具管理 // ============================================ // 检查上传工具是否存在 app.get('/api/admin/check-upload-tool', authMiddleware, adminMiddleware, (req, res) => { try { const toolPath = path.join(__dirname, '..', 'upload-tool', 'dist', '玩玩云上传工具.exe'); if (fs.existsSync(toolPath)) { const stats = fs.statSync(toolPath); const sizeMB = (stats.size / (1024 * 1024)).toFixed(2); res.json({ success: true, exists: true, fileInfo: { path: toolPath, size: stats.size, sizeMB: sizeMB, modifiedAt: stats.mtime } }); } else { res.json({ success: true, exists: false, message: '上传工具不存在' }); } } catch (error) { console.error('检查上传工具失败:', error); res.status(500).json({ success: false, message: '检查失败: ' + error.message }); } }); // 上传工具文件 const uploadToolStorage = multer.diskStorage({ destination: (req, file, cb) => { const uploadDir = path.join(__dirname, '..', 'upload-tool', 'dist'); // 确保目录存在 if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } cb(null, uploadDir); }, filename: (req, file, cb) => { // 固定文件名 cb(null, '玩玩云上传工具.exe'); } }); const uploadTool = multer({ storage: uploadToolStorage, limits: { fileSize: 100 * 1024 * 1024 // 限制100MB }, fileFilter: (req, file, cb) => { // 只允许.exe文件 if (!file.originalname.toLowerCase().endsWith('.exe')) { return cb(new Error('只允许上传.exe文件')); } cb(null, true); } }); app.post('/api/admin/upload-tool', authMiddleware, adminMiddleware, uploadTool.single('file'), (req, res) => { try { if (!req.file) { return res.status(400).json({ success: false, message: '请选择要上传的文件' }); } const fileSizeMB = (req.file.size / (1024 * 1024)).toFixed(2); // 验证文件大小(至少20MB,上传工具通常很大) if (req.file.size < 20 * 1024 * 1024) { // 删除上传的文件 fs.unlinkSync(req.file.path); return res.status(400).json({ success: false, message: '文件大小异常,上传工具通常大于20MB' }); } console.log(`[上传工具] 管理员上传成功: ${fileSizeMB}MB`); res.json({ success: true, message: '上传工具已上传', fileInfo: { size: req.file.size, sizeMB: fileSizeMB } }); } catch (error) { console.error('上传工具失败:', error); res.status(500).json({ success: false, message: '上传失败: ' + error.message }); } }); // 分享页面访问路由 app.get("/s/:code", (req, res) => { const shareCode = req.params.code; // 使用相对路径重定向,浏览器会自动使用当前的协议和host const frontendUrl = `/share.html?code=${shareCode}`; console.log(`[分享] 重定向到: ${frontendUrl}`); res.redirect(frontendUrl); }); // 启动时清理旧临时文件 cleanupOldTempFiles(); // 启动服务器 app.listen(PORT, '0.0.0.0', () => { console.log(`\n========================================`); console.log(`玩玩云已启动`); console.log(`服务器地址: http://localhost:${PORT}`); console.log(`外网访问地址: http://0.0.0.0:${PORT}`); console.log(`========================================\n`); });