8056 lines
250 KiB
JavaScript
8056 lines
250 KiB
JavaScript
// 加载环境变量(必须在最开始)
|
||
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,
|
||
SettingsDB,
|
||
VerificationDB,
|
||
PasswordResetTokenDB,
|
||
DownloadTrafficReportDB,
|
||
DownloadTrafficReservationDB,
|
||
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 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 的浏览器会话启用 CSRF(Bearer API 客户端不强制)
|
||
const hasCookieAuth = !!(
|
||
req.cookies?.token ||
|
||
req.cookies?.refreshToken
|
||
);
|
||
|
||
if (!hasCookieAuth) {
|
||
return next();
|
||
}
|
||
|
||
const cookieToken = req.cookies[CSRF_COOKIE_NAME];
|
||
const headerToken = req.headers['x-csrf-token'];
|
||
|
||
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
||
console.warn(`[CSRF] 验证失败: path=${req.path}, cookie=${!!cookieToken}, header=${!!headerToken}`);
|
||
return res.status(403).json({
|
||
success: false,
|
||
message: 'CSRF 验证失败,请刷新页面后重试'
|
||
});
|
||
}
|
||
|
||
next();
|
||
}
|
||
|
||
// CSRF 开关策略:
|
||
// - 显式配置 ENABLE_CSRF 时按配置值
|
||
// - 未配置时,生产环境默认开启
|
||
const ENABLE_CSRF = process.env.ENABLE_CSRF !== undefined
|
||
? process.env.ENABLE_CSRF === 'true'
|
||
: process.env.NODE_ENV === 'production';
|
||
|
||
if (ENABLE_CSRF) {
|
||
console.log('[安全] CSRF 保护已启用');
|
||
app.use(csrfProtection);
|
||
}
|
||
|
||
// 强制HTTPS(可通过环境变量控制,默认关闭以兼容本地环境)
|
||
// 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置,
|
||
// 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头
|
||
app.use((req, res, next) => {
|
||
if (!ENFORCE_HTTPS) return next();
|
||
|
||
// req.secure 由 Express 根据 trust proxy 配置计算:
|
||
// - 如果 trust proxy = false,仅检查直接连接是否为 TLS
|
||
// - 如果 trust proxy 已配置,会检查可信代理的 X-Forwarded-Proto
|
||
if (!req.secure) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '仅支持HTTPS访问,请使用HTTPS'
|
||
});
|
||
}
|
||
return next();
|
||
});
|
||
|
||
// Session配置(用于验证码)
|
||
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
|
||
const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码
|
||
|
||
// 安全检查:Session密钥配置
|
||
const SESSION_SECRET = process.env.SESSION_SECRET || 'your-session-secret-change-in-production';
|
||
const DEFAULT_SESSION_SECRETS = [
|
||
'your-session-secret-change-in-production',
|
||
'session-secret-change-me'
|
||
];
|
||
|
||
if (DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET)) {
|
||
const sessionWarnMsg = `
|
||
[安全警告] SESSION_SECRET 使用默认值,存在安全风险!
|
||
请在 .env 文件中设置随机生成的 SESSION_SECRET
|
||
生成命令: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||
`;
|
||
if (process.env.NODE_ENV === 'production') {
|
||
console.error(sessionWarnMsg);
|
||
throw new Error('生产环境必须设置 SESSION_SECRET!');
|
||
} else {
|
||
console.warn(sessionWarnMsg);
|
||
}
|
||
}
|
||
|
||
app.use(session({
|
||
secret: SESSION_SECRET,
|
||
resave: false,
|
||
saveUninitialized: false, // 仅在写入 session 时创建,减少无效会话
|
||
name: 'captcha.sid', // 自定义session cookie名称
|
||
cookie: {
|
||
secure: isSecureCookie,
|
||
httpOnly: true,
|
||
sameSite: sameSiteMode,
|
||
maxAge: 10 * 60 * 1000 // 10分钟
|
||
}
|
||
}));
|
||
|
||
// 安全响应头中间件
|
||
app.use((req, res, next) => {
|
||
applySecurityHeaders(req, res);
|
||
next();
|
||
});
|
||
|
||
/**
|
||
* XSS过滤函数 - 过滤用户输入中的潜在XSS攻击代码
|
||
* 注意:不转义 / 因为它是文件路径的合法字符
|
||
* @param {string} str - 需要过滤的输入字符串
|
||
* @returns {string} 过滤后的安全字符串
|
||
*/
|
||
function sanitizeInput(str) {
|
||
if (typeof str !== 'string') return str;
|
||
|
||
// 1. 基础HTML实体转义(不包括 / 因为是路径分隔符,不包括 ` 因为是合法文件名字符)
|
||
let sanitized = str
|
||
.replace(/[&<>"']/g, (char) => {
|
||
const map = {
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
};
|
||
return map[char];
|
||
});
|
||
|
||
// 2. 过滤危险协议(javascript:, data:, vbscript:等)
|
||
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
|
||
|
||
// 3. 移除空字节
|
||
sanitized = sanitized.replace(/\x00/g, '');
|
||
|
||
return sanitized;
|
||
}
|
||
|
||
/**
|
||
* 将 HTML 实体解码为原始字符
|
||
* 用于处理经过XSS过滤后的文件名/路径字段,恢复原始字符
|
||
* 支持嵌套实体的递归解码(如 &#x60; -> ` -> `)
|
||
* @param {string} str - 包含HTML实体的字符串
|
||
* @returns {string} 解码后的原始字符串
|
||
*/
|
||
function decodeHtmlEntities(str) {
|
||
if (typeof str !== 'string') return str;
|
||
|
||
// 支持常见实体和数字实体(含多次嵌套,如 &#x60;)
|
||
const entityMap = {
|
||
amp: '&',
|
||
lt: '<',
|
||
gt: '>',
|
||
quot: '"',
|
||
apos: "'",
|
||
'#x27': "'",
|
||
'#x2F': '/',
|
||
'#x60': '`'
|
||
};
|
||
|
||
const decodeOnce = (input) =>
|
||
input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => {
|
||
if (code[0] === '#') {
|
||
const isHex = code[1]?.toLowerCase() === 'x';
|
||
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
|
||
if (!Number.isNaN(num)) {
|
||
return String.fromCharCode(num);
|
||
}
|
||
return match;
|
||
}
|
||
const mapped = entityMap[code];
|
||
return mapped !== undefined ? mapped : match;
|
||
});
|
||
|
||
let output = str;
|
||
let decoded = decodeOnce(output);
|
||
// 处理嵌套实体(如 &#x60;),直到稳定
|
||
while (decoded !== output) {
|
||
output = decoded;
|
||
decoded = decodeOnce(output);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
// HTML转义(用于模板输出)
|
||
function escapeHtml(str) {
|
||
if (typeof str !== 'string') return str;
|
||
return str.replace(/[&<>"']/g, char => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
}[char]));
|
||
}
|
||
|
||
// 规范化并校验HTTP直链前缀,只允许http/https
|
||
function sanitizeHttpBaseUrl(raw) {
|
||
if (!raw) return null;
|
||
try {
|
||
const url = new URL(raw);
|
||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||
return null;
|
||
}
|
||
url.search = '';
|
||
url.hash = '';
|
||
// 去掉多余的结尾斜杠,保持路径稳定
|
||
url.pathname = url.pathname.replace(/\/+$/, '');
|
||
return url.toString();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 构建安全的下载URL,编码路径片段并拒绝非HTTP(S)前缀
|
||
function buildHttpDownloadUrl(rawBaseUrl, filePath) {
|
||
const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl);
|
||
if (!baseUrl || !filePath) return null;
|
||
|
||
try {
|
||
const url = new URL(baseUrl);
|
||
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
||
const safeSegments = normalizedPath
|
||
.split('/')
|
||
.filter(Boolean)
|
||
.map(segment => encodeURIComponent(segment));
|
||
const safePath = safeSegments.length ? '/' + safeSegments.join('/') : '';
|
||
|
||
const basePath = url.pathname.replace(/\/+$/, '');
|
||
const joinedPath = `${basePath}${safePath || '/'}`;
|
||
url.pathname = joinedPath || '/';
|
||
url.search = '';
|
||
url.hash = '';
|
||
return url.toString();
|
||
} catch (err) {
|
||
console.warn('[安全] 生成下载URL失败:', err.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 校验文件名/路径片段安全(禁止分隔符、控制字符、..)
|
||
function isSafePathSegment(name) {
|
||
return (
|
||
typeof name === 'string' &&
|
||
name.length > 0 &&
|
||
name.length <= 255 && // 限制文件名长度
|
||
!name.includes('..') &&
|
||
!/[/\\]/.test(name) &&
|
||
!/[\x00-\x1F]/.test(name)
|
||
);
|
||
}
|
||
|
||
// 危险文件扩展名黑名单(仅限可能被Web服务器解析执行的脚本文件)
|
||
// 注意:这是网盘应用,.exe等可执行文件允许上传(服务器不会执行)
|
||
const DANGEROUS_EXTENSIONS = [
|
||
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar', // PHP
|
||
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf', // Java Server Pages
|
||
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', // ASP.NET
|
||
'.htaccess', '.htpasswd' // Apache配置(可能改变服务器行为)
|
||
];
|
||
|
||
// 检查文件扩展名是否安全
|
||
function isFileExtensionSafe(filename) {
|
||
if (!filename || typeof filename !== 'string') return false;
|
||
|
||
const ext = path.extname(filename).toLowerCase();
|
||
const nameLower = filename.toLowerCase();
|
||
|
||
// 检查危险扩展名
|
||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
||
return false;
|
||
}
|
||
|
||
// 特殊处理:检查以危险名称开头的文件(如 .htaccess, .htpasswd)
|
||
// 因为 path.extname('.htaccess') 返回空字符串
|
||
const dangerousFilenames = ['.htaccess', '.htpasswd'];
|
||
if (dangerousFilenames.includes(nameLower)) {
|
||
return false;
|
||
}
|
||
|
||
// 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行)
|
||
for (const dangerExt of DANGEROUS_EXTENSIONS) {
|
||
if (nameLower.includes(dangerExt + '.')) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 应用XSS过滤到所有POST/PUT请求的body
|
||
app.use((req, res, next) => {
|
||
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
|
||
// 递归过滤所有字符串字段
|
||
function sanitizeObject(obj) {
|
||
if (typeof obj === 'string') {
|
||
return sanitizeInput(obj);
|
||
} else if (Array.isArray(obj)) {
|
||
return obj.map(item => sanitizeObject(item));
|
||
} else if (obj && typeof obj === 'object') {
|
||
const sanitized = {};
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
sanitized[key] = sanitizeObject(value);
|
||
}
|
||
return sanitized;
|
||
}
|
||
return obj;
|
||
}
|
||
req.body = sanitizeObject(req.body);
|
||
}
|
||
next();
|
||
});
|
||
|
||
// 请求日志
|
||
app.use((req, res, next) => {
|
||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
|
||
next();
|
||
});
|
||
|
||
// 获取正确的协议(基于可信代理链)
|
||
// 安全说明:req.protocol 由 Express 根据 trust proxy 配置计算,
|
||
// 只有可信代理的 X-Forwarded-Proto 才会被采信
|
||
function getProtocol(req) {
|
||
// req.protocol 会根据 trust proxy 配置:
|
||
// - trust proxy = false: 仅检查直接连接(TLS -> 'https', 否则 'http')
|
||
// - trust proxy 已配置: 会检查可信代理的 X-Forwarded-Proto
|
||
return req.protocol || (req.secure ? 'https' : 'http');
|
||
}
|
||
|
||
function normalizeOssQuota(rawQuota) {
|
||
const parsedQuota = Number(rawQuota);
|
||
if (!Number.isFinite(parsedQuota) || parsedQuota <= 0) {
|
||
return DEFAULT_OSS_STORAGE_QUOTA_BYTES;
|
||
}
|
||
return parsedQuota;
|
||
}
|
||
|
||
function normalizeDownloadTrafficQuota(rawQuota) {
|
||
const parsedQuota = Number(rawQuota);
|
||
if (!Number.isFinite(parsedQuota)) {
|
||
return 0; // 0 表示禁止下载
|
||
}
|
||
if (parsedQuota < 0) {
|
||
return -1; // -1 表示不限流量
|
||
}
|
||
return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota));
|
||
}
|
||
|
||
function normalizeDownloadTrafficUsed(rawUsed, quota = 0) {
|
||
const parsedUsed = Number(rawUsed);
|
||
const normalizedUsed = Number.isFinite(parsedUsed) && parsedUsed > 0
|
||
? Math.floor(parsedUsed)
|
||
: 0;
|
||
if (quota >= 0) {
|
||
return Math.min(normalizedUsed, quota);
|
||
}
|
||
return normalizedUsed;
|
||
}
|
||
|
||
function getDownloadTrafficState(user) {
|
||
const quota = normalizeDownloadTrafficQuota(user?.download_traffic_quota);
|
||
const used = normalizeDownloadTrafficUsed(user?.download_traffic_used, quota);
|
||
const isUnlimited = quota < 0;
|
||
return {
|
||
quota,
|
||
used,
|
||
isUnlimited,
|
||
remaining: isUnlimited ? Number.POSITIVE_INFINITY : Math.max(0, quota - used)
|
||
};
|
||
}
|
||
|
||
function getBusyDownloadMessage() {
|
||
return '当前网络繁忙,请稍后再试';
|
||
}
|
||
|
||
function 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 });
|
||
DownloadTrafficReportDB.addUsage(userId, bytes, count > 0 ? count : 1, eventDate);
|
||
const consumeResult = DownloadTrafficReservationDB.consumePendingBytes(userId, bytes);
|
||
|
||
return {
|
||
userId,
|
||
confirmed: bytes,
|
||
added: Math.max(0, nextUsed - trafficState.used),
|
||
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);
|
||
|
||
// 构建用于存储客户端的用户对象(自动尝试解密 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: 使用直接连接的 IP(socket 地址)
|
||
// - trust proxy = 1: 取 X-Forwarded-For 的最后 1 个 IP
|
||
// - trust proxy = true: 取 X-Forwarded-For 的第 1 个 IP(不推荐)
|
||
return req.ip || req.socket?.remoteAddress || 'unknown';
|
||
}
|
||
|
||
// 检查是否被封锁
|
||
isBlocked(key) {
|
||
const blockInfo = this.blockedKeys.get(key);
|
||
if (!blockInfo) {
|
||
return false;
|
||
}
|
||
|
||
// 检查封锁是否过期
|
||
if (Date.now() > blockInfo.expiresAt) {
|
||
this.blockedKeys.delete(key);
|
||
this.attempts.delete(key);
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 记录失败尝试
|
||
recordFailure(key) {
|
||
const now = Date.now();
|
||
|
||
// 如果已被封锁,返回封锁信息
|
||
if (this.isBlocked(key)) {
|
||
const blockInfo = this.blockedKeys.get(key);
|
||
return {
|
||
blocked: true,
|
||
remainingAttempts: 0,
|
||
resetTime: blockInfo.expiresAt,
|
||
waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000),
|
||
needCaptcha: true
|
||
};
|
||
}
|
||
|
||
// 获取或创建尝试记录
|
||
let attemptInfo = this.attempts.get(key);
|
||
if (!attemptInfo || now > attemptInfo.windowEnd) {
|
||
attemptInfo = {
|
||
count: 0,
|
||
windowEnd: now + this.windowMs,
|
||
firstAttempt: now
|
||
};
|
||
}
|
||
|
||
attemptInfo.count++;
|
||
this.attempts.set(key, attemptInfo);
|
||
|
||
// 检查是否达到封锁阈值
|
||
if (attemptInfo.count >= this.maxAttempts) {
|
||
const blockExpiresAt = now + this.blockDuration;
|
||
this.blockedKeys.set(key, {
|
||
expiresAt: blockExpiresAt,
|
||
blockedAt: now
|
||
});
|
||
console.warn(`[防爆破] 封锁Key: ${key}, 失败次数: ${attemptInfo.count}, 封锁时长: ${Math.ceil(this.blockDuration / 60000)}分钟`);
|
||
return {
|
||
blocked: true,
|
||
remainingAttempts: 0,
|
||
resetTime: blockExpiresAt,
|
||
waitMinutes: Math.ceil(this.blockDuration / 60000),
|
||
needCaptcha: true
|
||
};
|
||
}
|
||
|
||
return {
|
||
blocked: false,
|
||
remainingAttempts: this.maxAttempts - attemptInfo.count,
|
||
resetTime: attemptInfo.windowEnd,
|
||
waitMinutes: 0,
|
||
needCaptcha: attemptInfo.count >= 2 // 失败2次后需要验证码
|
||
};
|
||
}
|
||
|
||
// 获取失败次数
|
||
getFailureCount(key) {
|
||
const attemptInfo = this.attempts.get(key);
|
||
if (!attemptInfo || Date.now() > attemptInfo.windowEnd) {
|
||
return 0;
|
||
}
|
||
return attemptInfo.count;
|
||
}
|
||
|
||
// 记录成功(清除失败记录)
|
||
recordSuccess(key) {
|
||
this.attempts.delete(key);
|
||
this.blockedKeys.delete(key);
|
||
}
|
||
|
||
// 清理过期记录
|
||
cleanup() {
|
||
const now = Date.now();
|
||
let cleanedAttempts = 0;
|
||
let cleanedBlocks = 0;
|
||
|
||
// 清理过期的尝试记录
|
||
for (const [key, info] of this.attempts.entries()) {
|
||
if (now > info.windowEnd) {
|
||
this.attempts.delete(key);
|
||
cleanedAttempts++;
|
||
}
|
||
}
|
||
|
||
// 清理过期的封锁记录
|
||
for (const [key, info] of this.blockedKeys.entries()) {
|
||
if (now > info.expiresAt) {
|
||
this.blockedKeys.delete(key);
|
||
cleanedBlocks++;
|
||
}
|
||
}
|
||
|
||
if (cleanedAttempts > 0 || cleanedBlocks > 0) {
|
||
console.log(`[防爆破清理] 已清理 ${cleanedAttempts} 个过期尝试记录, ${cleanedBlocks} 个过期封锁记录`);
|
||
}
|
||
}
|
||
|
||
// 获取统计信息
|
||
getStats() {
|
||
return {
|
||
activeAttempts: this.attempts.size,
|
||
blockedKeys: this.blockedKeys.size
|
||
};
|
||
}
|
||
|
||
// 停止清理定时器
|
||
destroy() {
|
||
if (this.cleanupInterval) {
|
||
clearInterval(this.cleanupInterval);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 创建登录限流器(5次失败/15分钟,封锁30分钟)
|
||
const loginLimiter = new RateLimiter({
|
||
maxAttempts: 5,
|
||
windowMs: 15 * 60 * 1000,
|
||
blockDuration: 30 * 60 * 1000
|
||
});
|
||
|
||
// 创建分享密码限流器(10次失败/10分钟,封锁20分钟)
|
||
const shareLimiter = new RateLimiter({
|
||
maxAttempts: 10,
|
||
windowMs: 10 * 60 * 1000,
|
||
blockDuration: 20 * 60 * 1000
|
||
});
|
||
|
||
// 邮件发送限流(防刷)
|
||
// 半小时最多3次,超过封30分钟;全天最多10次,超过封24小时
|
||
const mailLimiter30Min = new RateLimiter({
|
||
maxAttempts: 3,
|
||
windowMs: 30 * 60 * 1000,
|
||
blockDuration: 30 * 60 * 1000
|
||
});
|
||
const mailLimiterDay = new RateLimiter({
|
||
maxAttempts: 10,
|
||
windowMs: 24 * 60 * 60 * 1000,
|
||
blockDuration: 24 * 60 * 60 * 1000
|
||
});
|
||
|
||
// 创建验证码获取限流器(30次请求/10分钟,封锁30分钟)
|
||
const captchaLimiter = new RateLimiter({
|
||
maxAttempts: 30,
|
||
windowMs: 10 * 60 * 1000,
|
||
blockDuration: 30 * 60 * 1000
|
||
});
|
||
|
||
// 创建API密钥验证限流器(防止暴力枚举API密钥,5次失败/小时,封锁24小时)
|
||
const apiKeyLimiter = new RateLimiter({
|
||
maxAttempts: 5,
|
||
windowMs: 60 * 60 * 1000, // 1小时窗口
|
||
blockDuration: 24 * 60 * 60 * 1000 // 封锁24小时
|
||
});
|
||
|
||
// 创建文件上传限流器(每用户每小时最多100次上传)
|
||
const uploadLimiter = new RateLimiter({
|
||
maxAttempts: 100,
|
||
windowMs: 60 * 60 * 1000,
|
||
blockDuration: 60 * 60 * 1000
|
||
});
|
||
|
||
// 创建文件列表查询限流器(每用户每分钟最多60次)
|
||
const fileListLimiter = new RateLimiter({
|
||
maxAttempts: 60,
|
||
windowMs: 60 * 1000,
|
||
blockDuration: 5 * 60 * 1000
|
||
});
|
||
|
||
// 验证码最小请求间隔控制
|
||
const CAPTCHA_MIN_INTERVAL = 1000; // 1秒
|
||
const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理
|
||
|
||
// 验证码防刷中间件
|
||
function captchaRateLimitMiddleware(req, res, next) {
|
||
const clientKey = `captcha:${captchaLimiter.getClientKey(req)}`;
|
||
const now = Date.now();
|
||
|
||
// 最小时间间隔限制
|
||
const lastRequest = captchaLastRequest.get(clientKey);
|
||
if (lastRequest && (now - lastRequest) < CAPTCHA_MIN_INTERVAL) {
|
||
return res.status(429).json({
|
||
success: false,
|
||
message: '验证码请求过于频繁,请稍后再试'
|
||
});
|
||
}
|
||
captchaLastRequest.set(clientKey, now, 15 * 60 * 1000);
|
||
|
||
// 窗口内总次数限流
|
||
const result = captchaLimiter.recordFailure(clientKey);
|
||
if (result.blocked) {
|
||
return res.status(429).json({
|
||
success: false,
|
||
message: `验证码请求过多,请在 ${result.waitMinutes} 分钟后再试`,
|
||
blocked: true,
|
||
resetTime: result.resetTime
|
||
});
|
||
}
|
||
|
||
next();
|
||
}
|
||
|
||
// 登录防爆破中间件
|
||
function loginRateLimitMiddleware(req, res, next) {
|
||
const clientIP = loginLimiter.getClientKey(req);
|
||
const { username } = req.body;
|
||
const ipKey = `login:ip:${clientIP}`;
|
||
|
||
// 检查IP是否被封锁
|
||
if (loginLimiter.isBlocked(ipKey)) {
|
||
const result = loginLimiter.recordFailure(ipKey);
|
||
console.warn(`[防爆破] 拦截登录尝试 - IP: ${clientIP}, 原因: IP被封锁`);
|
||
return res.status(429).json({
|
||
success: false,
|
||
message: `登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`,
|
||
blocked: true,
|
||
resetTime: result.resetTime
|
||
});
|
||
}
|
||
|
||
// 检查用户名是否被封锁
|
||
if (username) {
|
||
const usernameKey = `login:username:${username}`;
|
||
if (loginLimiter.isBlocked(usernameKey)) {
|
||
const result = loginLimiter.recordFailure(usernameKey);
|
||
console.warn(`[防爆破] 拦截登录尝试 - 用户名: ${username}, 原因: 用户名被封锁`);
|
||
return res.status(429).json({
|
||
success: false,
|
||
message: `该账号登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`,
|
||
blocked: true,
|
||
resetTime: result.resetTime
|
||
});
|
||
}
|
||
}
|
||
|
||
// 将限流key附加到请求对象,供后续使用
|
||
req.rateLimitKeys = {
|
||
ipKey,
|
||
usernameKey: username ? `login:username:${username}` : null
|
||
};
|
||
next();
|
||
}
|
||
|
||
// 分享密码防爆破中间件
|
||
function shareRateLimitMiddleware(req, res, next) {
|
||
const clientIP = shareLimiter.getClientKey(req);
|
||
const { code } = req.params;
|
||
|
||
if (!isValidShareCode(code)) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '无效的分享码'
|
||
});
|
||
}
|
||
|
||
const key = `share:${code}:${clientIP}`;
|
||
|
||
// 检查是否被封锁
|
||
if (shareLimiter.isBlocked(key)) {
|
||
const result = shareLimiter.recordFailure(key);
|
||
console.warn(`[防爆破] 拦截分享密码尝试 - 分享码: ${code}, IP: ${clientIP}`);
|
||
return res.status(429).json({
|
||
success: false,
|
||
message: `密码尝试过多,请在 ${result.waitMinutes} 分钟后重试`,
|
||
blocked: true,
|
||
resetTime: result.resetTime
|
||
});
|
||
}
|
||
|
||
req.shareRateLimitKey = key;
|
||
next();
|
||
}
|
||
|
||
|
||
|
||
|
||
// ===== 工具函数 =====
|
||
|
||
/**
|
||
* 安全的错误响应处理
|
||
* 在生产环境中隐藏敏感的错误详情,仅在开发环境显示详细信息
|
||
* @param {Error} error - 原始错误对象
|
||
* @param {string} userMessage - 给用户显示的友好消息
|
||
* @param {string} logContext - 日志上下文标识
|
||
* @returns {string} 返回给客户端的错误消息
|
||
*/
|
||
function getSafeErrorMessage(error, userMessage, logContext = '') {
|
||
// 记录完整错误日志
|
||
if (logContext) {
|
||
console.error(`[${logContext}]`, error);
|
||
} else {
|
||
console.error(error);
|
||
}
|
||
|
||
// 生产环境返回通用消息,开发环境返回详细信息
|
||
if (process.env.NODE_ENV === 'production') {
|
||
return userMessage;
|
||
}
|
||
// 开发环境下,返回详细错误信息便于调试
|
||
return `${userMessage}: ${error.message}`;
|
||
}
|
||
|
||
// 安全删除文件(不抛出异常)
|
||
function safeDeleteFile(filePath) {
|
||
try {
|
||
if (fs.existsSync(filePath)) {
|
||
fs.unlinkSync(filePath);
|
||
console.log(`[清理] 已删除临时文件: ${filePath}`);
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
console.error(`[清理] 删除临时文件失败: ${filePath}`, error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
|
||
// 规范化虚拟文件路径(统一用于分享路径校验)
|
||
function normalizeVirtualPath(rawPath) {
|
||
if (typeof rawPath !== 'string') {
|
||
return null;
|
||
}
|
||
|
||
let decoded = rawPath;
|
||
try {
|
||
decoded = decodeURIComponent(rawPath);
|
||
} catch {
|
||
// 忽略解码失败,使用原始输入继续校验
|
||
}
|
||
|
||
if (decoded.includes('\x00') || decoded.includes('%00')) {
|
||
return null;
|
||
}
|
||
|
||
const unifiedPath = decoded.replace(/\\/g, '/');
|
||
|
||
// 严格拦截路径遍历片段(在 normalize 前先检查)
|
||
if (/(^|\/)\.\.(\/|$)/.test(unifiedPath)) {
|
||
return null;
|
||
}
|
||
|
||
let normalized = path.posix.normalize(unifiedPath);
|
||
if (normalized === '' || normalized === '.') {
|
||
normalized = '/';
|
||
}
|
||
|
||
if (!normalized.startsWith('/')) {
|
||
normalized = `/${normalized}`;
|
||
}
|
||
|
||
normalized = normalized.replace(/\/+$/g, '');
|
||
return normalized || '/';
|
||
}
|
||
|
||
function isValidShareCode(code) {
|
||
return typeof code === 'string' && SHARE_CODE_REGEX.test(code);
|
||
}
|
||
|
||
// 验证请求路径是否在分享范围内(防止越权访问)
|
||
function isPathWithinShare(requestPath, share) {
|
||
if (!requestPath || !share) {
|
||
return false;
|
||
}
|
||
|
||
const normalizedRequest = normalizeVirtualPath(requestPath);
|
||
const normalizedShare = normalizeVirtualPath(share.share_path);
|
||
|
||
if (!normalizedRequest || !normalizedShare) {
|
||
return false;
|
||
}
|
||
|
||
if (share.share_type === 'file') {
|
||
// 单文件分享:只允许下载该文件
|
||
return normalizedRequest === normalizedShare;
|
||
}
|
||
|
||
// 目录分享:只允许下载该目录及其子目录下的文件
|
||
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`;
|
||
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
|
||
}
|
||
// 清理旧的临时文件(启动时执行一次)
|
||
function cleanupOldTempFiles() {
|
||
const uploadsDir = path.join(__dirname, 'uploads');
|
||
if (!fs.existsSync(uploadsDir)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const files = fs.readdirSync(uploadsDir);
|
||
const now = Date.now();
|
||
const maxAge = 24 * 60 * 60 * 1000; // 24小时
|
||
|
||
let cleaned = 0;
|
||
files.forEach(file => {
|
||
const filePath = path.join(uploadsDir, file);
|
||
try {
|
||
const stats = fs.statSync(filePath);
|
||
if (now - stats.mtimeMs > maxAge) {
|
||
fs.unlinkSync(filePath);
|
||
cleaned++;
|
||
}
|
||
} catch (err) {
|
||
console.error(`[清理] 检查文件失败: ${filePath}`, err.message);
|
||
}
|
||
});
|
||
|
||
if (cleaned > 0) {
|
||
console.log(`[清理] 已清理 ${cleaned} 个超过24小时的临时文件`);
|
||
}
|
||
} catch (error) {
|
||
console.error('[清理] 清理临时文件目录失败:', error.message);
|
||
}
|
||
}
|
||
|
||
// formatFileSize 已在文件顶部导入
|
||
|
||
// 生成随机Token(crypto 已在文件顶部导入)
|
||
function generateRandomToken(length = 48) {
|
||
return crypto.randomBytes(length).toString('hex');
|
||
}
|
||
|
||
// 获取SMTP配置
|
||
function getSmtpConfig() {
|
||
const host = SettingsDB.get('smtp_host');
|
||
const port = SettingsDB.get('smtp_port');
|
||
const secure = SettingsDB.get('smtp_secure');
|
||
const user = SettingsDB.get('smtp_user');
|
||
const pass = SettingsDB.get('smtp_password');
|
||
const from = SettingsDB.get('smtp_from') || user;
|
||
|
||
if (!host || !port || !user || !pass) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
host,
|
||
port: parseInt(port, 10) || 465,
|
||
secure: secure === 'true' || secure === true || port === '465',
|
||
auth: { user, pass },
|
||
from
|
||
};
|
||
}
|
||
|
||
// 创建邮件传输器
|
||
function createTransport() {
|
||
const config = getSmtpConfig();
|
||
if (!config) return null;
|
||
|
||
return nodemailer.createTransport({
|
||
host: config.host,
|
||
port: config.port,
|
||
secure: config.secure,
|
||
auth: config.auth
|
||
});
|
||
}
|
||
|
||
// 发送邮件
|
||
async function sendMail(to, subject, html) {
|
||
const config = getSmtpConfig();
|
||
const transporter = createTransport();
|
||
if (!config || !transporter) {
|
||
throw new Error('SMTP未配置');
|
||
}
|
||
|
||
const from = (config.from && config.from.trim()) ? config.from.trim() : config.auth.user;
|
||
|
||
await transporter.sendMail({
|
||
from,
|
||
to,
|
||
subject,
|
||
html
|
||
});
|
||
}
|
||
|
||
// 检查邮件发送限流
|
||
function checkMailRateLimit(req, type = 'mail') {
|
||
// 使用 req.ip,基于 trust proxy 配置获取可信的客户端 IP
|
||
const clientKey = `${type}:${req.ip || req.socket?.remoteAddress || 'unknown'}`;
|
||
|
||
const res30 = mailLimiter30Min.recordFailure(clientKey);
|
||
if (res30.blocked) {
|
||
const err = new Error(`请求过于频繁,30分钟内最多3次,请在 ${res30.waitMinutes} 分钟后再试`);
|
||
err.status = 429;
|
||
throw err;
|
||
}
|
||
|
||
const resDay = mailLimiterDay.recordFailure(clientKey);
|
||
if (resDay.blocked) {
|
||
const err = new Error(`今天的次数已用完(最多10次),请稍后再试`);
|
||
err.status = 429;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// ===== 验证码验证辅助函数 =====
|
||
|
||
/**
|
||
* 验证验证码
|
||
* @param {Object} req - 请求对象
|
||
* @param {string} captcha - 用户输入的验证码
|
||
* @returns {{valid: boolean, message?: string}} 验证结果
|
||
*/
|
||
function verifyCaptcha(req, captcha) {
|
||
if (!captcha) {
|
||
return { valid: false, message: '请输入验证码' };
|
||
}
|
||
|
||
const sessionCaptcha = req.session.captcha;
|
||
const captchaTime = req.session.captchaTime;
|
||
|
||
// 调试日志
|
||
console.log('[验证码验证] SessionID:', req.sessionID, '输入:', captcha?.toLowerCase(), 'Session中:', sessionCaptcha);
|
||
|
||
if (!sessionCaptcha || !captchaTime) {
|
||
console.log('[验证码验证] 失败: session中无验证码');
|
||
return { valid: false, message: '验证码已过期,请刷新验证码' };
|
||
}
|
||
|
||
// 验证码有效期5分钟
|
||
if (Date.now() - captchaTime > 5 * 60 * 1000) {
|
||
console.log('[验证码验证] 失败: 验证码已超时');
|
||
return { valid: false, message: '验证码已过期,请刷新验证码' };
|
||
}
|
||
|
||
if (captcha.toLowerCase() !== sessionCaptcha) {
|
||
console.log('[验证码验证] 失败: 验证码不匹配');
|
||
return { valid: false, message: '验证码错误' };
|
||
}
|
||
|
||
console.log('[验证码验证] 成功');
|
||
// 验证通过后清除session中的验证码
|
||
delete req.session.captcha;
|
||
delete req.session.captchaTime;
|
||
|
||
return { valid: true };
|
||
}
|
||
|
||
// ===== 公开API =====
|
||
|
||
// 健康检查
|
||
app.get('/api/health', (req, res) => {
|
||
res.json({ success: true, message: 'Server is running' });
|
||
});
|
||
|
||
// 获取公开的系统配置(不需要登录)
|
||
app.get('/api/config', (req, res) => {
|
||
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
|
||
res.json({
|
||
success: true,
|
||
config: {
|
||
max_upload_size: maxUploadSize
|
||
}
|
||
});
|
||
});
|
||
|
||
// 生成验证码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 Cookie(2小时有效)
|
||
res.cookie('token', token, {
|
||
...cookieOptions,
|
||
maxAge: 2 * 60 * 60 * 1000
|
||
});
|
||
|
||
// 设置 refresh token Cookie(7天有效)
|
||
res.cookie('refreshToken', refreshToken, {
|
||
...cookieOptions,
|
||
maxAge: 7 * 24 * 60 * 60 * 1000
|
||
});
|
||
|
||
// 记录登录成功日志
|
||
logAuth(req, 'login', `用户登录成功: ${user.username}`, { userId: user.id, isAdmin: user.is_admin });
|
||
|
||
res.json({
|
||
success: true,
|
||
message: '登录成功',
|
||
expiresIn: 2 * 60 * 60 * 1000, // 告知前端access token有效期(毫秒)
|
||
user: {
|
||
id: user.id,
|
||
username: user.username,
|
||
email: user.email,
|
||
is_admin: user.is_admin,
|
||
has_oss_config: user.has_oss_config,
|
||
// 存储相关字段
|
||
storage_permission: user.storage_permission || 'oss_only',
|
||
current_storage_type: user.current_storage_type || 'oss',
|
||
local_storage_quota: user.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES,
|
||
local_storage_used: user.local_storage_used || 0,
|
||
oss_storage_quota: normalizeOssQuota(user.oss_storage_quota),
|
||
storage_used: user.storage_used || 0,
|
||
download_traffic_quota: normalizeDownloadTrafficQuota(user.download_traffic_quota),
|
||
download_traffic_used: normalizeDownloadTrafficUsed(
|
||
user.download_traffic_used,
|
||
normalizeDownloadTrafficQuota(user.download_traffic_quota)
|
||
),
|
||
download_traffic_quota_expires_at: user.download_traffic_quota_expires_at || null,
|
||
download_traffic_reset_cycle: user.download_traffic_reset_cycle || 'none',
|
||
download_traffic_last_reset_at: user.download_traffic_last_reset_at || null,
|
||
// OSS配置来源(重要:用于前端判断是否使用OSS直连上传)
|
||
oss_config_source: SettingsDB.hasUnifiedOssConfig() ? 'unified' : (user.has_oss_config ? 'personal' : 'none')
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('登录失败:', error);
|
||
logAuth(req, 'login_error', `登录异常: ${req.body.username || 'unknown'}`, { error: error.message }, 'error');
|
||
// 安全修复:不向客户端泄露具体错误信息
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '登录失败,请稍后重试'
|
||
});
|
||
}
|
||
}
|
||
);
|
||
|
||
// 刷新Access Token(从 HttpOnly Cookie 读取 refreshToken)
|
||
app.post('/api/refresh-token', (req, res) => {
|
||
// 优先从 Cookie 读取,兼容从请求体读取(向后兼容)
|
||
const refreshToken = req.cookies?.refreshToken || req.body?.refreshToken;
|
||
|
||
if (!refreshToken) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '缺少刷新令牌'
|
||
});
|
||
}
|
||
|
||
const result = refreshAccessToken(refreshToken);
|
||
|
||
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.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);
|
||
|
||
// 清除 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;
|
||
|
||
if (!fileName) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '缺少文件名参数'
|
||
});
|
||
}
|
||
|
||
try {
|
||
// 使用统一存储接口
|
||
const { StorageInterface } = require('./storage');
|
||
const storageInterface = new StorageInterface(req.user);
|
||
storage = await storageInterface.connect();
|
||
|
||
const tried = new Set();
|
||
const candidates = [fileName];
|
||
|
||
// 兼容被二次编码的实体(如 &#x60; -> `)
|
||
if (typeof rawFileName === 'string') {
|
||
const entityName = rawFileName.replace(/&/g, '&');
|
||
if (entityName && !candidates.includes(entityName)) {
|
||
candidates.push(entityName);
|
||
}
|
||
if (rawFileName && !candidates.includes(rawFileName)) {
|
||
candidates.push(rawFileName);
|
||
}
|
||
}
|
||
|
||
const pathsToDelete = candidates.map(name => (path === '/' ? `/${name}` : `${path}/${name}`));
|
||
|
||
try {
|
||
for (const targetPath of pathsToDelete) {
|
||
if (tried.has(targetPath)) continue;
|
||
tried.add(targetPath);
|
||
try {
|
||
// 删除文件并获取文件大小(用于更新缓存)
|
||
const deleteResult = await storage.delete(targetPath);
|
||
|
||
// 如果返回了文件大小,记录下来
|
||
if (deleteResult && deleteResult.size !== undefined) {
|
||
deletedSize = deleteResult.size;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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 直连相关接口(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);
|
||
|
||
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
|
||
}, 30 * 60);
|
||
|
||
// 创建 PutObject 命令
|
||
const command = new PutObjectCommand({
|
||
Bucket: bucket,
|
||
Key: objectKey,
|
||
ContentType: contentType
|
||
});
|
||
|
||
// 生成签名 URL(15分钟有效)
|
||
const signedUrl = await getSignedUrl(client, command, { expiresIn: 900 });
|
||
|
||
res.json({
|
||
success: true,
|
||
uploadUrl: signedUrl,
|
||
objectKey: objectKey,
|
||
previousSize,
|
||
completionToken,
|
||
expiresIn: 900
|
||
});
|
||
} catch (error) {
|
||
console.error('[OSS签名] 生成上传签名失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '生成上传签名失败: ' + error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
// OSS 上传完成通知(用于更新缓存和数据库)
|
||
// ===== P0 性能优化:使用增量更新替代全量统计 =====
|
||
app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
|
||
const { objectKey, size, completionToken } = req.body;
|
||
|
||
const normalizedObjectKey = typeof objectKey === 'string'
|
||
? objectKey.replace(/\\/g, '/').replace(/^\/+/, '')
|
||
: '';
|
||
const reportedSize = Number(size);
|
||
|
||
if (!normalizedObjectKey) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '缺少对象Key参数'
|
||
});
|
||
}
|
||
|
||
if (!Number.isFinite(reportedSize) || reportedSize < 0) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '文件大小参数无效'
|
||
});
|
||
}
|
||
|
||
const expectedPrefix = `user_${req.user.id}/`;
|
||
if (
|
||
!normalizedObjectKey.startsWith(expectedPrefix) ||
|
||
normalizedObjectKey.includes('..') ||
|
||
normalizedObjectKey.includes('\x00')
|
||
) {
|
||
return res.status(403).json({
|
||
success: false,
|
||
message: '对象Key不属于当前用户或格式非法'
|
||
});
|
||
}
|
||
|
||
// 安全检查:验证用户是否配置了OSS(个人配置或系统级统一配置)
|
||
const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig();
|
||
if (!req.user.has_oss_config && !hasUnifiedConfig) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '未配置OSS服务,无法完成上传'
|
||
});
|
||
}
|
||
|
||
const completionTokenResult = verifyEphemeralToken(completionToken, 'upload_complete');
|
||
if (!completionTokenResult.valid) {
|
||
return res.status(403).json({
|
||
success: false,
|
||
message: '上传完成凭证无效或已过期'
|
||
});
|
||
}
|
||
|
||
const completionPayload = completionTokenResult.payload || {};
|
||
if (
|
||
Number(completionPayload.userId) !== Number(req.user.id) ||
|
||
completionPayload.objectKey !== normalizedObjectKey
|
||
) {
|
||
return res.status(403).json({
|
||
success: false,
|
||
message: '上传完成凭证与对象不匹配'
|
||
});
|
||
}
|
||
|
||
const previousObjectSize = Number.isFinite(Number(completionPayload.previousSize)) && Number(completionPayload.previousSize) >= 0
|
||
? Number(completionPayload.previousSize)
|
||
: 0;
|
||
|
||
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);
|
||
|
||
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;
|
||
|
||
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 命令
|
||
const command = new GetObjectCommand({
|
||
Bucket: bucket,
|
||
Key: objectKey,
|
||
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(normalizedPath.split('/').pop())}"`
|
||
});
|
||
|
||
// 生成签名 URL(1小时有效)
|
||
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 || '/';
|
||
// 修复中文文件名: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}`);
|
||
|
||
// 清除 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 } = 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个字符'
|
||
});
|
||
}
|
||
}
|
||
|
||
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 }
|
||
});
|
||
|
||
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
|
||
});
|
||
|
||
// 更新分享的存储类型
|
||
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 }
|
||
);
|
||
|
||
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
|
||
});
|
||
} 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.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);
|
||
}
|
||
|
||
// 增加查看次数
|
||
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 // 添加到期时间
|
||
}
|
||
};
|
||
|
||
// 如果是单文件分享,查询存储获取文件信息(带缓存)
|
||
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 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: '分享不存在'
|
||
});
|
||
}
|
||
|
||
// 增加下载次数
|
||
ShareDB.incrementDownloadCount(code);
|
||
|
||
res.json({
|
||
success: true,
|
||
message: '下载统计已记录'
|
||
});
|
||
} catch (error) {
|
||
console.error('记录下载失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '记录下载失败: ' + error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
// 生成分享文件下载签名 URL(OSS 直连下载,公开 API,添加限流保护)
|
||
app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, res) => {
|
||
const { code } = req.params;
|
||
const { path: filePath, password } = req.body || {};
|
||
|
||
// 参数验证:code 不能为空
|
||
if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '无效的分享码'
|
||
});
|
||
}
|
||
|
||
const normalizedFilePath = normalizeVirtualPath(filePath);
|
||
if (!normalizedFilePath) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '文件路径非法'
|
||
});
|
||
}
|
||
|
||
try {
|
||
const share = ShareDB.findByCode(code);
|
||
|
||
if (!share) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: '分享不存在'
|
||
});
|
||
}
|
||
|
||
// 验证密码(如果需要)
|
||
if (share.share_password) {
|
||
if (!password || !ShareDB.verifyPassword(password, share.share_password)) {
|
||
if (req.shareRateLimitKey) {
|
||
shareLimiter.recordFailure(req.shareRateLimitKey);
|
||
}
|
||
return res.status(401).json({
|
||
success: false,
|
||
message: '密码错误或未提供密码',
|
||
needPassword: true
|
||
});
|
||
}
|
||
|
||
if (req.shareRateLimitKey) {
|
||
shareLimiter.recordSuccess(req.shareRateLimitKey);
|
||
}
|
||
}
|
||
|
||
// 安全验证:检查请求路径是否在分享范围内
|
||
if (!isPathWithinShare(normalizedFilePath, share)) {
|
||
return res.status(403).json({
|
||
success: false,
|
||
message: '无权访问该文件'
|
||
});
|
||
}
|
||
|
||
// 获取分享者的用户信息
|
||
const 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())}"`
|
||
});
|
||
|
||
// 生成签名 URL(1小时有效)
|
||
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 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 users = UserDB.getAll().map(user => {
|
||
const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list');
|
||
return policyState?.user || user;
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
users: users.map(u => ({
|
||
id: u.id,
|
||
username: u.username,
|
||
email: u.email,
|
||
is_admin: u.is_admin,
|
||
is_active: u.is_active,
|
||
is_verified: u.is_verified,
|
||
is_banned: u.is_banned,
|
||
has_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
|
||
}))
|
||
});
|
||
} 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
|
||
});
|
||
}
|
||
});
|
||
|
||
// ===== 第二轮修复:存储缓存一致性检查和修复接口 =====
|
||
|
||
/**
|
||
* 检查单个用户的存储缓存完整性
|
||
* 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("/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`);
|
||
});
|