// 加载环境变量(必须在最开始) 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 multer = require('multer'); const nodemailer = require('nodemailer'); const path = require('path'); const fs = require('fs'); const zlib = require('zlib'); const { body, validationResult } = require('express-validator'); const archiver = require('archiver'); const crypto = require('crypto'); const { exec, execSync, execFile } = require('child_process'); const util = require('util'); const execAsync = util.promisify(exec); const execFileAsync = util.promisify(execFile); // ===== OSS 使用情况缓存 ===== // 缓存 OSS 空间统计结果,避免频繁遍历对象 const OSS_USAGE_CACHE = new Map(); const OSS_USAGE_CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存 /** * 获取缓存的 OSS 使用情况 * @param {number} userId - 用户ID * @returns {object|null} 缓存的数据或 null */ function getOssUsageCache(userId) { const cacheKey = `oss_usage_${userId}`; const cached = OSS_USAGE_CACHE.get(cacheKey); if (cached && Date.now() - cached.timestamp < OSS_USAGE_CACHE_TTL) { console.log(`[OSS缓存] 命中缓存: 用户 ${userId}`); return cached.data; } return null; } /** * 设置 OSS 使用情况缓存 * @param {number} userId - 用户ID * @param {object} data - 使用情况数据 */ function setOssUsageCache(userId, data) { const cacheKey = `oss_usage_${userId}`; OSS_USAGE_CACHE.set(cacheKey, { data, timestamp: Date.now() }); console.log(`[OSS缓存] 已缓存: 用户 ${userId}, 大小: ${data.totalSize}`); } /** * 清除用户的 OSS 使用情况缓存 * @param {number} userId - 用户ID */ function clearOssUsageCache(userId) { const cacheKey = `oss_usage_${userId}`; OSS_USAGE_CACHE.delete(cacheKey); console.log(`[OSS缓存] 已清除: 用户 ${userId}`); } const { db, UserDB, ShareDB, DirectLinkDB, SettingsDB, VerificationDB, PasswordResetTokenDB, DownloadTrafficReportDB, DownloadTrafficReservationDB, UploadSessionDB, DeviceSessionDB, FileHashIndexDB, DownloadTrafficIngestDB, SystemLogDB, TransactionDB, WalManager } = require('./database'); const StorageUsageCache = require('./utils/storage-cache'); const { JWT_SECRET, generateToken, generateRefreshToken, decodeAccessToken, decodeRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, isJwtSecretSecure } = require('./auth'); const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage'); const { encryptSecret, decryptSecret } = require('./utils/encryption'); 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'; const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB const MAX_DOWNLOAD_TRAFFIC_BYTES = 10 * 1024 * 1024 * 1024 * 1024; // 10TB const DOWNLOAD_POLICY_SWEEP_INTERVAL_MS = 30 * 60 * 1000; // 30分钟 const DOWNLOAD_RESERVATION_TTL_MS = Number(process.env.DOWNLOAD_RESERVATION_TTL_MS || (30 * 60 * 1000)); // 30分钟 const DOWNLOAD_LOG_RECONCILE_INTERVAL_MS = Number(process.env.DOWNLOAD_LOG_RECONCILE_INTERVAL_MS || (5 * 60 * 1000)); // 5分钟 const DOWNLOAD_LOG_MAX_FILES_PER_SWEEP = Number(process.env.DOWNLOAD_LOG_MAX_FILES_PER_SWEEP || 40); const DOWNLOAD_LOG_LIST_MAX_KEYS = Number(process.env.DOWNLOAD_LOG_LIST_MAX_KEYS || 200); const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max( 10, Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30)) ); const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.26'; const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || ''; const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SHA256 || '').trim().toLowerCase(); const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0)); const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || ''; const FRONTEND_ROOT_DIR = path.resolve(__dirname, '../frontend'); const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads'); const DESKTOP_INSTALLER_FILE_PATTERN = /^(wanwan-cloud-desktop|玩玩云)_.*_x64-setup\.exe$/i; const DESKTOP_INSTALLER_SHA256_PATTERN = /^[a-f0-9]{64}$/; const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时 const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB const GLOBAL_SEARCH_DEFAULT_LIMIT = Number(process.env.GLOBAL_SEARCH_DEFAULT_LIMIT || 80); const GLOBAL_SEARCH_MAX_LIMIT = Number(process.env.GLOBAL_SEARCH_MAX_LIMIT || 200); const GLOBAL_SEARCH_MAX_SCANNED_NODES = Number(process.env.GLOBAL_SEARCH_MAX_SCANNED_NODES || 4000); const SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/; const DOWNLOAD_SECURITY_DEFAULTS = Object.freeze({ enabled: true, same_ip_same_file: { enabled: true, limit_5m: 3, limit_1h: 10, limit_1d: 20 }, same_ip_same_user: { enabled: false, limit_1h: 80, limit_1d: 300 }, same_ip_same_file_min_interval: { enabled: false, seconds: 2 } }); const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase(); const SHOULD_USE_SECURE_COOKIES = COOKIE_SECURE_MODE === 'true' || (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false'); function normalizeVersion(rawVersion, fallback = '0.0.0') { const value = String(rawVersion || '').trim(); if (!value) return fallback; return value; } function compareLooseVersion(left, right) { const normalize = (value) => normalizeVersion(value, '0.0.0') .replace(/^v/i, '') .split('.') .map((part) => parseInt(part, 10)) .map((num) => (Number.isFinite(num) ? num : 0)); const a = normalize(left); const b = normalize(right); const size = Math.max(a.length, b.length); for (let i = 0; i < size; i += 1) { const av = a[i] || 0; const bv = b[i] || 0; if (av > bv) return 1; if (av < bv) return -1; } return 0; } function normalizeReleaseNotes(rawValue) { return String(rawValue || '') .replace(/\\r\\n/g, '\n') .replace(/\\n/g, '\n') .replace(/\\r/g, '\n') .trim(); } function normalizeSha256(rawValue) { const digest = String(rawValue || '').trim().toLowerCase(); return DESKTOP_INSTALLER_SHA256_PATTERN.test(digest) ? digest : ''; } function normalizeNonNegativeInteger(rawValue, fallback = 0) { const value = Number(rawValue); if (!Number.isFinite(value) || value < 0) return Math.max(0, Number(fallback) || 0); return Math.floor(value); } function isPathInside(parent, child) { const rel = path.relative(parent, child); return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); } function resolveDesktopInstallerLocalPath(installerUrl) { const raw = String(installerUrl || '').trim(); if (!raw) return null; try { const parsed = new URL(raw, 'http://local.invalid'); const pathname = decodeURIComponent(parsed.pathname || ''); const normalizedPath = pathname.replace(/^\/+/, ''); if (!normalizedPath) return null; const preferredPath = path.resolve(FRONTEND_ROOT_DIR, normalizedPath); if (isPathInside(FRONTEND_ROOT_DIR, preferredPath) && fs.existsSync(preferredPath) && fs.statSync(preferredPath).isFile()) { return preferredPath; } const fallbackPath = path.resolve(FRONTEND_ROOT_DIR, path.basename(normalizedPath)); if (isPathInside(FRONTEND_ROOT_DIR, fallbackPath) && fs.existsSync(fallbackPath) && fs.statSync(fallbackPath).isFile()) { return fallbackPath; } } catch (error) { // ignore malformed installer url } return null; } function computeFileSha256HexSync(filePath) { const hash = crypto.createHash('sha256'); const content = fs.readFileSync(filePath); hash.update(content); return hash.digest('hex'); } function getLocalDesktopInstallerMeta(installerUrl) { const localPath = resolveDesktopInstallerLocalPath(installerUrl); if (!localPath) return null; try { const stats = fs.statSync(localPath); if (!stats.isFile() || stats.size <= 0) return null; return { path: localPath, size: stats.size, sha256: normalizeSha256(computeFileSha256HexSync(localPath)) }; } catch (error) { console.warn('[桌面端更新] 读取本地安装包元数据失败:', error.message); return null; } } function getDesktopUpdateConfig() { const latestVersion = normalizeVersion( SettingsDB.get('desktop_latest_version') || DEFAULT_DESKTOP_VERSION, DEFAULT_DESKTOP_VERSION ); const installerUrl = String( SettingsDB.get('desktop_installer_url_win_x64') || SettingsDB.get('desktop_installer_url') || DEFAULT_DESKTOP_INSTALLER_URL || '' ).trim(); const releaseNotes = normalizeReleaseNotes( SettingsDB.get('desktop_release_notes') || DEFAULT_DESKTOP_RELEASE_NOTES || '' ); const mandatory = SettingsDB.get('desktop_force_update') === 'true'; let installerSha256 = normalizeSha256( SettingsDB.get('desktop_installer_sha256') || DEFAULT_DESKTOP_INSTALLER_SHA256 ); let packageSize = normalizeNonNegativeInteger( SettingsDB.get('desktop_installer_size'), DEFAULT_DESKTOP_INSTALLER_SIZE ); if (installerUrl && (!installerSha256 || packageSize <= 0)) { const localMeta = getLocalDesktopInstallerMeta(installerUrl); if (localMeta) { if (!installerSha256 && localMeta.sha256) { installerSha256 = localMeta.sha256; } if (packageSize <= 0 && localMeta.size > 0) { packageSize = localMeta.size; } } } return { latestVersion, installerUrl, installerSha256, packageSize, releaseNotes, mandatory }; } function resolveDesktopInstallerFileName(installerUrl) { const raw = String(installerUrl || '').trim(); if (!raw) return null; try { const parsed = new URL(raw, 'http://local.invalid'); const pathname = decodeURIComponent(parsed.pathname || ''); const fileName = path.basename(pathname); return fileName && fileName !== '.' && fileName !== '/' ? fileName : null; } catch (error) { const sanitized = raw.split('?')[0].split('#')[0]; const fileName = path.basename(sanitized); return fileName && fileName !== '.' && fileName !== '/' ? fileName : null; } } function cleanupDesktopInstallerPackages(installerUrl) { try { const keepFileName = resolveDesktopInstallerFileName(installerUrl); if (!keepFileName || !DESKTOP_INSTALLER_FILE_PATTERN.test(keepFileName)) { return { executed: false, removed: 0, kept: 0, reason: 'installer_url_not_local_package' }; } if (!fs.existsSync(DESKTOP_INSTALLERS_DIR)) { return { executed: false, removed: 0, kept: 0, reason: 'downloads_dir_missing' }; } const candidates = fs.readdirSync(DESKTOP_INSTALLERS_DIR) .filter((name) => DESKTOP_INSTALLER_FILE_PATTERN.test(name)); const keepFilePath = path.join(DESKTOP_INSTALLERS_DIR, keepFileName); if (!fs.existsSync(keepFilePath)) { return { executed: false, removed: 0, kept: candidates.length, reason: 'keep_package_missing' }; } let removed = 0; for (const fileName of candidates) { if (fileName === keepFileName) continue; fs.unlinkSync(path.join(DESKTOP_INSTALLERS_DIR, fileName)); removed += 1; console.log(`[桌面端更新] 已清理旧安装包: ${fileName}`); } return { executed: true, removed, kept: candidates.length - removed, keep_file: keepFileName }; } catch (error) { console.error('[桌面端更新] 清理旧安装包失败:', error); return { executed: false, removed: 0, kept: 0, reason: 'cleanup_failed', error: error.message }; } } function getResolvedStorageRoot() { const configuredRoot = process.env.STORAGE_ROOT; if (!configuredRoot) { return path.join(__dirname, 'storage'); } return path.isAbsolute(configuredRoot) ? configuredRoot : path.resolve(__dirname, configuredRoot); } function getLocalUserStorageDir(userId) { return path.join(getResolvedStorageRoot(), `user_${userId}`); } function syncLocalStorageUsageFromDisk(userId, currentUsed = 0) { const userStorageDir = getLocalUserStorageDir(userId); if (!fs.existsSync(userStorageDir)) { fs.mkdirSync(userStorageDir, { recursive: true, mode: 0o755 }); } const actualUsed = getUserDirectorySize(userStorageDir); const normalizedCurrentUsed = Number(currentUsed) || 0; if (actualUsed !== normalizedCurrentUsed) { UserDB.update(userId, { local_storage_used: actualUsed }); console.log(`[本地存储] 已校准用户 ${userId} 本地用量: ${normalizedCurrentUsed} -> ${actualUsed}`); } return actualUsed; } if (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'true') { console.warn('[安全警告] 生产环境建议设置 COOKIE_SECURE=true,以避免会话Cookie在HTTP下传输'); } // ===== 安全配置:公开域名白名单(防止 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 rawAllowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim()).filter(Boolean) : []; const wildcardOriginConfigured = rawAllowedOrigins.includes('*'); const allowAllOriginsForDev = wildcardOriginConfigured && process.env.NODE_ENV !== 'production'; const allowedOrigins = rawAllowedOrigins.filter(origin => origin !== '*'); if (wildcardOriginConfigured && process.env.NODE_ENV === 'production') { console.error('❌ 错误: 生产环境禁止 ALLOWED_ORIGINS=*。请改为明确的域名白名单。'); } const corsOptions = { credentials: true, origin: (origin, callback) => { // 生产环境禁止通配符(credentials=true 时会导致任意来源携带Cookie) if (wildcardOriginConfigured && process.env.NODE_ENV === 'production') { callback(new Error('生产环境不允许 CORS 通配符配置')); return; } // 生产环境必须配置白名单 if (allowedOrigins.length === 0 && !allowAllOriginsForDev && process.env.NODE_ENV === 'production') { console.error('❌ 错误: 生产环境必须配置 ALLOWED_ORIGINS 环境变量!'); callback(new Error('CORS未配置')); return; } // 开发环境如果没有配置,允许 localhost if (allowedOrigins.length === 0 && !allowAllOriginsForDev) { const devOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000']; if (!origin || devOrigins.some(o => origin.startsWith(o))) { callback(null, true); return; } } // 允许没有Origin头的同源请求和服务器请求 if (!origin) { callback(null, true); return; } if (allowedOrigins.includes(origin) || allowAllOriginsForDev) { callback(null, true); return; } // 拒绝不在白名单中的跨域请求 console.warn(`[CORS] 拒绝来自未授权来源的请求: ${origin}`); callback(new Error('CORS策略不允许来自该来源的访问')); } }; function applySecurityHeaders(req, res) { // 防止点击劫持 res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // 防止MIME类型嗅探 res.setHeader('X-Content-Type-Options', 'nosniff'); // XSS保护 res.setHeader('X-XSS-Protection', '1; mode=block'); // HTTPS严格传输安全(仅在可信的 HTTPS 连接时设置) // req.secure 基于 trust proxy 配置,不会被不可信代理伪造 if ((req && req.secure) || (!req && (ENFORCE_HTTPS || SHOULD_USE_SECURE_COOKIES))) { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } // 内容安全策略 res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';"); // 隐藏X-Powered-By res.removeHeader('X-Powered-By'); } // 中间件 app.use(cors(corsOptions)); // 静态文件服务 - 提供前端页面 const frontendPath = path.join(__dirname, '../frontend'); console.log('[静态文件] 前端目录:', frontendPath); app.use(express.static(frontendPath, { setHeaders: (res) => { applySecurityHeaders(null, res); } })); app.use(express.json({ limit: '10mb' })); // 限制请求体大小防止DoS app.use(cookieParser()); // ===== CSRF 防护 ===== // 基于 Double Submit Cookie 模式的 CSRF 保护 // 对于修改数据的请求(POST/PUT/DELETE),验证请求头中的 X-CSRF-Token 与 Cookie 中的值匹配 // 生成 CSRF Token function generateCsrfToken() { return crypto.randomBytes(32).toString('hex'); } // CSRF Token Cookie 名称 const CSRF_COOKIE_NAME = 'csrf_token'; // 设置 CSRF Cookie 的中间件 app.use((req, res, next) => { // 如果没有 CSRF cookie,则生成一个 if (!req.cookies[CSRF_COOKIE_NAME]) { const csrfToken = generateCsrfToken(); const isSecureEnv = SHOULD_USE_SECURE_COOKIES; res.cookie(CSRF_COOKIE_NAME, csrfToken, { httpOnly: false, // 前端需要读取此值 secure: isSecureEnv, sameSite: isSecureEnv ? 'strict' : 'lax', maxAge: 24 * 60 * 60 * 1000 // 24小时 }); } next(); }); // CSRF 验证中间件(仅用于需要保护的路由) function csrfProtection(req, res, next) { // GET、HEAD、OPTIONS 请求不需要 CSRF 保护 if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { return next(); } // 仅对基于 Cookie 的浏览器会话启用 CSRF(Bearer API 客户端不强制) const hasCookieAuth = !!( req.cookies?.token || req.cookies?.refreshToken ); if (!hasCookieAuth) { return next(); } const cookieToken = req.cookies[CSRF_COOKIE_NAME]; const headerToken = req.headers['x-csrf-token']; if (!cookieToken || !headerToken || cookieToken !== headerToken) { console.warn(`[CSRF] 验证失败: path=${req.path}, cookie=${!!cookieToken}, header=${!!headerToken}`); return res.status(403).json({ success: false, message: 'CSRF 验证失败,请刷新页面后重试' }); } next(); } // CSRF 开关策略: // - 显式配置 ENABLE_CSRF 时按配置值 // - 未配置时,生产环境默认开启 const ENABLE_CSRF = process.env.ENABLE_CSRF !== undefined ? process.env.ENABLE_CSRF === 'true' : process.env.NODE_ENV === 'production'; if (ENABLE_CSRF) { console.log('[安全] CSRF 保护已启用'); app.use(csrfProtection); } // 强制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 = SHOULD_USE_SECURE_COOKIES; const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码 // 安全检查:Session密钥配置 const SESSION_SECRET = process.env.SESSION_SECRET || 'your-session-secret-change-in-production'; const DEFAULT_SESSION_SECRETS = [ 'your-session-secret-change-in-production', 'session-secret-change-me' ]; if (DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET)) { const sessionWarnMsg = ` [安全警告] SESSION_SECRET 使用默认值,存在安全风险! 请在 .env 文件中设置随机生成的 SESSION_SECRET 生成命令: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" `; if (process.env.NODE_ENV === 'production') { console.error(sessionWarnMsg); throw new Error('生产环境必须设置 SESSION_SECRET!'); } else { console.warn(sessionWarnMsg); } } app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false, // 仅在写入 session 时创建,减少无效会话 name: 'captcha.sid', // 自定义session cookie名称 cookie: { secure: isSecureCookie, httpOnly: true, sameSite: sameSiteMode, maxAge: 10 * 60 * 1000 // 10分钟 } })); // 安全响应头中间件 app.use((req, res, next) => { applySecurityHeaders(req, res); next(); }); /** * XSS过滤函数 - 过滤用户输入中的潜在XSS攻击代码 * 注意:不转义 / 因为它是文件路径的合法字符 * @param {string} str - 需要过滤的输入字符串 * @returns {string} 过滤后的安全字符串 */ function sanitizeInput(str) { if (typeof str !== 'string') return str; // 1. 基础HTML实体转义(不包括 / 因为是路径分隔符,不包括 ` 因为是合法文件名字符) let sanitized = str .replace(/[&<>"']/g, (char) => { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return map[char]; }); // 2. 过滤危险协议(javascript:, data:, vbscript:等) sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, ''); // 3. 移除空字节 sanitized = sanitized.replace(/\x00/g, ''); return sanitized; } /** * 将 HTML 实体解码为原始字符 * 用于处理经过XSS过滤后的文件名/路径字段,恢复原始字符 * 支持嵌套实体的递归解码(如 &#x60; -> ` -> `) * @param {string} str - 包含HTML实体的字符串 * @returns {string} 解码后的原始字符串 */ function decodeHtmlEntities(str) { if (typeof str !== 'string') return str; // 支持常见实体和数字实体(含多次嵌套,如 &#x60;) const entityMap = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#x27': "'", '#x2F': '/', '#x60': '`' }; const decodeOnce = (input) => input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => { if (code[0] === '#') { const isHex = code[1]?.toLowerCase() === 'x'; const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10); if (!Number.isNaN(num)) { return String.fromCharCode(num); } return match; } const mapped = entityMap[code]; return mapped !== undefined ? mapped : match; }); let output = str; let decoded = decodeOnce(output); // 处理嵌套实体(如 &#x60;),直到稳定 while (decoded !== output) { output = decoded; decoded = decodeOnce(output); } return output; } // HTML转义(用于模板输出) function escapeHtml(str) { if (typeof str !== 'string') return str; return str.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); } // 规范化并校验HTTP直链前缀,只允许http/https function sanitizeHttpBaseUrl(raw) { if (!raw) return null; 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(); const nameLower = filename.toLowerCase(); // 检查危险扩展名 if (DANGEROUS_EXTENSIONS.includes(ext)) { return false; } // 特殊处理:检查以危险名称开头的文件(如 .htaccess, .htpasswd) // 因为 path.extname('.htaccess') 返回空字符串 const dangerousFilenames = ['.htaccess', '.htpasswd']; if (dangerousFilenames.includes(nameLower)) { return false; } // 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行) for (const dangerExt of DANGEROUS_EXTENSIONS) { if (nameLower.includes(dangerExt + '.')) { return false; } } return true; } // 应用XSS过滤到所有POST/PUT请求的body app.use((req, res, next) => { if ((req.method === 'POST' || req.method === 'PUT') && req.body) { // 递归过滤所有字符串字段 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 normalizeOssQuota(rawQuota) { const parsedQuota = Number(rawQuota); if (!Number.isFinite(parsedQuota) || parsedQuota <= 0) { return DEFAULT_OSS_STORAGE_QUOTA_BYTES; } return parsedQuota; } function normalizeDownloadTrafficQuota(rawQuota) { const parsedQuota = Number(rawQuota); if (!Number.isFinite(parsedQuota)) { return 0; // 0 表示禁止下载 } if (parsedQuota < 0) { return -1; // -1 表示不限流量 } return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota)); } function normalizeDownloadTrafficUsed(rawUsed, quota = 0) { const parsedUsed = Number(rawUsed); const normalizedUsed = Number.isFinite(parsedUsed) && parsedUsed > 0 ? Math.floor(parsedUsed) : 0; if (quota >= 0) { return Math.min(normalizedUsed, quota); } return normalizedUsed; } function getDownloadTrafficState(user) { const quota = normalizeDownloadTrafficQuota(user?.download_traffic_quota); const used = normalizeDownloadTrafficUsed(user?.download_traffic_used, quota); const isUnlimited = quota < 0; return { quota, used, isUnlimited, remaining: isUnlimited ? Number.POSITIVE_INFINITY : Math.max(0, quota - used) }; } function getBusyDownloadMessage() { return '当前网络繁忙,请稍后再试'; } function parseBooleanLike(rawValue, fallback = false) { if (rawValue === null || rawValue === undefined || rawValue === '') { return fallback; } if (typeof rawValue === 'boolean') { return rawValue; } const normalized = String(rawValue).trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalized)) { return true; } if (['0', 'false', 'no', 'off'].includes(normalized)) { return false; } return fallback; } function clampIntegerSetting(rawValue, fallback, min, max) { const value = Number(rawValue); if (!Number.isFinite(value)) { return fallback; } return Math.min(max, Math.max(min, Math.floor(value))); } function getDownloadSecuritySettings() { const defaults = DOWNLOAD_SECURITY_DEFAULTS; return { enabled: parseBooleanLike(SettingsDB.get('download_security_enabled'), defaults.enabled), same_ip_same_file: { enabled: parseBooleanLike( SettingsDB.get('download_security_same_file_enabled'), defaults.same_ip_same_file.enabled ), limit_5m: clampIntegerSetting( SettingsDB.get('download_security_same_file_limit_5m'), defaults.same_ip_same_file.limit_5m, 1, 20000 ), limit_1h: clampIntegerSetting( SettingsDB.get('download_security_same_file_limit_1h'), defaults.same_ip_same_file.limit_1h, 1, 50000 ), limit_1d: clampIntegerSetting( SettingsDB.get('download_security_same_file_limit_1d'), defaults.same_ip_same_file.limit_1d, 1, 200000 ) }, same_ip_same_user: { enabled: parseBooleanLike( SettingsDB.get('download_security_same_user_enabled'), defaults.same_ip_same_user.enabled ), limit_1h: clampIntegerSetting( SettingsDB.get('download_security_same_user_limit_1h'), defaults.same_ip_same_user.limit_1h, 1, 50000 ), limit_1d: clampIntegerSetting( SettingsDB.get('download_security_same_user_limit_1d'), defaults.same_ip_same_user.limit_1d, 1, 200000 ) }, same_ip_same_file_min_interval: { enabled: parseBooleanLike( SettingsDB.get('download_security_same_file_min_interval_enabled'), defaults.same_ip_same_file_min_interval.enabled ), seconds: clampIntegerSetting( SettingsDB.get('download_security_same_file_min_interval_seconds'), defaults.same_ip_same_file_min_interval.seconds, 1, 3600 ) } }; } function normalizeDownloadSecuritySettingsPayload(rawPayload, fallbackSettings = DOWNLOAD_SECURITY_DEFAULTS) { const source = rawPayload && typeof rawPayload === 'object' ? rawPayload : {}; const base = fallbackSettings && typeof fallbackSettings === 'object' ? fallbackSettings : DOWNLOAD_SECURITY_DEFAULTS; const sameFileRaw = source.same_ip_same_file && typeof source.same_ip_same_file === 'object' ? source.same_ip_same_file : {}; const sameUserRaw = source.same_ip_same_user && typeof source.same_ip_same_user === 'object' ? source.same_ip_same_user : {}; const minIntervalRaw = source.same_ip_same_file_min_interval && typeof source.same_ip_same_file_min_interval === 'object' ? source.same_ip_same_file_min_interval : {}; return { enabled: parseBooleanLike(source.enabled, !!base.enabled), same_ip_same_file: { enabled: parseBooleanLike(sameFileRaw.enabled, !!base.same_ip_same_file?.enabled), limit_5m: clampIntegerSetting( sameFileRaw.limit_5m, Number(base.same_ip_same_file?.limit_5m || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_file.limit_5m), 1, 20000 ), limit_1h: clampIntegerSetting( sameFileRaw.limit_1h, Number(base.same_ip_same_file?.limit_1h || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_file.limit_1h), 1, 50000 ), limit_1d: clampIntegerSetting( sameFileRaw.limit_1d, Number(base.same_ip_same_file?.limit_1d || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_file.limit_1d), 1, 200000 ) }, same_ip_same_user: { enabled: parseBooleanLike(sameUserRaw.enabled, !!base.same_ip_same_user?.enabled), limit_1h: clampIntegerSetting( sameUserRaw.limit_1h, Number(base.same_ip_same_user?.limit_1h || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_user.limit_1h), 1, 50000 ), limit_1d: clampIntegerSetting( sameUserRaw.limit_1d, Number(base.same_ip_same_user?.limit_1d || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_user.limit_1d), 1, 200000 ) }, same_ip_same_file_min_interval: { enabled: parseBooleanLike( minIntervalRaw.enabled, !!base.same_ip_same_file_min_interval?.enabled ), seconds: clampIntegerSetting( minIntervalRaw.seconds, Number(base.same_ip_same_file_min_interval?.seconds || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_file_min_interval.seconds), 1, 3600 ) } }; } function saveDownloadSecuritySettings(settings) { SettingsDB.set('download_security_enabled', settings.enabled ? 'true' : 'false'); SettingsDB.set('download_security_same_file_enabled', settings.same_ip_same_file.enabled ? 'true' : 'false'); SettingsDB.set('download_security_same_file_limit_5m', String(settings.same_ip_same_file.limit_5m)); SettingsDB.set('download_security_same_file_limit_1h', String(settings.same_ip_same_file.limit_1h)); SettingsDB.set('download_security_same_file_limit_1d', String(settings.same_ip_same_file.limit_1d)); SettingsDB.set('download_security_same_user_enabled', settings.same_ip_same_user.enabled ? 'true' : 'false'); SettingsDB.set('download_security_same_user_limit_1h', String(settings.same_ip_same_user.limit_1h)); SettingsDB.set('download_security_same_user_limit_1d', String(settings.same_ip_same_user.limit_1d)); SettingsDB.set( 'download_security_same_file_min_interval_enabled', settings.same_ip_same_file_min_interval.enabled ? 'true' : 'false' ); SettingsDB.set( 'download_security_same_file_min_interval_seconds', String(settings.same_ip_same_file_min_interval.seconds) ); } class DownloadSecurityCounter { constructor() { this.windowEvents = new Map(); this.lastSeenAt = new Map(); this.cleanupInterval = setInterval(() => { this.cleanup(); }, 10 * 60 * 1000); } getRecentEvents(key, maxWindowMs, now = Date.now()) { const safeWindow = Math.max(60 * 1000, Number(maxWindowMs) || (24 * 60 * 60 * 1000)); const cutoff = now - safeWindow; const list = this.windowEvents.get(key) || []; if (list.length === 0) { return []; } let firstValidIndex = 0; while (firstValidIndex < list.length && list[firstValidIndex] <= cutoff) { firstValidIndex += 1; } if (firstValidIndex > 0) { list.splice(0, firstValidIndex); if (list.length === 0) { this.windowEvents.delete(key); } else { this.windowEvents.set(key, list); } } return this.windowEvents.get(key) || []; } wouldExceedWindowLimit(key, rules = [], now = Date.now()) { if (!key || !Array.isArray(rules) || rules.length === 0) { return { blocked: false }; } const normalizedRules = rules .map((rule) => ({ code: String(rule?.code || 'window_limit'), windowMs: Math.max(1000, Math.floor(Number(rule?.windowMs) || 0)), limit: Math.max(1, Math.floor(Number(rule?.limit) || 0)) })) .filter(rule => rule.windowMs > 0 && rule.limit > 0); if (normalizedRules.length === 0) { return { blocked: false }; } const maxWindowMs = normalizedRules.reduce((max, item) => Math.max(max, item.windowMs), 0); const events = this.getRecentEvents(key, maxWindowMs, now); for (const rule of normalizedRules) { const threshold = now - rule.windowMs; let count = 0; let limitHitTimestamp = null; for (let i = events.length - 1; i >= 0; i -= 1) { const ts = events[i]; if (ts <= threshold) { break; } count += 1; if (count === rule.limit) { limitHitTimestamp = ts; break; } } if (count >= rule.limit) { const retryAfterMs = limitHitTimestamp ? Math.max(1000, (limitHitTimestamp + rule.windowMs) - now + 120) : rule.windowMs; return { blocked: true, code: rule.code, retryAfterSec: Math.ceil(retryAfterMs / 1000) }; } } return { blocked: false }; } recordWindowEvent(key, now = Date.now()) { if (!key) { return; } const list = this.windowEvents.get(key); if (list) { list.push(now); this.windowEvents.set(key, list); return; } this.windowEvents.set(key, [now]); } wouldViolateMinInterval(key, minIntervalMs, now = Date.now()) { const safeIntervalMs = Math.max(1000, Math.floor(Number(minIntervalMs) || 0)); if (!key || safeIntervalMs <= 0) { return { blocked: false }; } const lastSeen = Number(this.lastSeenAt.get(key) || 0); if (lastSeen > 0 && now - lastSeen < safeIntervalMs) { return { blocked: true, code: 'same_ip_same_file_min_interval', retryAfterSec: Math.ceil((safeIntervalMs - (now - lastSeen)) / 1000) }; } return { blocked: false }; } recordMinIntervalEvent(key, now = Date.now()) { if (!key) { return; } this.lastSeenAt.set(key, now); } cleanup() { const now = Date.now(); const keepWindowMs = 2 * 24 * 60 * 60 * 1000; const staleWindowBefore = now - keepWindowMs; for (const [key, list] of this.windowEvents.entries()) { if (!Array.isArray(list) || list.length === 0) { this.windowEvents.delete(key); continue; } let firstValidIndex = 0; while (firstValidIndex < list.length && list[firstValidIndex] <= staleWindowBefore) { firstValidIndex += 1; } if (firstValidIndex > 0) { list.splice(0, firstValidIndex); } if (list.length === 0) { this.windowEvents.delete(key); } else { this.windowEvents.set(key, list); } } for (const [key, value] of this.lastSeenAt.entries()) { if (!Number.isFinite(value) || value <= staleWindowBefore) { this.lastSeenAt.delete(key); } } } } const downloadSecurityCounter = new DownloadSecurityCounter(); function evaluateDownloadSecurityPolicy(req, { ownerUserId, filePath, source = 'download' } = {}) { const settings = getDownloadSecuritySettings(); if (!settings.enabled) { return { allowed: true }; } const ownerId = Math.floor(Number(ownerUserId) || 0); const normalizedPath = normalizeVirtualPath(filePath || ''); if (ownerId <= 0 || !normalizedPath) { return { allowed: true }; } const clientIp = String(req?.ip || req?.socket?.remoteAddress || '').trim(); if (!clientIp) { return { allowed: true }; } const now = Date.now(); const eventKeys = []; const minIntervalKeys = []; if (settings.same_ip_same_file.enabled) { const sameFileKey = `download_sec:file:${clientIp}:${ownerId}:${normalizedPath}`; const sameFileRules = [ { code: 'same_ip_same_file_5m', windowMs: 5 * 60 * 1000, limit: settings.same_ip_same_file.limit_5m }, { code: 'same_ip_same_file_1h', windowMs: 60 * 60 * 1000, limit: settings.same_ip_same_file.limit_1h }, { code: 'same_ip_same_file_1d', windowMs: 24 * 60 * 60 * 1000, limit: settings.same_ip_same_file.limit_1d } ]; const sameFileCheck = downloadSecurityCounter.wouldExceedWindowLimit(sameFileKey, sameFileRules, now); if (sameFileCheck.blocked) { return { allowed: false, code: sameFileCheck.code, retryAfterSec: sameFileCheck.retryAfterSec }; } eventKeys.push(sameFileKey); if (settings.same_ip_same_file_min_interval.enabled) { const minIntervalKey = `download_sec:min:${clientIp}:${ownerId}:${normalizedPath}`; const minIntervalCheck = downloadSecurityCounter.wouldViolateMinInterval( minIntervalKey, settings.same_ip_same_file_min_interval.seconds * 1000, now ); if (minIntervalCheck.blocked) { return { allowed: false, code: minIntervalCheck.code, retryAfterSec: minIntervalCheck.retryAfterSec }; } minIntervalKeys.push(minIntervalKey); } } if (settings.same_ip_same_user.enabled) { const sameUserKey = `download_sec:user:${clientIp}:${ownerId}`; const sameUserRules = [ { code: 'same_ip_same_user_1h', windowMs: 60 * 60 * 1000, limit: settings.same_ip_same_user.limit_1h }, { code: 'same_ip_same_user_1d', windowMs: 24 * 60 * 60 * 1000, limit: settings.same_ip_same_user.limit_1d } ]; const sameUserCheck = downloadSecurityCounter.wouldExceedWindowLimit(sameUserKey, sameUserRules, now); if (sameUserCheck.blocked) { return { allowed: false, code: sameUserCheck.code, retryAfterSec: sameUserCheck.retryAfterSec }; } eventKeys.push(sameUserKey); } for (const key of eventKeys) { downloadSecurityCounter.recordWindowEvent(key, now); } for (const key of minIntervalKeys) { downloadSecurityCounter.recordMinIntervalEvent(key, now); } return { allowed: true, source }; } function handleDownloadSecurityBlock(req, res, blockResult, options = {}) { const statusCode = Number(options.statusCode || 503); const plainText = options.plainText === true; const ownerUserId = Number(options.ownerUserId || 0); const filePath = typeof options.filePath === 'string' ? options.filePath : ''; const source = options.source || 'download'; logSecurity( req, 'download_security_block', `下载请求触发限速策略(${source})`, { source, policy_code: blockResult?.code || 'download_security_blocked', retry_after_sec: Number(blockResult?.retryAfterSec || 0), owner_user_id: ownerUserId || null, file_path: filePath || null }, 'warn' ); if (plainText) { return sendPlainTextError(res, statusCode, getBusyDownloadMessage()); } return res.status(statusCode).json({ success: false, message: getBusyDownloadMessage(), security_blocked: true }); } function sendPlainTextError(res, statusCode, message) { return res.status(statusCode).type('text/plain; charset=utf-8').send(message); } function parseDateTimeValue(value) { if (!value || typeof value !== 'string') { return null; } const directDate = new Date(value); if (!Number.isNaN(directDate.getTime())) { return directDate; } // 兼容 SQLite 常见 DATETIME 格式: YYYY-MM-DD HH:mm:ss const normalized = value.replace(' ', 'T'); const normalizedDate = new Date(normalized); if (!Number.isNaN(normalizedDate.getTime())) { return normalizedDate; } return null; } function formatDateTimeForSqlite(date = new Date()) { const target = date instanceof Date ? date : new Date(date); const year = target.getFullYear(); const month = String(target.getMonth() + 1).padStart(2, '0'); const day = String(target.getDate()).padStart(2, '0'); const hours = String(target.getHours()).padStart(2, '0'); const minutes = String(target.getMinutes()).padStart(2, '0'); const seconds = String(target.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } function getDateKeyFromDate(date = new Date()) { const target = date instanceof Date ? date : new Date(date); if (Number.isNaN(target.getTime())) { return null; } const year = target.getFullYear(); const month = String(target.getMonth() + 1).padStart(2, '0'); const day = String(target.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function getRecentDateKeys(days = 30, now = new Date()) { const safeDays = Math.max(1, Math.floor(Number(days) || 30)); const keys = []; for (let i = safeDays - 1; i >= 0; i -= 1) { const date = new Date(now.getTime()); date.setDate(date.getDate() - i); const key = getDateKeyFromDate(date); if (key) { keys.push(key); } } return keys; } function getNextDownloadResetTime(lastResetAt, resetCycle) { const baseDate = parseDateTimeValue(lastResetAt); if (!baseDate) { return null; } const next = new Date(baseDate.getTime()); if (resetCycle === 'daily') { next.setDate(next.getDate() + 1); return next; } if (resetCycle === 'weekly') { next.setDate(next.getDate() + 7); return next; } if (resetCycle === 'monthly') { next.setMonth(next.getMonth() + 1); return next; } return null; } function resolveDownloadTrafficPolicyUpdates(user, now = new Date()) { if (!user) { return { updates: {}, hasUpdates: false, expired: false, resetApplied: false }; } const updates = {}; let hasUpdates = false; let expired = false; let resetApplied = false; const normalizedQuota = normalizeDownloadTrafficQuota(user.download_traffic_quota); const normalizedUsed = normalizeDownloadTrafficUsed(user.download_traffic_used, normalizedQuota); if (normalizedQuota !== Number(user.download_traffic_quota || 0)) { updates.download_traffic_quota = normalizedQuota; hasUpdates = true; } if (normalizedUsed !== Number(user.download_traffic_used || 0)) { updates.download_traffic_used = normalizedUsed; hasUpdates = true; } const resetCycle = ['none', 'daily', 'weekly', 'monthly'].includes(user.download_traffic_reset_cycle) ? user.download_traffic_reset_cycle : 'none'; if (resetCycle !== (user.download_traffic_reset_cycle || 'none')) { updates.download_traffic_reset_cycle = resetCycle; hasUpdates = true; } const expiresAt = parseDateTimeValue(user.download_traffic_quota_expires_at); if (normalizedQuota <= 0 && user.download_traffic_quota_expires_at) { updates.download_traffic_quota_expires_at = null; hasUpdates = true; } else if (normalizedQuota > 0 && expiresAt && now >= expiresAt) { // 到期后自动恢复为不限并重置已用量 updates.download_traffic_quota = 0; updates.download_traffic_used = 0; updates.download_traffic_quota_expires_at = null; updates.download_traffic_reset_cycle = 'none'; updates.download_traffic_last_reset_at = null; hasUpdates = true; expired = true; } if (!expired && resetCycle !== 'none') { const lastResetAt = user.download_traffic_last_reset_at; if (!lastResetAt) { updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now); hasUpdates = true; } else { const nextResetAt = getNextDownloadResetTime(lastResetAt, resetCycle); if (nextResetAt && now >= nextResetAt) { updates.download_traffic_used = 0; updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now); hasUpdates = true; resetApplied = true; } } } else if (resetCycle === 'none' && user.download_traffic_last_reset_at) { updates.download_traffic_last_reset_at = null; hasUpdates = true; } return { updates, hasUpdates, expired, resetApplied }; } const enforceDownloadTrafficPolicyTransaction = db.transaction((userId, trigger = 'runtime') => { let user = UserDB.findById(userId); if (!user) { return null; } const now = new Date(); const policyResult = resolveDownloadTrafficPolicyUpdates(user, now); if (policyResult.hasUpdates) { UserDB.update(userId, policyResult.updates); user = UserDB.findById(userId); if (policyResult.expired) { console.log(`[下载流量策略] 用户 ${userId} 配额已到期,已自动恢复为不限 (trigger=${trigger})`); } else if (policyResult.resetApplied) { console.log(`[下载流量策略] 用户 ${userId} 流量已按周期重置 (trigger=${trigger})`); } } return { user, expired: policyResult.expired, resetApplied: policyResult.resetApplied }; }); function enforceDownloadTrafficPolicy(userId, trigger = 'runtime') { if (!userId) { return null; } return enforceDownloadTrafficPolicyTransaction(userId, trigger); } const applyDownloadTrafficUsageTransaction = db.transaction((userId, bytesToAdd) => { const policyState = enforceDownloadTrafficPolicyTransaction(userId, 'usage'); const user = policyState?.user || UserDB.findById(userId); if (!user) { return null; } const trafficState = getDownloadTrafficState(user); if (bytesToAdd <= 0) { return { quota: trafficState.quota, usedBefore: trafficState.used, usedAfter: trafficState.used, added: 0 }; } const nextUsed = trafficState.isUnlimited ? trafficState.used + bytesToAdd : Math.min(trafficState.quota, trafficState.used + bytesToAdd); UserDB.update(userId, { download_traffic_used: nextUsed }); const addedBytes = Math.max(0, nextUsed - trafficState.used); if (addedBytes > 0) { DownloadTrafficReportDB.addUsage(userId, addedBytes, 1, new Date()); } return { quota: trafficState.quota, usedBefore: trafficState.used, usedAfter: nextUsed, added: addedBytes }; }); function applyDownloadTrafficUsage(userId, bytesToAdd) { const parsedBytes = Number(bytesToAdd); if (!Number.isFinite(parsedBytes) || parsedBytes <= 0) { return null; } return applyDownloadTrafficUsageTransaction(userId, Math.floor(parsedBytes)); } const reserveDirectDownloadTrafficTransaction = db.transaction((userId, bytesToReserve, reservationOptions = {}) => { const policyState = enforceDownloadTrafficPolicyTransaction(userId, 'direct_download_reserve'); const user = policyState?.user || UserDB.findById(userId); if (!user) { return { ok: false, reason: 'user_not_found' }; } const reserveBytes = Math.floor(Number(bytesToReserve)); if (!Number.isFinite(reserveBytes) || reserveBytes <= 0) { return { ok: true, reserved: 0, isUnlimited: true }; } const trafficState = getDownloadTrafficState(user); if (trafficState.isUnlimited) { return { ok: true, reserved: 0, isUnlimited: true }; } const pendingReserved = Number(DownloadTrafficReservationDB.getPendingReservedBytes(userId) || 0); const available = Math.max(0, trafficState.remaining - pendingReserved); if (reserveBytes > available) { return { ok: false, reason: 'insufficient_available', quota: trafficState.quota, usedBefore: trafficState.used, pendingReserved, remaining: trafficState.remaining, available }; } const ttlMs = Math.max(60 * 1000, Number(reservationOptions.ttlMs || DOWNLOAD_RESERVATION_TTL_MS)); const expiresAt = new Date(Date.now() + ttlMs); const reservation = DownloadTrafficReservationDB.create({ userId, source: reservationOptions.source || 'direct', objectKey: reservationOptions.objectKey || null, reservedBytes: reserveBytes, expiresAt: formatDateTimeForSqlite(expiresAt) }); if (!reservation) { return { ok: false, reason: 'create_failed' }; } return { ok: true, isUnlimited: false, quota: trafficState.quota, usedBefore: trafficState.used, pendingReserved, availableBefore: available, reserved: reserveBytes, reservation }; }); function reserveDirectDownloadTraffic(userId, bytesToReserve, reservationOptions = {}) { const parsedBytes = Number(bytesToReserve); if (!Number.isFinite(parsedBytes) || parsedBytes <= 0) { return { ok: true, reserved: 0, isUnlimited: true }; } return reserveDirectDownloadTrafficTransaction(userId, Math.floor(parsedBytes), reservationOptions); } const applyConfirmedDownloadTrafficFromLogTransaction = db.transaction((userId, confirmedBytes, downloadCount = 0, eventDate = new Date()) => { const policyState = enforceDownloadTrafficPolicyTransaction(userId, 'log_confirm'); const user = policyState?.user || UserDB.findById(userId); if (!user) { return null; } const bytes = Math.floor(Number(confirmedBytes)); const count = Math.floor(Number(downloadCount)); if (!Number.isFinite(bytes) || bytes <= 0) { return { userId, confirmed: 0, added: 0, consumedReserved: 0 }; } const trafficState = getDownloadTrafficState(user); const nextUsed = trafficState.isUnlimited ? (trafficState.used + bytes) : Math.min(trafficState.quota, trafficState.used + bytes); UserDB.update(userId, { download_traffic_used: nextUsed }); const addedBytes = Math.max(0, nextUsed - trafficState.used); if (addedBytes > 0) { DownloadTrafficReportDB.addUsage(userId, addedBytes, count > 0 ? count : 1, eventDate); } const consumeResult = DownloadTrafficReservationDB.consumePendingBytes(userId, bytes); return { userId, confirmed: bytes, added: addedBytes, usedBefore: trafficState.used, usedAfter: nextUsed, consumedReserved: Number(consumeResult?.consumed || 0), finalizedReservations: Number(consumeResult?.finalizedCount || 0) }; }); function applyConfirmedDownloadTrafficFromLog(userId, confirmedBytes, downloadCount = 0, eventDate = new Date()) { const parsed = Number(confirmedBytes); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return applyConfirmedDownloadTrafficFromLogTransaction( userId, Math.floor(parsed), Math.floor(Number(downloadCount || 0)), eventDate ); } function runDownloadTrafficPolicySweep(trigger = 'scheduled') { try { const users = UserDB.getAll(); let affected = 0; for (const user of users) { if ( !user.download_traffic_quota_expires_at && (!user.download_traffic_reset_cycle || user.download_traffic_reset_cycle === 'none') ) { continue; } const result = enforceDownloadTrafficPolicy(user.id, trigger); if (result?.expired || result?.resetApplied) { affected += 1; } } if (affected > 0) { console.log(`[下载流量策略] 扫描完成: ${affected} 个用户发生变化 (trigger=${trigger})`); } } catch (error) { console.error(`[下载流量策略] 扫描失败 (trigger=${trigger}):`, error); } } function getDownloadTrafficLogIngestConfig(baseBucket = '') { const configuredBucket = (SettingsDB.get('download_traffic_log_bucket') || process.env.DOWNLOAD_TRAFFIC_LOG_BUCKET || '').trim(); const configuredPrefixRaw = SettingsDB.get('download_traffic_log_prefix') || process.env.DOWNLOAD_TRAFFIC_LOG_PREFIX || ''; const configuredPrefix = String(configuredPrefixRaw).replace(/^\/+/, ''); return { bucket: configuredBucket || baseBucket || '', prefix: configuredPrefix }; } async function readS3BodyToBuffer(body) { if (!body) return Buffer.alloc(0); if (typeof body.transformToByteArray === 'function') { const arr = await body.transformToByteArray(); return Buffer.from(arr); } if (typeof body.transformToString === 'function') { const text = await body.transformToString(); return Buffer.from(text, 'utf8'); } if (Buffer.isBuffer(body)) { return body; } if (typeof body === 'string') { return Buffer.from(body, 'utf8'); } if (typeof body[Symbol.asyncIterator] === 'function') { const chunks = []; for await (const chunk of body) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks); } return Buffer.alloc(0); } function parseDownloadTrafficLogTime(line) { if (!line || typeof line !== 'string') return null; const match = line.match(/\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{4})?\]/); if (!match) return null; const monthMap = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 }; const month = monthMap[match[2]]; if (month === undefined) return null; const year = Number(match[3]); const day = Number(match[1]); const hour = Number(match[4]); const minute = Number(match[5]); const second = Number(match[6]); if ([year, day, hour, minute, second].some(v => !Number.isFinite(v))) { return null; } // 先按 UTC 构造,再应用时区偏移(若存在) let utcMillis = Date.UTC(year, month, day, hour, minute, second); const tzRaw = match[7]; if (tzRaw && /^[+-]\d{4}$/.test(tzRaw)) { const sign = tzRaw[0] === '+' ? 1 : -1; const tzHour = Number(tzRaw.slice(1, 3)); const tzMin = Number(tzRaw.slice(3, 5)); const offsetMinutes = sign * (tzHour * 60 + tzMin); utcMillis -= offsetMinutes * 60 * 1000; } const parsed = new Date(utcMillis); return Number.isNaN(parsed.getTime()) ? null : parsed; } function parseDownloadTrafficLine(line) { if (!line || typeof line !== 'string') { return null; } const trimmed = line.trim(); if (!trimmed) return null; // 仅处理 GET 请求(HEAD/PUT/POST 等不计下载流量) if (!/\bGET\b/i.test(trimmed)) { return null; } let statusCode = 0; let bytesSent = 0; const statusMatch = trimmed.match(/"\s*(\d{3})\s+(\d+|-)\b/); if (statusMatch) { statusCode = Number(statusMatch[1]); bytesSent = statusMatch[2] === '-' ? 0 : Number(statusMatch[2]); } if (![200, 206].includes(statusCode) || !Number.isFinite(bytesSent) || bytesSent <= 0) { return null; } // 尝试从请求路径提取 object key let objectKey = null; const requestMatch = trimmed.match(/"(?:GET|HEAD)\s+([^" ]+)\s+HTTP\//i); if (requestMatch && requestMatch[1]) { let requestPath = requestMatch[1]; const qIndex = requestPath.indexOf('?'); if (qIndex >= 0) { requestPath = requestPath.slice(0, qIndex); } requestPath = requestPath.replace(/^https?:\/\/[^/]+/i, ''); requestPath = requestPath.replace(/^\/+/, ''); try { requestPath = decodeURIComponent(requestPath); } catch { // ignore decode error } objectKey = requestPath || null; } if (!objectKey) { const keyMatch = trimmed.match(/\buser_(\d+)\/[^\s"]+/); if (keyMatch && keyMatch[0]) { objectKey = keyMatch[0]; } } if (!objectKey) { return null; } const userMatch = objectKey.match(/(?:^|\/)user_(\d+)\//); if (!userMatch) { return null; } const userId = Number(userMatch[1]); if (!Number.isFinite(userId) || userId <= 0) { return null; } return { userId, bytes: Math.floor(bytesSent), objectKey, eventAt: parseDownloadTrafficLogTime(trimmed) || new Date() }; } function extractLogLinesFromBuffer(buffer, logKey = '') { let content = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || ''); if ((logKey || '').toLowerCase().endsWith('.gz')) { try { content = zlib.gunzipSync(content); } catch (error) { console.warn(`[下载流量日志] 解压失败: ${logKey}`, error.message); } } const text = content.toString('utf8'); return text.split(/\r?\n/); } async function runDownloadTrafficLogReconcile(trigger = 'interval') { try { const expiredResult = DownloadTrafficReservationDB.expirePendingReservations(); if ((expiredResult?.changes || 0) > 0) { console.log(`[下载流量预扣] 已释放过期保留额度: ${expiredResult.changes} 条 (trigger=${trigger})`); } if (!SettingsDB.hasUnifiedOssConfig()) { return; } const serviceUser = { id: 0, has_oss_config: 0, current_storage_type: 'oss' }; const { client, bucket: defaultBucket } = createS3ClientContextForUser(serviceUser); const ingestConfig = getDownloadTrafficLogIngestConfig(defaultBucket); if (!ingestConfig.bucket) { return; } const { ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3'); let continuationToken = null; let listed = 0; const candidates = []; do { const listResp = await client.send(new ListObjectsV2Command({ Bucket: ingestConfig.bucket, Prefix: ingestConfig.prefix || undefined, ContinuationToken: continuationToken || undefined, MaxKeys: DOWNLOAD_LOG_LIST_MAX_KEYS })); const contents = Array.isArray(listResp?.Contents) ? listResp.Contents : []; for (const item of contents) { listed += 1; const key = item?.Key; if (!key) continue; const size = Number(item?.Size || 0); if (!Number.isFinite(size) || size <= 0) continue; const etag = (item?.ETag || '').replace(/"/g, ''); const processed = DownloadTrafficIngestDB.isProcessed(ingestConfig.bucket, key, etag); if (processed) continue; // 兼容无后缀日志(阿里云 OSS 默认日志文件通常没有 .log 后缀) if (key.endsWith('/')) { continue; } const lowerKey = key.toLowerCase(); const hasKnownLogExt = lowerKey.endsWith('.log') || lowerKey.endsWith('.txt') || lowerKey.endsWith('.gz'); const hasLogLikeToken = lowerKey.includes('traffic') || lowerKey.includes('access') || lowerKey.includes('log'); // 当前缀未配置时,避免误扫整桶普通业务对象 if (!ingestConfig.prefix && !hasKnownLogExt && !hasLogLikeToken) { continue; } candidates.push({ key, etag, size }); if (candidates.length >= DOWNLOAD_LOG_MAX_FILES_PER_SWEEP) { break; } } if (candidates.length >= DOWNLOAD_LOG_MAX_FILES_PER_SWEEP) { break; } continuationToken = listResp?.IsTruncated ? listResp?.NextContinuationToken : null; } while (continuationToken); if (candidates.length === 0) { return; } let processedFiles = 0; let processedLines = 0; let confirmedBytes = 0; for (const candidate of candidates) { const { key, etag, size } = candidate; try { const getResp = await client.send(new GetObjectCommand({ Bucket: ingestConfig.bucket, Key: key })); const bodyBuffer = await readS3BodyToBuffer(getResp?.Body); const lines = extractLogLinesFromBuffer(bodyBuffer, key); const aggregateByUser = new Map(); let parsedLineCount = 0; for (const line of lines) { const parsed = parseDownloadTrafficLine(line); if (!parsed) continue; parsedLineCount += 1; const existing = aggregateByUser.get(parsed.userId) || { bytes: 0, count: 0, eventAt: parsed.eventAt }; existing.bytes += parsed.bytes; existing.count += 1; if (parsed.eventAt && existing.eventAt && parsed.eventAt < existing.eventAt) { existing.eventAt = parsed.eventAt; } aggregateByUser.set(parsed.userId, existing); } let fileBytes = 0; for (const [uid, stat] of aggregateByUser.entries()) { if (!stat || !Number.isFinite(stat.bytes) || stat.bytes <= 0) continue; const result = applyConfirmedDownloadTrafficFromLog(uid, stat.bytes, stat.count, stat.eventAt || new Date()); if (result) { fileBytes += stat.bytes; } } processedFiles += 1; processedLines += parsedLineCount; confirmedBytes += fileBytes; DownloadTrafficIngestDB.markProcessed({ bucket: ingestConfig.bucket, logKey: key, etag, fileSize: size, lineCount: parsedLineCount, bytesCount: fileBytes, status: 'success', errorMessage: null }); } catch (error) { console.error(`[下载流量日志] 处理失败: ${key}`, error); DownloadTrafficIngestDB.markProcessed({ bucket: ingestConfig.bucket, logKey: key, etag, fileSize: size, lineCount: 0, bytesCount: 0, status: 'failed', errorMessage: String(error?.message || error).slice(0, 500) }); } } if (processedFiles > 0) { console.log( `[下载流量日志] 扫描完成 (trigger=${trigger}) ` + `listed=${listed}, files=${processedFiles}, lines=${processedLines}, bytes=${confirmedBytes}` ); } } catch (error) { console.error(`[下载流量日志] 扫描失败 (trigger=${trigger}):`, error); } } const downloadPolicySweepTimer = setInterval(() => { runDownloadTrafficPolicySweep('interval'); }, DOWNLOAD_POLICY_SWEEP_INTERVAL_MS); if (downloadPolicySweepTimer && typeof downloadPolicySweepTimer.unref === 'function') { downloadPolicySweepTimer.unref(); } setTimeout(() => { runDownloadTrafficPolicySweep('startup'); }, 10 * 1000); const downloadTrafficLogReconcileTimer = setInterval(() => { runDownloadTrafficLogReconcile('interval'); }, DOWNLOAD_LOG_RECONCILE_INTERVAL_MS); if (downloadTrafficLogReconcileTimer && typeof downloadTrafficLogReconcileTimer.unref === 'function') { downloadTrafficLogReconcileTimer.unref(); } setTimeout(() => { runDownloadTrafficLogReconcile('startup'); }, 30 * 1000); function cleanupExpiredUploadSessions(trigger = 'interval') { try { const expiredRows = UploadSessionDB.listExpiredActive(300); if (!Array.isArray(expiredRows) || expiredRows.length === 0) { return; } let cleaned = 0; for (const session of expiredRows) { const tempFilePath = typeof session.temp_file_path === 'string' ? session.temp_file_path : ''; if (tempFilePath) { safeUnlink(tempFilePath); } UploadSessionDB.expireSession(session.session_id); cleaned += 1; } if (cleaned > 0) { console.log(`[断点上传] 已清理过期会话 ${cleaned} 个 (trigger=${trigger})`); } } catch (error) { console.error(`[断点上传] 清理过期会话失败 (trigger=${trigger}):`, error); } } const uploadSessionSweepTimer = setInterval(() => { cleanupExpiredUploadSessions('interval'); }, 5 * 60 * 1000); if (uploadSessionSweepTimer && typeof uploadSessionSweepTimer.unref === 'function') { uploadSessionSweepTimer.unref(); } setTimeout(() => { cleanupExpiredUploadSessions('startup'); }, 20 * 1000); const deviceSessionSweepTimer = setInterval(() => { try { const result = DeviceSessionDB.cleanupExpired(30); const cleaned = Number(result?.changes || 0); if (cleaned > 0) { console.log(`[在线设备] 已清理过期会话 ${cleaned} 条`); } } catch (error) { console.error('[在线设备] 清理过期会话失败:', error); } }, 6 * 60 * 60 * 1000); if (deviceSessionSweepTimer && typeof deviceSessionSweepTimer.unref === 'function') { deviceSessionSweepTimer.unref(); } setTimeout(() => { try { const result = DeviceSessionDB.cleanupExpired(30); const cleaned = Number(result?.changes || 0); if (cleaned > 0) { console.log(`[在线设备] 启动清理过期会话 ${cleaned} 条`); } } catch (error) { console.error('[在线设备] 启动清理过期会话失败:', error); } }, 25 * 1000); // 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret) function buildStorageUserContext(user, overrides = {}) { if (!user) { return user; } const storageUser = { ...user, ...overrides }; if (typeof storageUser.oss_access_key_secret === 'string' && storageUser.oss_access_key_secret) { try { storageUser.oss_access_key_secret = decryptSecret(storageUser.oss_access_key_secret); } catch { // 已是明文或不是加密串,保持原值 } } return storageUser; } // 为指定用户创建 OSS 客户端(兼容加密存储的密钥) function createOssClientForUser(user, overrides = {}) { const storageUser = buildStorageUserContext(user, { current_storage_type: 'oss', ...overrides }); return new OssStorageClient(storageUser); } // 为指定用户创建可直接用于签名的 S3 客户端上下文 function createS3ClientContextForUser(user, overrides = {}) { const { S3Client } = require('@aws-sdk/client-s3'); const ossClient = createOssClientForUser(user, overrides); const effectiveConfig = ossClient.getEffectiveConfig(); // 修复:未 connect() 时 getBucket() 会回退到用户个人 bucket, // 在启用统一 OSS 配置时可能拿错 bucket(如误用 user_2)。 // 这里显式绑定当前有效配置,确保签名下载与上传使用同一 bucket。 ossClient.currentConfig = effectiveConfig; return { client: new S3Client(ossClient.buildConfig(effectiveConfig)), bucket: effectiveConfig.oss_bucket, ossClient }; } const EPHEMERAL_TOKEN_SECRET = JWT_SECRET; function signEphemeralToken(payload, expiresInSeconds = 900) { const tokenPayload = { ...payload, exp: Math.floor(Date.now() / 1000) + expiresInSeconds }; const encodedPayload = Buffer.from(JSON.stringify(tokenPayload)).toString('base64url'); const signature = crypto .createHmac('sha256', EPHEMERAL_TOKEN_SECRET) .update(encodedPayload) .digest('base64url'); return `${encodedPayload}.${signature}`; } function verifyEphemeralToken(token, expectedType) { if (!token || typeof token !== 'string') { return { valid: false, error: 'token_missing' }; } const parts = token.split('.'); if (parts.length !== 2 || !parts[0] || !parts[1]) { return { valid: false, error: 'token_format_invalid' }; } const [encodedPayload, receivedSignature] = parts; const expectedSignature = crypto .createHmac('sha256', EPHEMERAL_TOKEN_SECRET) .update(encodedPayload) .digest('base64url'); const receivedBuffer = Buffer.from(receivedSignature); const expectedBuffer = Buffer.from(expectedSignature); if ( receivedBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(receivedBuffer, expectedBuffer) ) { return { valid: false, error: 'token_signature_invalid' }; } try { const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')); if (!payload || typeof payload !== 'object') { return { valid: false, error: 'token_payload_invalid' }; } if (expectedType && payload.type !== expectedType) { return { valid: false, error: 'token_type_mismatch' }; } if (!payload.exp || Math.floor(Date.now() / 1000) > payload.exp) { return { valid: false, error: 'token_expired' }; } return { valid: true, payload }; } catch { return { valid: false, error: 'token_parse_failed' }; } } // ===== 系统日志工具函数 ===== // 从请求中提取日志信息 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 MULTER_UPLOAD_MAX_BYTES = Math.max( 1 * 1024 * 1024, Number(process.env.MULTER_UPLOAD_MAX_BYTES || (50 * 1024 * 1024 * 1024)) ); const upload = multer({ dest: path.join(__dirname, 'uploads'), limits: { fileSize: MULTER_UPLOAD_MAX_BYTES } }); // ===== 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; if (!isValidShareCode(code)) { return res.status(400).json({ success: false, message: '无效的分享码' }); } 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(); } // ===== 工具函数 ===== /** * 安全的错误响应处理 * 在生产环境中隐藏敏感的错误详情,仅在开发环境显示详细信息 * @param {Error} error - 原始错误对象 * @param {string} userMessage - 给用户显示的友好消息 * @param {string} logContext - 日志上下文标识 * @returns {string} 返回给客户端的错误消息 */ function getSafeErrorMessage(error, userMessage, logContext = '') { // 记录完整错误日志 if (logContext) { console.error(`[${logContext}]`, error); } else { console.error(error); } // 生产环境返回通用消息,开发环境返回详细信息 if (process.env.NODE_ENV === 'production') { return userMessage; } // 开发环境下,返回详细错误信息便于调试 return `${userMessage}: ${error.message}`; } // 安全删除文件(不抛出异常) 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 normalizeVirtualPath(rawPath) { if (typeof rawPath !== 'string') { return null; } let decoded = rawPath; try { decoded = decodeURIComponent(rawPath); } catch { // 忽略解码失败,使用原始输入继续校验 } if (decoded.includes('\x00') || decoded.includes('%00')) { return null; } const unifiedPath = decoded.replace(/\\/g, '/'); // 严格拦截路径遍历片段(在 normalize 前先检查) if (/(^|\/)\.\.(\/|$)/.test(unifiedPath)) { return null; } let normalized = path.posix.normalize(unifiedPath); if (normalized === '' || normalized === '.') { normalized = '/'; } if (!normalized.startsWith('/')) { normalized = `/${normalized}`; } normalized = normalized.replace(/\/+$/g, ''); return normalized || '/'; } function isValidShareCode(code) { return typeof code === 'string' && SHARE_CODE_REGEX.test(code); } // 验证请求路径是否在分享范围内(防止越权访问) function isPathWithinShare(requestPath, share) { if (!requestPath || !share) { return false; } const normalizedRequest = normalizeVirtualPath(requestPath); const normalizedShare = normalizeVirtualPath(share.share_path); if (!normalizedRequest || !normalizedShare) { return false; } if (share.share_type === 'file') { // 单文件分享:只允许下载该文件 return normalizedRequest === normalizedShare; } // 目录分享:只允许下载该目录及其子目录下的文件 const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`; return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix); } function normalizeClientIp(rawIp) { const ip = String(rawIp || '').trim(); if (!ip) return ''; if (ip.startsWith('::ffff:')) { return ip.slice(7); } if (ip === '::1') { return '127.0.0.1'; } return ip; } function getClientIp(req) { return normalizeClientIp( req?.ip || req?.socket?.remoteAddress || req?.connection?.remoteAddress || '' ); } function parseShareIpWhitelist(rawValue) { if (typeof rawValue !== 'string') return []; return rawValue .split(/[\s,;]+/) .map(item => item.trim()) .filter(Boolean) .slice(0, 100); } function isShareIpAllowed(clientIp, whitelist = []) { if (!clientIp || !Array.isArray(whitelist) || whitelist.length === 0) { return true; } for (const rule of whitelist) { const normalizedRule = String(rule || '').trim(); if (!normalizedRule) continue; if (normalizedRule === clientIp) { return true; } if (normalizedRule.endsWith('*')) { const prefix = normalizedRule.slice(0, -1); if (prefix && clientIp.startsWith(prefix)) { return true; } } } return false; } function detectDeviceTypeFromUserAgent(userAgent = '') { const ua = String(userAgent || ''); const mobilePattern = /(Mobile|Android|iPhone|iPad|iPod|Windows Phone|HarmonyOS|Mobi)/i; return mobilePattern.test(ua) ? 'mobile' : 'desktop'; } function inferPlatformFromUserAgent(userAgent = '') { const ua = String(userAgent || ''); if (!ua) return '未知平台'; if (/windows/i.test(ua)) return 'Windows'; if (/macintosh|mac os x/i.test(ua)) return 'macOS'; if (/android/i.test(ua)) return 'Android'; if (/iphone|ipad|ios/i.test(ua)) return 'iOS'; if (/linux/i.test(ua)) return 'Linux'; return '未知平台'; } function normalizeClientType(value = '') { const normalized = String(value || '').trim().toLowerCase(); if (['web', 'desktop', 'mobile', 'api'].includes(normalized)) { return normalized; } return ''; } function resolveClientType(clientType, userAgent = '') { const normalized = normalizeClientType(clientType); if (normalized) return normalized; const ua = String(userAgent || '').toLowerCase(); if (ua.includes('tauri') || ua.includes('electron') || ua.includes('wanwan-cloud-desktop') || ua.includes('玩玩云')) { return 'desktop'; } return detectDeviceTypeFromUserAgent(ua) === 'mobile' ? 'mobile' : 'web'; } function sanitizeDeviceText(value, maxLength = 120) { if (typeof value !== 'string') return ''; return value.trim().slice(0, maxLength); } function buildDeviceName({ clientType, deviceName, platform }) { const preferred = sanitizeDeviceText(deviceName, 120); if (preferred) return preferred; const platformText = sanitizeDeviceText(platform, 80) || '未知平台'; if (clientType === 'desktop') return `桌面客户端 · ${platformText}`; if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`; if (clientType === 'api') return `API 客户端 · ${platformText}`; return `网页端浏览器 · ${platformText}`; } function buildDeviceSessionContext(req, payload = {}) { const userAgent = sanitizeDeviceText(req.get('user-agent') || req.headers?.['user-agent'] || '', 1024); const clientType = resolveClientType(payload.client_type || req.headers?.['x-client-type'], userAgent); const platform = sanitizeDeviceText(payload.platform, 80) || inferPlatformFromUserAgent(userAgent); return { clientType, deviceId: sanitizeDeviceText(payload.device_id, 128) || null, deviceName: buildDeviceName({ clientType, deviceName: payload.device_name, platform }), platform, ipAddress: sanitizeDeviceText(getClientIp(req), 80) || null, userAgent }; } function formatOnlineDeviceRecord(row, currentSessionId = '') { const sid = String(row?.session_id || ''); const isCurrent = !!currentSessionId && sid === currentSessionId; return { session_id: sid, client_type: String(row?.client_type || 'web'), device_name: String(row?.device_name || '未知设备'), platform: String(row?.platform || ''), ip_address: String(row?.ip_address || ''), last_active_at: row?.last_active_at || row?.updated_at || row?.created_at || null, created_at: row?.created_at || null, expires_at: row?.expires_at || null, is_current: isCurrent, is_local: isCurrent }; } function buildDeviceDedupKey(row = {}) { const sessionId = String(row?.session_id || '').trim(); const clientType = String(row?.client_type || 'web').trim().toLowerCase() || 'web'; const deviceId = String(row?.device_id || '').trim(); if (deviceId) { return `${clientType}:id:${deviceId}`; } if (clientType === 'desktop') { const deviceName = String(row?.device_name || '').trim().toLowerCase(); const platform = String(row?.platform || '').trim().toLowerCase(); return `${clientType}:fallback:${deviceName}|${platform}`; } return `session:${sessionId}`; } function dedupeOnlineDeviceRows(rows = [], currentSessionId = '') { if (!Array.isArray(rows) || rows.length <= 1) return Array.isArray(rows) ? rows : []; const currentSid = String(currentSessionId || '').trim(); const deduped = new Map(); for (const row of rows) { const key = buildDeviceDedupKey(row); const sid = String(row?.session_id || '').trim(); if (!deduped.has(key)) { deduped.set(key, row); continue; } const currentPicked = deduped.get(key); const pickedSid = String(currentPicked?.session_id || '').trim(); const incomingIsCurrent = !!currentSid && sid === currentSid; const pickedIsCurrent = !!currentSid && pickedSid === currentSid; if (incomingIsCurrent && !pickedIsCurrent) { deduped.set(key, row); } } return Array.from(deduped.values()); } function normalizeTimeHHmm(value) { if (typeof value !== 'string') return null; const trimmed = value.trim(); const match = trimmed.match(/^(\d{2}):(\d{2})$/); if (!match) return null; const hours = Number(match[1]); const minutes = Number(match[2]); if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null; if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; } function toMinutesOfDay(hhmm) { const normalized = normalizeTimeHHmm(hhmm); if (!normalized) return null; const [hh, mm] = normalized.split(':').map(Number); return hh * 60 + mm; } function isCurrentTimeInWindow(startTime, endTime, now = new Date()) { const start = toMinutesOfDay(startTime); const end = toMinutesOfDay(endTime); if (start === null || end === null) { return true; } const nowMinutes = now.getHours() * 60 + now.getMinutes(); if (start === end) { return true; } if (start < end) { return nowMinutes >= start && nowMinutes < end; } // 跨天窗口:如 22:00 - 06:00 return nowMinutes >= start || nowMinutes < end; } function getSharePolicySummary(share) { const maxDownloads = Number(share?.max_downloads); const whitelist = parseShareIpWhitelist(share?.ip_whitelist || ''); const deviceLimit = ['all', 'mobile', 'desktop'].includes(share?.device_limit) ? share.device_limit : 'all'; const accessTimeStart = normalizeTimeHHmm(share?.access_time_start || ''); const accessTimeEnd = normalizeTimeHHmm(share?.access_time_end || ''); return { max_downloads: Number.isFinite(maxDownloads) && maxDownloads > 0 ? Math.floor(maxDownloads) : null, ip_whitelist_count: whitelist.length, device_limit: deviceLimit, access_time_start: accessTimeStart, access_time_end: accessTimeEnd }; } function evaluateShareSecurityPolicy(share, req, options = {}) { const action = options.action || 'view'; const enforceDownloadLimit = options.enforceDownloadLimit === true; if (!share) { return { allowed: false, code: 'share_not_found', message: '分享不存在' }; } const clientIp = normalizeClientIp(req?.ip || req?.socket?.remoteAddress || ''); const whitelist = parseShareIpWhitelist(share.ip_whitelist || ''); if (whitelist.length > 0 && !isShareIpAllowed(clientIp, whitelist)) { return { allowed: false, code: 'ip_not_allowed', message: '当前访问环境受限,请稍后再试' }; } const deviceLimit = ['all', 'mobile', 'desktop'].includes(share.device_limit) ? share.device_limit : 'all'; if (deviceLimit !== 'all') { const deviceType = detectDeviceTypeFromUserAgent(req?.get('user-agent') || req?.headers?.['user-agent'] || ''); if (deviceType !== deviceLimit) { return { allowed: false, code: 'device_not_allowed', message: '当前访问环境受限,请稍后再试' }; } } const accessTimeStart = normalizeTimeHHmm(share.access_time_start || ''); const accessTimeEnd = normalizeTimeHHmm(share.access_time_end || ''); if (accessTimeStart && accessTimeEnd && !isCurrentTimeInWindow(accessTimeStart, accessTimeEnd, new Date())) { return { allowed: false, code: 'time_not_allowed', message: '当前访问环境受限,请稍后再试' }; } if (enforceDownloadLimit && action === 'download') { const maxDownloads = Number(share.max_downloads); const currentDownloads = Number(share.download_count || 0); if (Number.isFinite(maxDownloads) && maxDownloads > 0 && currentDownloads >= maxDownloads) { return { allowed: false, code: 'download_limit_reached', message: '下载次数已达到上限' }; } } return { allowed: true, code: 'ok', message: '' }; } function normalizeFileHash(rawHash) { if (typeof rawHash !== 'string') return null; const trimmed = rawHash.trim(); if (!trimmed) return null; if (trimmed.length < 16 || trimmed.length > 128) return null; if (!/^[A-Za-z0-9:_-]+$/.test(trimmed)) return null; return trimmed; } function getResumableUploadTempRoot() { return path.join(__dirname, 'uploads', 'resumable'); } function ensureResumableUploadTempRoot() { const tempRoot = getResumableUploadTempRoot(); if (!fs.existsSync(tempRoot)) { fs.mkdirSync(tempRoot, { recursive: true, mode: 0o755 }); } return tempRoot; } function buildResumableUploadExpiresAt(ttlMs = RESUMABLE_UPLOAD_SESSION_TTL_MS) { const safeTtl = Math.max(10 * 60 * 1000, Number(ttlMs) || RESUMABLE_UPLOAD_SESSION_TTL_MS); return formatDateTimeForSqlite(new Date(Date.now() + safeTtl)); } function safeUnlink(filePath) { if (!filePath) return; try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } catch (error) { console.error('[清理] 删除文件失败:', filePath, error.message); } } async function searchFilesRecursively(storage, startPath, keyword, options = {}) { const queue = [startPath || '/']; const visited = new Set(); const results = []; const normalizedKeyword = String(keyword || '').toLowerCase(); const limit = Math.max(1, Math.min(GLOBAL_SEARCH_MAX_LIMIT, Math.floor(Number(options.limit) || GLOBAL_SEARCH_DEFAULT_LIMIT))); const maxNodes = Math.max(limit, Math.floor(Number(options.maxNodes) || GLOBAL_SEARCH_MAX_SCANNED_NODES)); const type = ['all', 'file', 'directory'].includes(options.type) ? options.type : 'all'; let scannedNodes = 0; let scannedDirs = 0; let truncated = false; while (queue.length > 0 && results.length < limit && scannedNodes < maxNodes) { const currentPath = queue.shift() || '/'; if (visited.has(currentPath)) continue; visited.add(currentPath); let list; try { list = await storage.list(currentPath); } catch (error) { // 某个目录无法访问时跳过,不中断全局搜索 continue; } scannedDirs += 1; for (const item of (Array.isArray(list) ? list : [])) { const name = String(item?.name || ''); if (!name) continue; const isDirectory = item?.type === 'd'; const absolutePath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`; scannedNodes += 1; if (scannedNodes > maxNodes) { truncated = true; break; } if (isDirectory) { queue.push(absolutePath); } if (normalizedKeyword && !name.toLowerCase().includes(normalizedKeyword)) { continue; } if (type === 'file' && isDirectory) { continue; } if (type === 'directory' && !isDirectory) { continue; } results.push({ name, path: absolutePath, parent_path: currentPath, isDirectory, type: isDirectory ? 'directory' : 'file', size: Number(item?.size || 0), sizeFormatted: formatFileSize(Number(item?.size || 0)), modifiedAt: new Date(item?.modifyTime || Date.now()) }); if (results.length >= limit) { truncated = queue.length > 0 || scannedNodes >= maxNodes; break; } } } return { results, meta: { keyword: keyword || '', limit, scanned_nodes: scannedNodes, scanned_dirs: scannedDirs, truncated } }; } function normalizeUploadPath(rawPath) { const safeRaw = typeof rawPath === 'string' ? rawPath : '/'; if (safeRaw.includes('..') || safeRaw.includes('\x00')) { return null; } const normalized = path.posix.normalize(safeRaw || '/'); if (normalized.includes('..')) { return null; } return normalized === '.' ? '/' : normalized; } function buildVirtualFilePath(basePath, filename) { const normalizedBasePath = normalizeUploadPath(basePath || '/'); if (!normalizedBasePath) return null; const safeName = sanitizeFilename(filename || ''); return normalizedBasePath === '/' ? `/${safeName}` : `${normalizedBasePath}/${safeName}`; } function isTrackableFileHash(fileHash, fileSize) { return !!normalizeFileHash(fileHash) && Number.isFinite(Number(fileSize)) && Number(fileSize) > 0; } async function trackFileHashIndexForUpload({ userId, storageType, fileHash, fileSize, filePath, objectKey = null }) { const normalizedHash = normalizeFileHash(fileHash); const size = Math.floor(Number(fileSize) || 0); if (!normalizedHash || !filePath || size <= 0) { return; } FileHashIndexDB.upsert({ userId, storageType, fileHash: normalizedHash, fileSize: size, filePath, objectKey }); } async function tryInstantUploadByHash(user, { storageType, fileHash, fileSize, targetPath } = {}) { const uid = Number(user?.id); const normalizedStorageType = storageType === 'oss' ? 'oss' : 'local'; const normalizedHash = normalizeFileHash(fileHash); const normalizedTargetPath = normalizeVirtualPath(targetPath || ''); const normalizedSize = Math.floor(Number(fileSize) || 0); if (!Number.isFinite(uid) || uid <= 0 || !normalizedHash || !normalizedTargetPath || normalizedSize <= 0) { return { instant: false }; } const sourceEntry = FileHashIndexDB.findLatestByHash(uid, normalizedStorageType, normalizedHash, normalizedSize); if (!sourceEntry) { return { instant: false }; } if (normalizedStorageType === 'local') { const latestUser = UserDB.findById(uid); if (!latestUser) { return { instant: false }; } const localUser = buildStorageUserContext(latestUser, { current_storage_type: 'local' }); const localStorage = new LocalStorageClient(localUser); await localStorage.init(); const sourcePath = normalizeVirtualPath(sourceEntry.file_path || ''); if (!sourcePath) { return { instant: false }; } if (sourcePath === normalizedTargetPath) { const sourceFullPath = localStorage.getFullPath(sourcePath); if (!fs.existsSync(sourceFullPath)) { FileHashIndexDB.deleteByPath(uid, 'local', sourcePath); return { instant: false }; } return { instant: true, alreadyExists: true, path: normalizedTargetPath }; } const sourceFullPath = localStorage.getFullPath(sourcePath); if (!fs.existsSync(sourceFullPath)) { FileHashIndexDB.deleteByPath(uid, 'local', sourcePath); return { instant: false }; } await localStorage.put(sourceFullPath, normalizedTargetPath); await trackFileHashIndexForUpload({ userId: uid, storageType: 'local', fileHash: normalizedHash, fileSize: normalizedSize, filePath: normalizedTargetPath }); return { instant: true, alreadyExists: false, path: normalizedTargetPath }; } // OSS 秒传:服务端 CopyObject,不走客户端上传 const latestUser = UserDB.findById(uid); if (!latestUser) { return { instant: false }; } const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!latestUser.has_oss_config && !hasUnifiedConfig) { return { instant: false }; } const { HeadObjectCommand, CopyObjectCommand } = require('@aws-sdk/client-s3'); const { client, bucket, ossClient } = createS3ClientContextForUser(latestUser); const sourcePath = normalizeVirtualPath(sourceEntry.file_path || ''); const sourceObjectKey = sourceEntry.object_key || (sourcePath ? ossClient.getObjectKey(sourcePath) : null); const targetObjectKey = ossClient.getObjectKey(normalizedTargetPath); if (!sourceObjectKey || !targetObjectKey) { return { instant: false }; } if (sourceObjectKey === targetObjectKey) { try { await client.send(new HeadObjectCommand({ Bucket: bucket, Key: sourceObjectKey })); return { instant: true, alreadyExists: true, path: normalizedTargetPath }; } catch (error) { FileHashIndexDB.deleteByPath(uid, 'oss', sourcePath || normalizedTargetPath); return { instant: false }; } } let sourceSize = 0; try { const headSource = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: sourceObjectKey })); sourceSize = Number(headSource?.ContentLength || 0); } catch (error) { const statusCode = error?.$metadata?.httpStatusCode; if (error?.name === 'NotFound' || error?.name === 'NoSuchKey' || statusCode === 404) { if (sourcePath) { FileHashIndexDB.deleteByPath(uid, 'oss', sourcePath); } return { instant: false }; } throw error; } if (!Number.isFinite(sourceSize) || sourceSize <= 0) { return { instant: false }; } let previousTargetSize = 0; try { const headTarget = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: targetObjectKey })); previousTargetSize = Number(headTarget?.ContentLength || 0); } catch (error) { const statusCode = error?.$metadata?.httpStatusCode; if (error?.name !== 'NotFound' && error?.name !== 'NoSuchKey' && statusCode !== 404) { throw error; } } const latestUsageUser = UserDB.findById(uid) || latestUser; const ossQuota = normalizeOssQuota(latestUsageUser?.oss_storage_quota); const currentUsage = Number(latestUsageUser?.storage_used || 0); const projectedUsage = Math.max(0, currentUsage + (sourceSize - previousTargetSize)); if (projectedUsage > ossQuota) { return { instant: false, blocked: true, message: `OSS 配额不足:剩余 ${formatFileSize(Math.max(0, ossQuota - currentUsage))}` }; } const encodedSourceKey = sourceObjectKey .split('/') .map(segment => encodeURIComponent(segment)) .join('/'); const copySource = `${bucket}/${encodedSourceKey}`; await client.send(new CopyObjectCommand({ Bucket: bucket, Key: targetObjectKey, CopySource: copySource })); const deltaSize = sourceSize - previousTargetSize; if (deltaSize !== 0) { await StorageUsageCache.updateUsage(uid, deltaSize); } clearOssUsageCache(uid); await trackFileHashIndexForUpload({ userId: uid, storageType: 'oss', fileHash: normalizedHash, fileSize: sourceSize, filePath: normalizedTargetPath, objectKey: targetObjectKey }); return { instant: true, alreadyExists: false, path: normalizedTargetPath }; } // 清理旧的临时文件(启动时执行一次) 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); } } // formatFileSize 已在文件顶部导入 // 生成随机Token(crypto 已在文件顶部导入) function generateRandomToken(length = 48) { return 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; // 调试日志 console.log('[验证码验证] SessionID:', req.sessionID, '输入:', captcha?.toLowerCase(), 'Session中:', sessionCaptcha); if (!sessionCaptcha || !captchaTime) { console.log('[验证码验证] 失败: session中无验证码'); return { valid: false, message: '验证码已过期,请刷新验证码' }; } // 验证码有效期5分钟 if (Date.now() - captchaTime > 5 * 60 * 1000) { console.log('[验证码验证] 失败: 验证码已超时'); return { valid: false, message: '验证码已过期,请刷新验证码' }; } if (captcha.toLowerCase() !== sessionCaptcha) { console.log('[验证码验证] 失败: 验证码不匹配'); return { valid: false, message: '验证码错误' }; } console.log('[验证码验证] 成功'); // 验证通过后清除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 } }); }); // 桌面客户端更新信息(无需登录) app.get('/api/client/desktop-update', (req, res) => { try { const currentVersion = normalizeVersion(req.query.currentVersion || '0.0.0', '0.0.0'); const platform = String(req.query.platform || 'windows-x64').trim(); const channel = String(req.query.channel || 'stable').trim(); const config = getDesktopUpdateConfig(); const hasDownload = Boolean(config.installerUrl); const updateAvailable = hasDownload && compareLooseVersion(currentVersion, config.latestVersion) < 0; res.json({ success: true, currentVersion, latestVersion: config.latestVersion, updateAvailable, downloadUrl: config.installerUrl, sha256: config.installerSha256, packageSize: config.packageSize, releaseNotes: config.releaseNotes, mandatory: config.mandatory && updateAvailable, platform, channel, message: hasDownload ? (updateAvailable ? '检测到新版本' : '当前已是最新版本') : '服务器暂未配置桌面端升级包地址' }); } catch (error) { console.error('[客户端更新] 获取升级信息失败:', error); res.status(500).json({ success: false, message: '获取客户端更新信息失败' }); } }); // 生成验证码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后再返回响应(修复:确保session保存成功) req.session.save((err) => { if (err) { console.error('[验证码] Session保存失败:', err); return res.status(500).json({ success: false, message: '验证码生成失败' }); } console.log('[验证码] 生成成功, SessionID:', req.sessionID); res.type('svg'); res.send(captcha.data); }); } catch (error) { console.error('生成验证码失败:', error); res.status(500).json({ success: false, message: '生成验证码失败' }); } }); // 获取 CSRF Token(用于前端初始化) app.get('/api/csrf-token', (req, res) => { let csrfToken = req.cookies[CSRF_COOKIE_NAME]; // 如果没有 token,生成一个新的 if (!csrfToken) { csrfToken = generateCsrfToken(); const isSecureEnv = SHOULD_USE_SECURE_COOKIES; res.cookie(CSRF_COOKIE_NAME, csrfToken, { httpOnly: false, secure: isSecureEnv, sameSite: isSecureEnv ? 'strict' : 'lax', maxAge: 24 * 60 * 60 * 1000 }); } res.json({ success: true, csrfToken: csrfToken }); }); // 密码强度验证函数 function validatePasswordStrength(password) { if (!password || password.length < 8) { return { valid: false, message: '密码至少8个字符' }; } if (password.length > 128) { return { valid: false, message: '密码不能超过128个字符' }; } // 检查是否包含至少两种字符类型(字母、数字、特殊字符) const hasLetter = /[a-zA-Z]/.test(password); const hasNumber = /[0-9]/.test(password); const hasSpecial = /[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]/.test(password); const typeCount = [hasLetter, hasNumber, hasSpecial].filter(Boolean).length; if (typeCount < 2) { return { valid: false, message: '密码必须包含字母、数字、特殊字符中的至少两种' }; } // 检查常见弱密码 const commonWeakPasswords = [ 'password', '12345678', '123456789', 'qwerty123', 'admin123', 'letmein', 'welcome', 'monkey', 'dragon', 'master' ]; if (commonWeakPasswords.includes(password.toLowerCase())) { return { valid: false, message: '密码过于简单,请使用更复杂的密码' }; } return { valid: true }; } // 用户注册(简化版) 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: 8, max: 128 }).withMessage('密码长度8-128个字符') .custom((value) => { const result = validatePasswordStrength(value); if (!result.valid) { throw new Error(result.message); } return true; }), 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'); // 安全修复:不向客户端泄露具体错误信息 const safeMessage = error.message?.includes('UNIQUE constraint') ? '用户名或邮箱已被注册' : '注册失败,请稍后重试'; res.status(500).json({ success: false, message: safeMessage }); } } ); // 重新发送邮箱验证邮件 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; // 参数验证:token 不能为空且长度合理(48字符的hex字符串) if (!token || typeof token !== 'string') { return res.status(400).json({ success: false, message: '缺少token' }); } // token 格式验证:应该是 hex 字符串,长度合理 if (!/^[a-f0-9]{32,96}$/i.test(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: '无效或已过期的验证链接' }); } // 记录验证成功日志 logAuth(req, 'email_verified', `邮箱验证成功: ${user.email || user.username}`, { userId: user.id }); 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: 8, max: 128 }).withMessage('密码长度8-128个字符') .custom((value) => { const result = validatePasswordStrength(value); if (!result.valid) { throw new Error(result.message); } return true; }) ], 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, client_type, device_id, device_name, platform } = 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; } let 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: '用户名或密码错误' }); } // 本地存储用量校准:防止目录被外部清理后仍显示旧用量 if ((user.current_storage_type || 'oss') === 'local') { user.local_storage_used = syncLocalStorageUsageFromDisk(user.id, user.local_storage_used); } // 下载流量策略应用:处理到期恢复/周期重置 const loginPolicyState = enforceDownloadTrafficPolicy(user.id, 'login'); if (loginPolicyState?.user) { user = loginPolicyState.user; } const deviceContext = buildDeviceSessionContext(req, { client_type, device_id, device_name, platform }); let sessionId = null; try { let reusedSessionId = ''; const shouldReuseDeviceSession = Boolean(deviceContext.deviceId) || deviceContext.clientType === 'desktop'; if (shouldReuseDeviceSession) { const matchedDeviceSessions = DeviceSessionDB.listActiveByDevice(user.id, { clientType: deviceContext.clientType, deviceId: deviceContext.deviceId, deviceName: deviceContext.deviceName, platform: deviceContext.platform, limit: 20 }); if (matchedDeviceSessions.length > 0) { reusedSessionId = String(matchedDeviceSessions[0]?.session_id || '').trim(); let revokedCount = 0; for (const matched of matchedDeviceSessions) { const duplicateSid = String(matched?.session_id || '').trim(); if (!duplicateSid || duplicateSid === reusedSessionId) continue; const revokeResult = DeviceSessionDB.revoke(duplicateSid, user.id, 'dedupe_login_same_device'); if (Number(revokeResult?.changes || 0) > 0) { revokedCount += 1; } } if (revokedCount > 0) { console.log(`[在线设备] 登录会话去重 user=${user.id}, revoked=${revokedCount}`); } } } const createdSession = DeviceSessionDB.create({ sessionId: reusedSessionId || crypto.randomBytes(24).toString('hex'), userId: user.id, clientType: deviceContext.clientType, deviceId: deviceContext.deviceId, deviceName: deviceContext.deviceName, platform: deviceContext.platform, ipAddress: deviceContext.ipAddress, userAgent: deviceContext.userAgent, ttlDays: 7 }); sessionId = createdSession?.session_id || null; } catch (sessionError) { console.error('[在线设备] 创建登录会话失败:', sessionError.message); } const token = generateToken(user, sessionId); const refreshToken = generateRefreshToken(user, sessionId); // 清除失败记录 if (req.rateLimitKeys) { loginLimiter.recordSuccess(req.rateLimitKeys.ipKey); if (req.rateLimitKeys.usernameKey) { loginLimiter.recordSuccess(req.rateLimitKeys.usernameKey); } } // 增强Cookie安全设置 const isSecureEnv = SHOULD_USE_SECURE_COOKIES; const cookieOptions = { httpOnly: true, secure: isSecureEnv, sameSite: isSecureEnv ? 'strict' : 'lax', path: '/' }; // 设置 access token Cookie(2小时有效) res.cookie('token', token, { ...cookieOptions, maxAge: 2 * 60 * 60 * 1000 }); // 设置 refresh token Cookie(7天有效) res.cookie('refreshToken', refreshToken, { ...cookieOptions, maxAge: 7 * 24 * 60 * 60 * 1000 }); // 记录登录成功日志 logAuth(req, 'login', `用户登录成功: ${user.username}`, { userId: user.id, isAdmin: user.is_admin }); res.json({ success: true, message: '登录成功', expiresIn: 2 * 60 * 60 * 1000, // 告知前端access token有效期(毫秒) user: { id: user.id, username: user.username, email: user.email, is_admin: user.is_admin, has_oss_config: user.has_oss_config, // 存储相关字段 storage_permission: user.storage_permission || 'oss_only', current_storage_type: user.current_storage_type || 'oss', local_storage_quota: user.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES, local_storage_used: user.local_storage_used || 0, oss_storage_quota: normalizeOssQuota(user.oss_storage_quota), storage_used: user.storage_used || 0, download_traffic_quota: normalizeDownloadTrafficQuota(user.download_traffic_quota), download_traffic_used: normalizeDownloadTrafficUsed( user.download_traffic_used, normalizeDownloadTrafficQuota(user.download_traffic_quota) ), download_traffic_quota_expires_at: user.download_traffic_quota_expires_at || null, download_traffic_reset_cycle: user.download_traffic_reset_cycle || 'none', download_traffic_last_reset_at: user.download_traffic_last_reset_at || null, // OSS配置来源(重要:用于前端判断是否使用OSS直连上传) oss_config_source: SettingsDB.hasUnifiedOssConfig() ? 'unified' : (user.has_oss_config ? 'personal' : 'none') } }); } catch (error) { console.error('登录失败:', error); logAuth(req, 'login_error', `登录异常: ${req.body.username || 'unknown'}`, { error: error.message }, 'error'); // 安全修复:不向客户端泄露具体错误信息 res.status(500).json({ success: false, message: '登录失败,请稍后重试' }); } } ); // 刷新Access Token(从 HttpOnly Cookie 读取 refreshToken) app.post('/api/refresh-token', (req, res) => { // 优先从 Cookie 读取,兼容从请求体读取(向后兼容) const refreshToken = req.cookies?.refreshToken || req.body?.refreshToken; if (!refreshToken) { return res.status(400).json({ success: false, message: '缺少刷新令牌' }); } const result = refreshAccessToken(refreshToken, { ipAddress: getClientIp(req), userAgent: req.get('user-agent') || '' }); if (!result.success) { return res.status(401).json({ success: false, message: result.message }); } // 更新Cookie中的token const isSecureEnv = SHOULD_USE_SECURE_COOKIES; res.cookie('token', result.token, { httpOnly: true, secure: isSecureEnv, sameSite: isSecureEnv ? 'strict' : 'lax', maxAge: 2 * 60 * 60 * 1000, path: '/' }); res.json({ success: true, expiresIn: 2 * 60 * 60 * 1000 }); }); // 登出(清除Cookie) app.post('/api/logout', (req, res) => { const accessToken = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token || ''; const refreshToken = req.cookies?.refreshToken || ''; const accessPayload = decodeAccessToken(accessToken); const refreshPayload = decodeRefreshToken(refreshToken); const sessionId = String(accessPayload?.sid || refreshPayload?.sid || '').trim(); if (sessionId) { try { DeviceSessionDB.revoke(sessionId, null, 'logout'); } catch (error) { console.error('[在线设备] 登出时撤销会话失败:', error.message); } } // 清除所有认证Cookie res.clearCookie('token', { path: '/' }); res.clearCookie('refreshToken', { path: '/' }); res.json({ success: true, message: '已登出' }); }); // ===== 需要认证的API ===== // 获取当前用户信息 app.get('/api/user/profile', authMiddleware, (req, res) => { const userPayload = { ...req.user }; const profilePolicyState = enforceDownloadTrafficPolicy(userPayload.id, 'profile'); if (profilePolicyState?.user) { const policyUser = profilePolicyState.user; userPayload.download_traffic_quota = normalizeDownloadTrafficQuota(policyUser.download_traffic_quota); userPayload.download_traffic_used = normalizeDownloadTrafficUsed( policyUser.download_traffic_used, userPayload.download_traffic_quota ); userPayload.download_traffic_quota_expires_at = policyUser.download_traffic_quota_expires_at || null; userPayload.download_traffic_reset_cycle = policyUser.download_traffic_reset_cycle || 'none'; userPayload.download_traffic_last_reset_at = policyUser.download_traffic_last_reset_at || null; } // 本地存储用量校准:避免“有占用但目录为空”的显示错乱 if ((userPayload.current_storage_type || 'oss') === 'local') { userPayload.local_storage_used = syncLocalStorageUsageFromDisk(userPayload.id, userPayload.local_storage_used); } // 不返回敏感信息(密码和 OSS 密钥) const { password, oss_access_key_secret, ...safeUser } = userPayload; // 检查是否使用统一 OSS 配置 const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); res.json({ success: true, user: { ...safeUser, // 添加配置来源信息 oss_config_source: hasUnifiedConfig ? 'unified' : (safeUser.has_oss_config ? 'personal' : 'none') } }); }); // 在线设备列表 app.get('/api/user/online-devices', authMiddleware, (req, res) => { try { const currentSessionId = String(req.authSessionId || '').trim(); const rawRows = DeviceSessionDB.listActiveByUser(req.user.id, 80); const dedupedRows = dedupeOnlineDeviceRows(rawRows, currentSessionId); const devices = dedupedRows.map((row) => formatOnlineDeviceRecord(row, currentSessionId)); res.json({ success: true, devices, current_session_id: currentSessionId || null }); } catch (error) { console.error('获取在线设备失败:', error); res.status(500).json({ success: false, message: '获取在线设备失败' }); } }); // 强制踢下线(可踢其它设备,也可踢当前设备) app.post('/api/user/online-devices/:sessionId/kick', authMiddleware, (req, res) => { try { const sessionId = String(req.params.sessionId || '').trim(); if (!sessionId || sessionId.length < 16 || sessionId.length > 128) { return res.status(400).json({ success: false, message: '会话标识无效' }); } const target = DeviceSessionDB.findBySessionId(sessionId); if (!target || Number(target.user_id) !== Number(req.user.id)) { return res.status(404).json({ success: false, message: '目标设备不存在' }); } if (target.revoked_at) { return res.status(409).json({ success: false, message: '设备已离线' }); } const revokeResult = DeviceSessionDB.revoke(sessionId, req.user.id, 'kicked_by_user'); if (!(Number(revokeResult?.changes || 0) > 0)) { return res.status(409).json({ success: false, message: '设备已离线' }); } const kickedCurrent = String(req.authSessionId || '') === sessionId; if (kickedCurrent) { res.clearCookie('token', { path: '/' }); res.clearCookie('refreshToken', { path: '/' }); } logAuth(req, 'device_kick', `用户踢下线设备: ${sessionId}`, { userId: req.user.id, kickedCurrent }); res.json({ success: true, kicked_current: kickedCurrent, message: kickedCurrent ? '当前设备已下线' : '设备已下线' }); } catch (error) { console.error('踢设备下线失败:', error); res.status(500).json({ success: false, message: '踢设备下线失败' }); } }); // 获取用户下载流量额度与报表 app.get('/api/user/download-traffic-report', authMiddleware, (req, res) => { try { const days = DownloadTrafficReportDB.normalizeDays(req.query.days, 30, 365); const policyState = enforceDownloadTrafficPolicy(req.user.id, 'traffic_report'); const latestUser = policyState?.user || UserDB.findById(req.user.id); if (!latestUser) { return res.status(404).json({ success: false, message: '用户不存在' }); } const trafficState = getDownloadTrafficState(latestUser); const dailyRows = DownloadTrafficReportDB.getDailyUsage(latestUser.id, days); const dailyRowMap = new Map( dailyRows.map(row => [row.date_key, row]) ); const dateKeys = getRecentDateKeys(days, new Date()); const dailySeries = dateKeys.map(dateKey => { const row = dailyRowMap.get(dateKey); const bytesUsed = Number(row?.bytes_used || 0); const downloadCount = Number(row?.download_count || 0); return { date: dateKey, bytes_used: Number.isFinite(bytesUsed) && bytesUsed > 0 ? Math.floor(bytesUsed) : 0, download_count: Number.isFinite(downloadCount) && downloadCount > 0 ? Math.floor(downloadCount) : 0 }; }); const selectedSummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, days); const todaySummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, 1); const sevenDaySummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, 7); const thirtyDaySummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, 30); const allSummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, null); const peakDay = dailySeries.reduce((peak, current) => { if (!peak || current.bytes_used > peak.bytes_used) { return current; } return peak; }, null); const safeQuota = trafficState.isUnlimited ? 0 : Math.max(0, trafficState.quota); const remainingBytes = trafficState.isUnlimited ? null : Math.max(0, safeQuota - trafficState.used); const usagePercentage = trafficState.isUnlimited ? null : (safeQuota > 0 ? Math.min(100, Math.round((trafficState.used / safeQuota) * 100)) : 100); res.json({ success: true, quota: { quota: safeQuota, used: trafficState.used, remaining: remainingBytes, usage_percentage: usagePercentage, is_unlimited: trafficState.isUnlimited, reset_cycle: latestUser.download_traffic_reset_cycle || 'none', expires_at: latestUser.download_traffic_quota_expires_at || null, last_reset_at: latestUser.download_traffic_last_reset_at || null }, report: { days, daily: dailySeries, summary: { today: { bytes_used: Number(todaySummary?.bytes_used || 0), download_count: Number(todaySummary?.download_count || 0) }, last_7_days: { bytes_used: Number(sevenDaySummary?.bytes_used || 0), download_count: Number(sevenDaySummary?.download_count || 0) }, last_30_days: { bytes_used: Number(thirtyDaySummary?.bytes_used || 0), download_count: Number(thirtyDaySummary?.download_count || 0) }, selected_range: { bytes_used: Number(selectedSummary?.bytes_used || 0), download_count: Number(selectedSummary?.download_count || 0), average_daily_bytes: Math.round(Number(selectedSummary?.bytes_used || 0) / Math.max(1, days)) }, all_time: { bytes_used: Number(allSummary?.bytes_used || 0), download_count: Number(allSummary?.download_count || 0) }, peak_day: peakDay ? { date: peakDay.date, bytes_used: peakDay.bytes_used, download_count: peakDay.download_count } : null } } }); } catch (error) { console.error('获取下载流量报表失败:', error); res.status(500).json({ success: false, message: '获取下载流量报表失败' }); } }); // 获取用户主题偏好(包含全局默认主题) 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: '更新主题失败' }); } }); // 更新OSS配置 app.post('/api/user/update-oss', authMiddleware, [ body('oss_provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), body('oss_region').notEmpty().withMessage('地域不能为空'), body('oss_access_key_id').notEmpty().withMessage('Access Key ID不能为空'), body('oss_access_key_secret').notEmpty().withMessage('Access Key Secret不能为空'), body('oss_bucket').notEmpty().withMessage('存储桶名称不能为空'), body('oss_endpoint').optional({ checkFalsy: true }).isURL().withMessage('Endpoint必须是有效的URL') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { // 检查是否已配置系统级统一 OSS const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (hasUnifiedConfig && !req.user.is_admin) { return res.status(403).json({ success: false, message: '系统已配置统一 OSS,普通用户无法配置个人 OSS。如需修改,请联系管理员' }); } const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint } = req.body; // 如果用户已配置OSS且密钥为空,使用现有密钥 let actualSecret = oss_access_key_secret; if (!oss_access_key_secret && req.user.has_oss_config && req.user.oss_access_key_secret) { actualSecret = req.user.oss_access_key_secret; } else if (!oss_access_key_secret) { return res.status(400).json({ success: false, message: 'Access Key Secret不能为空' }); } // 验证OSS连接 try { // OssStorageClient 已在文件顶部导入 const testUser = { id: req.user.id, has_oss_config: 1, // 标记为已配置,允许使用个人配置 oss_provider, oss_region, oss_access_key_id, oss_access_key_secret: actualSecret, oss_bucket, oss_endpoint }; const ossClient = new OssStorageClient(testUser); await ossClient.connect(); // 尝试列出 bucket 内容(验证配置是否正确) await ossClient.list('/'); await ossClient.end(); } catch (error) { return res.status(400).json({ success: false, message: 'OSS连接失败,请检查配置: ' + error.message }); } // 安全修复:加密存储 OSS Access Key Secret let encryptedSecret; try { encryptedSecret = encryptSecret(actualSecret); } catch (error) { console.error('[安全] 加密 OSS 密钥失败:', error); return res.status(500).json({ success: false, message: '加密配置失败' }); } // 更新用户配置(存储加密后的密钥) UserDB.update(req.user.id, { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret: encryptedSecret, oss_bucket, oss_endpoint: oss_endpoint || null, has_oss_config: 1 }); res.json({ success: true, message: 'OSS配置已更新' }); } catch (error) { console.error('更新OSS配置失败:', error); res.status(500).json({ success: false, message: '更新配置失败: ' + error.message }); } } ); // 测试 OSS 连接(不保存配置,仅验证) app.post('/api/user/test-oss', authMiddleware, [ body('oss_provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), body('oss_region').notEmpty().withMessage('地域不能为空'), body('oss_access_key_id').notEmpty().withMessage('Access Key ID不能为空'), body('oss_bucket').notEmpty().withMessage('存储桶名称不能为空') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint } = req.body; // 如果密钥为空且用户已配置OSS,使用现有密钥 let actualSecret = oss_access_key_secret; if (!oss_access_key_secret && req.user.has_oss_config && req.user.oss_access_key_secret) { actualSecret = req.user.oss_access_key_secret; } else if (!oss_access_key_secret) { return res.status(400).json({ success: false, message: 'Access Key Secret不能为空' }); } // 验证 OSS 连接 // OssStorageClient 已在文件顶部导入 const testUser = { id: req.user.id, has_oss_config: 1, // 标记为已配置,允许使用个人配置 oss_provider, oss_region, oss_access_key_id, oss_access_key_secret: actualSecret, oss_bucket, oss_endpoint }; const ossClient = new OssStorageClient(testUser); await ossClient.connect(); // 尝试列出 bucket 内容(验证配置是否正确) await ossClient.list('/'); await ossClient.end(); res.json({ success: true, message: 'OSS 连接测试成功' }); } catch (error) { console.error('[OSS测试] 连接失败:', error); res.status(400).json({ success: false, message: 'OSS 连接失败: ' + error.message }); } } ); // 获取 OSS 存储空间使用情况(带缓存) // ===== P0 性能优化:优先使用数据库缓存,避免全量统计 ===== app.get('/api/user/oss-usage', authMiddleware, async (req, res) => { try { // 检查是否有可用的OSS配置(个人配置或系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!req.user.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '未配置OSS服务' }); } // ===== P0 优化:优先使用数据库缓存 ===== // 从数据库 storage_used 字段读取(上传/删除时增量更新) const user = UserDB.findById(req.user.id); const storageUsed = Number(user.storage_used || 0); const ossQuota = normalizeOssQuota(user.oss_storage_quota); const usagePercentage = Math.min(100, Math.round((storageUsed / ossQuota) * 100)); const remainingSize = Math.max(ossQuota - storageUsed, 0); return res.json({ success: true, usage: { totalSize: storageUsed, totalSizeFormatted: formatFileSize(storageUsed), fileCount: null, // 缓存模式不提供文件数 cached: true, quota: ossQuota, quotaFormatted: formatFileSize(ossQuota), quotaLimited: true, remainingSize, remainingSizeFormatted: formatFileSize(remainingSize), usagePercentage }, cached: true }); } catch (error) { console.error('[OSS统计] 获取失败:', error); res.status(500).json({ success: false, message: '获取存储使用情况失败: ' + error.message }); } }); // 获取 OSS 存储空间详细统计(全量统计,仅限管理员或需要精确统计时使用) // ===== P0 性能优化:此接口较慢,建议只在必要时调用 ===== app.get('/api/user/oss-usage-full', authMiddleware, async (req, res) => { let ossClient; try { // 检查是否有可用的OSS配置(个人配置或系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!req.user.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '未配置OSS服务' }); } // 先检查内存缓存(5分钟 TTL) const cached = getOssUsageCache(req.user.id); if (cached) { return res.json({ success: true, usage: cached, cached: true }); } // 执行全量统计(较慢,仅在缓存未命中时执行) ossClient = createOssClientForUser(req.user); await ossClient.connect(); let totalSize = 0; let fileCount = 0; let continuationToken = null; do { const { ListObjectsV2Command } = require('@aws-sdk/client-s3'); const command = new ListObjectsV2Command({ Bucket: ossClient.getBucket(), Prefix: `user_${req.user.id}/`, ContinuationToken: continuationToken }); const response = await ossClient.s3Client.send(command); if (response.Contents) { for (const obj of response.Contents) { totalSize += obj.Size || 0; fileCount++; } } continuationToken = response.NextContinuationToken; } while (continuationToken); const usageData = { totalSize, totalSizeFormatted: formatFileSize(totalSize), fileCount, dirCount: 0 // OSS 没有目录概念 }; // 存入内存缓存 setOssUsageCache(req.user.id, usageData); res.json({ success: true, usage: usageData, cached: false }); } catch (error) { console.error('[OSS统计] 全量统计失败:', error); res.status(500).json({ success: false, message: '获取OSS空间使用情况失败: ' + error.message }); } finally { if (ossClient) { await ossClient.end(); } } }); // 修改管理员账号信息(仅管理员可修改用户名) 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: '更新失败,请稍后重试' }); } } ); // 修改当前用户密码(需要验证当前密码) app.post('/api/user/change-password', authMiddleware, [ body('current_password').notEmpty().withMessage('当前密码不能为空'), body('new_password') .isLength({ min: 8, max: 128 }).withMessage('密码长度8-128个字符') .custom((value) => { const result = validatePasswordStrength(value); if (!result.valid) { throw new Error(result.message); } return true; }) ], (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) { res.status(500).json({ success: false, message: getSafeErrorMessage(error, '修改密码失败,请稍后重试', '修改密码失败') }); } } ); // 更新当前用户资料(目前支持邮箱) app.post('/api/user/update-profile', authMiddleware, [ body('email').isEmail().withMessage('邮箱格式不正确') ], (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { email } = req.body; const existingUser = UserDB.findByEmail(email); if (existingUser && existingUser.id !== req.user.id) { return res.status(400).json({ success: false, message: '邮箱已被使用' }); } UserDB.update(req.user.id, { email }); const updatedUser = UserDB.findById(req.user.id); res.json({ success: true, message: '资料更新成功', user: { id: updatedUser.id, username: updatedUser.username, email: updatedUser.email, is_admin: updatedUser.is_admin } }); } catch (error) { res.status(500).json({ success: false, message: getSafeErrorMessage(error, '更新资料失败,请稍后重试', '更新资料失败') }); } } ); // 修改当前用户名 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) { res.status(500).json({ success: false, message: getSafeErrorMessage(error, '修改用户名失败,请稍后重试', '修改用户名失败') }); } } ); // 切换存储方式 app.post('/api/user/switch-storage', authMiddleware, [ body('storage_type').isIn(['local', 'oss']).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 === 'oss_only' && storage_type !== 'oss') { return res.status(403).json({ success: false, message: '您只能使用OSS存储' }); } // 检查OSS配置(包括个人配置和系统级统一配置) if (storage_type === 'oss') { const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!req.user.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: 'OSS服务未配置,请联系管理员配置系统级OSS服务' }); } } // 更新存储类型(切到本地时同步校准本地用量) const updates = { current_storage_type: storage_type }; if (storage_type === 'local') { const latestUser = UserDB.findById(req.user.id); if (latestUser) { updates.local_storage_used = syncLocalStorageUsageFromDisk( latestUser.id, latestUser.local_storage_used ); } } UserDB.update(req.user.id, updates); 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 rawPath = req.query.path || '/'; // 路径安全验证:在 API 层提前拒绝包含 .. 或空字节的路径 if (rawPath.includes('..') || rawPath.includes('\x00') || rawPath.includes('%00')) { return res.status(400).json({ success: false, message: '路径包含非法字符' }); } // 规范化路径 const dirPath = path.posix.normalize(rawPath); 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 || 'oss'; const formattedList = list.map(item => { return { name: item.name, displayName: decodeHtmlEntities(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: null // OSS 使用 API 下载,不需要 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 || 'oss_only' }); } catch (error) { console.error('获取文件列表失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '获取文件列表失败,请稍后重试', '获取文件列表') }); } finally { if (storage) await storage.end(); } }); // 全局文件搜索(递归搜索当前存储空间) app.get('/api/files/search', authMiddleware, async (req, res) => { const keyword = typeof req.query?.keyword === 'string' ? req.query.keyword.trim() : ''; const searchType = typeof req.query?.type === 'string' ? req.query.type.trim() : 'all'; const rawStartPath = typeof req.query?.path === 'string' ? req.query.path : '/'; const startPath = normalizeVirtualPath(rawStartPath); const limit = Math.max(1, Math.min(GLOBAL_SEARCH_MAX_LIMIT, parseInt(req.query?.limit, 10) || GLOBAL_SEARCH_DEFAULT_LIMIT)); let storage; if (!keyword || keyword.length < 1) { return res.status(400).json({ success: false, message: '搜索关键词不能为空' }); } if (keyword.length > 80) { return res.status(400).json({ success: false, message: '搜索关键词过长(最多80个字符)' }); } if (!startPath) { return res.status(400).json({ success: false, message: '搜索路径非法' }); } if (!['all', 'file', 'directory'].includes(searchType)) { return res.status(400).json({ success: false, message: '搜索类型无效' }); } try { const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); const searchResult = await searchFilesRecursively(storage, startPath, keyword, { type: searchType, limit, maxNodes: GLOBAL_SEARCH_MAX_SCANNED_NODES }); res.json({ success: true, keyword, path: startPath, type: searchType, storageType: req.user.current_storage_type || 'oss', items: searchResult.results, meta: searchResult.meta }); } catch (error) { console.error('全局搜索失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '全局搜索失败,请稍后重试', '全局搜索') }); } finally { if (storage) await storage.end(); } }); // 重命名文件 app.post('/api/files/rename', authMiddleware, async (req, res) => { const oldName = decodeHtmlEntities(req.body.oldName); const newName = decodeHtmlEntities(req.body.newName); const path = decodeHtmlEntities(req.body.path) || '/'; 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); const normalizedStorageType = req.user.current_storage_type === 'oss' ? 'oss' : 'local'; const normalizedOldPath = normalizeVirtualPath(oldPath); const normalizedNewPath = normalizeVirtualPath(newPath); if (normalizedOldPath && normalizedNewPath) { const oldHashRow = FileHashIndexDB.getByPath(req.user.id, normalizedStorageType, normalizedOldPath); if (oldHashRow) { FileHashIndexDB.upsert({ userId: req.user.id, storageType: normalizedStorageType, fileHash: oldHashRow.file_hash, fileSize: oldHashRow.file_size, filePath: normalizedNewPath, objectKey: oldHashRow.object_key || null }); } FileHashIndexDB.deleteByPath(req.user.id, normalizedStorageType, normalizedOldPath); } // 清除 OSS 使用情况缓存(如果用户使用 OSS) if (req.user.current_storage_type === 'oss') { clearOssUsageCache(req.user.id); } res.json({ success: true, message: '文件重命名成功' }); } catch (error) { console.error('重命名文件失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '重命名文件失败,请稍后重试', '重命名文件') }); } finally { if (storage) await storage.end(); } }); // 创建文件夹(支持本地存储和OSS) app.post('/api/files/mkdir', authMiddleware, async (req, res) => { const path = decodeHtmlEntities(req.body.path) || '/'; const folderName = decodeHtmlEntities(req.body.folderName); let storage; // 参数验证 if (!folderName || folderName.trim() === '') { return res.status(400).json({ success: false, message: '文件夹名称不能为空' }); } // 文件名长度检查 if (folderName.length > 255) { return res.status(400).json({ success: false, message: '文件夹名称过长(最大255个字符)' }); } // 文件名安全检查 - 防止路径遍历攻击 if (folderName.includes('/') || folderName.includes('\\') || folderName.includes('..') || folderName.includes(':')) { 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}`; // 根据存储类型创建文件夹 if (req.user.current_storage_type === 'local') { // 本地存储:使用 fs.mkdirSync 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}`); } else { // OSS 存储:使用 storage.mkdir() 创建空对象模拟文件夹 await storage.mkdir(folderPath); console.log(`[创建文件夹成功] OSS存储 - 用户${req.user.id}: ${folderPath}`); // 清除 OSS 使用情况缓存 clearOssUsageCache(req.user.id); } res.json({ success: true, message: '文件夹创建成功' }); } catch (error) { console.error('[创建文件夹失败]', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '创建文件夹失败,请稍后重试', '创建文件夹') }); } finally { if (storage) await storage.end(); } }); // 获取文件夹详情(大小统计) - 支持本地存储和OSS app.post('/api/files/folder-info', authMiddleware, async (req, res) => { const dirPath = decodeHtmlEntities(req.body.path) || '/'; const folderName = decodeHtmlEntities(req.body.folderName); let storage; if (!folderName) { 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}`; if (req.user.current_storage_type === 'local') { // 本地存储实现 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 } }); } else { // OSS 存储实现 const { ListObjectsV2Command } = require('@aws-sdk/client-s3'); const folderKey = `user_${req.user.id}${folderPath}`; // 确保前缀以斜杠结尾 const prefix = folderKey.endsWith('/') ? folderKey : `${folderKey}/`; let totalSize = 0; let fileCount = 0; let continuationToken = null; do { const command = new ListObjectsV2Command({ Bucket: storage.getBucket(), Prefix: prefix, ContinuationToken: continuationToken }); const response = await storage.s3Client.send(command); if (response.Contents) { for (const obj of response.Contents) { // 跳过文件夹标记对象(以斜杠结尾且大小为0) if (obj.Key.endsWith('/') && obj.Size === 0) { continue; } totalSize += obj.Size || 0; fileCount++; } } continuationToken = response.NextContinuationToken; } while (continuationToken); res.json({ success: true, data: { name: folderName, path: folderPath, size: totalSize, fileCount: fileCount, folderCount: 0 // OSS 没有真正的文件夹概念 } }); } } catch (error) { console.error('[获取文件夹详情失败]', error); res.status(500).json({ success: false, message: '获取文件夹详情失败: ' + error.message }); } finally { if (storage) await storage.end(); } }); // 删除文件 // ===== P0 性能优化:更新存储使用量缓存 ===== app.post('/api/files/delete', authMiddleware, async (req, res) => { const rawFileName = req.body.fileName; const rawPath = req.body.path; const fileName = decodeHtmlEntities(rawFileName); const path = decodeHtmlEntities(rawPath) || '/'; let storage; let deletedSize = 0; let deletedTargetPath = null; 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 tried = new Set(); const candidates = [fileName]; // 兼容被二次编码的实体(如 &#x60; -> `) if (typeof rawFileName === 'string') { const entityName = rawFileName.replace(/&/g, '&'); if (entityName && !candidates.includes(entityName)) { candidates.push(entityName); } if (rawFileName && !candidates.includes(rawFileName)) { candidates.push(rawFileName); } } const pathsToDelete = candidates.map(name => (path === '/' ? `/${name}` : `${path}/${name}`)); try { for (const targetPath of pathsToDelete) { if (tried.has(targetPath)) continue; tried.add(targetPath); try { // 删除文件并获取文件大小(用于更新缓存) const deleteResult = await storage.delete(targetPath); // 如果返回了文件大小,记录下来 if (deleteResult && deleteResult.size !== undefined) { deletedSize = deleteResult.size; } deletedTargetPath = normalizeVirtualPath(targetPath); break; } catch (err) { if (err.code === 'ENOENT') { // 尝试下一个候选路径 if (targetPath === pathsToDelete[pathsToDelete.length - 1]) throw err; } else { throw err; } } } } catch (err) { throw err; } // ===== P0 性能优化:更新存储使用量 ===== if (req.user.current_storage_type === 'oss' && deletedSize > 0) { // 减少存储使用量 await StorageUsageCache.updateUsage(req.user.id, -deletedSize); // 同时更新旧的内存缓存(保持兼容性) clearOssUsageCache(req.user.id); console.log(`[删除文件] 用户 ${req.user.id} 释放空间: ${deletedSize} 字节`); } else if (req.user.current_storage_type === 'oss') { // 如果没有获取到文件大小,清除缓存(下次查询时会重新统计) clearOssUsageCache(req.user.id); } if (deletedTargetPath) { FileHashIndexDB.deleteByPath( req.user.id, req.user.current_storage_type === 'oss' ? 'oss' : 'local', deletedTargetPath ); } res.json({ success: true, message: '删除成功' }); } catch (error) { console.error('删除文件失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '删除文件失败,请稍后重试', '删除文件') }); } finally { if (storage) await storage.end(); } }); function calculateResumableUploadedBytes(chunks = [], totalChunks = 0, chunkSize = 0, fileSize = 0) { if (!Array.isArray(chunks) || chunks.length === 0) return 0; const safeTotalChunks = Math.max(1, Math.floor(Number(totalChunks) || 1)); const safeChunkSize = Math.max(1, Math.floor(Number(chunkSize) || 1)); const safeFileSize = Math.max(0, Math.floor(Number(fileSize) || 0)); let total = 0; for (const chunkIndexRaw of chunks) { const chunkIndex = Math.floor(Number(chunkIndexRaw)); if (!Number.isFinite(chunkIndex) || chunkIndex < 0 || chunkIndex >= safeTotalChunks) { continue; } const start = chunkIndex * safeChunkSize; const end = Math.min(safeFileSize, start + safeChunkSize); if (end > start) { total += (end - start); } } return total; } // 秒传预检查:命中哈希后由服务器直接复制,不走客户端上传流量 app.post('/api/files/instant-upload/check', authMiddleware, async (req, res) => { try { const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''; const fileHash = normalizeFileHash(req.body?.file_hash); const fileSize = Math.floor(Number(req.body?.size) || 0); const uploadPath = normalizeUploadPath(req.body?.path || '/'); const storageType = req.user.current_storage_type === 'oss' ? 'oss' : 'local'; if (!filename || !isSafePathSegment(filename)) { return res.status(400).json({ success: false, message: '文件名无效' }); } if (!uploadPath) { return res.status(400).json({ success: false, message: '上传路径非法' }); } if (!fileHash || fileSize <= 0) { return res.json({ success: true, instant: false, message: '未命中秒传' }); } const targetPath = buildVirtualFilePath(uploadPath, filename); if (!targetPath) { return res.status(400).json({ success: false, message: '目标路径非法' }); } const instantResult = await tryInstantUploadByHash(req.user, { storageType, fileHash, fileSize, targetPath }); if (instantResult?.blocked) { return res.status(400).json({ success: false, message: instantResult.message || '当前无法执行秒传' }); } if (instantResult?.instant) { return res.json({ success: true, instant: true, already_exists: !!instantResult.alreadyExists, path: instantResult.path || targetPath, message: instantResult.alreadyExists ? '文件已存在,无需上传' : '秒传成功' }); } res.json({ success: true, instant: false, message: '未命中秒传' }); } catch (error) { console.error('秒传检查失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '秒传检查失败,请稍后重试', '秒传检查') }); } }); // 本地分片上传初始化(断点续传) app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => { try { const uploadStorageType = (req.user.current_storage_type || 'oss') === 'local' ? 'local' : 'oss'; if (uploadStorageType === 'oss') { const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!req.user.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '未配置 OSS 服务,无法上传文件' }); } } const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : ''; const uploadPath = normalizeUploadPath(req.body?.path || '/'); const fileSize = Math.floor(Number(req.body?.size) || 0); const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240', 10); const requestedChunkSize = Math.floor(Number(req.body?.chunk_size) || RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES); const chunkSize = Math.min( RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES, Math.max(256 * 1024, requestedChunkSize) ); const fileHash = normalizeFileHash(req.body?.file_hash); if (!filename || !isSafePathSegment(filename)) { return res.status(400).json({ success: false, message: '文件名无效' }); } if (!isFileExtensionSafe(filename)) { return res.status(400).json({ success: false, message: '不允许上传此类型的文件(安全限制)' }); } if (!uploadPath) { return res.status(400).json({ success: false, message: '上传路径非法' }); } if (!Number.isFinite(fileSize) || fileSize <= 0) { return res.status(400).json({ success: false, message: '文件大小无效' }); } if (Number.isFinite(maxUploadSize) && maxUploadSize > 0 && fileSize > maxUploadSize) { return res.status(400).json({ success: false, message: `文件过大,最大允许 ${formatFileSize(maxUploadSize)}` }); } const targetPath = buildVirtualFilePath(uploadPath, filename); if (!targetPath) { return res.status(400).json({ success: false, message: '目标路径非法' }); } const totalChunks = Math.ceil(fileSize / chunkSize); const existingSession = UploadSessionDB.findActiveForResume( req.user.id, uploadStorageType, targetPath, fileSize, fileHash || null ); if (existingSession) { if (existingSession.temp_file_path && fs.existsSync(existingSession.temp_file_path)) { const uploadedChunks = UploadSessionDB.parseUploadedChunks(existingSession.uploaded_chunks); const uploadedBytes = calculateResumableUploadedBytes( uploadedChunks, existingSession.total_chunks, existingSession.chunk_size, existingSession.file_size ); UploadSessionDB.updateProgress( existingSession.session_id, uploadedChunks, uploadedBytes, buildResumableUploadExpiresAt() ); return res.json({ success: true, resumed: true, storage_type: existingSession.storage_type || uploadStorageType, session_id: existingSession.session_id, target_path: existingSession.target_path, file_name: existingSession.file_name, file_size: existingSession.file_size, chunk_size: existingSession.chunk_size, total_chunks: existingSession.total_chunks, uploaded_chunks: uploadedChunks, uploaded_bytes: uploadedBytes }); } // 会话存在但临时文件丢失,标记过期并重建 UploadSessionDB.expireSession(existingSession.session_id); } const tempRoot = ensureResumableUploadTempRoot(); const sessionId = crypto.randomBytes(20).toString('hex'); const tempFilePath = path.join(tempRoot, `${sessionId}.part`); fs.closeSync(fs.openSync(tempFilePath, 'w')); const createdSession = UploadSessionDB.create({ sessionId, userId: req.user.id, storageType: uploadStorageType, targetPath, fileName: filename, fileSize, chunkSize, totalChunks, uploadedChunks: [], uploadedBytes: 0, tempFilePath, fileHash: fileHash || null, expiresAt: buildResumableUploadExpiresAt() }); if (!createdSession) { safeUnlink(tempFilePath); return res.status(500).json({ success: false, message: '创建分片上传会话失败' }); } res.json({ success: true, resumed: false, storage_type: createdSession.storage_type || uploadStorageType, session_id: createdSession.session_id, target_path: createdSession.target_path, file_name: createdSession.file_name, file_size: createdSession.file_size, chunk_size: createdSession.chunk_size, total_chunks: createdSession.total_chunks, uploaded_chunks: [] }); } catch (error) { console.error('初始化分片上传失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '初始化分片上传失败,请稍后重试', '分片上传初始化') }); } }); // 查询分片上传会话状态 app.get('/api/upload/resumable/status', authMiddleware, (req, res) => { try { const sessionId = typeof req.query?.session_id === 'string' ? req.query.session_id.trim() : ''; if (!sessionId) { return res.status(400).json({ success: false, message: '缺少会话ID' }); } const session = UploadSessionDB.findBySessionId(sessionId); if (!session || Number(session.user_id) !== Number(req.user.id)) { return res.status(404).json({ success: false, message: '上传会话不存在' }); } const uploadedChunks = UploadSessionDB.parseUploadedChunks(session.uploaded_chunks); const uploadedBytes = calculateResumableUploadedBytes( uploadedChunks, session.total_chunks, session.chunk_size, session.file_size ); res.json({ success: true, session_id: session.session_id, status: session.status, target_path: session.target_path, file_name: session.file_name, file_size: session.file_size, chunk_size: session.chunk_size, total_chunks: session.total_chunks, uploaded_chunks: uploadedChunks, uploaded_bytes: uploadedBytes }); } catch (error) { console.error('获取分片上传状态失败:', error); res.status(500).json({ success: false, message: '获取上传状态失败' }); } }); // 上传分片 app.post('/api/upload/resumable/chunk', authMiddleware, upload.single('chunk'), async (req, res) => { const tempChunkPath = req.file?.path; try { const sessionId = typeof req.body?.session_id === 'string' ? req.body.session_id.trim() : ''; const chunkIndex = Math.floor(Number(req.body?.chunk_index)); if (!sessionId) { return res.status(400).json({ success: false, message: '缺少会话ID' }); } if (!Number.isFinite(chunkIndex) || chunkIndex < 0) { return res.status(400).json({ success: false, message: '分片索引无效' }); } if (!req.file) { return res.status(400).json({ success: false, message: '缺少分片文件' }); } const session = UploadSessionDB.findBySessionId(sessionId); if (!session || Number(session.user_id) !== Number(req.user.id)) { return res.status(404).json({ success: false, message: '上传会话不存在' }); } if (session.status !== 'active') { return res.status(409).json({ success: false, message: '上传会话已结束,请重新开始上传' }); } const sessionExpireAt = parseDateTimeValue(session.expires_at); if (sessionExpireAt && Date.now() >= sessionExpireAt.getTime()) { UploadSessionDB.expireSession(session.session_id); return res.status(409).json({ success: false, message: '上传会话已过期,请重新开始上传' }); } const totalChunks = Math.max(1, Math.floor(Number(session.total_chunks) || 1)); const chunkSize = Math.max(1, Math.floor(Number(session.chunk_size) || 1)); const fileSize = Math.max(0, Math.floor(Number(session.file_size) || 0)); if (chunkIndex >= totalChunks) { return res.status(400).json({ success: false, message: '分片索引超出范围' }); } const expectedChunkSize = chunkIndex === totalChunks - 1 ? Math.max(0, fileSize - (chunkIndex * chunkSize)) : chunkSize; const receivedChunkSize = Math.max(0, Number(req.file.size || 0)); if (receivedChunkSize <= 0 || receivedChunkSize > chunkSize || (expectedChunkSize > 0 && receivedChunkSize > expectedChunkSize)) { return res.status(400).json({ success: false, message: '分片大小无效' }); } if (!session.temp_file_path || !fs.existsSync(session.temp_file_path)) { return res.status(409).json({ success: false, message: '上传会话文件已丢失,请重新开始上传' }); } const chunkBuffer = await fs.promises.readFile(req.file.path); const offset = chunkIndex * chunkSize; const fd = await fs.promises.open(session.temp_file_path, 'r+'); try { await fd.write(chunkBuffer, 0, chunkBuffer.length, offset); } finally { await fd.close(); } const uploadedChunks = UploadSessionDB.parseUploadedChunks(session.uploaded_chunks); if (!uploadedChunks.includes(chunkIndex)) { uploadedChunks.push(chunkIndex); uploadedChunks.sort((a, b) => a - b); } const uploadedBytes = calculateResumableUploadedBytes( uploadedChunks, totalChunks, chunkSize, fileSize ); UploadSessionDB.updateProgress( session.session_id, uploadedChunks, uploadedBytes, buildResumableUploadExpiresAt() ); res.json({ success: true, session_id: session.session_id, chunk_index: chunkIndex, uploaded_chunks_count: uploadedChunks.length, total_chunks: totalChunks, uploaded_bytes: uploadedBytes, progress: fileSize > 0 ? Math.min(100, Math.round((uploadedBytes / fileSize) * 100)) : 0 }); } catch (error) { console.error('上传分片失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '上传分片失败,请稍后重试', '上传分片') }); } finally { if (tempChunkPath) { safeDeleteFile(tempChunkPath); } } }); // 完成分片上传(写入当前存储:本地或 OSS) app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => { let storage = null; try { const sessionId = typeof req.body?.session_id === 'string' ? req.body.session_id.trim() : ''; if (!sessionId) { return res.status(400).json({ success: false, message: '缺少会话ID' }); } const session = UploadSessionDB.findBySessionId(sessionId); if (!session || Number(session.user_id) !== Number(req.user.id)) { return res.status(404).json({ success: false, message: '上传会话不存在' }); } if (session.status !== 'active') { return res.status(409).json({ success: false, message: '上传会话已结束,请重新上传' }); } const uploadedChunks = UploadSessionDB.parseUploadedChunks(session.uploaded_chunks); const totalChunks = Math.max(1, Math.floor(Number(session.total_chunks) || 1)); if (uploadedChunks.length < totalChunks) { return res.status(409).json({ success: false, message: `分片未上传完成(${uploadedChunks.length}/${totalChunks})` }); } if (!session.temp_file_path || !fs.existsSync(session.temp_file_path)) { return res.status(409).json({ success: false, message: '上传临时文件不存在,请重新上传' }); } const tempFileStats = fs.statSync(session.temp_file_path); const fileSize = Math.max(0, Math.floor(Number(session.file_size) || 0)); if (tempFileStats.size < fileSize) { return res.status(409).json({ success: false, message: '分片数据不完整,请重试' }); } const sessionStorageType = session.storage_type === 'local' ? 'local' : 'oss'; const latestUser = UserDB.findById(req.user.id); if (!latestUser) { return res.status(404).json({ success: false, message: '用户不存在' }); } if (sessionStorageType === 'oss') { const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!latestUser.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '未配置 OSS 服务,无法完成分片上传' }); } } const storageUserContext = buildStorageUserContext(latestUser, { current_storage_type: sessionStorageType }); const storageInterface = new StorageInterface(storageUserContext); storage = await storageInterface.connect(); await storage.put(session.temp_file_path, session.target_path); if (sessionStorageType === 'oss') { clearOssUsageCache(req.user.id); } UploadSessionDB.setStatus(session.session_id, 'completed', { completed: true, expiresAt: buildResumableUploadExpiresAt(60 * 1000) }); safeUnlink(session.temp_file_path); await trackFileHashIndexForUpload({ userId: req.user.id, storageType: sessionStorageType, fileHash: session.file_hash, fileSize, filePath: session.target_path }); res.json({ success: true, message: '分片上传完成', path: session.target_path, file_name: session.file_name, file_size: fileSize }); } catch (error) { console.error('完成分片上传失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '完成分片上传失败,请稍后重试', '完成分片上传') }); } finally { if (storage) { await storage.end().catch(() => {}); } } }); // ========== OSS 直连相关接口(Presigned URL)========== // 生成 OSS 上传签名 URL(用户直连 OSS 上传,不经过后端) app.get('/api/files/upload-signature', authMiddleware, async (req, res) => { const filename = req.query.filename; const uploadPath = req.query.path || '/'; // 上传目标路径 const contentType = req.query.contentType || 'application/octet-stream'; const fileSize = Number(req.query.size); const fileHash = normalizeFileHash(req.query.fileHash); if (!filename) { return res.status(400).json({ success: false, message: '缺少文件名参数' }); } // 文件名长度限制(最大255字符) if (filename.length > 255) { return res.status(400).json({ success: false, message: '文件名过长,最大支持255个字符' }); } // 文件名安全校验 if (!isSafePathSegment(filename)) { return res.status(400).json({ success: false, message: '文件名包含非法字符' }); } // 文件扩展名安全检查(防止上传危险文件) if (!isFileExtensionSafe(filename)) { console.warn(`[安全] 拒绝上传危险文件: ${filename}, 用户: ${req.user.username}`); return res.status(400).json({ success: false, message: '不允许上传此类型的文件(安全限制)' }); } // 文件大小参数校验(用于 OSS 配额校验) if (!Number.isFinite(fileSize) || fileSize <= 0) { return res.status(400).json({ success: false, message: '缺少或无效的文件大小参数' }); } const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240', 10); if (Number.isFinite(maxUploadSize) && maxUploadSize > 0 && fileSize > maxUploadSize) { return res.status(400).json({ success: false, message: `文件过大,最大允许 ${formatFileSize(maxUploadSize)}` }); } // 路径安全验证:防止目录遍历攻击 if (uploadPath.includes('..') || uploadPath.includes('\x00')) { return res.status(400).json({ success: false, message: '上传路径非法' }); } // 检查用户是否配置了 OSS(包括个人配置和系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!req.user.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '未配置 OSS 服务' }); } try { const { PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { client, bucket } = createS3ClientContextForUser(req.user); // 构建对象 Key(与 OssStorageClient.getObjectKey 格式一致) // 格式:user_${id}/${path}/${filename} const sanitizedFilename = sanitizeFilename(filename); let normalizedPath = uploadPath.replace(/\\/g, '/').replace(/\/+/g, '/'); // 移除开头的斜杠 normalizedPath = normalizedPath.replace(/^\/+/, ''); // 移除结尾的斜杠 normalizedPath = normalizedPath.replace(/\/+$/, ''); // 构建完整的 objectKey let objectKey; if (normalizedPath === '' || normalizedPath === '.') { // 根目录上传 objectKey = `user_${req.user.id}/${sanitizedFilename}`; } else { // 子目录上传 objectKey = `user_${req.user.id}/${normalizedPath}/${sanitizedFilename}`; } let previousSize = 0; try { const headResponse = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey })); previousSize = Number(headResponse.ContentLength || 0); if (!Number.isFinite(previousSize) || previousSize < 0) { previousSize = 0; } } catch (headError) { const statusCode = headError?.$metadata?.httpStatusCode; if (headError?.name !== 'NotFound' && headError?.name !== 'NoSuchKey' && statusCode !== 404) { throw headError; } } // OSS 配额校验(未配置时默认 1GB) const latestUser = UserDB.findById(req.user.id); const ossQuota = normalizeOssQuota(latestUser?.oss_storage_quota); const currentUsage = Number(latestUser?.storage_used || 0); const baseUsage = Math.max(0, currentUsage - previousSize); const projectedUsage = baseUsage + fileSize; if (projectedUsage > ossQuota) { const remainingBytes = Math.max(ossQuota - baseUsage, 0); return res.status(400).json({ success: false, message: `OSS 配额不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(remainingBytes)}(总配额 ${formatFileSize(ossQuota)})` }); } const completionToken = signEphemeralToken({ type: 'upload_complete', userId: req.user.id, objectKey, previousSize, expectedSize: fileSize, fileHash: fileHash || null }, 30 * 60); // 创建 PutObject 命令 const command = new PutObjectCommand({ Bucket: bucket, Key: objectKey, ContentType: contentType }); // 生成签名 URL(15分钟有效) const signedUrl = await getSignedUrl(client, command, { expiresIn: 900 }); res.json({ success: true, uploadUrl: signedUrl, objectKey: objectKey, previousSize, completionToken, expiresIn: 900 }); } catch (error) { console.error('[OSS签名] 生成上传签名失败:', error); res.status(500).json({ success: false, message: '生成上传签名失败: ' + error.message }); } }); // OSS 上传完成通知(用于更新缓存和数据库) // ===== P0 性能优化:使用增量更新替代全量统计 ===== app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { const { objectKey, size, completionToken } = req.body; const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.replace(/\\/g, '/').replace(/^\/+/, '') : ''; const reportedSize = Number(size); if (!normalizedObjectKey) { return res.status(400).json({ success: false, message: '缺少对象Key参数' }); } if (!Number.isFinite(reportedSize) || reportedSize < 0) { return res.status(400).json({ success: false, message: '文件大小参数无效' }); } const expectedPrefix = `user_${req.user.id}/`; if ( !normalizedObjectKey.startsWith(expectedPrefix) || normalizedObjectKey.includes('..') || normalizedObjectKey.includes('\x00') ) { return res.status(403).json({ success: false, message: '对象Key不属于当前用户或格式非法' }); } // 安全检查:验证用户是否配置了OSS(个人配置或系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!req.user.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '未配置OSS服务,无法完成上传' }); } const completionTokenResult = verifyEphemeralToken(completionToken, 'upload_complete'); if (!completionTokenResult.valid) { return res.status(403).json({ success: false, message: '上传完成凭证无效或已过期' }); } const completionPayload = completionTokenResult.payload || {}; if ( Number(completionPayload.userId) !== Number(req.user.id) || completionPayload.objectKey !== normalizedObjectKey ) { return res.status(403).json({ success: false, message: '上传完成凭证与对象不匹配' }); } const previousObjectSize = Number.isFinite(Number(completionPayload.previousSize)) && Number(completionPayload.previousSize) >= 0 ? Number(completionPayload.previousSize) : 0; const completionFileHash = normalizeFileHash(completionPayload.fileHash); const virtualFilePath = `/${normalizedObjectKey.slice(expectedPrefix.length)}`; let ossClient; try { const { HeadObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); ossClient = createOssClientForUser(req.user); await ossClient.connect(); const headResponse = await ossClient.s3Client.send(new HeadObjectCommand({ Bucket: ossClient.getBucket(), Key: normalizedObjectKey })); const verifiedSize = Number(headResponse.ContentLength || 0); if (!Number.isFinite(verifiedSize) || verifiedSize < 0) { throw new Error('无法确认上传对象大小'); } if (verifiedSize !== reportedSize) { console.warn(`[上传完成] 用户 ${req.user.id} 上报大小(${reportedSize})与实际大小(${verifiedSize})不一致,已使用实际大小`); } const deltaSize = verifiedSize - previousObjectSize; // 二次校验 OSS 配额(防止签名后并发上传导致超限) const latestUser = UserDB.findById(req.user.id); const ossQuota = normalizeOssQuota(latestUser?.oss_storage_quota); const currentUsage = Number(latestUser?.storage_used || 0); const projectedUsage = Math.max(0, currentUsage + deltaSize); if (projectedUsage > ossQuota) { // 回滚:删除刚上传的对象,避免超配额文件残留 await ossClient.s3Client.send(new DeleteObjectCommand({ Bucket: ossClient.getBucket(), Key: normalizedObjectKey })); // 若为覆盖上传,旧对象已被替换,删除后需扣除旧对象体积 if (previousObjectSize > 0) { await StorageUsageCache.updateUsage(req.user.id, -previousObjectSize); } clearOssUsageCache(req.user.id); return res.status(400).json({ success: false, message: `OSS 配额不足:已用 ${formatFileSize(currentUsage)},配额 ${formatFileSize(ossQuota)},上传后将超限` }); } // 更新存储使用量缓存(增量更新,覆盖上传只记录差值) if (deltaSize !== 0) { await StorageUsageCache.updateUsage(req.user.id, deltaSize); } // 同时更新旧的内存缓存(保持兼容性) clearOssUsageCache(req.user.id); await trackFileHashIndexForUpload({ userId: req.user.id, storageType: 'oss', fileHash: completionFileHash, fileSize: verifiedSize, filePath: normalizeVirtualPath(virtualFilePath), objectKey: normalizedObjectKey }); console.log(`[上传完成] 用户 ${req.user.id} 文件大小: ${verifiedSize} 字节, 增量: ${deltaSize} 字节`); res.json({ success: true, message: '上传完成已记录', recordedSize: verifiedSize, deltaSize }); } catch (error) { console.error('[OSS上传] 记录上传完成失败:', error); res.status(500).json({ success: false, message: '记录上传完成失败: ' + error.message }); } finally { if (ossClient) { await ossClient.end(); } } }); // 生成 OSS 下载签名 URL(用户直连 OSS 下载,不经过后端) app.get('/api/files/download-url', authMiddleware, async (req, res) => { const filePath = req.query.path; const mode = String(req.query.mode || 'download').toLowerCase(); const isPreviewMode = mode === 'preview'; if (!filePath) { return res.status(400).json({ success: false, message: '缺少文件路径参数' }); } // 路径安全验证:防止目录遍历攻击 const normalizedPath = path.posix.normalize(filePath); if (normalizedPath.includes('..') || filePath.includes('\x00')) { return res.status(400).json({ success: false, message: '文件路径非法' }); } const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download_url'); const latestUser = policyState?.user || UserDB.findById(req.user.id); if (!latestUser) { return res.status(401).json({ success: false, message: '用户不存在' }); } if (!isPreviewMode) { const securityResult = evaluateDownloadSecurityPolicy(req, { ownerUserId: latestUser.id, filePath: normalizedPath, source: 'user_download_url' }); if (!securityResult.allowed) { return handleDownloadSecurityBlock(req, res, securityResult, { statusCode: 503, ownerUserId: latestUser.id, filePath: normalizedPath, source: 'user_download_url' }); } } const trafficState = getDownloadTrafficState(latestUser); // 检查用户是否配置了 OSS(包括个人配置和系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!req.user.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '未配置 OSS 服务' }); } try { const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { client, bucket, ossClient } = createS3ClientContextForUser(latestUser); const objectKey = ossClient.getObjectKey(normalizedPath); let fileSize = 0; // 启用下载流量限制时,签发前先获取文件大小(用于预扣保留额度) if (!trafficState.isUnlimited) { let headResponse; try { headResponse = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey })); } catch (headError) { const statusCode = headError?.$metadata?.httpStatusCode; if (headError?.name === 'NotFound' || headError?.name === 'NoSuchKey' || statusCode === 404) { return res.status(404).json({ success: false, message: '文件不存在' }); } throw headError; } const contentLength = Number(headResponse?.ContentLength || 0); fileSize = Number.isFinite(contentLength) && contentLength > 0 ? Math.floor(contentLength) : 0; if (fileSize <= 0) { return res.status(503).json({ success: false, message: getBusyDownloadMessage() }); } } // 创建 GetObject 命令(预览模式使用 inline,下载模式使用 attachment) const fileName = normalizedPath.split('/').pop() || 'download.bin'; const commandInput = { Bucket: bucket, Key: objectKey, ResponseContentDisposition: isPreviewMode ? `inline; filename*=UTF-8''${encodeURIComponent(fileName)}` : `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}` }; const command = new GetObjectCommand(commandInput); // 生成签名 URL(短时有效,默认30秒) const signedUrl = await getSignedUrl(client, command, { expiresIn: DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS }); // 直连模式:先预扣保留额度(不写入已用),实际用量由 OSS 日志异步确认入账 if (!trafficState.isUnlimited && fileSize > 0) { const reserveResult = reserveDirectDownloadTraffic(latestUser.id, fileSize, { source: 'direct', objectKey, ttlMs: DOWNLOAD_RESERVATION_TTL_MS }); if (!reserveResult?.ok) { return res.status(503).json({ success: false, message: getBusyDownloadMessage() }); } } res.json({ success: true, downloadUrl: signedUrl, expiresIn: DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS, direct: true, quotaLimited: !trafficState.isUnlimited }); } catch (error) { console.error('[OSS签名] 生成下载签名失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '生成下载签名失败,请稍后重试', '生成下载签名') }); } }); // 辅助函数:清理文件名(增强版安全处理) function sanitizeFilename(filename) { if (!filename || typeof filename !== 'string') { return 'unnamed_file'; } let sanitized = filename; // 1. 移除空字节和控制字符 sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, ''); // 2. 移除或替换危险字符(Windows/Linux 文件系统不允许的字符) sanitized = sanitized.replace(/[<>:"/\\|?*]/g, '_'); // 3. 移除前导/尾随的点和空格(防止隐藏文件和路径混淆) sanitized = sanitized.replace(/^[\s.]+|[\s.]+$/g, ''); // 4. 限制文件名长度(防止过长文件名攻击) if (sanitized.length > 200) { const ext = path.extname(sanitized); const base = path.basename(sanitized, ext); sanitized = base.substring(0, 200 - ext.length) + ext; } // 5. 如果处理后为空,使用默认名称 if (!sanitized || sanitized.length === 0) { sanitized = 'unnamed_file'; } return sanitized; } // ========== 本地存储上传接口(保留用于本地存储模式)========== // 上传文件(添加速率限制)- 仅用于本地存储 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 || '/'; const fileHash = normalizeFileHash(req.body?.file_hash || req.body?.fileHash); // 修复中文文件名:multer将UTF-8转为了Latin1,需要转回来 const originalFilename = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); // 文件名长度限制(最大255字符) if (originalFilename.length > 255) { safeDeleteFile(req.file.path); return res.status(400).json({ success: false, message: '文件名过长,最大支持255个字符' }); } // 文件名安全校验 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}`); await trackFileHashIndexForUpload({ userId: req.user.id, storageType: 'local', fileHash, fileSize: req.file.size, filePath: normalizeVirtualPath(remoteFilePath) }); // 清除 OSS 使用情况缓存(如果用户使用 OSS) if (req.user.current_storage_type === 'oss') { clearOssUsageCache(req.user.id); } // 删除本地临时文件 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: getSafeErrorMessage(error, '文件上传失败,请稍后重试', '文件上传') }); } finally { if (storage) await storage.end(); } }); // 下载预检(避免前端直接下载到 JSON 错误响应) app.get('/api/files/download-check', authMiddleware, async (req, res) => { const filePath = req.query.path; let storage; if (!filePath) { return res.status(400).json({ success: false, message: '缺少文件路径参数' }); } const normalizedPath = path.posix.normalize(filePath); if (normalizedPath.includes('..') || filePath.includes('\x00')) { return res.status(400).json({ success: false, message: '文件路径非法' }); } try { const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download_check'); const latestUser = policyState?.user || UserDB.findById(req.user.id); if (!latestUser) { return res.status(401).json({ success: false, message: '用户不存在' }); } const trafficState = getDownloadTrafficState(latestUser); const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); const fileStats = await storage.stat(normalizedPath); const fileSize = Math.max(0, Number(fileStats?.size) || 0); const fileName = normalizedPath.split('/').pop() || 'download.bin'; if (!trafficState.isUnlimited && fileSize > trafficState.remaining) { return res.status(403).json({ success: false, message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(trafficState.remaining)}` }); } res.json({ success: true, fileName, fileSize }); } catch (error) { console.error('下载预检失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '下载检查失败,请稍后重试', '下载预检') }); } finally { if (storage) await storage.end(); } }); // 下载文件 app.get('/api/files/download', authMiddleware, async (req, res) => { const filePath = req.query.path; let storage; let storageEnded = false; // 防止重复关闭 let transferFinalized = false; // 防止重复结算 let downloadedBytes = 0; let responseBodyStartSocketBytes = 0; // Express 会将 HEAD 映射到 GET 处理器,这里显式拒绝,避免误触发下载计量 if (req.method === 'HEAD') { res.setHeader('Allow', 'GET'); return res.status(405).end(); } // 安全关闭存储连接的辅助函数 const safeEndStorage = async () => { if (storage && !storageEnded) { storageEnded = true; try { await storage.end(); } catch (err) { console.error('关闭存储连接失败:', err); } } }; const finalizeTransfer = async (reason = '') => { if (transferFinalized) { return; } transferFinalized = true; try { const socketBytesWritten = Number(res.socket?.bytesWritten); const socketBodyBytes = Number.isFinite(socketBytesWritten) && socketBytesWritten > responseBodyStartSocketBytes ? Math.floor(socketBytesWritten - responseBodyStartSocketBytes) : 0; const billableBytes = socketBodyBytes > 0 ? Math.min(downloadedBytes, socketBodyBytes) : downloadedBytes; if (billableBytes > 0) { const usageResult = applyDownloadTrafficUsage(req.user.id, billableBytes); if (usageResult) { const quotaText = usageResult.quota > 0 ? formatFileSize(usageResult.quota) : '不限'; console.log( `[下载流量] 用户 ${req.user.id} 新增 ${formatFileSize(usageResult.added)},` + `累计 ${formatFileSize(usageResult.usedAfter)} / ${quotaText} ` + `(reason=${reason || 'unknown'}, streamed=${downloadedBytes}, socket=${socketBodyBytes})` ); } } } catch (error) { console.error(`[下载流量] 结算失败: user=${req.user.id}, bytes=${downloadedBytes}`, error); } await safeEndStorage(); }; if (!filePath) { return res.status(400).json({ success: false, message: '缺少文件路径参数' }); } // 路径安全验证:防止目录遍历攻击 const normalizedPath = path.posix.normalize(filePath); if (normalizedPath.includes('..') || filePath.includes('\x00')) { return res.status(400).json({ success: false, message: '文件路径非法' }); } try { const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download'); const latestUser = policyState?.user || UserDB.findById(req.user.id); if (!latestUser) { return res.status(401).json({ success: false, message: '用户不存在' }); } const securityResult = evaluateDownloadSecurityPolicy(req, { ownerUserId: latestUser.id, filePath: normalizedPath, source: 'user_download_stream' }); if (!securityResult.allowed) { return handleDownloadSecurityBlock(req, res, securityResult, { statusCode: 503, ownerUserId: latestUser.id, filePath: normalizedPath, source: 'user_download_stream' }); } const trafficState = getDownloadTrafficState(latestUser); // 使用统一存储接口 const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); // 获取文件名 const fileName = normalizedPath.split('/').pop() || 'download.bin'; // 先获取文件信息(获取文件大小) const fileStats = await storage.stat(normalizedPath); const fileSize = Math.max(0, Number(fileStats?.size) || 0); console.log('[下载] 文件: ' + fileName + ', 大小: ' + fileSize + ' 字节'); if (!trafficState.isUnlimited && fileSize > trafficState.remaining) { await safeEndStorage(); return res.status(403).json({ success: false, message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(trafficState.remaining)}` }); } // 设置响应头(包含文件大小,浏览器可显示下载进度) res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Length', fileSize); // 关闭 Nginx 代理缓冲,避免上游提前读完整文件导致流量计量失真 res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(fileName) + '"; filename*=UTF-8\'\'' + encodeURIComponent(fileName)); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0; // 创建文件流并传输(流式下载,服务器不保存临时文件) const stream = await storage.createReadStream(normalizedPath); stream.on('data', (chunk) => { if (!chunk) return; downloadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); }); res.on('finish', () => { finalizeTransfer('finish').catch(err => { console.error('下载完成后资源释放失败:', err); }); }); res.on('close', () => { finalizeTransfer('close').catch(err => { console.error('下载连接关闭后资源释放失败:', err); }); }); stream.on('error', (error) => { console.error('文件流错误:', error); if (!res.headersSent) { res.status(500).json({ success: false, message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '下载文件') }); } finalizeTransfer('stream_error').catch(err => { console.error('流错误后资源释放失败:', err); }); }); stream.pipe(res); } catch (error) { console.error('下载文件失败:', error); // 如果stream还未创建或发生错误,关闭storage连接 await finalizeTransfer('catch_error'); if (!res.headersSent) { res.status(500).json({ success: false, message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '下载文件') }); } } }); // 生成上传工具(生成新密钥并创建配置文件) app.post('/api/upload/generate-tool', authMiddleware, async (req, res) => { try { // 生成新的API密钥(32位随机字符串) 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 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密钥获取OSS配置(供上传工具调用) // 添加速率限制防止暴力枚举 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: '账号已被封禁' }); } // 确定用户的存储类型 const storageType = user.current_storage_type || 'local'; // 返回配置信息(新版上传工具 v3.0 使用服务器 API 上传,无需返回 OSS 凭证) res.json({ success: true, config: { storage_type: storageType, username: user.username, // OSS 配置(仅用于显示) oss_provider: user.oss_provider, oss_bucket: user.oss_bucket } }); } catch (error) { console.error('获取OSS配置失败:', error); res.status(500).json({ success: false, message: '服务器内部错误,请稍后重试' }); } }); // 创建分享链接 app.post('/api/share/create', authMiddleware, (req, res) => { try { const { share_type, file_path, file_name, password, expiry_days, max_downloads, ip_whitelist, device_limit, access_time_start, access_time_end } = req.body; // 参数验证:share_type 只能是 'file' 或 'directory' const validShareTypes = ['file', 'directory']; const actualShareType = share_type || 'file'; if (!validShareTypes.includes(actualShareType)) { return res.status(400).json({ success: false, message: '无效的分享类型,只能是 file 或 directory' }); } // 参数验证:file_path 不能为空 if (!file_path) { return res.status(400).json({ success: false, message: actualShareType === 'file' ? '文件路径不能为空' : '目录路径不能为空' }); } // 参数验证:expiry_days 必须为正整数或 null if (expiry_days !== undefined && expiry_days !== null) { const days = parseInt(expiry_days, 10); if (isNaN(days) || days <= 0 || days > 365) { return res.status(400).json({ success: false, message: '有效期必须是1-365之间的整数' }); } } const hasPasswordField = Object.prototype.hasOwnProperty.call(req.body || {}, 'password'); let normalizedPassword = null; if (hasPasswordField) { if (password !== null && password !== undefined && typeof password !== 'string') { return res.status(400).json({ success: false, message: '密码格式无效' }); } if (typeof password === 'string') { normalizedPassword = password.trim(); } if (password !== null && password !== undefined && !normalizedPassword) { return res.status(400).json({ success: false, message: '已启用密码保护时,访问密码不能为空' }); } if (normalizedPassword && normalizedPassword.length > 32) { return res.status(400).json({ success: false, message: '密码长度不能超过32个字符' }); } } let normalizedMaxDownloads = null; if (max_downloads !== undefined && max_downloads !== null && max_downloads !== '') { const parsedMaxDownloads = parseInt(max_downloads, 10); if (!Number.isFinite(parsedMaxDownloads) || parsedMaxDownloads < 1 || parsedMaxDownloads > 1000000) { return res.status(400).json({ success: false, message: '下载次数上限必须是 1 到 1000000 的整数' }); } normalizedMaxDownloads = parsedMaxDownloads; } let normalizedWhitelist = null; if (ip_whitelist !== undefined && ip_whitelist !== null && ip_whitelist !== '') { const rawWhitelist = Array.isArray(ip_whitelist) ? ip_whitelist.join(',') : String(ip_whitelist); const whitelistItems = parseShareIpWhitelist(rawWhitelist); if (whitelistItems.length === 0) { return res.status(400).json({ success: false, message: 'IP 白名单格式无效' }); } normalizedWhitelist = whitelistItems.join(','); } const allowedDeviceLimit = new Set(['all', 'mobile', 'desktop']); const normalizedDeviceLimit = typeof device_limit === 'string' && device_limit.trim() ? device_limit.trim() : 'all'; if (!allowedDeviceLimit.has(normalizedDeviceLimit)) { return res.status(400).json({ success: false, message: '设备限制参数无效' }); } const normalizedAccessTimeStart = normalizeTimeHHmm(access_time_start || ''); const normalizedAccessTimeEnd = normalizeTimeHHmm(access_time_end || ''); if ((normalizedAccessTimeStart && !normalizedAccessTimeEnd) || (!normalizedAccessTimeStart && normalizedAccessTimeEnd)) { return res.status(400).json({ success: false, message: '访问时间限制需同时提供开始和结束时间' }); } if ((access_time_start && !normalizedAccessTimeStart) || (access_time_end && !normalizedAccessTimeEnd)) { return res.status(400).json({ success: false, message: '访问时间格式必须为 HH:mm' }); } const normalizedSharePath = normalizeVirtualPath(file_path); if (!normalizedSharePath) { return res.status(400).json({ success: false, message: '路径包含非法字符' }); } const storageType = req.user.current_storage_type || 'oss'; const existingShare = ShareDB.findExistingByTarget(req.user.id, { share_type: actualShareType, file_path: normalizedSharePath, storage_type: storageType }); if (existingShare) { const shareUrl = `${getSecureBaseUrl(req)}/s/${existingShare.share_code}`; const securityPolicy = getSharePolicySummary(existingShare); logShare( req, 'reuse_share', `用户复用现有分享: ${actualShareType === 'file' ? '文件' : '目录'} ${normalizedSharePath}`, { shareId: existingShare.id, shareCode: existingShare.share_code, sharePath: normalizedSharePath, shareType: existingShare.share_type } ); return res.json({ success: true, message: '已复用现有分享链接', reused: true, share_id: existingShare.id, share_code: existingShare.share_code, share_url: shareUrl, share_type: existingShare.share_type, expires_at: existingShare.expires_at, has_password: !!existingShare.share_password, security_policy: securityPolicy }); } SystemLogDB.log({ level: 'info', category: 'share', action: 'create_share', message: '创建分享请求', details: { share_type: actualShareType, file_path: normalizedSharePath, file_name, expiry_days, max_downloads: normalizedMaxDownloads, ip_whitelist_count: normalizedWhitelist ? parseShareIpWhitelist(normalizedWhitelist).length : 0, device_limit: normalizedDeviceLimit, access_time_start: normalizedAccessTimeStart, access_time_end: normalizedAccessTimeEnd } }); const result = ShareDB.create(req.user.id, { share_type: actualShareType, file_path: normalizedSharePath, file_name: file_name || '', password: normalizedPassword || null, expiry_days: expiry_days || null, max_downloads: normalizedMaxDownloads, ip_whitelist: normalizedWhitelist, device_limit: normalizedDeviceLimit, access_time_start: normalizedAccessTimeStart, access_time_end: normalizedAccessTimeEnd }); // 更新分享的存储类型 db.prepare('UPDATE shares SET storage_type = ? WHERE id = ?') .run(storageType, result.id); const shareUrl = `${getSecureBaseUrl(req)}/s/${result.share_code}`; // 记录分享创建日志 logShare(req, 'create_share', `用户创建分享: ${actualShareType === 'file' ? '文件' : '目录'} ${normalizedSharePath}`, { shareCode: result.share_code, sharePath: normalizedSharePath, shareType: actualShareType, hasPassword: !!normalizedPassword, policy: getSharePolicySummary({ ...result, max_downloads: normalizedMaxDownloads, ip_whitelist: normalizedWhitelist, device_limit: normalizedDeviceLimit, access_time_start: normalizedAccessTimeStart, access_time_end: normalizedAccessTimeEnd }) } ); const securityPolicy = getSharePolicySummary({ ...result, max_downloads: normalizedMaxDownloads, ip_whitelist: normalizedWhitelist, device_limit: normalizedDeviceLimit, access_time_start: normalizedAccessTimeStart, access_time_end: normalizedAccessTimeEnd }); res.json({ success: true, message: '分享链接创建成功', reused: false, share_code: result.share_code, share_url: shareUrl, share_type: result.share_type, expires_at: result.expires_at, has_password: !!normalizedPassword, security_policy: securityPolicy }); } catch (error) { console.error('创建分享链接失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '创建分享链接失败,请稍后重试', '创建分享') }); } }); // 获取我的分享列表 app.get('/api/share/my', authMiddleware, (req, res) => { try { const shares = ShareDB.getUserShares(req.user.id); res.json({ success: true, shares: shares.map(share => { const hasPassword = !!share.share_password; const { share_password, ...safeShare } = share; return { ...safeShare, has_password: hasPassword, share_url: `${getSecureBaseUrl(req)}/s/${share.share_code}` }; }) }); } catch (error) { console.error('获取分享列表失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '获取分享列表失败,请稍后重试', '获取分享列表') }); } }); // 删除分享(增强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: getSafeErrorMessage(error, '删除分享失败,请稍后重试', '删除分享') }); } }); // ===== 直链管理(与分享链接独立) ===== // 创建文件直链 app.post('/api/direct-link/create', authMiddleware, [ body('file_path').isString().notEmpty().withMessage('文件路径不能为空'), body('file_name').optional({ nullable: true }).isString().withMessage('文件名格式无效'), body('expiry_days').optional({ nullable: true }).custom((value) => { if (value === null || value === undefined || value === '') { return true; } const days = parseInt(value, 10); if (Number.isNaN(days) || days < 1 || days > 365) { throw new Error('有效期必须是1-365之间的整数'); } return true; }) ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } const normalizedPath = normalizeVirtualPath(req.body?.file_path || ''); if (!normalizedPath) { return res.status(400).json({ success: false, message: '文件路径非法' }); } const expiryDays = req.body?.expiry_days === null || req.body?.expiry_days === undefined || req.body?.expiry_days === '' ? null : parseInt(req.body.expiry_days, 10); let storage; try { // 创建前校验文件是否存在且为文件 const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); const fileStats = await storage.stat(normalizedPath); if (fileStats?.isDirectory) { return res.status(400).json({ success: false, message: '直链仅支持文件,不支持目录' }); } const resolvedFileName = (typeof req.body?.file_name === 'string' && req.body.file_name.trim()) ? req.body.file_name.trim() : (normalizedPath.split('/').pop() || 'download.bin'); const storageType = req.user.current_storage_type || 'oss'; const existingLink = DirectLinkDB.findExistingByTarget(req.user.id, { file_path: normalizedPath, storage_type: storageType }); if (existingLink) { const directUrl = `${getSecureBaseUrl(req)}/d/${existingLink.link_code}`; logShare( req, 'reuse_direct_link', `用户复用直链: ${normalizedPath}`, { linkId: existingLink.id, linkCode: existingLink.link_code, filePath: normalizedPath, storageType } ); return res.json({ success: true, message: '已复用现有直链', reused: true, link_id: existingLink.id, link_code: existingLink.link_code, file_path: normalizedPath, file_name: existingLink.file_name || resolvedFileName, storage_type: storageType, expires_at: existingLink.expires_at || null, direct_url: directUrl }); } const directLink = DirectLinkDB.create(req.user.id, { file_path: normalizedPath, file_name: resolvedFileName, storage_type: storageType, expiry_days: expiryDays }); const directUrl = `${getSecureBaseUrl(req)}/d/${directLink.link_code}`; logShare( req, 'create_direct_link', `用户创建直链: ${normalizedPath}`, { linkId: directLink.id, linkCode: directLink.link_code, filePath: normalizedPath, storageType, expiresAt: directLink.expires_at || null } ); res.json({ success: true, message: '直链创建成功', reused: false, link_id: directLink.id, link_code: directLink.link_code, file_path: normalizedPath, file_name: resolvedFileName, storage_type: storageType, expires_at: directLink.expires_at || null, direct_url: directUrl }); } catch (error) { console.error('创建直链失败:', error); if (String(error?.message || '').includes('不存在')) { return res.status(404).json({ success: false, message: '文件不存在' }); } res.status(500).json({ success: false, message: getSafeErrorMessage(error, '创建直链失败,请稍后重试', '创建直链') }); } finally { if (storage) await storage.end(); } } ); // 获取我的直链列表 app.get('/api/direct-link/my', authMiddleware, (req, res) => { try { const links = DirectLinkDB.getUserLinks(req.user.id); res.json({ success: true, links: links.map((link) => ({ ...link, direct_url: `${getSecureBaseUrl(req)}/d/${link.link_code}` })) }); } catch (error) { console.error('获取直链列表失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '获取直链列表失败,请稍后重试', '获取直链列表') }); } }); // 删除直链 app.delete('/api/direct-link/:id', authMiddleware, (req, res) => { try { const linkId = parseInt(req.params.id, 10); if (Number.isNaN(linkId) || linkId <= 0) { return res.status(400).json({ success: false, message: '无效的直链ID' }); } const link = DirectLinkDB.findById(linkId); if (!link) { return res.status(404).json({ success: false, message: '直链不存在' }); } if (link.user_id !== req.user.id) { return res.status(403).json({ success: false, message: '无权限删除此直链' }); } DirectLinkDB.delete(linkId, req.user.id); logShare( req, 'delete_direct_link', `用户删除直链: ${link.file_path}`, { linkId: link.id, linkCode: link.link_code, filePath: link.file_path } ); res.json({ success: true, message: '直链已删除' }); } catch (error) { console.error('删除直链失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '删除直链失败,请稍后重试', '删除直链') }); } }); // ===== 分享链接访问(公开) ===== // 获取公共主题设置(用于分享页面,无需认证) 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 globalTheme = SettingsDB.get('global_theme') || 'dark'; if (!isValidShareCode(code)) { return res.json({ success: true, theme: globalTheme }); } const share = ShareDB.findByCode(code); 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' }); } }); // 访问分享链接 - 验证密码(支持本地存储和OSS) app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) => { const { code } = req.params; const { password } = req.body; let storage; try { const share = ShareDB.findByCode(code); 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: '密码错误', needPassword: true }); } } // 清除失败记录(密码验证成功) if (req.shareRateLimitKey) { shareLimiter.recordSuccess(req.shareRateLimitKey); } const accessPolicy = evaluateShareSecurityPolicy(share, req, { action: 'view', enforceDownloadLimit: false }); if (!accessPolicy.allowed) { return res.status(403).json({ success: false, message: accessPolicy.message || '当前访问环境受限,请稍后再试', policy_code: accessPolicy.code || 'policy_blocked' }); } // 增加查看次数 ShareDB.incrementViewCount(code); // 构建返回数据 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, // 添加到期时间 security_policy: getSharePolicySummary(share) } }; // 如果是单文件分享,查询存储获取文件信息(带缓存) 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)) { responseData.file = shareFileCache.get(code); } else { // 缓存未命中,查询存储 try { // 获取分享者的用户信息 const shareOwner = UserDB.findById(share.user_id); if (!shareOwner) { throw new Error('分享者不存在'); } // 使用分享创建时记录的存储类型,而非用户当前的存储类型 // 这样即使用户切换了存储,分享链接仍然有效 const storageType = share.storage_type || 'oss'; // 使用统一存储接口 const { StorageInterface } = require('./storage'); const userForStorage = buildStorageUserContext(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 fileData = { name: fileName, type: 'file', isDirectory: false, httpDownloadUrl: null, // OSS 使用 API 下载 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; } // 存储失败时仍返回基本信息,只是没有大小 responseData.file = { name: fileName, type: 'file', isDirectory: false, httpDownloadUrl: null, size: 0, sizeFormatted: '-' }; } } } res.json(responseData); } catch (error) { console.error('验证分享失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '验证失败,请稍后重试', '分享验证') }); } finally { if (storage) await storage.end(); } }); // 获取分享的文件列表(支持本地存储和OSS) 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); if (!share) { return res.status(404).json({ success: false, message: '分享不存在' }); } // 验证密码 if (share.share_password) { const normalizedPassword = typeof password === 'string' ? password : ''; if (!normalizedPassword || !ShareDB.verifyPassword(normalizedPassword, share.share_password)) { // 记录密码错误 if (req.shareRateLimitKey) { shareLimiter.recordFailure(req.shareRateLimitKey); } return res.status(401).json({ success: false, message: '密码错误或未提供密码', needPassword: true }); } } // 清除失败记录(密码验证成功或无密码) if (req.shareRateLimitKey && share.share_password) { shareLimiter.recordSuccess(req.shareRateLimitKey); } const accessPolicy = evaluateShareSecurityPolicy(share, req, { action: 'view', enforceDownloadLimit: false }); if (!accessPolicy.allowed) { return res.status(403).json({ success: false, message: accessPolicy.message || '当前访问环境受限,请稍后再试', policy_code: accessPolicy.code || 'policy_blocked' }); } // 获取分享者的用户信息(查看列表不触发下载流量策略) // 仅在实际下载接口中校验和消耗下载流量,避免“可见性”受配额影响 const shareOwner = UserDB.findById(share.user_id); if (!shareOwner) { return res.status(404).json({ success: false, message: '分享者不存在' }); } // 构造安全的请求路径,防止越权遍历 const baseSharePath = normalizeVirtualPath(share.share_path || '/'); if (!baseSharePath) { return res.status(400).json({ success: false, message: '分享路径非法' }); } const rawSubPath = typeof subPath === 'string' ? subPath : ''; const requestedPath = rawSubPath ? normalizeVirtualPath(`${baseSharePath}/${rawSubPath}`) : baseSharePath; if (!requestedPath) { return res.status(400).json({ success: false, message: '请求路径非法' }); } // 校验请求路径是否在分享范围内 if (!isPathWithinShare(requestedPath, share)) { return res.status(403).json({ success: false, message: '无权访问该路径' }); } // 使用统一存储接口,根据分享的storage_type选择存储后端 const { StorageInterface } = require('./storage'); const storageType = share.storage_type || 'oss'; console.log(`[分享列表] 存储类型: ${storageType}, 分享路径: ${share.share_path}`); // 临时构造用户对象以使用存储接口 const userForStorage = buildStorageUserContext(shareOwner, { current_storage_type: storageType }); const storageInterface = new StorageInterface(userForStorage); storage = await storageInterface.connect(); 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) { formattedList = [{ name: fileInfo.name, type: 'file', size: fileInfo.size, sizeFormatted: formatFileSize(fileInfo.size), modifiedAt: new Date(fileInfo.modifyTime), isDirectory: false, httpDownloadUrl: null // OSS 使用 API 下载 }]; } } // 如果是目录分享(分享所有文件) else { const list = await storage.list(requestedPath); formattedList = list.map(item => { 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: null // OSS 使用 API 下载 }; }); 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: getSafeErrorMessage(error, '获取文件列表失败,请稍后重试', '分享列表') }); } finally { if (storage) await storage.end(); } }); // 记录下载次数(添加限流保护防止滥用) app.post('/api/share/:code/download', shareRateLimitMiddleware, (req, res) => { const { code } = req.params; // 参数验证:code 不能为空 if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) { return res.status(400).json({ success: false, message: '无效的分享码' }); } try { const share = ShareDB.findByCode(code); if (!share) { return res.status(404).json({ success: false, message: '分享不存在' }); } const accessPolicy = evaluateShareSecurityPolicy(share, req, { action: 'download', enforceDownloadLimit: true }); if (!accessPolicy.allowed) { return res.status(403).json({ success: false, message: accessPolicy.message || '下载已受限', policy_code: accessPolicy.code || 'policy_blocked' }); } // 增加下载次数 ShareDB.incrementDownloadCount(code); res.json({ success: true, message: '下载统计已记录' }); } catch (error) { console.error('记录下载失败:', error); res.status(500).json({ success: false, message: '记录下载失败: ' + error.message }); } }); // 生成分享文件下载签名 URL(OSS 直连下载,公开 API,添加限流保护) app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, res) => { const { code } = req.params; const { path: filePath, password } = req.body || {}; // 参数验证:code 不能为空 if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) { return res.status(400).json({ success: false, message: '无效的分享码' }); } const normalizedFilePath = normalizeVirtualPath(filePath); if (!normalizedFilePath) { 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: '密码错误或未提供密码', needPassword: true }); } if (req.shareRateLimitKey) { shareLimiter.recordSuccess(req.shareRateLimitKey); } } // 安全验证:检查请求路径是否在分享范围内 if (!isPathWithinShare(normalizedFilePath, share)) { return res.status(403).json({ success: false, message: '无权访问该文件' }); } const accessPolicy = evaluateShareSecurityPolicy(share, req, { action: 'download', enforceDownloadLimit: true }); if (!accessPolicy.allowed) { return res.status(403).json({ success: false, message: accessPolicy.message || '下载已受限', policy_code: accessPolicy.code || 'policy_blocked' }); } // 获取分享者的用户信息 const ownerPolicyState = enforceDownloadTrafficPolicy(share.user_id, 'share_download'); const shareOwner = ownerPolicyState?.user || UserDB.findById(share.user_id); if (!shareOwner) { return res.status(404).json({ success: false, message: '分享者不存在' }); } const storageType = share.storage_type || 'oss'; const ownerTrafficState = getDownloadTrafficState(shareOwner); if (storageType === 'oss') { const securityResult = evaluateDownloadSecurityPolicy(req, { ownerUserId: shareOwner.id, filePath: normalizedFilePath, source: 'share_download_url_oss' }); if (!securityResult.allowed) { return handleDownloadSecurityBlock(req, res, securityResult, { statusCode: 503, ownerUserId: shareOwner.id, filePath: normalizedFilePath, source: 'share_download_url_oss' }); } } // 本地存储:继续走后端下载 if (storageType !== 'oss') { if (!ownerTrafficState.isUnlimited) { const { StorageInterface } = require('./storage'); const userForStorage = buildStorageUserContext(shareOwner, { current_storage_type: storageType }); const storageInterface = new StorageInterface(userForStorage); let storage = null; try { storage = await storageInterface.connect(); const fileStats = await storage.stat(normalizedFilePath); const fileSize = Number(fileStats?.size || 0); if (!Number.isFinite(fileSize) || fileSize <= 0) { return res.status(503).json({ success: false, message: getBusyDownloadMessage() }); } if (fileSize > ownerTrafficState.remaining) { return res.status(503).json({ success: false, message: getBusyDownloadMessage() }); } } catch (statError) { if (statError && (statError.code === 'ENOENT' || String(statError.message || '').includes('不存在'))) { return res.status(404).json({ success: false, message: '文件不存在' }); } throw statError; } finally { if (storage) { await storage.end(); } } } let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(normalizedFilePath)}`; if (share.share_password) { const downloadToken = signEphemeralToken({ type: 'share_download', code, path: normalizedFilePath }, 15 * 60); downloadUrl += `&token=${encodeURIComponent(downloadToken)}`; } return res.json({ success: true, downloadUrl, direct: false, quotaLimited: !ownerTrafficState.isUnlimited }); } // OSS 模式:检查配置并生成签名 URL const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!shareOwner.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '分享者未配置 OSS 服务' }); } const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { client, bucket, ossClient } = createS3ClientContextForUser(shareOwner); const objectKey = ossClient.getObjectKey(normalizedFilePath); let fileSize = 0; if (!ownerTrafficState.isUnlimited) { let headResponse; try { headResponse = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey })); } catch (headError) { const statusCode = headError?.$metadata?.httpStatusCode; if (headError?.name === 'NotFound' || headError?.name === 'NoSuchKey' || statusCode === 404) { return res.status(404).json({ success: false, message: '文件不存在' }); } throw headError; } const contentLength = Number(headResponse?.ContentLength || 0); fileSize = Number.isFinite(contentLength) && contentLength > 0 ? Math.floor(contentLength) : 0; if (fileSize <= 0) { return res.status(503).json({ success: false, message: getBusyDownloadMessage() }); } } // 创建 GetObject 命令 const command = new GetObjectCommand({ Bucket: bucket, Key: objectKey, ResponseContentDisposition: `attachment; filename="${encodeURIComponent(normalizedFilePath.split('/').pop())}"` }); // 生成签名 URL(短时有效,默认30秒) const signedUrl = await getSignedUrl(client, command, { expiresIn: DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS }); if (!ownerTrafficState.isUnlimited && fileSize > 0) { const reserveResult = reserveDirectDownloadTraffic(shareOwner.id, fileSize, { source: 'share_direct', objectKey, ttlMs: DOWNLOAD_RESERVATION_TTL_MS }); if (!reserveResult?.ok) { return res.status(503).json({ success: false, message: getBusyDownloadMessage() }); } } res.json({ success: true, downloadUrl: signedUrl, direct: true, quotaLimited: !ownerTrafficState.isUnlimited, expiresIn: DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS }); } catch (error) { console.error('[分享签名] 生成下载签名失败:', error); res.status(500).json({ success: false, message: getSafeErrorMessage(error, '生成下载签名失败,请稍后重试', '生成下载签名') }); } }); // 分享文件下载(支持本地存储和 OSS,公开 API,需要分享码和密码验证) app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, res) => { const { code } = req.params; const rawFilePath = typeof req.query?.path === 'string' ? req.query.path : ''; const { password, token } = req.query; const filePath = normalizeVirtualPath(rawFilePath); let storage; let storageEnded = false; // 防止重复关闭 let transferFinalized = false; // 防止重复结算 let downloadedBytes = 0; let responseBodyStartSocketBytes = 0; let shareOwnerId = null; // Express 会将 HEAD 映射到 GET 处理器,这里显式拒绝,避免误触发下载计量 if (req.method === 'HEAD') { res.setHeader('Allow', 'GET'); return res.status(405).end(); } // 安全关闭存储连接的辅助函数 const safeEndStorage = async () => { if (storage && !storageEnded) { storageEnded = true; try { await storage.end(); } catch (err) { console.error('关闭存储连接失败:', err); } } }; const finalizeTransfer = async (reason = '') => { if (transferFinalized) { return; } transferFinalized = true; try { if (shareOwnerId && downloadedBytes > 0) { const socketBytesWritten = Number(res.socket?.bytesWritten); const socketBodyBytes = Number.isFinite(socketBytesWritten) && socketBytesWritten > responseBodyStartSocketBytes ? Math.floor(socketBytesWritten - responseBodyStartSocketBytes) : 0; const billableBytes = socketBodyBytes > 0 ? Math.min(downloadedBytes, socketBodyBytes) : downloadedBytes; const usageResult = applyDownloadTrafficUsage(shareOwnerId, billableBytes); if (usageResult) { const quotaText = usageResult.quota > 0 ? formatFileSize(usageResult.quota) : '不限'; console.log( `[分享下载流量] 用户 ${shareOwnerId} 新增 ${formatFileSize(usageResult.added)},` + `累计 ${formatFileSize(usageResult.usedAfter)} / ${quotaText} ` + `(reason=${reason || 'unknown'}, streamed=${downloadedBytes}, socket=${socketBodyBytes})` ); } } } catch (error) { console.error(`[分享下载流量] 结算失败: user=${shareOwnerId}, bytes=${downloadedBytes}`, error); } await safeEndStorage(); }; 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: '分享不存在' }); } // 验证密码(如果需要),支持短期下载 token(避免密码出现在 URL) if (share.share_password) { let verifiedByToken = false; if (token) { const tokenResult = verifyEphemeralToken(token, 'share_download'); if (tokenResult.valid) { const tokenPayload = tokenResult.payload || {}; if (tokenPayload.code === code && tokenPayload.path === filePath) { verifiedByToken = true; } } } if (!verifiedByToken) { if (!password || !ShareDB.verifyPassword(password, share.share_password)) { // 只在密码错误时记录失败 if (req.shareRateLimitKey) { shareLimiter.recordFailure(req.shareRateLimitKey); } return res.status(401).json({ success: false, message: '密码错误或未提供密码', needPassword: true }); } } // 密码验证成功,清除失败记录 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 accessPolicy = evaluateShareSecurityPolicy(share, req, { action: 'download', enforceDownloadLimit: true }); if (!accessPolicy.allowed) { return res.status(403).json({ success: false, message: accessPolicy.message || '下载已受限', policy_code: accessPolicy.code || 'policy_blocked' }); } // 获取分享者的用户信息 const shareOwner = UserDB.findById(share.user_id); if (!shareOwner) { return res.status(404).json({ success: false, message: '分享者不存在' }); } shareOwnerId = shareOwner.id; const securityResult = evaluateDownloadSecurityPolicy(req, { ownerUserId: shareOwner.id, filePath, source: 'share_download_stream' }); if (!securityResult.allowed) { return handleDownloadSecurityBlock(req, res, securityResult, { statusCode: 503, ownerUserId: shareOwner.id, filePath, source: 'share_download_stream' }); } const ownerTrafficState = getDownloadTrafficState(shareOwner); // 使用统一存储接口,根据分享的storage_type选择存储后端 // 注意:必须使用分享创建时记录的 storage_type,而不是分享者当前的存储类型 // 这样即使用户后来切换了存储类型,之前创建的分享仍然可以正常工作 const { StorageInterface } = require('./storage'); const storageType = share.storage_type || 'oss'; console.log(`[分享下载] 存储类型: ${storageType} (分享记录), 文件路径: ${filePath}`); // 临时构造用户对象以使用存储接口 const userForStorage = buildStorageUserContext(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} 字节`); if (!ownerTrafficState.isUnlimited && fileSize > ownerTrafficState.remaining) { await safeEndStorage(); return res.status(403).json({ success: false, message: `分享者下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(ownerTrafficState.remaining)}` }); } // 增加下载次数 ShareDB.incrementDownloadCount(code); // 设置响应头(包含文件大小,浏览器可显示下载进度) res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Length', fileSize); // 关闭 Nginx 代理缓冲,减少代理预读导致的计量偏差 res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0; // 创建文件流并传输(流式下载,服务器不保存临时文件) const stream = await storage.createReadStream(filePath); stream.on('data', (chunk) => { if (!chunk) return; downloadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); }); res.on('finish', () => { finalizeTransfer('finish').catch(err => { console.error('分享下载完成后资源释放失败:', err); }); }); res.on('close', () => { finalizeTransfer('close').catch(err => { console.error('分享下载连接关闭后资源释放失败:', err); }); }); stream.on('error', (error) => { console.error('文件流错误:', error); if (!res.headersSent) { res.status(500).json({ success: false, message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载') }); } finalizeTransfer('stream_error').catch(err => { console.error('分享下载流错误后资源释放失败:', err); }); }); stream.pipe(res); } catch (error) { console.error('分享下载文件失败:', error); if (!res.headersSent) { res.status(500).json({ success: false, message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载') }); } // 如果发生错误,结算并关闭存储连接 await finalizeTransfer('catch_error'); } }); // ===== 管理员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'; const downloadSecurity = getDownloadSecuritySettings(); const desktopUpdate = getDesktopUpdateConfig(); res.json({ success: true, settings: { max_upload_size: maxUploadSize, global_theme: globalTheme, download_security: downloadSecurity, desktop_update: { latest_version: desktopUpdate.latestVersion, installer_url: desktopUpdate.installerUrl, installer_sha256: desktopUpdate.installerSha256, installer_size: desktopUpdate.packageSize, release_notes: desktopUpdate.releaseNotes, force_update: desktopUpdate.mandatory }, 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 }); } }); // 更新系统设置 // 注意:已移除 requirePasswordConfirmation 中间件,依赖管理员登录认证 app.post('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { try { const { max_upload_size, smtp, global_theme, download_security, desktop_update } = req.body; let desktopInstallerCleanup = null; 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 (download_security !== undefined) { if (!download_security || typeof download_security !== 'object') { return res.status(400).json({ success: false, message: '下载安全策略配置格式错误' }); } const currentSecuritySettings = getDownloadSecuritySettings(); const normalizedSecuritySettings = normalizeDownloadSecuritySettingsPayload( download_security, currentSecuritySettings ); saveDownloadSecuritySettings(normalizedSecuritySettings); } 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); } } if (desktop_update !== undefined) { if (!desktop_update || typeof desktop_update !== 'object') { return res.status(400).json({ success: false, message: '桌面端更新配置格式错误' }); } if (desktop_update.latest_version !== undefined) { SettingsDB.set('desktop_latest_version', normalizeVersion(desktop_update.latest_version, DEFAULT_DESKTOP_VERSION)); } if (desktop_update.installer_url !== undefined) { const normalizedInstallerUrl = String(desktop_update.installer_url || '').trim(); SettingsDB.set('desktop_installer_url', normalizedInstallerUrl); SettingsDB.set('desktop_installer_url_win_x64', normalizedInstallerUrl); if (!normalizedInstallerUrl) { SettingsDB.set('desktop_installer_sha256', ''); SettingsDB.set('desktop_installer_size', '0'); } else { const localMeta = getLocalDesktopInstallerMeta(normalizedInstallerUrl); if (localMeta?.sha256) { SettingsDB.set('desktop_installer_sha256', localMeta.sha256); } if (localMeta?.size) { SettingsDB.set('desktop_installer_size', String(localMeta.size)); } } desktopInstallerCleanup = cleanupDesktopInstallerPackages(normalizedInstallerUrl); } if (desktop_update.installer_sha256 !== undefined) { const rawDigest = String(desktop_update.installer_sha256 || '').trim().toLowerCase(); const normalizedDigest = normalizeSha256(rawDigest); if (rawDigest && !normalizedDigest) { return res.status(400).json({ success: false, message: '安装包 SHA256 格式无效' }); } SettingsDB.set('desktop_installer_sha256', normalizedDigest); } if (desktop_update.installer_size !== undefined) { const rawSize = Number(desktop_update.installer_size); if (!Number.isFinite(rawSize) || rawSize < 0) { return res.status(400).json({ success: false, message: '安装包大小格式无效' }); } SettingsDB.set('desktop_installer_size', String(normalizeNonNegativeInteger(rawSize, 0))); } if (desktop_update.release_notes !== undefined) { SettingsDB.set('desktop_release_notes', String(desktop_update.release_notes || '').trim()); } if (desktop_update.force_update !== undefined) { SettingsDB.set('desktop_force_update', desktop_update.force_update ? 'true' : 'false'); } } const payload = { success: true, message: '系统设置已更新' }; if (desktopInstallerCleanup) { payload.desktop_installer_cleanup = desktopInstallerCleanup; } res.json(payload); } 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) }); } }); // ===== 统一 OSS 配置管理(管理员专用) ===== // 获取统一 OSS 配置 app.get('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, (req, res) => { try { const config = SettingsDB.getUnifiedOssConfig(); if (!config) { return res.json({ success: true, configured: false, config: null }); } // 返回配置(隐藏 Secret Key) res.json({ success: true, configured: true, config: { provider: config.provider, region: config.region, access_key_id: config.access_key_id, bucket: config.bucket, endpoint: config.endpoint, has_secret: !!config.access_key_secret } }); } catch (error) { console.error('获取统一 OSS 配置失败:', error); res.status(500).json({ success: false, message: '获取配置失败: ' + error.message }); } }); // 设置/更新统一 OSS 配置 app.post('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, [ body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), body('region').notEmpty().withMessage('地域不能为空'), body('access_key_id').notEmpty().withMessage('Access Key ID不能为空'), body('access_key_secret').notEmpty().withMessage('Access Key Secret不能为空'), body('bucket').notEmpty().withMessage('存储桶名称不能为空'), body('endpoint').optional({ checkFalsy: true }).isURL().withMessage('Endpoint必须是有效的URL') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { provider, region, access_key_id, access_key_secret, bucket, endpoint } = req.body; // 验证 OSS 连接 try { const testUser = { id: 0, // 系统测试用户 oss_provider: provider, oss_region: region, oss_access_key_id: access_key_id, oss_access_key_secret: access_key_secret, oss_bucket: bucket, oss_endpoint: endpoint }; const ossClient = new OssStorageClient(testUser); await ossClient.connect(); // 尝试列出 bucket 内容(验证配置是否正确) await ossClient.list('/'); await ossClient.end(); } catch (error) { return res.status(400).json({ success: false, message: 'OSS连接失败,请检查配置: ' + error.message }); } // 保存统一 OSS 配置 SettingsDB.setUnifiedOssConfig({ provider, region, access_key_id, access_key_secret, bucket, endpoint: endpoint || '' }); // 记录系统日志 SystemLogDB.log({ level: SystemLogDB.LEVELS.INFO, category: SystemLogDB.CATEGORIES.SYSTEM, action: 'update_unified_oss_config', message: '管理员更新了统一 OSS 配置', userId: req.user.id, username: req.user.username, details: { provider, region, bucket, endpoint: endpoint || '' } }); res.json({ success: true, message: '统一 OSS 配置已更新,所有用户将使用此配置' }); } catch (error) { console.error('更新统一 OSS 配置失败:', error); res.status(500).json({ success: false, message: '更新配置失败: ' + error.message }); } } ); // 测试统一 OSS 配置(不保存) app.post('/api/admin/unified-oss-config/test', authMiddleware, adminMiddleware, [ body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), body('region').notEmpty().withMessage('地域不能为空'), body('access_key_id').notEmpty().withMessage('Access Key ID不能为空'), body('bucket').notEmpty().withMessage('存储桶名称不能为空') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } try { const { provider, region, access_key_id, access_key_secret, bucket, endpoint } = req.body; // 验证 OSS 连接 const testUser = { id: 0, oss_provider: provider, oss_region: region, oss_access_key_id: access_key_id, oss_access_key_secret: access_key_secret, oss_bucket: bucket, oss_endpoint: endpoint }; const ossClient = new OssStorageClient(testUser); await ossClient.connect(); // 尝试列出 bucket 内容 await ossClient.list('/'); await ossClient.end(); res.json({ success: true, message: 'OSS 连接测试成功' }); } catch (error) { console.error('[OSS测试] 连接失败:', error); res.status(400).json({ success: false, message: 'OSS 连接失败: ' + error.message }); } } ); // 删除统一 OSS 配置 app.delete('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, (req, res) => { try { SettingsDB.clearUnifiedOssConfig(); // 记录系统日志 SystemLogDB.log({ level: SystemLogDB.LEVELS.INFO, category: SystemLogDB.CATEGORIES.SYSTEM, action: 'delete_unified_oss_config', message: '管理员删除了统一 OSS 配置', userId: req.user.id, username: req.user.username }); res.json({ success: true, message: '统一 OSS 配置已删除,用户将使用个人配置' }); } catch (error) { console.error('删除统一 OSS 配置失败:', error); res.status(500).json({ success: false, 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 = SHOULD_USE_SECURE_COOKIES; 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 = getResolvedStorageRoot(); 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 }); // 11. CSRF 保护检查 const csrfEnabled = ENABLE_CSRF; checks.push({ name: 'CSRF保护', category: 'security', status: csrfEnabled ? 'pass' : 'warning', message: csrfEnabled ? 'CSRF保护已启用(Double Submit Cookie模式)' : 'CSRF保护未启用(通过ENABLE_CSRF=true开启)', suggestion: csrfEnabled ? null : '生产环境建议启用CSRF保护以防止跨站请求伪造攻击' }); // 12. Session密钥检查 const sessionSecure = !DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET) && SESSION_SECRET.length >= 32; checks.push({ name: 'Session密钥', category: 'security', status: sessionSecure ? 'pass' : 'fail', message: sessionSecure ? 'Session密钥已正确配置' : 'Session密钥使用默认值或长度不足,存在安全风险!', suggestion: sessionSecure ? null : '请在.env中设置随机生成的SESSION_SECRET,至少32字符' }); if (!sessionSecure && overallStatus !== 'critical') overallStatus = 'critical'; // 统计 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 }); } }); // ===== 第二轮修复:WAL 文件管理接口 ===== /** * 获取 WAL 文件信息 * GET /api/admin/wal-info */ app.get('/api/admin/wal-info', authMiddleware, adminMiddleware, (req, res) => { try { const walSize = WalManager.getWalFileSize(); const walSizeMB = (walSize / 1024 / 1024).toFixed(2); res.json({ success: true, data: { walSize, walSizeMB: parseFloat(walSizeMB), status: walSize > 100 * 1024 * 1024 ? 'warning' : 'normal' } }); } catch (error) { console.error('获取 WAL 信息失败:', error); res.status(500).json({ success: false, message: '获取 WAL 信息失败: ' + error.message }); } }); /** * 手动执行 WAL 检查点(清理 WAL 文件) * POST /api/admin/wal-checkpoint */ app.post('/api/admin/wal-checkpoint', authMiddleware, adminMiddleware, (req, res) => { try { const beforeSize = WalManager.getWalFileSize(); const success = WalManager.performCheckpoint(); const afterSize = WalManager.getWalFileSize(); if (!success) { return res.status(500).json({ success: false, message: 'WAL 检查点执行失败' }); } // 记录 WAL 清理操作 logSystem(req, 'wal_checkpoint', `管理员手动执行 WAL 检查点`, { beforeSize, afterSize, freed: beforeSize - afterSize }); res.json({ success: true, message: `WAL 检查点完成: ${(beforeSize / 1024 / 1024).toFixed(2)}MB → ${(afterSize / 1024 / 1024).toFixed(2)}MB`, data: { beforeSize, afterSize, freed: beforeSize - afterSize } }); } catch (error) { console.error('执行 WAL 检查点失败:', error); res.status(500).json({ success: false, message: '执行 WAL 检查点失败: ' + error.message }); } } ); // 获取服务器存储统计信息 app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, res) => { try { // 获取本地存储目录(与 storage.js 保持一致) const localStorageDir = getResolvedStorageRoot(); // 确保存储目录存在 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 || 'oss_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 toAdminUserPayload = (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_oss_config: u.has_oss_config, created_at: u.created_at, storage_permission: u.storage_permission || 'oss_only', current_storage_type: u.current_storage_type || 'oss', local_storage_quota: u.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES, local_storage_used: u.local_storage_used || 0, oss_storage_quota: normalizeOssQuota(u.oss_storage_quota), storage_used: u.storage_used || 0, download_traffic_quota: normalizeDownloadTrafficQuota(u.download_traffic_quota), download_traffic_used: normalizeDownloadTrafficUsed( u.download_traffic_used, normalizeDownloadTrafficQuota(u.download_traffic_quota) ), download_traffic_quota_expires_at: u.download_traffic_quota_expires_at || null, download_traffic_reset_cycle: u.download_traffic_reset_cycle || 'none', download_traffic_last_reset_at: u.download_traffic_last_reset_at || null }); const hasPagedQuery = req.query?.paged === '1' || ['page', 'pageSize', 'keyword', 'role', 'status', 'storage', 'sort'] .some((key) => req.query && req.query[key] !== undefined); if (hasPagedQuery) { const queryResult = UserDB.queryAdminUsers(req.query || {}); const users = queryResult.rows.map((user) => { const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list_page'); return policyState?.user || user; }); return res.json({ success: true, users: users.map(toAdminUserPayload), pagination: queryResult.pagination, summary: queryResult.summary }); } const users = UserDB.getAll().map((user) => { const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list'); return policyState?.user || user; }); res.json({ success: true, users: users.map(toAdminUserPayload) }); } 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.get('/api/admin/download-reservations', authMiddleware, adminMiddleware, (req, res) => { try { const queryResult = DownloadTrafficReservationDB.getAdminList({ status: req.query?.status, userId: req.query?.user_id, keyword: req.query?.keyword, page: req.query?.page, pageSize: req.query?.pageSize }); res.json({ success: true, reservations: queryResult.rows || [], pagination: queryResult.pagination, summary: DownloadTrafficReservationDB.getAdminSummary() }); } catch (error) { console.error('获取下载预扣列表失败:', error); res.status(500).json({ success: false, message: '获取下载预扣列表失败' }); } }); // 下载流量预扣运维面板:手动释放单条 pending 预扣 app.post('/api/admin/download-reservations/:id/cancel', authMiddleware, adminMiddleware, (req, res) => { try { const reservationId = parseInt(req.params.id, 10); if (!Number.isFinite(reservationId) || reservationId <= 0) { return res.status(400).json({ success: false, message: '无效的预扣ID' }); } const cancelResult = DownloadTrafficReservationDB.cancelPendingById(reservationId); if (!cancelResult?.changes) { return res.status(404).json({ success: false, message: '预扣记录不存在或已完成' }); } logSystem( req, 'download_reservation_cancel', `管理员手动释放预扣 #${reservationId}`, { reservationId, row: cancelResult.row || null } ); res.json({ success: true, message: '预扣额度已释放', reservation: cancelResult.row || null }); } catch (error) { console.error('释放下载预扣失败:', error); res.status(500).json({ success: false, message: '释放下载预扣失败' }); } }); // 下载流量预扣运维面板:批量清理(过期 pending + 历史 finalized) app.post('/api/admin/download-reservations/cleanup', authMiddleware, adminMiddleware, (req, res) => { try { // 默认 keep_days=0:立即清理全部已完成历史记录,避免“清理无效果”的感知 const rawKeepDays = req.body?.keep_days; const parsedKeepDays = Number.isFinite(Number(rawKeepDays)) ? parseInt(rawKeepDays, 10) : 0; const keepDays = Math.min(365, Math.max(0, Number.isFinite(parsedKeepDays) ? parsedKeepDays : 0)); const expireResult = DownloadTrafficReservationDB.expirePendingReservations(); const cleanupResult = DownloadTrafficReservationDB.cleanupFinalizedHistory(keepDays); logSystem( req, 'download_reservation_cleanup', `管理员执行预扣清理(保留${keepDays}天)`, { keep_days: keepDays, expired_pending: Number(expireResult?.changes || 0), deleted_finalized: Number(cleanupResult?.changes || 0) } ); res.json({ success: true, message: `预扣清理完成(过期待确认 ${Number(expireResult?.changes || 0)} 条,删除历史 ${Number(cleanupResult?.changes || 0)} 条)`, result: { keep_days: keepDays, expired_pending: Number(expireResult?.changes || 0), deleted_finalized: Number(cleanupResult?.changes || 0), summary: DownloadTrafficReservationDB.getAdminSummary() } }); } catch (error) { console.error('清理下载预扣失败:', error); res.status(500).json({ success: false, message: '清理下载预扣失败' }); } }); // ===== 第二轮修复:存储缓存一致性检查和修复接口 ===== /** * 检查单个用户的存储缓存完整性 * GET /api/admin/storage-cache/check/:userId */ app.get('/api/admin/storage-cache/check/:userId', authMiddleware, adminMiddleware, async (req, res) => { try { const { userId } = req.params; const user = UserDB.findById(userId); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } // 检查是否有可用的OSS配置(个人配置或系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!user.has_oss_config && !hasUnifiedConfig) { return res.json({ success: true, message: '用户未配置 OSS,无需检查', data: { userId: user.id, username: user.username, hasOssConfig: false, consistent: true } }); } // 创建 OSS 客户端 const ossClient = createOssClientForUser(user); const result = await StorageUsageCache.checkIntegrity(userId, ossClient); // 记录检查操作 logSystem(req, 'storage_cache_check', `管理员检查用户 ${user.username} 的存储缓存`, { userId, username: user.username, ...result }); res.json({ success: true, message: result.consistent ? '缓存一致' : '缓存不一致', data: { userId: user.id, username: user.username, ...result } }); } catch (error) { console.error('检查存储缓存失败:', error); res.status(500).json({ success: false, message: '检查存储缓存失败: ' + error.message }); } } ); /** * 重建单个用户的存储缓存 * POST /api/admin/storage-cache/rebuild/:userId */ app.post('/api/admin/storage-cache/rebuild/:userId', authMiddleware, adminMiddleware, async (req, res) => { try { const { userId } = req.params; const user = UserDB.findById(userId); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } // 检查是否有可用的OSS配置(个人配置或系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!user.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '用户未配置 OSS' }); } // 创建 OSS 客户端 const ossClient = createOssClientForUser(user); const result = await StorageUsageCache.rebuildCache(userId, ossClient); // 记录修复操作 logSystem(req, 'storage_cache_rebuild', `管理员重建用户 ${user.username} 的存储缓存`, { userId, username: user.username, ...result }); res.json({ success: true, message: `缓存已重建: ${formatFileSize(result.previous)} → ${formatFileSize(result.current)} (${result.fileCount} 个文件)`, data: { userId: user.id, username: user.username, ...result } }); } catch (error) { console.error('重建存储缓存失败:', error); res.status(500).json({ success: false, message: '重建存储缓存失败: ' + error.message }); } } ); /** * 批量检查所有用户的存储缓存一致性 * GET /api/admin/storage-cache/check-all */ app.get('/api/admin/storage-cache/check-all', authMiddleware, adminMiddleware, async (req, res) => { try { const users = UserDB.getAll(); // 创建获取 OSS 客户端的函数 const getOssClient = (user) => createOssClientForUser(user); const results = await StorageUsageCache.checkAllUsersIntegrity(users, getOssClient); // 统计 const total = results.length; const inconsistent = results.filter(r => !r.consistent && !r.error).length; const errors = results.filter(r => r.error).length; // 记录批量检查操作 logSystem(req, 'storage_cache_check_all', `管理员批量检查存储缓存`, { total, inconsistent, errors }); res.json({ success: true, message: `检查完成: ${total} 个用户,${inconsistent} 个不一致,${errors} 个错误`, data: { summary: { total, consistent: total - inconsistent - errors, inconsistent, errors }, results } }); } catch (error) { console.error('批量检查存储缓存失败:', error); res.status(500).json({ success: false, message: '批量检查存储缓存失败: ' + error.message }); } } ); /** * 自动检测并修复所有用户的缓存不一致 * POST /api/admin/storage-cache/auto-fix */ app.post('/api/admin/storage-cache/auto-fix', authMiddleware, adminMiddleware, async (req, res) => { try { const { threshold = 0 } = req.body; // 差异阈值(字节) const users = UserDB.getAll(); const fixResults = []; // 检查是否有系统级统一OSS配置 const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); for (const user of users) { // 跳过没有配置 OSS 的用户(个人配置或系统级配置都没有) if (!user.has_oss_config && !hasUnifiedConfig) { continue; } try { const ossClient = createOssClientForUser(user); const result = await StorageUsageCache.autoDetectAndFix(user.id, ossClient, threshold); fixResults.push({ userId: user.id, username: user.username, ...result }); } catch (error) { console.error(`自动修复用户 ${user.id} 失败:`, error.message); fixResults.push({ userId: user.id, username: user.username, error: error.message }); } } // 统计 const total = fixResults.length; const fixed = fixResults.filter(r => r.autoFixed).length; const errors = fixResults.filter(r => r.error).length; // 记录批量修复操作 logSystem(req, 'storage_cache_auto_fix', `管理员自动修复存储缓存`, { total, fixed, errors, threshold }); res.json({ success: true, message: `自动修复完成: ${total} 个用户,${fixed} 个已修复,${errors} 个错误`, data: { summary: { total, fixed, skipped: total - fixed - errors, errors }, results: fixResults } }); } 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; // 参数验证:验证 ID 格式 const userId = parseInt(id, 10); if (isNaN(userId) || userId <= 0) { return res.status(400).json({ success: false, message: '无效的用户ID' }); } // 参数验证:验证 banned 是否为布尔值 if (typeof banned !== 'boolean') { return res.status(400).json({ success: false, message: 'banned 参数必须为布尔值' }); } // 安全检查:不能封禁自己 if (userId === req.user.id) { return res.status(400).json({ success: false, message: '不能封禁自己的账号' }); } // 检查目标用户是否存在 const targetUser = UserDB.findById(userId); if (!targetUser) { return res.status(404).json({ success: false, message: '用户不存在' }); } // 安全检查:不能封禁其他管理员(除非是超级管理员) if (targetUser.is_admin && !req.user.is_super_admin) { return res.status(403).json({ success: false, message: '不能封禁管理员账号' }); } UserDB.setBanStatus(userId, banned); // 记录管理员操作日志 logUser(req, banned ? 'ban_user' : 'unban_user', `管理员${banned ? '封禁' : '解封'}用户: ${targetUser.username}`, { targetUserId: userId, targetUsername: targetUser.username } ); 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; // 参数验证:验证 ID 格式 const userId = parseInt(id, 10); if (isNaN(userId) || userId <= 0) { return res.status(400).json({ success: false, message: '无效的用户ID' }); } if (userId === req.user.id) { return res.status(400).json({ success: false, message: '不能删除自己的账号' }); } // 获取用户信息 const user = UserDB.findById(userId); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } const deletionLog = { userId: userId, username: user.username, deletedFiles: [], deletedShares: 0, warnings: [] }; // 1. 删除本地存储文件(如果用户使用了本地存储) const storagePermission = user.storage_permission || 'oss_only'; if (storagePermission === 'local_only' || storagePermission === 'user_choice') { const storageRoot = getResolvedStorageRoot(); const userStorageDir = path.join(storageRoot, `user_${userId}`); 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. OSS存储文件 - 只记录警告,不实际删除(安全考虑) if (user.has_oss_config && (storagePermission === 'oss_only' || storagePermission === 'user_choice')) { deletionLog.warnings.push( `用户配置了OSS存储 (${user.oss_provider}:${user.oss_bucket}),OSS文件未自动删除,请手动处理` ); } // 3. 删除用户的所有分享记录 try { const userShares = ShareDB.getUserShares(userId); 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(userId); // 构建响应消息 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} 条分享`; } // 记录管理员删除用户操作日志 logUser(req, 'delete_user', `管理员删除用户: ${user.username}`, { targetUserId: userId, targetUsername: user.username, targetEmail: user.email, deletedShares: deletionLog.deletedShares, deletedFiles: deletionLog.deletedFiles.length, warnings: deletionLog.warnings }); res.json({ success: true, message, details: deletionLog }); } catch (error) { console.error('删除用户失败:', error); res.status(500).json({ success: false, message: '删除用户失败: ' + error.message }); } }); // 辅助函数:计算目录大小 function getUserDirectorySize(dirPath) { if (!dirPath || !fs.existsSync(dirPath)) { return 0; } 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', 'oss_only', 'user_choice']).withMessage('无效的存储权限'), body('local_storage_quota').optional({ nullable: true }).isInt({ min: 1048576, max: 10995116277760 }).withMessage('本地配额必须在 1MB 到 10TB 之间'), body('oss_storage_quota').optional({ nullable: true }).isInt({ min: 1048576, max: 10995116277760 }).withMessage('OSS配额必须在 1MB 到 10TB 之间'), body('download_traffic_quota').optional({ nullable: true }).isInt({ min: -1, max: MAX_DOWNLOAD_TRAFFIC_BYTES }).withMessage('下载流量配额必须在 -1 到 10TB 之间(-1表示不限,0表示禁止下载)'), body('download_traffic_delta').optional({ nullable: true }).isInt({ min: -MAX_DOWNLOAD_TRAFFIC_BYTES, max: MAX_DOWNLOAD_TRAFFIC_BYTES }).withMessage('下载流量增减值必须在 -10TB 到 10TB 之间'), body('download_traffic_reset_cycle').optional({ nullable: true }).isIn(['none', 'daily', 'weekly', 'monthly']).withMessage('下载流量重置周期无效'), body('download_traffic_quota_expires_at').optional({ nullable: true }).custom((value) => { if (value === null || value === undefined || value === '') { return true; } const parsedDate = parseDateTimeValue(String(value)); if (!parsedDate) { throw new Error('下载流量到期时间格式无效'); } return true; }), body('reset_download_traffic_used').optional({ nullable: true }).isBoolean().withMessage('reset_download_traffic_used 必须是布尔值') ], (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, oss_storage_quota, download_traffic_quota, download_traffic_delta, download_traffic_quota_expires_at, download_traffic_reset_cycle, reset_download_traffic_used } = req.body; // 参数验证:验证 ID 格式 const userId = parseInt(id, 10); if (isNaN(userId) || userId <= 0) { return res.status(400).json({ success: false, message: '无效的用户ID' }); } const now = new Date(); const nowSql = formatDateTimeForSqlite(now); const updates = { storage_permission }; const hasSetTrafficQuota = download_traffic_quota !== undefined && download_traffic_quota !== null; const hasTrafficDelta = download_traffic_delta !== undefined && download_traffic_delta !== null && Number(download_traffic_delta) !== 0; const shouldResetTrafficUsedNow = reset_download_traffic_used === true || reset_download_traffic_used === 'true'; if (hasSetTrafficQuota && hasTrafficDelta) { return res.status(400).json({ success: false, message: '下载流量配额不能同时使用“直接设置”和“增减调整”' }); } // 如果提供了本地配额,更新配额(单位:字节) if (local_storage_quota !== undefined && local_storage_quota !== null) { updates.local_storage_quota = parseInt(local_storage_quota, 10); } // 如果提供了 OSS 配额,更新配额(单位:字节) if (oss_storage_quota !== undefined && oss_storage_quota !== null) { updates.oss_storage_quota = parseInt(oss_storage_quota, 10); } // 根据权限设置自动调整存储类型 const beforePolicyState = enforceDownloadTrafficPolicy(userId, 'admin_update_before'); const user = beforePolicyState?.user || UserDB.findById(userId); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } const beforeTrafficState = getDownloadTrafficState(user); let targetTrafficQuota = beforeTrafficState.quota; if (hasSetTrafficQuota) { targetTrafficQuota = normalizeDownloadTrafficQuota(parseInt(download_traffic_quota, 10)); } else if (hasTrafficDelta) { const delta = parseInt(download_traffic_delta, 10); targetTrafficQuota = Math.max( 0, Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, beforeTrafficState.quota + delta) ); } if (hasSetTrafficQuota || hasTrafficDelta) { updates.download_traffic_quota = targetTrafficQuota; updates.download_traffic_used = normalizeDownloadTrafficUsed( user.download_traffic_used, targetTrafficQuota ); } if (targetTrafficQuota <= 0) { updates.download_traffic_quota_expires_at = null; updates.download_traffic_reset_cycle = 'none'; updates.download_traffic_last_reset_at = null; } else { if (download_traffic_quota_expires_at !== undefined) { if (download_traffic_quota_expires_at === null || download_traffic_quota_expires_at === '') { updates.download_traffic_quota_expires_at = null; } else { const parsedExpireAt = parseDateTimeValue(String(download_traffic_quota_expires_at)); updates.download_traffic_quota_expires_at = parsedExpireAt ? formatDateTimeForSqlite(parsedExpireAt) : null; } } if (download_traffic_reset_cycle !== undefined && download_traffic_reset_cycle !== null) { updates.download_traffic_reset_cycle = download_traffic_reset_cycle; if (download_traffic_reset_cycle === 'none') { updates.download_traffic_last_reset_at = null; } else if (!user.download_traffic_last_reset_at) { updates.download_traffic_last_reset_at = nowSql; } } } if (shouldResetTrafficUsedNow) { updates.download_traffic_used = 0; const effectiveCycle = updates.download_traffic_reset_cycle || user.download_traffic_reset_cycle || 'none'; updates.download_traffic_last_reset_at = effectiveCycle === 'none' ? null : nowSql; } if (storage_permission === 'local_only') { updates.current_storage_type = 'local'; } else if (storage_permission === 'oss_only') { // 只有配置了OSS才切换到OSS(个人配置或系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (user.has_oss_config || hasUnifiedConfig) { updates.current_storage_type = 'oss'; } } // user_choice 不自动切换,保持用户当前选择 UserDB.update(userId, updates); const afterPolicyState = enforceDownloadTrafficPolicy(userId, 'admin_update_after'); const updatedUser = afterPolicyState?.user || UserDB.findById(userId); logUser( req, 'update_user_storage_and_traffic', `管理员更新用户额度策略: ${user.username}`, { targetUserId: userId, targetUsername: user.username, storage_permission, downloadTrafficOperation: hasSetTrafficQuota ? 'set' : (hasTrafficDelta ? (parseInt(download_traffic_delta, 10) > 0 ? 'increase' : 'decrease') : 'none'), before: { quota: beforeTrafficState.quota, used: beforeTrafficState.used, expires_at: user.download_traffic_quota_expires_at || null, reset_cycle: user.download_traffic_reset_cycle || 'none', last_reset_at: user.download_traffic_last_reset_at || null }, request: { set_quota: hasSetTrafficQuota ? parseInt(download_traffic_quota, 10) : null, delta: hasTrafficDelta ? parseInt(download_traffic_delta, 10) : 0, expires_at: download_traffic_quota_expires_at ?? undefined, reset_cycle: download_traffic_reset_cycle ?? undefined, reset_used_now: shouldResetTrafficUsedNow }, after: updatedUser ? { quota: normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota), used: normalizeDownloadTrafficUsed( updatedUser.download_traffic_used, normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota) ), expires_at: updatedUser.download_traffic_quota_expires_at || null, reset_cycle: updatedUser.download_traffic_reset_cycle || 'none', last_reset_at: updatedUser.download_traffic_last_reset_at || null } : null } ); res.json({ success: true, message: '存储权限已更新', user: updatedUser ? { id: updatedUser.id, storage_permission: updatedUser.storage_permission || 'oss_only', current_storage_type: updatedUser.current_storage_type || 'oss', local_storage_quota: updatedUser.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES, local_storage_used: updatedUser.local_storage_used || 0, oss_storage_quota: normalizeOssQuota(updatedUser.oss_storage_quota), download_traffic_quota: normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota), download_traffic_used: normalizeDownloadTrafficUsed( updatedUser.download_traffic_used, normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota) ), download_traffic_quota_expires_at: updatedUser.download_traffic_quota_expires_at || null, download_traffic_reset_cycle: updatedUser.download_traffic_reset_cycle || 'none', download_traffic_last_reset_at: updatedUser.download_traffic_last_reset_at || null } : null }); } 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 ossClient; // 参数验证:验证 ID 格式 const userId = parseInt(id, 10); if (isNaN(userId) || userId <= 0) { return res.status(400).json({ success: false, message: '无效的用户ID' }); } try { const user = UserDB.findById(userId); if (!user) { return res.status(404).json({ success: false, message: '用户不存在' }); } // 检查是否有可用的OSS配置(个人配置或系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!user.has_oss_config && !hasUnifiedConfig) { return res.status(400).json({ success: false, message: '该用户未配置OSS服务' }); } // OssStorageClient 已在文件顶部导入 ossClient = createOssClientForUser(user); await ossClient.connect(); const list = await ossClient.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 (ossClient) await ossClient.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 { // 参数验证:验证 ID 格式 const shareId = parseInt(req.params.id, 10); if (isNaN(shareId) || shareId <= 0) { return res.status(400).json({ success: false, message: '无效的分享ID' }); } // 先获取分享信息以获得share_code const share = ShareDB.findById(shareId); if (share) { // 删除缓存 if (shareFileCache.has(share.share_code)) { shareFileCache.delete(share.share_code); console.log(`[缓存清除] 分享码: ${share.share_code} (管理员操作)`); } // 删除数据库记录 ShareDB.delete(shareId); // 记录管理员操作日志 logShare(req, 'admin_delete_share', `管理员删除分享: ${share.share_code}`, { shareId, shareCode: share.share_code, sharePath: share.share_path, ownerId: share.user_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('/d/:code', async (req, res) => { const { code } = req.params; let storage; let storageEnded = false; let transferFinalized = false; let downloadedBytes = 0; let responseBodyStartSocketBytes = 0; let linkOwnerId = null; // 显式拒绝 HEAD,避免误触发计量 if (req.method === 'HEAD') { res.setHeader('Allow', 'GET'); return res.status(405).end(); } const safeEndStorage = async () => { if (storage && !storageEnded) { storageEnded = true; try { await storage.end(); } catch (err) { console.error('关闭存储连接失败:', err); } } }; const finalizeTransfer = async (reason = '') => { if (transferFinalized) { return; } transferFinalized = true; try { if (linkOwnerId && downloadedBytes > 0) { const socketBytesWritten = Number(res.socket?.bytesWritten); const socketBodyBytes = Number.isFinite(socketBytesWritten) && socketBytesWritten > responseBodyStartSocketBytes ? Math.floor(socketBytesWritten - responseBodyStartSocketBytes) : 0; const billableBytes = socketBodyBytes > 0 ? Math.min(downloadedBytes, socketBodyBytes) : downloadedBytes; const usageResult = applyDownloadTrafficUsage(linkOwnerId, billableBytes); if (usageResult) { const quotaText = usageResult.quota > 0 ? formatFileSize(usageResult.quota) : '不限'; console.log( `[直链下载流量] 用户 ${linkOwnerId} 新增 ${formatFileSize(usageResult.added)},` + `累计 ${formatFileSize(usageResult.usedAfter)} / ${quotaText} ` + `(reason=${reason || 'unknown'}, streamed=${downloadedBytes}, socket=${socketBodyBytes})` ); } } } catch (error) { console.error(`[直链下载流量] 结算失败: user=${linkOwnerId}, bytes=${downloadedBytes}`, error); } await safeEndStorage(); }; if (!isValidShareCode(code)) { return sendPlainTextError(res, 404, '直链不存在'); } try { const directLink = DirectLinkDB.findByCode(code); if (!directLink) { return sendPlainTextError(res, 404, '直链不存在或已过期'); } const normalizedPath = normalizeVirtualPath(directLink.file_path || ''); if (!normalizedPath) { return sendPlainTextError(res, 404, '直链不存在或已失效'); } const ownerPolicyState = enforceDownloadTrafficPolicy(directLink.user_id, 'direct_link_download'); const linkOwner = ownerPolicyState?.user || UserDB.findById(directLink.user_id); if (!linkOwner || linkOwner.is_banned) { return sendPlainTextError(res, 404, '直链不存在或已失效'); } linkOwnerId = linkOwner.id; const securityResult = evaluateDownloadSecurityPolicy(req, { ownerUserId: linkOwner.id, filePath: normalizedPath, source: 'direct_link_download' }); if (!securityResult.allowed) { return handleDownloadSecurityBlock(req, res, securityResult, { statusCode: 503, plainText: true, ownerUserId: linkOwner.id, filePath: normalizedPath, source: 'direct_link_download' }); } const ownerTrafficState = getDownloadTrafficState(linkOwner); const storageType = directLink.storage_type || 'oss'; const directFileName = (directLink.file_name && String(directLink.file_name).trim()) ? String(directLink.file_name).trim() : (normalizedPath.split('/').pop() || 'download.bin'); // OSS 直链下载:重定向到签名 URL if (storageType === 'oss') { const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!linkOwner.has_oss_config && !hasUnifiedConfig) { return sendPlainTextError(res, 404, '文件不存在或暂不可用'); } const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { client, bucket, ossClient } = createS3ClientContextForUser(linkOwner); const objectKey = ossClient.getObjectKey(normalizedPath); let fileSize = 0; try { const headResponse = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey })); const contentLength = Number(headResponse?.ContentLength || 0); fileSize = Number.isFinite(contentLength) && contentLength > 0 ? Math.floor(contentLength) : 0; } catch (headError) { const statusCode = headError?.$metadata?.httpStatusCode; if (headError?.name === 'NotFound' || headError?.name === 'NoSuchKey' || statusCode === 404) { return sendPlainTextError(res, 404, '文件不存在'); } throw headError; } if (!ownerTrafficState.isUnlimited) { if (fileSize <= 0 || fileSize > ownerTrafficState.remaining) { return sendPlainTextError(res, 503, getBusyDownloadMessage()); } const reserveResult = reserveDirectDownloadTraffic(linkOwner.id, fileSize, { source: 'direct_link', objectKey, ttlMs: DOWNLOAD_RESERVATION_TTL_MS }); if (!reserveResult?.ok) { return sendPlainTextError(res, 503, getBusyDownloadMessage()); } } const command = new GetObjectCommand({ Bucket: bucket, Key: objectKey, ResponseContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(directFileName)}` }); const signedUrl = await getSignedUrl(client, command, { expiresIn: DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS }); DirectLinkDB.incrementDownloadCount(code); logShare( req, 'direct_link_download', `访问直链下载: ${normalizedPath}`, { linkCode: code, ownerId: linkOwner.id, storageType: 'oss' } ); return res.redirect(signedUrl); } // 本地存储:通过后端流式下载 const { StorageInterface } = require('./storage'); const userForStorage = buildStorageUserContext(linkOwner, { current_storage_type: storageType }); const storageInterface = new StorageInterface(userForStorage); storage = await storageInterface.connect(); const fileStats = await storage.stat(normalizedPath); const fileSize = Number(fileStats?.size || 0); if (fileStats?.isDirectory) { await safeEndStorage(); return sendPlainTextError(res, 400, '直链仅支持文件下载'); } if (!Number.isFinite(fileSize) || fileSize <= 0) { await safeEndStorage(); return sendPlainTextError(res, 404, '文件不存在'); } if (!ownerTrafficState.isUnlimited && fileSize > ownerTrafficState.remaining) { await safeEndStorage(); return sendPlainTextError(res, 503, getBusyDownloadMessage()); } DirectLinkDB.incrementDownloadCount(code); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Length', fileSize); res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(directFileName)}"; filename*=UTF-8''${encodeURIComponent(directFileName)}`); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0; const stream = await storage.createReadStream(normalizedPath); stream.on('data', (chunk) => { if (!chunk) return; downloadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); }); res.on('finish', () => { finalizeTransfer('finish').catch(err => { console.error('直链下载完成后资源释放失败:', err); }); }); res.on('close', () => { finalizeTransfer('close').catch(err => { console.error('直链下载连接关闭后资源释放失败:', err); }); }); stream.on('error', (error) => { console.error('直链文件流错误:', error); if (!res.headersSent) { sendPlainTextError(res, 500, '下载失败,请稍后重试'); } finalizeTransfer('stream_error').catch(err => { console.error('直链下载流错误后资源释放失败:', err); }); }); logShare( req, 'direct_link_download', `访问直链下载: ${normalizedPath}`, { linkCode: code, ownerId: linkOwner.id, storageType: storageType || 'local' } ); stream.pipe(res); } catch (error) { console.error('直链下载失败:', error); if (!res.headersSent) { sendPlainTextError(res, 500, '下载失败,请稍后重试'); } await finalizeTransfer('catch_error'); } }); // 分享页面访问路由 app.get("/s/:code", (req, res) => { const shareCode = req.params.code; if (!isValidShareCode(shareCode)) { return res.status(404).send('分享链接不存在'); } // 使用相对路径重定向,浏览器会自动使用当前的协议和host const frontendUrl = `/share.html?code=${shareCode}`; console.log(`[分享] 重定向到: ${frontendUrl}`); res.redirect(frontendUrl); }); // 启动时清理旧临时文件 cleanupOldTempFiles(); const desktopCleanupOnStartup = cleanupDesktopInstallerPackages(getDesktopUpdateConfig().installerUrl); if (desktopCleanupOnStartup.executed && desktopCleanupOnStartup.removed > 0) { console.log(`[桌面端更新] 启动时已清理 ${desktopCleanupOnStartup.removed} 个旧安装包`); } // 启动服务器 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`); });