Files
vue-driven-cloud-storage/backend/server.js

10000 lines
310 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 加载环境变量(必须在最开始)
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,
FileHashIndexDB,
DownloadTrafficIngestDB,
SystemLogDB,
TransactionDB,
WalManager
} = require('./database');
const StorageUsageCache = require('./utils/storage-cache');
const { JWT_SECRET, generateToken, generateRefreshToken, 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 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 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 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 的浏览器会话启用 CSRFBearer API 客户端不强制)
const hasCookieAuth = !!(
req.cookies?.token ||
req.cookies?.refreshToken
);
if (!hasCookieAuth) {
return next();
}
const cookieToken = req.cookies[CSRF_COOKIE_NAME];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
console.warn(`[CSRF] 验证失败: path=${req.path}, cookie=${!!cookieToken}, header=${!!headerToken}`);
return res.status(403).json({
success: false,
message: 'CSRF 验证失败,请刷新页面后重试'
});
}
next();
}
// CSRF 开关策略:
// - 显式配置 ENABLE_CSRF 时按配置值
// - 未配置时,生产环境默认开启
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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;'
};
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过滤后的文件名/路径字段,恢复原始字符
* 支持嵌套实体的递归解码(如 &amp;#x60; -> &#x60; -> `
* @param {string} str - 包含HTML实体的字符串
* @returns {string} 解码后的原始字符串
*/
function decodeHtmlEntities(str) {
if (typeof str !== 'string') return str;
// 支持常见实体和数字实体(含多次嵌套,如 &amp;#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);
// 处理嵌套实体(如 &amp;#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 => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;'
}[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 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;
// 仅处理常见日志文件后缀
const lowerKey = key.toLowerCase();
if (!lowerKey.endsWith('.log') && !lowerKey.endsWith('.txt') && !lowerKey.endsWith('.gz')) {
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);
// 构建用于存储客户端的用户对象(自动尝试解密 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 upload = multer({
dest: path.join(__dirname, 'uploads'),
limits: { fileSize: 5 * 1024 * 1024 * 1024 } // 5GB限制
});
// ===== TTL缓存类 =====
// 带过期时间的缓存类
class TTLCache {
constructor(defaultTTL = 3600000) { // 默认1小时
this.cache = new Map();
this.defaultTTL = defaultTTL;
// 每10分钟清理一次过期缓存
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 10 * 60 * 1000);
}
set(key, value, ttl = this.defaultTTL) {
const expiresAt = Date.now() + ttl;
this.cache.set(key, { value, expiresAt });
}
get(key) {
const item = this.cache.get(key);
if (!item) {
return undefined;
}
// 检查是否过期
if (Date.now() > item.expiresAt) {
this.cache.delete(key);
return undefined;
}
return item.value;
}
has(key) {
const item = this.cache.get(key);
if (!item) {
return false;
}
// 检查是否过期
if (Date.now() > item.expiresAt) {
this.cache.delete(key);
return false;
}
return true;
}
delete(key) {
return this.cache.delete(key);
}
// 清理过期缓存
cleanup() {
const now = Date.now();
let cleaned = 0;
for (const [key, item] of this.cache.entries()) {
if (now > item.expiresAt) {
this.cache.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
console.log(`[缓存清理] 已清理 ${cleaned} 个过期的分享缓存`);
}
}
// 获取缓存大小
size() {
return this.cache.size;
}
// 停止清理定时器
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
}
// 分享文件信息缓存内存缓存1小时TTL
const shareFileCache = new TTLCache(60 * 60 * 1000);
// ===== 防爆破限流器 =====
// 防爆破限流器类
class RateLimiter {
constructor(options = {}) {
this.maxAttempts = options.maxAttempts || 5;
this.windowMs = options.windowMs || 15 * 60 * 1000;
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
this.attempts = new Map();
this.blockedKeys = new Map();
// 每5分钟清理一次过期记录
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 5 * 60 * 1000);
}
// 获取客户端IP基于可信代理链
// 安全说明req.ip 由 Express 根据 trust proxy 配置计算,
// 只有可信代理的 X-Forwarded-For 才会被采信
getClientKey(req) {
// req.ip 会根据 trust proxy 配置:
// - trust proxy = false: 使用直接连接的 IPsocket 地址)
// - trust proxy = 1: 取 X-Forwarded-For 的最后 1 个 IP
// - trust proxy = true: 取 X-Forwarded-For 的第 1 个 IP不推荐
return req.ip || req.socket?.remoteAddress || 'unknown';
}
// 检查是否被封锁
isBlocked(key) {
const blockInfo = this.blockedKeys.get(key);
if (!blockInfo) {
return false;
}
// 检查封锁是否过期
if (Date.now() > blockInfo.expiresAt) {
this.blockedKeys.delete(key);
this.attempts.delete(key);
return false;
}
return true;
}
// 记录失败尝试
recordFailure(key) {
const now = Date.now();
// 如果已被封锁,返回封锁信息
if (this.isBlocked(key)) {
const blockInfo = this.blockedKeys.get(key);
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockInfo.expiresAt,
waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000),
needCaptcha: true
};
}
// 获取或创建尝试记录
let attemptInfo = this.attempts.get(key);
if (!attemptInfo || now > attemptInfo.windowEnd) {
attemptInfo = {
count: 0,
windowEnd: now + this.windowMs,
firstAttempt: now
};
}
attemptInfo.count++;
this.attempts.set(key, attemptInfo);
// 检查是否达到封锁阈值
if (attemptInfo.count >= this.maxAttempts) {
const blockExpiresAt = now + this.blockDuration;
this.blockedKeys.set(key, {
expiresAt: blockExpiresAt,
blockedAt: now
});
console.warn(`[防爆破] 封锁Key: ${key}, 失败次数: ${attemptInfo.count}, 封锁时长: ${Math.ceil(this.blockDuration / 60000)}分钟`);
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockExpiresAt,
waitMinutes: Math.ceil(this.blockDuration / 60000),
needCaptcha: true
};
}
return {
blocked: false,
remainingAttempts: this.maxAttempts - attemptInfo.count,
resetTime: attemptInfo.windowEnd,
waitMinutes: 0,
needCaptcha: attemptInfo.count >= 2 // 失败2次后需要验证码
};
}
// 获取失败次数
getFailureCount(key) {
const attemptInfo = this.attempts.get(key);
if (!attemptInfo || Date.now() > attemptInfo.windowEnd) {
return 0;
}
return attemptInfo.count;
}
// 记录成功(清除失败记录)
recordSuccess(key) {
this.attempts.delete(key);
this.blockedKeys.delete(key);
}
// 清理过期记录
cleanup() {
const now = Date.now();
let cleanedAttempts = 0;
let cleanedBlocks = 0;
// 清理过期的尝试记录
for (const [key, info] of this.attempts.entries()) {
if (now > info.windowEnd) {
this.attempts.delete(key);
cleanedAttempts++;
}
}
// 清理过期的封锁记录
for (const [key, info] of this.blockedKeys.entries()) {
if (now > info.expiresAt) {
this.blockedKeys.delete(key);
cleanedBlocks++;
}
}
if (cleanedAttempts > 0 || cleanedBlocks > 0) {
console.log(`[防爆破清理] 已清理 ${cleanedAttempts} 个过期尝试记录, ${cleanedBlocks} 个过期封锁记录`);
}
}
// 获取统计信息
getStats() {
return {
activeAttempts: this.attempts.size,
blockedKeys: this.blockedKeys.size
};
}
// 停止清理定时器
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
}
// 创建登录限流器5次失败/15分钟封锁30分钟
const loginLimiter = new RateLimiter({
maxAttempts: 5,
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 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 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 已在文件顶部导入
// 生成随机Tokencrypto 已在文件顶部导入)
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
}
});
});
// 生成验证码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,
'邮箱验证 - 玩玩云',
`<p>您好,${safeUsernameForMail}</p>
<p>请点击下面的链接验证您的邮箱30分钟内有效</p>
<p><a href="${verifyLink}" target="_blank">${verifyLink}</a></p>
<p>如果不是您本人操作,请忽略此邮件。</p>`
);
} 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,
'邮箱验证 - 玩玩云',
`<p>您好,${safeUsernameForMail}</p>
<p>请点击下面的链接验证您的邮箱30分钟内有效</p>
<p><a href="${verifyLink}" target="_blank">${verifyLink}</a></p>
<p>如果不是您本人操作,请忽略此邮件。</p>`
);
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,
'密码重置 - 玩玩云',
`<p>您好,${safeUsernameForMail}</p>
<p>请点击下面的链接重置密码30分钟内有效</p>
<p><a href="${resetLink}" target="_blank">${resetLink}</a></p>
<p>如果不是您本人操作,请忽略此邮件。</p>`
).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 } = 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 token = generateToken(user);
const refreshToken = generateRefreshToken(user);
// 清除失败记录
if (req.rateLimitKeys) {
loginLimiter.recordSuccess(req.rateLimitKeys.ipKey);
if (req.rateLimitKeys.usernameKey) {
loginLimiter.recordSuccess(req.rateLimitKeys.usernameKey);
}
}
// 增强Cookie安全设置
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
const cookieOptions = {
httpOnly: true,
secure: isSecureEnv,
sameSite: isSecureEnv ? 'strict' : 'lax',
path: '/'
};
// 设置 access token Cookie2小时有效
res.cookie('token', token, {
...cookieOptions,
maxAge: 2 * 60 * 60 * 1000
});
// 设置 refresh token Cookie7天有效
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);
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) => {
// 清除所有认证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/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];
// 兼容被二次编码的实体(如 &amp;#x60; -> &#x60;
if (typeof rawFileName === 'string') {
const entityName = rawFileName.replace(/&amp;/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 {
if ((req.user.current_storage_type || 'oss') !== 'local') {
return res.status(400).json({
success: false,
message: '当前存储模式不支持分片上传'
});
}
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,
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,
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: 'local',
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,
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 {
if ((req.user.current_storage_type || 'oss') !== 'local') {
if (tempChunkPath) safeDeleteFile(tempChunkPath);
return res.status(400).json({
success: false,
message: '当前存储模式不支持分片上传'
});
}
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 = fs.readFileSync(req.file.path);
const offset = chunkIndex * chunkSize;
const fd = fs.openSync(session.temp_file_path, 'r+');
try {
fs.writeSync(fd, chunkBuffer, 0, chunkBuffer.length, offset);
} finally {
fs.closeSync(fd);
}
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);
}
}
});
// 完成分片上传(写入本地存储)
app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => {
try {
if ((req.user.current_storage_type || 'oss') !== 'local') {
return res.status(400).json({
success: false,
message: '当前存储模式不支持分片上传'
});
}
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 latestUser = UserDB.findById(req.user.id);
if (!latestUser) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
const localUserContext = buildStorageUserContext(latestUser, {
current_storage_type: 'local'
});
const localStorage = new LocalStorageClient(localUserContext);
await localStorage.init();
await localStorage.put(session.temp_file_path, session.target_path);
UploadSessionDB.setStatus(session.session_id, 'completed', {
completed: true,
expiresAt: buildResumableUploadExpiresAt(60 * 1000)
});
safeUnlink(session.temp_file_path);
await trackFileHashIndexForUpload({
userId: req.user.id,
storageType: 'local',
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, '完成分片上传失败,请稍后重试', '完成分片上传')
});
}
});
// ========== 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
});
// 生成签名 URL15分钟有效
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: '用户不存在'
});
}
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);
// 生成签名 URL1小时有效
const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 });
// 直连模式:先预扣保留额度(不写入已用),实际用量由 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: 3600,
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 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: '路径包含非法字符'
});
}
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(req.user.current_storage_type || 'oss', 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: '分享链接创建成功',
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 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: '直链创建成功',
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
});
}
});
// 生成分享文件下载签名 URLOSS 直连下载,公开 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') {
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())}"`
});
// 生成签名 URL1小时有效
const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 });
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: 3600
});
} 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 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';
res.json({
success: true,
settings: {
max_upload_size: maxUploadSize,
global_theme: globalTheme,
smtp: {
host: smtpHost || '',
port: smtpPort ? parseInt(smtpPort, 10) : 465,
secure: smtpSecure,
user: smtpUser || '',
from: smtpFrom || '',
has_password: smtpHasPassword
}
}
});
} catch (error) {
console.error('获取系统设置失败:', error);
res.status(500).json({
success: false,
message: '获取系统设置失败: ' + error.message
});
}
});
// 更新系统设置
// 注意:已移除 requirePasswordConfirmation 中间件,依赖管理员登录认证
app.post('/api/admin/settings',
authMiddleware,
adminMiddleware,
(req, res) => {
try {
const { max_upload_size, smtp, global_theme } = req.body;
if (max_upload_size !== undefined) {
const size = parseInt(max_upload_size);
if (isNaN(size) || size < 0) {
return res.status(400).json({
success: false,
message: '无效的文件大小'
});
}
SettingsDB.set('max_upload_size', size.toString());
}
// 更新全局主题
if (global_theme !== undefined) {
const validThemes = ['dark', 'light'];
if (!validThemes.includes(global_theme)) {
return res.status(400).json({
success: false,
message: '无效的主题设置'
});
}
SettingsDB.set('global_theme', global_theme);
}
if (smtp) {
if (!smtp.host || !smtp.port || !smtp.user) {
return res.status(400).json({ success: false, message: 'SMTP配置不完整' });
}
SettingsDB.set('smtp_host', smtp.host);
SettingsDB.set('smtp_port', smtp.port);
SettingsDB.set('smtp_secure', smtp.secure ? 'true' : 'false');
SettingsDB.set('smtp_user', smtp.user);
SettingsDB.set('smtp_from', smtp.from || smtp.user);
if (smtp.password) {
SettingsDB.set('smtp_password', smtp.password);
}
}
res.json({
success: true,
message: '系统设置已更新'
});
} catch (error) {
console.error('更新系统设置失败:', error);
res.status(500).json({
success: false,
message: '更新系统设置失败: ' + error.message
});
}
});
// 测试SMTP
app.post('/api/admin/settings/test-smtp', authMiddleware, adminMiddleware, async (req, res) => {
const { to } = req.body;
try {
const smtpConfig = getSmtpConfig();
if (!smtpConfig) {
return res.status(400).json({ success: false, message: 'SMTP未配置' });
}
const target = to || req.user.email || smtpConfig.user;
await sendMail(
target,
'SMTP测试 - 玩玩云',
`<p>您好这是一封测试邮件说明SMTP配置可用。</p><p>时间:${new Date().toISOString()}</p>`
);
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 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: 3600 });
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();
// 启动服务器
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`);
});