Files
vue-driven-cloud-storage/backend/server.js
yuyx 4f9b281039 feat: 添加SFTP空间使用统计功能
- 新增 /api/user/sftp-usage API,递归统计SFTP服务器空间使用情况
- 返回总使用空间、文件数、文件夹数
- 在设置页面显示SFTP空间统计信息
- 支持手动刷新统计数据
- 适配"仅SFTP"和"用户可选"两种权限模式的UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 13:39:51 +08:00

3936 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 加载环境变量(必须在最开始)
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const svgCaptcha = require('svg-captcha');
const SftpClient = require('ssh2-sftp-client');
const multer = require('multer');
const nodemailer = require('nodemailer');
const path = require('path');
const fs = require('fs');
const { body, validationResult } = require('express-validator');
const archiver = require('archiver');
const { exec, execSync, execFile } = require('child_process');
const net = require('net');
const dns = require('dns').promises;
const util = require('util');
const execAsync = util.promisify(exec);
const execFileAsync = util.promisify(execFile);
const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB } = require('./database');
const { generateToken, authMiddleware, adminMiddleware } = require('./auth');
const app = express();
const PORT = process.env.PORT || 40001;
const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英文、数字、下划线、点和短横线
const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true';
// ===== 安全配置:公开域名白名单(防止 Host Header 注入) =====
// 必须配置 PUBLIC_BASE_URL 环境变量,用于生成邮件链接和分享链接
// 例如: PUBLIC_BASE_URL=https://cloud.example.com
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || null;
const ALLOWED_HOSTS = process.env.ALLOWED_HOSTS
? process.env.ALLOWED_HOSTS.split(',').map(h => h.trim().toLowerCase())
: [];
// 获取安全的基础URL用于生成邮件链接、分享链接等
function getSecureBaseUrl(req) {
// 优先使用配置的公开域名
if (PUBLIC_BASE_URL) {
return PUBLIC_BASE_URL.replace(/\/+$/, ''); // 移除尾部斜杠
}
// 如果没有配置,验证 Host 头是否在白名单中
const host = (req.get('host') || '').toLowerCase();
if (ALLOWED_HOSTS.length > 0 && !ALLOWED_HOSTS.includes(host)) {
console.warn(`[安全警告] 检测到非白名单 Host 头: ${host}`);
// 返回第一个白名单域名作为后备
const protocol = getProtocol(req);
return `${protocol}://${ALLOWED_HOSTS[0]}`;
}
// 开发环境回退(仅在没有配置时使用)
if (process.env.NODE_ENV !== 'production') {
return `${getProtocol(req)}://${req.get('host')}`;
}
// 生产环境没有配置时,记录警告并使用请求的 Host不推荐
console.error('[安全警告] 生产环境未配置 PUBLIC_BASE_URL存在 Host Header 注入风险!');
return `${getProtocol(req)}://${req.get('host')}`;
}
// 在反向代理(如 Nginx/Cloudflare后部署时信任代理以正确识别协议/IP/HTTPS
app.set('trust proxy', process.env.TRUST_PROXY || true);
// 配置CORS - 严格白名单模式
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim())
: []; // 默认为空数组,不允许任何域名
const corsOptions = {
credentials: true,
origin: (origin, callback) => {
// 生产环境必须配置白名单
if (allowedOrigins.length === 0 && process.env.NODE_ENV === 'production') {
console.error('❌ 错误: 生产环境必须配置 ALLOWED_ORIGINS 环境变量!');
callback(new Error('CORS未配置'));
return;
}
// 开发环境如果没有配置,允许 localhost
if (allowedOrigins.length === 0) {
const devOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000'];
if (!origin || devOrigins.some(o => origin.startsWith(o))) {
callback(null, true);
return;
}
}
// 严格白名单模式:只允许白名单中的域名
// 但需要允许没有Origin头的同源请求浏览器访问时不会发送Origin
if (!origin) {
// 没有Origin头的请求通常是
// 1. 浏览器的同源请求不触发CORS
// 2. 直接的服务器请求
// 这些都应该允许
callback(null, true);
} else if (allowedOrigins.includes(origin)) {
// 白名单中的域名
callback(null, true);
} else {
// 拒绝不在白名单中的跨域请求
console.warn(`[CORS] 拒绝来自未授权来源的请求: ${origin}`);
callback(new Error('CORS策略不允许来自该来源的访问'));
}
}
};
// 中间件
app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
// 强制HTTPS可通过环境变量控制默认关闭以兼容本地环境
app.use((req, res, next) => {
if (!ENFORCE_HTTPS) return next();
const proto = req.get('x-forwarded-proto') || (req.secure ? 'https' : 'http');
if (proto !== 'https') {
return res.status(400).json({
success: false,
message: '仅支持HTTPS访问请使用HTTPS'
});
}
return next();
});
// Session配置用于验证码
const isSecureCookie = process.env.COOKIE_SECURE === 'true';
const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码
app.use(session({
secret: process.env.SESSION_SECRET || 'your-session-secret-change-in-production',
resave: false,
saveUninitialized: true, // 改为true确保验证码请求时创建session
name: 'captcha.sid', // 自定义session cookie名称
cookie: {
secure: isSecureCookie,
httpOnly: true,
sameSite: sameSiteMode,
maxAge: 10 * 60 * 1000 // 10分钟
}
}));
// 安全响应头中间件
app.use((req, res, next) => {
// 防止点击劫持
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
// 防止MIME类型嗅探
res.setHeader('X-Content-Type-Options', 'nosniff');
// XSS保护
res.setHeader('X-XSS-Protection', '1; mode=block');
// HTTPS严格传输安全
if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
// 内容安全策略
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';");
// 隐藏X-Powered-By
res.removeHeader('X-Powered-By');
next();
});
// XSS过滤中间件用于用户输入- 增强版
// 注意:不转义 / 因为它是文件路径的合法字符
function sanitizeInput(str) {
if (typeof str !== 'string') return str;
// 1. 基础HTML实体转义不包括 / 因为是路径分隔符)
let sanitized = str
.replace(/[&<>"'`]/g, (char) => {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'`': '&#x60;'
};
return map[char];
});
// 2. 过滤危险协议javascript:, data:, vbscript:等)
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
// 3. 移除空字节
sanitized = sanitized.replace(/\x00/g, '');
return sanitized;
}
// HTML转义用于模板输出
function escapeHtml(str) {
if (typeof str !== 'string') return str;
return str.replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;'
}[char]));
}
// 规范化并校验HTTP直链前缀只允许http/https
function sanitizeHttpBaseUrl(raw) {
if (!raw) return null;
try {
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol)) {
return null;
}
url.search = '';
url.hash = '';
// 去掉多余的结尾斜杠,保持路径稳定
url.pathname = url.pathname.replace(/\/+$/, '');
return url.toString();
} catch {
return null;
}
}
// 构建安全的下载URL编码路径片段并拒绝非HTTP(S)前缀
function buildHttpDownloadUrl(rawBaseUrl, filePath) {
const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl);
if (!baseUrl || !filePath) return null;
try {
const url = new URL(baseUrl);
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
const safeSegments = normalizedPath
.split('/')
.filter(Boolean)
.map(segment => encodeURIComponent(segment));
const safePath = safeSegments.length ? '/' + safeSegments.join('/') : '';
const basePath = url.pathname.replace(/\/+$/, '');
const joinedPath = `${basePath}${safePath || '/'}`;
url.pathname = joinedPath || '/';
url.search = '';
url.hash = '';
return url.toString();
} catch (err) {
console.warn('[安全] 生成下载URL失败:', err.message);
return null;
}
}
// 校验文件名/路径片段安全(禁止分隔符、控制字符、..
function isSafePathSegment(name) {
return (
typeof name === 'string' &&
name.length > 0 &&
name.length <= 255 && // 限制文件名长度
!name.includes('..') &&
!/[/\\]/.test(name) &&
!/[\x00-\x1F]/.test(name)
);
}
// 危险文件扩展名黑名单仅限可能被Web服务器解析执行的脚本文件
// 注意:这是网盘应用,.exe等可执行文件允许上传服务器不会执行
const DANGEROUS_EXTENSIONS = [
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar', // PHP
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf', // Java Server Pages
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', // ASP.NET
'.htaccess', '.htpasswd' // Apache配置可能改变服务器行为
];
// 检查文件扩展名是否安全
function isFileExtensionSafe(filename) {
if (!filename || typeof filename !== 'string') return false;
const ext = path.extname(filename).toLowerCase();
// 检查危险扩展名
if (DANGEROUS_EXTENSIONS.includes(ext)) {
return false;
}
// 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行)
const nameLower = filename.toLowerCase();
for (const dangerExt of DANGEROUS_EXTENSIONS) {
if (nameLower.includes(dangerExt + '.')) {
return false;
}
}
return true;
}
// 应用XSS过滤到所有POST/PUT请求的body
app.use((req, res, next) => {
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
// 递归过滤所有字符串字段
function sanitizeObject(obj) {
if (typeof obj === 'string') {
return sanitizeInput(obj);
} else if (Array.isArray(obj)) {
return obj.map(item => sanitizeObject(item));
} else if (obj && typeof obj === 'object') {
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
sanitized[key] = sanitizeObject(value);
}
return sanitized;
}
return obj;
}
req.body = sanitizeObject(req.body);
}
next();
});
// 请求日志
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
// 获取正确的协议(考虑反向代理)
function getProtocol(req) {
// 1. 检查 X-Forwarded-Proto 头nginx 代理传递的协议)
const forwardedProto = req.get('X-Forwarded-Proto');
if (forwardedProto) {
return forwardedProto.split(',')[0].trim();
}
// 2. 检查 req.protocol
if (req.protocol) {
return req.protocol;
}
// 3. 如果配置了SSL默认使用https
if (req.secure) {
return 'https';
}
// 4. 默认使用https因为生产环境应该都配置了SSL
return 'https';
}
// 文件上传配置(临时存储)
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支持反向代理
getClientKey(req) {
const forwarded = req.get('X-Forwarded-For');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
return req.ip || req.connection.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 = 3000; // 3秒
const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理
// 验证码防刷中间件
function captchaRateLimitMiddleware(req, res, next) {
const clientKey = `captcha:${captchaLimiter.getClientKey(req)}`;
const now = Date.now();
// 最小时间间隔限制
const lastRequest = captchaLastRequest.get(clientKey);
if (lastRequest && (now - lastRequest) < CAPTCHA_MIN_INTERVAL) {
return res.status(429).json({
success: false,
message: '验证码请求过于频繁,请稍后再试'
});
}
captchaLastRequest.set(clientKey, now, 15 * 60 * 1000);
// 窗口内总次数限流
const result = captchaLimiter.recordFailure(clientKey);
if (result.blocked) {
return res.status(429).json({
success: false,
message: `验证码请求过多,请在 ${result.waitMinutes} 分钟后再试`,
blocked: true,
resetTime: result.resetTime
});
}
next();
}
// 登录防爆破中间件
function loginRateLimitMiddleware(req, res, next) {
const clientIP = loginLimiter.getClientKey(req);
const { username } = req.body;
const ipKey = `login:ip:${clientIP}`;
// 检查IP是否被封锁
if (loginLimiter.isBlocked(ipKey)) {
const result = loginLimiter.recordFailure(ipKey);
console.warn(`[防爆破] 拦截登录尝试 - IP: ${clientIP}, 原因: IP被封锁`);
return res.status(429).json({
success: false,
message: `登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`,
blocked: true,
resetTime: result.resetTime
});
}
// 检查用户名是否被封锁
if (username) {
const usernameKey = `login:username:${username}`;
if (loginLimiter.isBlocked(usernameKey)) {
const result = loginLimiter.recordFailure(usernameKey);
console.warn(`[防爆破] 拦截登录尝试 - 用户名: ${username}, 原因: 用户名被封锁`);
return res.status(429).json({
success: false,
message: `该账号登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`,
blocked: true,
resetTime: result.resetTime
});
}
}
// 将限流key附加到请求对象供后续使用
req.rateLimitKeys = {
ipKey,
usernameKey: username ? `login:username:${username}` : null
};
next();
}
// 分享密码防爆破中间件
function shareRateLimitMiddleware(req, res, next) {
const clientIP = shareLimiter.getClientKey(req);
const { code } = req.params;
const key = `share:${code}:${clientIP}`;
// 检查是否被封锁
if (shareLimiter.isBlocked(key)) {
const result = shareLimiter.recordFailure(key);
console.warn(`[防爆破] 拦截分享密码尝试 - 分享码: ${code}, IP: ${clientIP}`);
return res.status(429).json({
success: false,
message: `密码尝试过多,请在 ${result.waitMinutes} 分钟后重试`,
blocked: true,
resetTime: result.resetTime
});
}
req.shareRateLimitKey = key;
next();
}
// ===== 工具函数 =====
// 安全删除文件(不抛出异常)
function safeDeleteFile(filePath) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
console.log(`[清理] 已删除临时文件: ${filePath}`);
return true;
}
} catch (error) {
console.error(`[清理] 删除临时文件失败: ${filePath}`, error.message);
return false;
}
}
// 验证请求路径是否在分享范围内(防止越权访问)
function isPathWithinShare(requestPath, share) {
if (!requestPath || !share) {
return false;
}
// 规范化路径(移除 ../ 等危险路径,统一分隔符)
const normalizedRequest = path.normalize(requestPath).replace(/^(\.\.[\/\\])+/, '').replace(/\\/g, '/');
const normalizedShare = path.normalize(share.share_path).replace(/\\/g, '/');
if (share.share_type === 'file') {
// 单文件分享:只允许下载该文件
return normalizedRequest === normalizedShare;
} else {
// 目录分享:只允许下载该目录及其子目录下的文件
// 确保分享路径以斜杠结尾用于前缀匹配
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : normalizedShare + '/';
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
}
}
// 清理旧的临时文件(启动时执行一次)
function cleanupOldTempFiles() {
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) {
return;
}
try {
const files = fs.readdirSync(uploadsDir);
const now = Date.now();
const maxAge = 24 * 60 * 60 * 1000; // 24小时
let cleaned = 0;
files.forEach(file => {
const filePath = path.join(uploadsDir, file);
try {
const stats = fs.statSync(filePath);
if (now - stats.mtimeMs > maxAge) {
fs.unlinkSync(filePath);
cleaned++;
}
} catch (err) {
console.error(`[清理] 检查文件失败: ${filePath}`, err.message);
}
});
if (cleaned > 0) {
console.log(`[清理] 已清理 ${cleaned} 个超过24小时的临时文件`);
}
} catch (error) {
console.error('[清理] 清理临时文件目录失败:', error.message);
}
}
function isPrivateIp(ip) {
if (!net.isIP(ip)) return false;
return ip.startsWith('10.') ||
ip.startsWith('192.168.') ||
ip.startsWith('172.16.') ||
ip.startsWith('172.17.') ||
ip.startsWith('172.18.') ||
ip.startsWith('172.19.') ||
ip.startsWith('172.20.') ||
ip.startsWith('172.21.') ||
ip.startsWith('172.22.') ||
ip.startsWith('172.23.') ||
ip.startsWith('172.24.') ||
ip.startsWith('172.25.') ||
ip.startsWith('172.26.') ||
ip.startsWith('172.27.') ||
ip.startsWith('172.28.') ||
ip.startsWith('172.29.') ||
ip.startsWith('172.30.') ||
ip.startsWith('172.31.') ||
ip.startsWith('127.') ||
ip === '0.0.0.0' ||
ip === '::1' ||
ip.startsWith('fe80:') ||
ip.startsWith('fd');
}
async function validateSftpDestination(host, port) {
if (!host || typeof host !== 'string' || host.length > 255) {
throw new Error('无效的SFTP主机');
}
if (host.includes('://') || host.includes('/')) {
throw new Error('SFTP主机不能包含协议或路径');
}
const portNum = parseInt(port, 10) || 22;
if (portNum < 1 || portNum > 65535) {
throw new Error('SFTP端口范围应为1-65535');
}
let resolvedIp;
try {
const lookup = await dns.lookup(host);
resolvedIp = lookup.address;
} catch (err) {
throw new Error('无法解析SFTP主机请检查域名或IP');
}
if (isPrivateIp(resolvedIp) && process.env.ALLOW_PRIVATE_SFTP !== 'true') {
throw new Error('出于安全考虑,不允许连接内网地址');
}
return { host, port: portNum, resolvedIp };
}
// SFTP连接
async function connectToSFTP(config) {
const sftp = new SftpClient();
await sftp.connect({
host: config.ftp_host,
port: parseInt(config.ftp_port, 10) || 22,
username: config.ftp_user,
password: config.ftp_password,
readyTimeout: parseInt(process.env.SFTP_CONNECT_TIMEOUT || '8000', 10)
});
return sftp;
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// 生成随机Token
function generateRandomToken(length = 48) {
return require('crypto').randomBytes(length).toString('hex');
}
// 获取SMTP配置
function getSmtpConfig() {
const host = SettingsDB.get('smtp_host');
const port = SettingsDB.get('smtp_port');
const secure = SettingsDB.get('smtp_secure');
const user = SettingsDB.get('smtp_user');
const pass = SettingsDB.get('smtp_password');
const from = SettingsDB.get('smtp_from') || user;
if (!host || !port || !user || !pass) {
return null;
}
return {
host,
port: parseInt(port, 10) || 465,
secure: secure === 'true' || secure === true || port === '465',
auth: { user, pass },
from
};
}
// 创建邮件传输器
function createTransport() {
const config = getSmtpConfig();
if (!config) return null;
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth
});
}
// 发送邮件
async function sendMail(to, subject, html) {
const config = getSmtpConfig();
const transporter = createTransport();
if (!config || !transporter) {
throw new Error('SMTP未配置');
}
const from = (config.from && config.from.trim()) ? config.from.trim() : config.auth.user;
await transporter.sendMail({
from,
to,
subject,
html
});
}
// 检查邮件发送限流
function checkMailRateLimit(req, type = 'mail') {
const clientKey = `${type}:${req.get('X-Forwarded-For') || req.ip || req.connection.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;
}
}
// ===== 公开API =====
// 健康检查
app.get('/api/health', (req, res) => {
res.json({ success: true, message: 'Server is running' });
});
// 获取公开的系统配置(不需要登录)
app.get('/api/config', (req, res) => {
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
res.json({
success: true,
config: {
max_upload_size: maxUploadSize
}
});
});
// 生成验证码API
app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => {
try {
const captcha = svgCaptcha.create({
size: 6, // 验证码长度
noise: 3, // 干扰线条数
color: true, // 使用彩色
background: '#f7f7f7', // 背景色
width: 140,
height: 44,
fontSize: 52,
charPreset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 去掉易混淆字符,字母+数字
});
// 将验证码文本存储在session中
req.session.captcha = captcha.text.toLowerCase();
req.session.captchaTime = Date.now();
// 保存session
req.session.save((err) => {
if (err) {
console.error('[验证码] Session保存失败:', err);
} else {
// 安全:不记录验证码明文到日志
console.log('[验证码] 生成成功, SessionID:', req.sessionID);
}
});
res.type('svg');
res.send(captcha.data);
} catch (error) {
console.error('生成验证码失败:', error);
res.status(500).json({
success: false,
message: '生成验证码失败'
});
}
});
// 用户注册(简化版)
app.post('/api/register',
[
body('username')
.isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符')
.matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'),
body('email').isEmail().withMessage('邮箱格式不正确'),
body('password').isLength({ min: 6 }).withMessage('密码至少6个字符')
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
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
});
}
res.json({
success: true,
message: '注册成功,请查收邮箱完成验证',
user_id: userId
});
} catch (error) {
console.error('注册失败:', error);
res.status(500).json({
success: false,
message: '注册失败: ' + error.message
});
}
}
);
// 重新发送邮箱验证邮件
app.post('/api/resend-verification', [
body('email').optional({ checkFalsy: true }).isEmail().withMessage('邮箱格式不正确'),
body('username')
.optional({ checkFalsy: true })
.isLength({ min: 3 }).withMessage('用户名格式不正确')
.matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线')
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
}
try {
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;
if (!token) {
return res.status(400).json({ success: false, message: '缺少token' });
}
try {
const user = VerificationDB.consumeVerificationToken(token);
if (!user) {
return res.status(400).json({ success: false, message: '无效或已过期的验证链接' });
}
res.json({ success: true, message: '邮箱验证成功,请登录' });
} catch (error) {
console.error('邮箱验证失败:', error);
res.status(500).json({ success: false, message: '邮箱验证失败' });
}
});
// 发起密码重置(邮件)
app.post('/api/password/forgot', [
body('email').isEmail().withMessage('邮箱格式不正确')
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
}
const { email } = req.body;
try {
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: 6 }).withMessage('新密码至少6个字符')
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
}
const { token, new_password } = req.body;
try {
const tokenRow = PasswordResetTokenDB.use(token);
if (!tokenRow) {
return res.status(400).json({ success: false, message: '无效或已过期的重置链接' });
}
const user = UserDB.findById(tokenRow.user_id);
if (!user) {
return res.status(404).json({ success: false, message: '用户不存在' });
}
if (user.is_banned || !user.is_active) {
return res.status(403).json({ success: false, message: '账号不可用,无法重置密码' });
}
if (!user.is_verified) {
return res.status(400).json({ success: false, message: '邮箱未验证,无法重置密码' });
}
// 更新密码
const hashed = require('bcryptjs').hashSync(new_password, 10);
db.prepare('UPDATE users SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(hashed, tokenRow.user_id);
res.json({ success: true, message: '密码重置成功,请重新登录' });
} catch (error) {
console.error('密码重置失败:', error);
res.status(500).json({ success: false, message: '密码重置失败' });
}
});
// 用户登录
app.post('/api/login',
loginRateLimitMiddleware,
[
body('username').notEmpty().withMessage('用户名不能为空'),
body('password').notEmpty().withMessage('密码不能为空')
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
const { username, password, captcha } = req.body;
try {
// 检查是否需要验证码
const ipKey = req.rateLimitKeys?.ipKey;
const usernameKey = req.rateLimitKeys?.usernameKey;
const ipFailures = ipKey ? loginLimiter.getFailureCount(ipKey) : 0;
const usernameFailures = usernameKey ? loginLimiter.getFailureCount(usernameKey) : 0;
const needCaptcha = ipFailures >= 2 || usernameFailures >= 2;
// 如果需要验证码,则验证验证码
if (needCaptcha) {
console.log('[登录验证] 需要验证码, IP失败次数:', ipFailures, '用户名失败次数:', usernameFailures);
if (!captcha) {
return res.status(400).json({
success: false,
message: '请输入验证码',
needCaptcha: true
});
}
// 验证验证码
const sessionCaptcha = req.session.captcha;
const captchaTime = req.session.captchaTime;
// 安全:不记录验证码明文
console.log('[登录验证] 正在验证验证码...');
if (!sessionCaptcha || !captchaTime) {
console.log('[登录验证] 验证码不存在于Session中');
return res.status(400).json({
success: false,
message: '验证码已过期,请刷新验证码',
needCaptcha: true
});
}
// 验证码有效期5分钟
if (Date.now() - captchaTime > 5 * 60 * 1000) {
console.log('[登录验证] 验证码已超过5分钟');
return res.status(400).json({
success: false,
message: '验证码已过期,请刷新验证码',
needCaptcha: true
});
}
if (captcha.toLowerCase() !== sessionCaptcha) {
console.log('[登录验证] 验证码不匹配');
return res.status(400).json({
success: false,
message: '验证码错误',
needCaptcha: true
});
}
console.log('[登录验证] 验证码验证通过');
// 验证通过后清除session中的验证码
delete req.session.captcha;
delete req.session.captchaTime;
}
const user = UserDB.findByUsername(username);
if (!user) {
// 记录失败尝试
if (req.rateLimitKeys) {
const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey);
if (req.rateLimitKeys.usernameKey) {
loginLimiter.recordFailure(req.rateLimitKeys.usernameKey);
}
return res.status(401).json({
success: false,
message: '用户名或密码错误',
needCaptcha: result.needCaptcha
});
}
return res.status(401).json({
success: false,
message: '用户名或密码错误'
});
}
if (user.is_banned) {
return res.status(403).json({
success: false,
message: '账号已被封禁'
});
}
if (!user.is_verified) {
return res.status(403).json({
success: false,
message: '邮箱未验证,请查收邮件或重新发送验证邮件',
needVerify: true,
email: user.email
});
}
if (!UserDB.verifyPassword(password, user.password)) {
// 记录失败尝试
if (req.rateLimitKeys) {
const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey);
if (req.rateLimitKeys.usernameKey) {
loginLimiter.recordFailure(req.rateLimitKeys.usernameKey);
}
return res.status(401).json({
success: false,
message: '用户名或密码错误',
needCaptcha: result.needCaptcha
});
}
return res.status(401).json({
success: false,
message: '用户名或密码错误'
});
}
const token = generateToken(user);
// 清除失败记录
if (req.rateLimitKeys) {
loginLimiter.recordSuccess(req.rateLimitKeys.ipKey);
if (req.rateLimitKeys.usernameKey) {
loginLimiter.recordSuccess(req.rateLimitKeys.usernameKey);
}
}
// 增强Cookie安全设置
const isSecureEnv = process.env.COOKIE_SECURE === 'true';
res.cookie('token', token, {
httpOnly: true,
secure: isSecureEnv,
// HTTPS环境使用strictHTTP环境使用lax开发环境兼容
sameSite: isSecureEnv ? 'strict' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
path: '/' // 限制Cookie作用域
});
res.json({
success: true,
message: '登录成功',
token,
user: {
id: user.id,
username: user.username,
email: user.email,
is_admin: user.is_admin,
has_ftp_config: user.has_ftp_config,
// 存储相关字段
storage_permission: user.storage_permission || 'sftp_only',
current_storage_type: user.current_storage_type || 'sftp',
local_storage_quota: user.local_storage_quota || 1073741824,
local_storage_used: user.local_storage_used || 0
}
});
} catch (error) {
console.error('登录失败:', error);
res.status(500).json({
success: false,
message: '登录失败: ' + error.message
});
}
}
);
// ===== 需要认证的API =====
// 获取当前用户信息
app.get('/api/user/profile', authMiddleware, (req, res) => {
// 不返回密码明文
const { ftp_password, password, ...safeUser } = req.user;
safeUser.http_download_base_url = sanitizeHttpBaseUrl(safeUser.http_download_base_url);
res.json({
success: true,
user: safeUser
});
});
// 更新FTP配置
app.post('/api/user/update-ftp',
authMiddleware,
[
body('ftp_host').notEmpty().withMessage('FTP主机不能为空'),
body('ftp_port').isInt({ min: 1, max: 65535 }).withMessage('FTP端口范围1-65535'),
body('ftp_user').notEmpty().withMessage('FTP用户名不能为空'),
body('http_download_base_url')
.optional({ checkFalsy: true })
.isURL({ protocols: ['http', 'https'], require_protocol: true, require_tld: false })
.withMessage('HTTP直链地址必须以 http/https 开头')
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
const { ftp_host, ftp_port, ftp_user, ftp_password, http_download_base_url } = req.body;
// 调试日志:查看接收到的配置(掩码密码)
console.log("[DEBUG] 收到SFTP配置:", {
ftp_host,
ftp_port,
ftp_user,
ftp_password: ftp_password ? "***" : "(empty)",
http_download_base_url
});
const safeHttpBaseUrl = sanitizeHttpBaseUrl(http_download_base_url);
if (http_download_base_url && !safeHttpBaseUrl) {
return res.status(400).json({
success: false,
message: 'HTTP直链地址必须是合法的http/https地址且不能包含查询或片段'
});
}
// 如果用户已配置FTP且密码为空使用现有密码
let actualPassword = ftp_password;
if (!ftp_password && req.user.has_ftp_config && req.user.ftp_password) {
actualPassword = req.user.ftp_password;
} else if (!ftp_password) {
return res.status(400).json({
success: false,
message: 'FTP密码不能为空'
});
}
// 主机校验与防SSRF
const { port: safePort } = await validateSftpDestination(ftp_host, ftp_port);
// 验证FTP连接
try {
const sftp = await connectToSFTP({ ftp_host, ftp_port: safePort, ftp_user, ftp_password: actualPassword });
await sftp.end();
} catch (error) {
return res.status(400).json({
success: false,
message: 'SFTP连接失败请检查配置: ' + error.message
});
}
// 更新用户配置
UserDB.update(req.user.id, {
ftp_host,
ftp_port: safePort,
ftp_user,
ftp_password: actualPassword,
http_download_base_url: safeHttpBaseUrl || null,
has_ftp_config: 1
});
res.json({
success: true,
message: 'SFTP配置已更新'
});
} catch (error) {
console.error('更新配置失败:', error);
res.status(500).json({
success: false,
message: '更新配置失败: ' + error.message
});
}
}
);
// 获取SFTP存储空间使用情况
app.get('/api/user/sftp-usage', authMiddleware, async (req, res) => {
let sftp = null;
try {
// 检查用户是否配置了SFTP
if (!req.user.has_ftp_config) {
return res.status(400).json({
success: false,
message: '未配置SFTP服务器'
});
}
// 连接SFTP
sftp = await connectToSFTP(req.user);
// 递归计算目录大小的函数
async function calculateDirSize(dirPath) {
let totalSize = 0;
let fileCount = 0;
let dirCount = 0;
try {
const list = await sftp.list(dirPath);
for (const item of list) {
// 跳过 . 和 .. 目录
if (item.name === '.' || item.name === '..') continue;
const itemPath = dirPath === '/' ? `/${item.name}` : `${dirPath}/${item.name}`;
if (item.type === 'd') {
// 是目录,递归计算
dirCount++;
const subResult = await calculateDirSize(itemPath);
totalSize += subResult.totalSize;
fileCount += subResult.fileCount;
dirCount += subResult.dirCount;
} else {
// 是文件,累加大小
fileCount++;
totalSize += item.size || 0;
}
}
} catch (err) {
// 跳过无法访问的目录
console.warn(`[SFTP统计] 无法访问目录 ${dirPath}: ${err.message}`);
}
return { totalSize, fileCount, dirCount };
}
// 从根目录开始计算
const result = await calculateDirSize('/');
res.json({
success: true,
usage: {
totalSize: result.totalSize,
totalSizeFormatted: formatFileSize(result.totalSize),
fileCount: result.fileCount,
dirCount: result.dirCount
}
});
} catch (error) {
console.error('[SFTP统计] 获取失败:', error);
res.status(500).json({
success: false,
message: '获取SFTP空间使用情况失败: ' + error.message
});
} finally {
if (sftp) {
try {
await sftp.end();
} catch (e) {
// 忽略关闭错误
}
}
}
});
// 修改管理员账号信息(仅管理员可修改用户名)
app.post('/api/admin/update-profile',
authMiddleware,
adminMiddleware,
[
body('username')
.isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符')
.matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线')
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
const { username } = req.body;
// 检查用户名是否被占用(排除自己)
if (username !== req.user.username) {
const existingUser = UserDB.findByUsername(username);
if (existingUser && existingUser.id !== req.user.id) {
return res.status(400).json({
success: false,
message: '用户名已被使用'
});
}
// 更新用户名
UserDB.update(req.user.id, { username });
// 获取更新后的用户信息
const updatedUser = UserDB.findById(req.user.id);
// 生成新的token因为用户名变了
const newToken = generateToken(updatedUser);
res.json({
success: true,
message: '用户名已更新',
token: newToken,
user: {
id: updatedUser.id,
username: updatedUser.username,
email: updatedUser.email,
is_admin: updatedUser.is_admin
}
});
} else {
res.json({
success: true,
message: '没有需要更新的信息'
});
}
} catch (error) {
console.error('更新账号信息失败:', error);
res.status(500).json({
success: false,
message: '更新失败: ' + error.message
});
}
}
);
// 修改当前用户密码(需要验证当前密码)
app.post('/api/user/change-password',
authMiddleware,
[
body('current_password').notEmpty().withMessage('当前密码不能为空'),
body('new_password').isLength({ min: 6 }).withMessage('新密码至少6个字符')
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
const { current_password, new_password } = req.body;
// 获取当前用户信息
const user = UserDB.findById(req.user.id);
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
// 验证当前密码
if (!UserDB.verifyPassword(current_password, user.password)) {
return res.status(401).json({
success: false,
message: '当前密码错误'
});
}
// 更新密码
UserDB.update(req.user.id, { password: new_password });
res.json({
success: true,
message: '密码修改成功'
});
} catch (error) {
console.error('修改密码失败:', error);
res.status(500).json({
success: false,
message: '修改密码失败: ' + error.message
});
}
}
);
// 修改当前用户名
app.post('/api/user/update-username',
authMiddleware,
[
body('username')
.isLength({ min: 3 }).withMessage('用户名至少3个字符')
.matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线')
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
const { username } = req.body;
// 检查用户名是否已存在
const existingUser = UserDB.findByUsername(username);
if (existingUser && existingUser.id !== req.user.id) {
return res.status(400).json({
success: false,
message: '用户名已存在'
});
}
// 更新用户名
UserDB.update(req.user.id, { username });
res.json({
success: true,
message: '用户名修改成功'
});
} catch (error) {
console.error('修改用户名失败:', error);
res.status(500).json({
success: false,
message: '修改用户名失败: ' + error.message
});
}
}
);
// 切换存储方式
app.post('/api/user/switch-storage',
authMiddleware,
[
body('storage_type').isIn(['local', 'sftp']).withMessage('无效的存储类型')
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
const { storage_type } = req.body;
// 检查权限
if (req.user.storage_permission === 'local_only' && storage_type !== 'local') {
return res.status(403).json({
success: false,
message: '您只能使用本地存储'
});
}
if (req.user.storage_permission === 'sftp_only' && storage_type !== 'sftp') {
return res.status(403).json({
success: false,
message: '您只能使用SFTP存储'
});
}
// 检查SFTP配置
if (storage_type === 'sftp' && !req.user.has_ftp_config) {
return res.status(400).json({
success: false,
message: '请先配置SFTP服务器'
});
}
// 更新存储类型
UserDB.update(req.user.id, { current_storage_type: storage_type });
res.json({
success: true,
message: '存储方式已切换',
storage_type
});
} catch (error) {
console.error('切换存储失败:', error);
res.status(500).json({
success: false,
message: '切换存储失败: ' + error.message
});
}
}
);
// 获取文件列表(添加速率限制)
app.get('/api/files', authMiddleware, async (req, res) => {
// 速率限制检查
const rateLimitKey = `file_list:${req.user.id}`;
const rateLimitResult = fileListLimiter.recordFailure(rateLimitKey);
if (rateLimitResult.blocked) {
return res.status(429).json({
success: false,
message: `请求过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试`
});
}
const dirPath = req.query.path || '/';
let storage;
try {
// 使用统一存储接口
const { StorageInterface } = require('./storage');
const storageInterface = new StorageInterface(req.user);
storage = await storageInterface.connect();
const list = await storage.list(dirPath);
const storageType = req.user.current_storage_type || 'sftp';
const sanitizedHttpBase = sanitizeHttpBaseUrl(req.user.http_download_base_url);
const formattedList = list.map(item => {
// 构建完整的文件路径用于下载
const httpDownloadUrl = (storageType === 'sftp' && sanitizedHttpBase && item.type !== 'd')
? buildHttpDownloadUrl(sanitizedHttpBase, dirPath === '/' ? `/${item.name}` : `${dirPath}/${item.name}`)
: null;
return {
name: item.name,
type: item.type === 'd' ? 'directory' : 'file',
size: item.size,
sizeFormatted: formatFileSize(item.size),
modifiedAt: new Date(item.modifyTime),
isDirectory: item.type === 'd',
httpDownloadUrl: httpDownloadUrl
};
});
formattedList.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
res.json({
success: true,
path: dirPath,
items: formattedList,
storageType: storageType,
storagePermission: req.user.storage_permission || 'sftp_only'
});
} catch (error) {
console.error('获取文件列表失败:', error);
res.status(500).json({
success: false,
message: '获取文件列表失败: ' + error.message
});
} finally {
if (storage) await storage.end();
}
});
// 重命名文件
app.post('/api/files/rename', authMiddleware, async (req, res) => {
const { oldName, newName, path } = req.body;
let storage;
if (!oldName || !newName) {
return res.status(400).json({
success: false,
message: '缺少文件名参数'
});
}
try {
// 使用统一存储接口
const { StorageInterface } = require('./storage');
const storageInterface = new StorageInterface(req.user);
storage = await storageInterface.connect();
const oldPath = path === '/' ? `/${oldName}` : `${path}/${oldName}`;
const newPath = path === '/' ? `/${newName}` : `${path}/${newName}`;
await storage.rename(oldPath, newPath);
res.json({
success: true,
message: '文件重命名成功'
});
} catch (error) {
console.error('重命名文件失败:', error);
res.status(500).json({
success: false,
message: '重命名文件失败: ' + error.message
});
} finally {
if (storage) await storage.end();
}
});
// 创建文件夹
app.post('/api/files/mkdir', authMiddleware, async (req, res) => {
const { path, folderName } = req.body;
let storage;
// 参数验证
if (!folderName || folderName.trim() === '') {
return res.status(400).json({
success: false,
message: '文件夹名称不能为空'
});
}
// 文件名安全检查 - 防止路径遍历攻击
if (folderName.includes('/') || folderName.includes('\\') || folderName.includes('..') || folderName.includes(':')) {
return res.status(400).json({
success: false,
message: '文件夹名称不能包含特殊字符 (/ \\ .. :)'
});
}
// 只允许本地存储创建文件夹
if (req.user.current_storage_type !== 'local') {
return res.status(400).json({
success: false,
message: '只有本地存储支持创建文件夹'
});
}
try {
const { StorageInterface } = require('./storage');
const storageInterface = new StorageInterface(req.user);
storage = await storageInterface.connect();
// 构造文件夹路径
const basePath = path || '/';
const folderPath = basePath === '/' ? `/${folderName}` : `${basePath}/${folderName}`;
const fullPath = storage.getFullPath(folderPath);
// 检查是否已存在
if (fs.existsSync(fullPath)) {
return res.status(400).json({
success: false,
message: '文件夹已存在'
});
}
// 创建文件夹 (不使用recursive,只创建当前层级)
fs.mkdirSync(fullPath, { mode: 0o755 });
console.log(`[创建文件夹成功] 用户${req.user.id}: ${folderPath}`);
res.json({
success: true,
message: '文件夹创建成功'
});
} catch (error) {
console.error('[创建文件夹失败]', error);
res.status(500).json({
success: false,
message: '创建文件夹失败: ' + error.message
});
} finally {
if (storage) await storage.end();
}
});
// 获取文件夹详情(大小统计)
app.post('/api/files/folder-info', authMiddleware, async (req, res) => {
const { path: dirPath, folderName } = req.body;
let storage;
if (!folderName) {
return res.status(400).json({
success: false,
message: '缺少文件夹名称参数'
});
}
// 只支持本地存储
if (req.user.current_storage_type !== 'local') {
return res.status(400).json({
success: false,
message: '只有本地存储支持此功能'
});
}
try {
const { StorageInterface } = require('./storage');
const storageInterface = new StorageInterface(req.user);
storage = await storageInterface.connect();
// 构造文件夹路径
const basePath = dirPath || '/';
const folderPath = basePath === '/' ? `/${folderName}` : `${basePath}/${folderName}`;
const fullPath = storage.getFullPath(folderPath);
// 检查是否存在且是文件夹
if (!fs.existsSync(fullPath)) {
return res.status(404).json({
success: false,
message: '文件夹不存在'
});
}
const stats = fs.statSync(fullPath);
if (!stats.isDirectory()) {
return res.status(400).json({
success: false,
message: '指定路径不是文件夹'
});
}
// 计算文件夹大小
const folderSize = storage.calculateFolderSize(fullPath);
// 计算文件数量
function countFiles(countDirPath) {
let fileCount = 0;
let folderCount = 0;
const items = fs.readdirSync(countDirPath, { withFileTypes: true });
for (const item of items) {
const itemPath = path.join(countDirPath, item.name);
if (item.isDirectory()) {
folderCount++;
const subCounts = countFiles(itemPath);
fileCount += subCounts.fileCount;
folderCount += subCounts.folderCount;
} else {
fileCount++;
}
}
return { fileCount, folderCount };
}
const counts = countFiles(fullPath);
res.json({
success: true,
data: {
name: folderName,
path: folderPath,
size: folderSize,
fileCount: counts.fileCount,
folderCount: counts.folderCount
}
});
} catch (error) {
console.error('[获取文件夹详情失败]', error);
res.status(500).json({
success: false,
message: '获取文件夹详情失败: ' + error.message
});
} finally {
if (storage) await storage.end();
}
});
// 删除文件
app.post('/api/files/delete', authMiddleware, async (req, res) => {
const { fileName, path } = req.body;
let storage;
if (!fileName) {
return res.status(400).json({
success: false,
message: '缺少文件名参数'
});
}
try {
// 使用统一存储接口
const { StorageInterface } = require('./storage');
const storageInterface = new StorageInterface(req.user);
storage = await storageInterface.connect();
const filePath = path === '/' ? `/${fileName}` : `${path}/${fileName}`;
await storage.delete(filePath);
res.json({
success: true,
message: '删除成功'
});
} catch (error) {
console.error('删除文件失败:', error);
res.status(500).json({
success: false,
message: '删除文件失败: ' + error.message
});
} finally {
if (storage) await storage.end();
}
});
// 上传文件(添加速率限制)
app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) => {
// 速率限制检查
const rateLimitKey = `upload:${req.user.id}`;
const rateLimitResult = uploadLimiter.recordFailure(rateLimitKey);
if (rateLimitResult.blocked) {
// 清理已上传的临时文件
if (req.file && fs.existsSync(req.file.path)) {
safeDeleteFile(req.file.path);
}
return res.status(429).json({
success: false,
message: `上传过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试`
});
}
if (!req.file) {
return res.status(400).json({
success: false,
message: '没有上传文件'
});
}
// 检查文件大小限制
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
if (req.file.size > maxUploadSize) {
// 删除已上传的临时文件
if (fs.existsSync(req.file.path)) {
safeDeleteFile(req.file.path);
}
return res.status(413).json({
success: false,
message: '文件超过上传限制',
maxSize: maxUploadSize,
fileSize: req.file.size
});
}
const remotePath = req.body.path || '/';
// 修复中文文件名multer将UTF-8转为了Latin1需要转回来
const originalFilename = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
// 文件名安全校验
if (!isSafePathSegment(originalFilename)) {
safeDeleteFile(req.file.path);
return res.status(400).json({
success: false,
message: '文件名包含非法字符'
});
}
// 文件扩展名安全检查(防止上传危险文件)
if (!isFileExtensionSafe(originalFilename)) {
console.warn(`[安全] 拒绝上传危险文件: ${originalFilename}, 用户: ${req.user.username}`);
safeDeleteFile(req.file.path);
return res.status(400).json({
success: false,
message: '不允许上传此类型的文件(安全限制)'
});
}
// 路径安全校验
const normalizedPath = path.posix.normalize(remotePath || '/');
if (normalizedPath.includes('..')) {
return res.status(400).json({
success: false,
message: '上传路径非法'
});
}
const safePath = normalizedPath === '.' ? '/' : normalizedPath;
const remoteFilePath = safePath === '/'
? `/${originalFilename}`
: `${safePath}/${originalFilename}`;
let storage;
try {
// 使用统一存储接口
const { StorageInterface } = require('./storage');
const storageInterface = new StorageInterface(req.user);
storage = await storageInterface.connect();
// storage.put() 内部已经实现了临时文件+重命名逻辑
await storage.put(req.file.path, remoteFilePath);
console.log(`[上传] 文件上传成功: ${remoteFilePath}`);
// 删除本地临时文件
safeDeleteFile(req.file.path);
res.json({
success: true,
message: '文件上传成功',
filename: originalFilename,
path: remoteFilePath
});
} catch (error) {
console.error('文件上传失败:', error);
// 删除临时文件
if (fs.existsSync(req.file.path)) {
safeDeleteFile(req.file.path);
}
res.status(500).json({
success: false,
message: '文件上传失败: ' + error.message
});
} finally {
if (storage) await storage.end();
}
});
// 下载文件
app.get('/api/files/download', authMiddleware, async (req, res) => {
const filePath = req.query.path;
let storage;
if (!filePath) {
return res.status(400).json({
success: false,
message: '缺少文件路径参数'
});
}
try {
// 使用统一存储接口
const { StorageInterface } = require('./storage');
const storageInterface = new StorageInterface(req.user);
storage = await storageInterface.connect();
// 获取文件名
const fileName = filePath.split('/').pop();
// 先获取文件信息(获取文件大小)
const fileStats = await storage.stat(filePath);
const fileSize = fileStats.size;
console.log('[下载] 文件: ' + fileName + ', 大小: ' + fileSize + ' 字节');
// 设置响应头(包含文件大小,浏览器可显示下载进度)
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Length', fileSize);
res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(fileName) + '"; filename*=UTF-8\'\'' + encodeURIComponent(fileName));
// 创建文件流并传输(流式下载,服务器不保存临时文件)
const stream = storage.createReadStream(filePath);
stream.on('error', (error) => {
console.error('文件流错误:', error);
if (!res.headersSent) {
res.status(500).json({
success: false,
message: '文件下载失败: ' + error.message
});
}
// 发生错误时关闭存储连接
if (storage) {
storage.end().catch(err => console.error('关闭存储连接失败:', err));
}
});
// 在传输完成后关闭存储连接
stream.on('close', () => {
console.log('[下载] 文件传输完成,关闭存储连接');
if (storage) {
storage.end().catch(err => console.error('关闭存储连接失败:', err));
}
});
stream.pipe(res);
} catch (error) {
console.error('下载文件失败:', error);
// 如果stream还未创建或发生错误关闭storage连接
if (storage) {
storage.end().catch(err => console.error('关闭存储连接失败:', err));
}
if (!res.headersSent) {
res.status(500).json({
success: false,
message: '下载文件失败: ' + error.message
});
}
}
});
// 生成上传工具(生成新密钥并创建配置文件)
app.post('/api/upload/generate-tool', authMiddleware, async (req, res) => {
try {
// 生成新的API密钥32位随机字符串
const crypto = require('crypto');
const newApiKey = crypto.randomBytes(16).toString('hex');
// 更新用户的upload_api_key
UserDB.update(req.user.id, { upload_api_key: newApiKey });
// 创建配置文件内容
const config = {
username: req.user.username,
api_key: newApiKey,
api_base_url: getSecureBaseUrl(req)
};
res.json({
success: true,
message: '上传工具已生成',
config: config
});
} catch (error) {
console.error('生成上传工具失败:', error);
res.status(500).json({
success: false,
message: '生成上传工具失败: ' + error.message
});
}
});
// 下载上传工具zip包含exe+config.json+README.txt
app.get('/api/upload/download-tool', authMiddleware, async (req, res) => {
let tempZipPath = null;
try {
console.log(`[上传工具] 用户 ${req.user.username} 请求下载上传工具`);
// 生成新的API密钥
const crypto = require('crypto');
const newApiKey = crypto.randomBytes(16).toString('hex');
// 更新用户的upload_api_key
UserDB.update(req.user.id, { upload_api_key: newApiKey });
// 创建配置文件内容
const config = {
username: req.user.username,
api_key: newApiKey,
api_base_url: getSecureBaseUrl(req)
};
console.log("[上传工具配置]", JSON.stringify(config, null, 2));
// 检查exe文件是否存在
const toolDir = path.join(__dirname, '..', 'upload-tool');
const exePath = path.join(toolDir, 'dist', '玩玩云上传工具.exe');
const readmePath = path.join(toolDir, 'README.txt');
if (!fs.existsSync(exePath)) {
console.error('[上传工具] exe文件不存在:', exePath);
return res.status(500).json({
success: false,
message: '上传工具尚未打包,请联系管理员运行 upload-tool/build.bat'
});
}
// 创建临时zip文件路径
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
tempZipPath = path.join(uploadsDir, `tool_${req.user.username}_${Date.now()}.zip`);
console.log('[上传工具] 开始创建zip包到临时文件:', tempZipPath);
// 创建文件写入流
const output = fs.createWriteStream(tempZipPath);
const archive = archiver('zip', {
store: true // 使用STORE模式不压缩速度最快
});
// 等待zip文件创建完成
await new Promise((resolve, reject) => {
output.on('close', () => {
console.log(`[上传工具] zip创建完成大小: ${archive.pointer()} 字节`);
resolve();
});
archive.on('error', (err) => {
console.error('[上传工具] archiver错误:', err);
reject(err);
});
// 连接archive到文件流
archive.pipe(output);
// 添加exe文件
console.log('[上传工具] 添加exe文件...');
archive.file(exePath, { name: '玩玩云上传工具.exe' });
// 添加config.json
console.log('[上传工具] 添加config.json...');
archive.append(JSON.stringify(config, null, 2), { name: 'config.json' });
// 添加README.txt
if (fs.existsSync(readmePath)) {
console.log('[上传工具] 添加README.txt...');
archive.file(readmePath, { name: 'README.txt' });
}
// 完成打包
console.log('[上传工具] 执行finalize...');
archive.finalize();
});
// 获取文件大小
const stats = fs.statSync(tempZipPath);
const fileSize = stats.size;
console.log(`[上传工具] 准备发送文件,大小: ${fileSize} 字节`);
// 设置响应头包含Content-Length
const filename = `玩玩云上传工具_${req.user.username}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', fileSize);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
// 创建文件读取流并发送
const fileStream = fs.createReadStream(tempZipPath);
fileStream.on('end', () => {
console.log(`[上传工具] 用户 ${req.user.username} 下载完成`);
// 删除临时文件
if (tempZipPath && fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
console.log('[上传工具] 临时文件已删除');
}
});
fileStream.on('error', (err) => {
console.error('[上传工具] 文件流错误:', err);
// 删除临时文件
if (tempZipPath && fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
}
});
fileStream.pipe(res);
} catch (error) {
console.error('[上传工具] 异常:', error);
// 删除临时文件
if (tempZipPath && fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
console.log('[上传工具] 临时文件已删除(异常)');
}
if (!res.headersSent) {
res.status(500).json({
success: false,
message: '下载失败: ' + error.message
});
}
}
});
// 通过API密钥获取SFTP配置供Python工具调用
// 添加速率限制防止暴力枚举
app.post('/api/upload/get-config', async (req, res) => {
// 获取客户端IP用于速率限制
const clientIP = apiKeyLimiter.getClientKey(req);
const rateLimitKey = `api_key:${clientIP}`;
// 检查是否被封锁
if (apiKeyLimiter.isBlocked(rateLimitKey)) {
const result = apiKeyLimiter.recordFailure(rateLimitKey);
console.warn(`[安全] API密钥暴力枚举检测 - IP: ${clientIP}`);
return res.status(429).json({
success: false,
message: `请求过于频繁,请在 ${result.waitMinutes} 分钟后重试`,
blocked: true
});
}
try {
const { api_key } = req.body;
if (!api_key) {
return res.status(400).json({
success: false,
message: 'API密钥不能为空'
});
}
// 查找拥有此API密钥的用户
const user = db.prepare('SELECT * FROM users WHERE upload_api_key = ?').get(api_key);
if (!user) {
// 记录失败尝试
const result = apiKeyLimiter.recordFailure(rateLimitKey);
console.warn(`[安全] API密钥验证失败 - IP: ${clientIP}, 剩余尝试: ${result.remainingAttempts}`);
return res.status(401).json({
success: false,
message: 'API密钥无效或已过期'
});
}
// 验证成功,清除失败记录
apiKeyLimiter.recordSuccess(rateLimitKey);
if (user.is_banned) {
return res.status(403).json({
success: false,
message: '账号已被封禁'
});
}
if (!user.has_ftp_config) {
return res.status(400).json({
success: false,
message: '用户未配置SFTP服务器'
});
}
// 返回SFTP配置注意密码通过此API返回给上传工具使用
// 上传工具需要密码才能连接SFTP这是设计上的需要
// 安全措施1. 速率限制防止暴力枚举 2. API密钥是32位随机字符串
res.json({
success: true,
sftp_config: {
host: user.ftp_host,
port: user.ftp_port,
username: user.ftp_user,
password: user.ftp_password
}
});
} catch (error) {
console.error('获取SFTP配置失败:', error);
res.status(500).json({
success: false,
message: '获取SFTP配置失败: ' + error.message
});
}
});
// 创建分享链接
app.post('/api/share/create', authMiddleware, (req, res) => {
try {
const { share_type, file_path, file_name, password, expiry_days } = req.body;
console.log("[DEBUG] 创建分享请求:", { share_type, file_path, file_name, password: password ? "***" : null, expiry_days });
if (share_type === 'file' && !file_path) {
return res.status(400).json({
success: false,
message: '文件路径不能为空'
});
}
const result = ShareDB.create(req.user.id, {
share_type: share_type || 'file',
file_path: file_path || '',
file_name: file_name || '',
password: password || null,
expiry_days: expiry_days || null
});
// 更新分享的存储类型
const { db } = require('./database');
db.prepare('UPDATE shares SET storage_type = ? WHERE id = ?')
.run(req.user.current_storage_type || 'sftp', result.id);
const shareUrl = `${getSecureBaseUrl(req)}/s/${result.share_code}`;
res.json({
success: true,
message: '分享链接创建成功',
share_code: result.share_code,
share_url: shareUrl,
share_type: result.share_type,
expires_at: result.expires_at,
});
} catch (error) {
console.error('创建分享链接失败:', error);
res.status(500).json({
success: false,
message: '创建分享链接失败: ' + error.message
});
}
});
// 获取我的分享列表
app.get('/api/share/my', authMiddleware, (req, res) => {
try {
const shares = ShareDB.getUserShares(req.user.id);
res.json({
success: true,
shares: shares.map(share => ({
...share,
share_url: `${getSecureBaseUrl(req)}/s/${share.share_code}`
}))
});
} catch (error) {
console.error('获取分享列表失败:', error);
res.status(500).json({
success: false,
message: '获取分享列表失败: ' + error.message
});
}
});
// 删除分享增强IDOR防护
app.delete('/api/share/:id', authMiddleware, (req, res) => {
try {
const shareId = parseInt(req.params.id, 10);
// 验证ID格式
if (isNaN(shareId) || shareId <= 0) {
return res.status(400).json({
success: false,
message: '无效的分享ID'
});
}
// 先获取分享信息以获得share_code
const share = ShareDB.findById(shareId);
if (!share) {
return res.status(404).json({
success: false,
message: '分享不存在'
});
}
// 严格的权限检查
if (share.user_id !== req.user.id) {
// 记录可疑的越权尝试
console.warn(`[安全] IDOR尝试 - 用户 ${req.user.id}(${req.user.username}) 试图删除用户 ${share.user_id} 的分享 ${shareId}`);
return res.status(403).json({
success: false,
message: '无权限删除此分享'
});
}
// 删除缓存
if (shareFileCache.has(share.share_code)) {
shareFileCache.delete(share.share_code);
console.log(`[缓存清除] 分享码: ${share.share_code}`);
}
// 删除数据库记录
ShareDB.delete(shareId, req.user.id);
res.json({
success: true,
message: '分享已删除'
});
} catch (error) {
console.error('删除分享失败:', error);
res.status(500).json({
success: false,
message: '删除分享失败: ' + error.message
});
}
});
// ===== 分享链接访问(公开) =====
// 访问分享链接 - 验证密码支持本地存储和SFTP
app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) => {
const { code } = req.params;
const { password } = req.body;
let storage;
try {
// ===== 调试日志: 分享验证开始 =====
console.log('[分享验证]', {
timestamp: new Date().toISOString(),
shareCode: code,
hasPassword: !!password,
requestIP: req.ip
});
const share = ShareDB.findByCode(code);
// 调试日志: findByCode 结果
console.log('[分享验证] findByCode结果:', {
found: !!share,
expires_at: share?.expires_at || null,
current_time: new Date().toISOString(),
is_expired: share?.expires_at ? new Date(share.expires_at) <= new Date() : false
});
if (!share) {
return res.status(404).json({
success: false,
message: '分享不存在'
});
}
// 如果设置了密码,验证密码
if (share.share_password) {
if (!password) {
return res.status(401).json({
success: false,
message: '需要密码',
needPassword: true
});
}
if (!ShareDB.verifyPassword(password, share.share_password)) {
// 记录密码错误
if (req.shareRateLimitKey) {
shareLimiter.recordFailure(req.shareRateLimitKey);
}
return res.status(401).json({
success: false,
message: '密码错误'
});
}
}
// 清除失败记录(密码验证成功)
if (req.shareRateLimitKey) {
shareLimiter.recordSuccess(req.shareRateLimitKey);
}
// 增加查看次数
ShareDB.incrementViewCount(code);
const sanitizedShareHttpBase = sanitizeHttpBaseUrl(share.http_download_base_url);
// 构建返回数据
const responseData = {
success: true,
share: {
share_path: share.share_path,
share_type: share.share_type,
username: share.username,
created_at: share.created_at,
expires_at: share.expires_at // 添加到期时间
}
};
// 如果是单文件分享,查询存储获取文件信息(带缓存)
if (share.share_type === 'file') {
const filePath = share.share_path;
const lastSlashIndex = filePath.lastIndexOf('/');
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : '/';
const fileName = lastSlashIndex >= 0 ? filePath.substring(lastSlashIndex + 1) : filePath;
// 检查缓存
if (shareFileCache.has(code)) {
console.log(`[缓存命中] 分享码: ${code}`);
responseData.file = shareFileCache.get(code);
} else {
// 缓存未命中,查询存储
try {
// 获取分享者的用户信息
const shareOwner = UserDB.findById(share.user_id);
if (!shareOwner) {
throw new Error('分享者不存在');
}
const storageType = shareOwner.current_storage_type || 'sftp';
console.log(`[缓存未命中] 分享码: ${code},存储类型: ${storageType}`);
// 使用统一存储接口
const { StorageInterface } = require('./storage');
const userForStorage = {
...shareOwner,
current_storage_type: storageType
};
const storageInterface = new StorageInterface(userForStorage);
storage = await storageInterface.connect();
const list = await storage.list(dirPath);
const fileInfo = list.find(item => item.name === fileName);
// 检查文件是否存在
if (!fileInfo) {
shareFileCache.delete(code);
throw new Error("分享的文件已被删除或不存在");
}
if (fileInfo) {
const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`;
// SFTP存储才提供HTTP下载URL本地存储使用API下载
const httpDownloadUrl = (storageType === 'sftp')
? buildHttpDownloadUrl(sanitizedShareHttpBase, normalizedFilePath)
: null;
const fileData = {
name: fileName,
type: 'file',
isDirectory: false,
httpDownloadUrl: httpDownloadUrl,
size: fileInfo.size,
sizeFormatted: formatFileSize(fileInfo.size),
modifiedAt: new Date(fileInfo.modifyTime)
};
// 存入缓存
shareFileCache.set(code, fileData);
console.log(`[缓存存储] 分享码: ${code},文件: ${fileName}`);
responseData.file = fileData;
}
} catch (storageError) {
console.error('获取文件信息失败:', storageError);
// 如果是文件不存在的错误,重新抛出
if (storageError.message && storageError.message.includes("分享的文件已被删除或不存在")) {
throw storageError;
}
// 存储失败时仍返回基本信息,只是没有大小
const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`;
const storageType = share.storage_type || 'sftp';
const httpDownloadUrl = (storageType === 'sftp')
? buildHttpDownloadUrl(sanitizedShareHttpBase, normalizedFilePath)
: null;
responseData.file = {
name: fileName,
type: 'file',
isDirectory: false,
httpDownloadUrl: httpDownloadUrl,
size: 0,
sizeFormatted: '-'
};
}
}
}
res.json(responseData);
} catch (error) {
console.error('验证分享失败:', error);
res.status(500).json({
success: false,
message: '验证失败: ' + error.message
});
} finally {
if (storage) await storage.end();
}
});
// 获取分享的文件列表支持本地存储和SFTP
app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => {
const { code } = req.params;
const { password, path: subPath } = req.body;
let storage;
try {
// ===== 调试日志: 获取分享文件列表 =====
console.log('[获取文件列表]', {
timestamp: new Date().toISOString(),
shareCode: code,
subPath: subPath,
hasPassword: !!password,
requestIP: req.ip
});
const share = ShareDB.findByCode(code);
// 调试日志: findByCode 结果
console.log('[获取文件列表] findByCode结果:', {
found: !!share,
expires_at: share?.expires_at || null,
current_time: new Date().toISOString(),
is_expired: share?.expires_at ? new Date(share.expires_at) <= new Date() : false
});
if (!share) {
return res.status(404).json({
success: false,
message: '分享不存在'
});
}
// 验证密码
if (share.share_password && !ShareDB.verifyPassword(password, share.share_password)) {
// 记录密码错误
if (req.shareRateLimitKey) {
shareLimiter.recordFailure(req.shareRateLimitKey);
}
return res.status(401).json({
success: false,
message: '密码错误'
});
}
// 清除失败记录(密码验证成功或无密码)
if (req.shareRateLimitKey && share.share_password) {
shareLimiter.recordSuccess(req.shareRateLimitKey);
}
// 获取分享者的用户信息
const shareOwner = UserDB.findById(share.user_id);
if (!shareOwner) {
return res.status(404).json({
success: false,
message: '分享者不存在'
});
}
// 构造安全的请求路径,防止越权遍历
const baseSharePath = (share.share_path || '/').replace(/\\/g, '/');
const requestedPath = subPath
? path.posix.normalize(`${baseSharePath}/${subPath}`)
: baseSharePath;
// 校验请求路径是否在分享范围内
if (!isPathWithinShare(requestedPath, share)) {
return res.status(403).json({
success: false,
message: '无权访问该路径'
});
}
// 使用统一存储接口根据分享的storage_type选择存储后端
const { StorageInterface } = require('./storage');
const storageType = share.storage_type || 'sftp';
console.log(`[分享列表] 存储类型: ${storageType}, 分享路径: ${share.share_path}`);
// 临时构造用户对象以使用存储接口
const userForStorage = {
...shareOwner,
current_storage_type: storageType
};
const storageInterface = new StorageInterface(userForStorage);
storage = await storageInterface.connect();
const sanitizedShareHttpBase = sanitizeHttpBaseUrl(share.http_download_base_url);
let formattedList = [];
// 如果是单文件分享
if (share.share_type === 'file') {
// share_path 就是文件路径
const filePath = share.share_path;
// 提取父目录和文件名
const lastSlashIndex = filePath.lastIndexOf('/');
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : '/';
const fileName = lastSlashIndex >= 0 ? filePath.substring(lastSlashIndex + 1) : filePath;
// 列出父目录
const list = await storage.list(dirPath);
// 只返回这个文件
const fileInfo = list.find(item => item.name === fileName);
if (fileInfo) {
// 确保文件路径以斜杠开头
const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`;
// SFTP存储才提供HTTP下载URL本地存储使用API下载
const httpDownloadUrl = (storageType === 'sftp')
? buildHttpDownloadUrl(sanitizedShareHttpBase, normalizedFilePath)
: null;
formattedList = [{
name: fileInfo.name,
type: 'file',
size: fileInfo.size,
sizeFormatted: formatFileSize(fileInfo.size),
modifiedAt: new Date(fileInfo.modifyTime),
isDirectory: false,
httpDownloadUrl: httpDownloadUrl
}];
}
}
// 如果是目录分享(分享所有文件)
else {
const list = await storage.list(requestedPath);
formattedList = list.map(item => {
// 构建完整的文件路径用于下载
let httpDownloadUrl = null;
if (storageType === 'sftp' && sanitizedShareHttpBase && item.type !== 'd') {
const normalizedPath = requestedPath.startsWith('/') ? requestedPath : `/${requestedPath}`;
const filePath = normalizedPath === '/' ? `/${item.name}` : `${normalizedPath}/${item.name}`;
httpDownloadUrl = buildHttpDownloadUrl(sanitizedShareHttpBase, filePath);
}
return {
name: item.name,
type: item.type === 'd' ? 'directory' : 'file',
size: item.size,
sizeFormatted: formatFileSize(item.size),
modifiedAt: new Date(item.modifyTime),
isDirectory: item.type === 'd',
httpDownloadUrl: httpDownloadUrl
};
});
formattedList.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
}
res.json({
success: true,
path: share.share_path,
items: formattedList
});
} catch (error) {
console.error('获取分享文件列表失败:', error);
res.status(500).json({
success: false,
message: '获取文件列表失败: ' + error.message
});
} finally {
if (storage) await storage.end();
}
});
// 记录下载次数
app.post('/api/share/:code/download', (req, res) => {
const { code } = req.params;
try {
const share = ShareDB.findByCode(code);
if (!share) {
return res.status(404).json({
success: false,
message: '分享不存在'
});
}
// 增加下载次数
ShareDB.incrementDownloadCount(code);
res.json({
success: true,
message: '下载统计已记录'
});
} catch (error) {
console.error('记录下载失败:', error);
res.status(500).json({
success: false,
message: '记录下载失败: ' + error.message
});
}
});
// 分享文件下载支持本地存储和SFTP公开API需要分享码和密码验证
app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, res) => {
const { code } = req.params;
const { path: filePath, password } = req.query;
let storage;
if (!filePath) {
return res.status(400).json({
success: false,
message: '缺少文件路径参数'
});
}
try {
const share = ShareDB.findByCode(code);
if (!share) {
return res.status(404).json({
success: false,
message: '分享不存在'
});
}
// 验证密码(如果需要)
if (share.share_password) {
if (!password || !ShareDB.verifyPassword(password, share.share_password)) {
// 只在密码错误时记录失败
if (req.shareRateLimitKey) {
shareLimiter.recordFailure(req.shareRateLimitKey);
}
return res.status(401).json({
success: false,
message: '密码错误或未提供密码'
});
}
// 密码验证成功,清除失败记录
if (req.shareRateLimitKey) {
shareLimiter.recordSuccess(req.shareRateLimitKey);
}
}
// ✅ 安全验证:检查请求路径是否在分享范围内(防止越权访问)
if (!isPathWithinShare(filePath, share)) {
console.warn(`[安全] 检测到越权访问尝试 - 分享码: ${code}, 请求路径: ${filePath}, 分享路径: ${share.share_path}`);
return res.status(403).json({
success: false,
message: '无权访问该文件'
});
}
// 获取分享者的用户信息
const shareOwner = UserDB.findById(share.user_id);
if (!shareOwner) {
return res.status(404).json({
success: false,
message: '分享者不存在'
});
}
// 使用统一存储接口根据分享的storage_type选择存储后端
const { StorageInterface } = require('./storage');
const storageType = shareOwner.current_storage_type || 'sftp';
console.log(`[分享下载] 存储类型: ${storageType} (分享者当前), 文件路径: ${filePath}`);
// 临时构造用户对象以使用存储接口
const userForStorage = {
...shareOwner,
current_storage_type: storageType
};
const storageInterface = new StorageInterface(userForStorage);
storage = await storageInterface.connect();
// 获取文件名
const fileName = filePath.split('/').pop();
// 获取文件信息(获取文件大小)
const fileStats = await storage.stat(filePath);
const fileSize = fileStats.size;
console.log(`[分享下载] 文件: ${fileName}, 大小: ${fileSize} 字节`);
// 增加下载次数
ShareDB.incrementDownloadCount(code);
// 设置响应头(包含文件大小,浏览器可显示下载进度)
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Length', fileSize);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
// 创建文件流并传输(流式下载,服务器不保存临时文件)
const stream = storage.createReadStream(filePath);
stream.on('error', (error) => {
console.error('文件流错误:', error);
if (!res.headersSent) {
res.status(500).json({
success: false,
message: '文件下载失败: ' + error.message
});
}
// 发生错误时关闭存储连接
if (storage) {
storage.end().catch(err => console.error('关闭存储连接失败:', err));
}
});
// 在传输完成后关闭存储连接
stream.on('close', () => {
console.log('[分享下载] 文件传输完成,关闭存储连接');
if (storage) {
storage.end().catch(err => console.error('关闭存储连接失败:', err));
}
});
stream.pipe(res);
} catch (error) {
console.error('分享下载文件失败:', error);
if (!res.headersSent) {
res.status(500).json({
success: false,
message: '下载文件失败: ' + error.message
});
}
// 如果发生错误,关闭存储连接
if (storage) {
storage.end().catch(err => console.error('关闭存储连接失败:', err));
}
}
});
// ===== 管理员API =====
// 获取系统设置
app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => {
try {
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
const smtpHost = SettingsDB.get('smtp_host');
const smtpPort = SettingsDB.get('smtp_port');
const smtpSecure = SettingsDB.get('smtp_secure') === 'true';
const smtpUser = SettingsDB.get('smtp_user');
const smtpFrom = SettingsDB.get('smtp_from') || smtpUser;
const smtpHasPassword = !!SettingsDB.get('smtp_password');
res.json({
success: true,
settings: {
max_upload_size: maxUploadSize,
smtp: {
host: smtpHost || '',
port: smtpPort ? parseInt(smtpPort, 10) : 465,
secure: smtpSecure,
user: smtpUser || '',
from: smtpFrom || '',
has_password: smtpHasPassword
}
}
});
} catch (error) {
console.error('获取系统设置失败:', error);
res.status(500).json({
success: false,
message: '获取系统设置失败: ' + error.message
});
}
});
// 更新系统设置
app.post('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => {
try {
const { max_upload_size, smtp } = 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 (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) });
}
});
// 获取服务器存储统计信息
app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, res) => {
try {
// 获取本地存储目录(与 storage.js 保持一致)
const localStorageDir = process.env.STORAGE_ROOT || path.join(__dirname, 'storage');
// 确保存储目录存在
if (!fs.existsSync(localStorageDir)) {
fs.mkdirSync(localStorageDir, { recursive: true });
}
// 获取磁盘信息使用df命令
let totalDisk = 0;
let usedDisk = 0;
let availableDisk = 0;
try {
// 获取本地存储目录所在分区的磁盘信息避免使用shell
const { stdout: dfOutput } = await execFileAsync('df', ['-B', '1', localStorageDir], { encoding: 'utf8' });
// 取最后一行数据
const lines = dfOutput.trim().split('\n');
const parts = lines[lines.length - 1].trim().split(/\s+/);
if (parts.length >= 4) {
totalDisk = parseInt(parts[1], 10) || 0; // 总大小
usedDisk = parseInt(parts[2], 10) || 0; // 已使用
availableDisk = parseInt(parts[3], 10) || 0; // 可用
}
} catch (dfError) {
console.error('获取磁盘信息失败:', dfError.message);
// 如果df命令失败尝试使用Windows的wmic命令
try {
// 获取本地存储目录所在的驱动器号
const driveLetter = localStorageDir.charAt(0);
const normalizedDrive = driveLetter.toUpperCase();
if (!/^[A-Z]$/.test(normalizedDrive)) {
throw new Error('Invalid drive letter');
}
const { stdout: wmicOutput } = await execFileAsync(
'wmic',
['logicaldisk', 'where', `DeviceID='${normalizedDrive}:'`, 'get', 'Size,FreeSpace', '/value'],
{ encoding: 'utf8' }
);
const freeMatch = wmicOutput.match(/FreeSpace=(\d+)/);
const sizeMatch = wmicOutput.match(/Size=(\d+)/);
if (sizeMatch && freeMatch) {
totalDisk = parseInt(sizeMatch[1]) || 0;
availableDisk = parseInt(freeMatch[1]) || 0;
usedDisk = totalDisk - availableDisk;
}
} catch (wmicError) {
console.error('获取Windows磁盘信息失败:', wmicError.message);
}
}
// 从数据库获取所有用户的本地存储配额和使用情况
const users = UserDB.getAll();
let totalUserQuotas = 0;
let totalUserUsed = 0;
users.forEach(user => {
// 只统计使用本地存储的用户local_only 或 user_choice
const storagePermission = user.storage_permission || 'sftp_only';
if (storagePermission === 'local_only' || storagePermission === 'user_choice') {
totalUserQuotas += user.local_storage_quota || 0;
totalUserUsed += user.local_storage_used || 0;
}
});
res.json({
success: true,
stats: {
totalDisk, // 磁盘总容量
usedDisk, // 磁盘已使用
availableDisk, // 磁盘可用空间
totalUserQuotas, // 用户配额总和
totalUserUsed, // 用户实际使用总和
totalUsers: users.length // 用户总数
}
});
// 获取所有用户
} catch (error) { console.error('获取存储统计失败:', error); res.status(500).json({ success: false, message: '获取存储统计失败: ' + error.message }); }});
app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
try {
const users = UserDB.getAll();
res.json({
success: true,
users: users.map(u => ({
id: u.id,
username: u.username,
email: u.email,
is_admin: u.is_admin,
is_active: u.is_active,
is_verified: u.is_verified,
is_banned: u.is_banned,
has_ftp_config: u.has_ftp_config,
created_at: u.created_at,
// 新增:存储相关字段
storage_permission: u.storage_permission || 'sftp_only',
current_storage_type: u.current_storage_type || 'sftp',
local_storage_quota: u.local_storage_quota || 1073741824,
local_storage_used: u.local_storage_used || 0
}))
});
} catch (error) {
console.error('获取用户列表失败:', error);
res.status(500).json({
success: false,
message: '获取用户列表失败: ' + error.message
});
}
});
// 封禁/解封用户
app.post('/api/admin/users/:id/ban', authMiddleware, adminMiddleware, (req, res) => {
try {
const { id } = req.params;
const { banned } = req.body;
UserDB.setBanStatus(id, banned);
res.json({
success: true,
message: banned ? '用户已封禁' : '用户已解封'
});
} catch (error) {
console.error('操作失败:', error);
res.status(500).json({
success: false,
message: '操作失败: ' + error.message
});
}
});
// 删除用户(级联删除文件和分享)
app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { id } = req.params;
if (parseInt(id) === req.user.id) {
return res.status(400).json({
success: false,
message: '不能删除自己的账号'
});
}
// 获取用户信息
const user = UserDB.findById(id);
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
const deletionLog = {
userId: id,
username: user.username,
deletedFiles: [],
deletedShares: 0,
warnings: []
};
// 1. 删除本地存储文件(如果用户使用了本地存储)
const storagePermission = user.storage_permission || 'sftp_only';
if (storagePermission === 'local_only' || storagePermission === 'user_choice') {
const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage');
const userStorageDir = path.join(storageRoot, `user_${id}`);
if (fs.existsSync(userStorageDir)) {
try {
// 递归删除用户目录
const deletedSize = getUserDirectorySize(userStorageDir);
fs.rmSync(userStorageDir, { recursive: true, force: true });
deletionLog.deletedFiles.push({
type: 'local',
path: userStorageDir,
size: deletedSize
});
console.log(`[删除用户] 已删除本地存储目录: ${userStorageDir}`);
} catch (error) {
console.error(`[删除用户] 删除本地存储失败:`, error);
deletionLog.warnings.push(`删除本地存储失败: ${error.message}`);
}
}
}
// 2. SFTP存储文件 - 只记录警告,不实际删除(安全考虑)
if (user.has_ftp_config && (storagePermission === 'sftp_only' || storagePermission === 'user_choice')) {
deletionLog.warnings.push(
`用户配置了SFTP存储 (${user.ftp_host}:${user.ftp_port})SFTP文件未自动删除请手动处理`
);
}
// 3. 删除用户的所有分享记录
try {
const userShares = ShareDB.getUserShares(id);
deletionLog.deletedShares = userShares.length;
userShares.forEach(share => {
ShareDB.delete(share.id);
// 清除分享缓存
if (shareFileCache.has(share.share_code)) {
shareFileCache.delete(share.share_code);
}
});
console.log(`[删除用户] 已删除 ${deletionLog.deletedShares} 条分享记录`);
} catch (error) {
console.error(`[删除用户] 删除分享记录失败:`, error);
deletionLog.warnings.push(`删除分享记录失败: ${error.message}`);
}
// 4. 删除用户记录
UserDB.delete(id);
// 构建响应消息
let message = `用户 ${user.username} 已删除`;
if (deletionLog.deletedFiles.length > 0) {
const totalSize = deletionLog.deletedFiles.reduce((sum, f) => sum + f.size, 0);
message += `,已清理本地文件 ${formatFileSize(totalSize)}`;
}
if (deletionLog.deletedShares > 0) {
message += `,已删除 ${deletionLog.deletedShares} 条分享`;
}
res.json({
success: true,
message,
details: deletionLog
});
} catch (error) {
console.error('删除用户失败:', error);
res.status(500).json({
success: false,
message: '删除用户失败: ' + error.message
});
}
});
// 辅助函数:计算目录大小
function getUserDirectorySize(dirPath) {
let totalSize = 0;
function calculateSize(currentPath) {
try {
const stats = fs.statSync(currentPath);
if (stats.isDirectory()) {
const files = fs.readdirSync(currentPath);
files.forEach(file => {
calculateSize(path.join(currentPath, file));
});
} else {
totalSize += stats.size;
}
} catch (error) {
console.error(`计算大小失败: ${currentPath}`, error);
}
}
calculateSize(dirPath);
return totalSize;
}
// 设置用户存储权限(管理员)
app.post('/api/admin/users/:id/storage-permission',
authMiddleware,
adminMiddleware,
[
body('storage_permission').isIn(['local_only', 'sftp_only', 'user_choice']).withMessage('无效的存储权限')
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
const { id } = req.params;
const { storage_permission, local_storage_quota } = req.body;
const updates = { storage_permission };
// 如果提供了配额,更新配额(单位:字节)
if (local_storage_quota !== undefined) {
updates.local_storage_quota = parseInt(local_storage_quota);
}
// 根据权限设置自动调整存储类型
const user = UserDB.findById(id);
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
if (storage_permission === 'local_only') {
updates.current_storage_type = 'local';
} else if (storage_permission === 'sftp_only') {
// 只有配置了SFTP才切换到SFTP
if (user.has_ftp_config) {
updates.current_storage_type = 'sftp';
}
}
// user_choice 不自动切换,保持用户当前选择
UserDB.update(id, updates);
res.json({
success: true,
message: '存储权限已更新'
});
} catch (error) {
console.error('设置存储权限失败:', error);
res.status(500).json({
success: false,
message: '设置存储权限失败: ' + error.message
});
}
}
);
// ===== 管理员文件审查功能 =====
// 查看用户文件列表(管理员,只读)
app.get('/api/admin/users/:id/files', authMiddleware, adminMiddleware, async (req, res) => {
const { id } = req.params;
const dirPath = req.query.path || '/';
let sftp;
try {
const user = UserDB.findById(id);
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
if (!user.has_ftp_config) {
return res.status(400).json({
success: false,
message: '该用户未配置SFTP服务器'
});
}
sftp = await connectToSFTP(user);
const list = await sftp.list(dirPath);
const formattedList = list.map(item => ({
name: item.name,
type: item.type === 'd' ? 'directory' : 'file',
size: item.size,
sizeFormatted: formatFileSize(item.size),
modifiedAt: new Date(item.modifyTime),
isDirectory: item.type === 'd'
}));
formattedList.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
res.json({
success: true,
username: user.username,
path: dirPath,
items: formattedList
});
} catch (error) {
console.error('管理员查看用户文件失败:', error);
res.status(500).json({
success: false,
message: '获取文件列表失败: ' + error.message
});
} finally {
if (sftp) await sftp.end();
}
});
// 获取所有分享(管理员)
app.get('/api/admin/shares', authMiddleware, adminMiddleware, (req, res) => {
try {
const shares = ShareDB.getAll();
res.json({
success: true,
shares
});
} catch (error) {
console.error('获取分享列表失败:', error);
res.status(500).json({
success: false,
message: '获取分享列表失败: ' + error.message
});
}
});
// 删除分享(管理员)
app.delete('/api/admin/shares/:id', authMiddleware, adminMiddleware, (req, res) => {
try {
// 先获取分享信息以获得share_code
const share = ShareDB.findById(req.params.id);
if (share) {
// 删除缓存
if (shareFileCache.has(share.share_code)) {
shareFileCache.delete(share.share_code);
console.log(`[缓存清除] 分享码: ${share.share_code} (管理员操作)`);
}
// 删除数据库记录
ShareDB.delete(req.params.id);
res.json({
success: true,
message: '分享已删除'
});
} else {
res.status(404).json({
success: false,
message: '分享不存在'
});
}
} catch (error) {
console.error('删除分享失败:', error);
res.status(500).json({
success: false,
message: '删除分享失败: ' + error.message
});
}
});
// ============================================
// 管理员:上传工具管理
// ============================================
// 检查上传工具是否存在
app.get('/api/admin/check-upload-tool', authMiddleware, adminMiddleware, (req, res) => {
try {
const toolPath = path.join(__dirname, '..', 'upload-tool', 'dist', '玩玩云上传工具.exe');
if (fs.existsSync(toolPath)) {
const stats = fs.statSync(toolPath);
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
res.json({
success: true,
exists: true,
fileInfo: {
path: toolPath,
size: stats.size,
sizeMB: sizeMB,
modifiedAt: stats.mtime
}
});
} else {
res.json({
success: true,
exists: false,
message: '上传工具不存在'
});
}
} catch (error) {
console.error('检查上传工具失败:', error);
res.status(500).json({
success: false,
message: '检查失败: ' + error.message
});
}
});
// 上传工具文件
const uploadToolStorage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '..', 'upload-tool', 'dist');
// 确保目录存在
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// 固定文件名
cb(null, '玩玩云上传工具.exe');
}
});
const uploadTool = multer({
storage: uploadToolStorage,
limits: {
fileSize: 100 * 1024 * 1024 // 限制100MB
},
fileFilter: (req, file, cb) => {
// 只允许.exe文件
if (!file.originalname.toLowerCase().endsWith('.exe')) {
return cb(new Error('只允许上传.exe文件'));
}
cb(null, true);
}
});
app.post('/api/admin/upload-tool', authMiddleware, adminMiddleware, uploadTool.single('file'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
message: '请选择要上传的文件'
});
}
const fileSizeMB = (req.file.size / (1024 * 1024)).toFixed(2);
// 验证文件大小至少20MB上传工具通常很大
if (req.file.size < 20 * 1024 * 1024) {
// 删除上传的文件
fs.unlinkSync(req.file.path);
return res.status(400).json({
success: false,
message: '文件大小异常上传工具通常大于20MB'
});
}
console.log(`[上传工具] 管理员上传成功: ${fileSizeMB}MB`);
res.json({
success: true,
message: '上传工具已上传',
fileInfo: {
size: req.file.size,
sizeMB: fileSizeMB
}
});
} catch (error) {
console.error('上传工具失败:', error);
res.status(500).json({
success: false,
message: '上传失败: ' + error.message
});
}
});
// 分享页面访问路由
app.get("/s/:code", (req, res) => {
const shareCode = req.params.code;
// 使用相对路径重定向浏览器会自动使用当前的协议和host
const frontendUrl = `/share.html?code=${shareCode}`;
console.log(`[分享] 重定向到: ${frontendUrl}`);
res.redirect(frontendUrl);
});
// 启动时清理旧临时文件
cleanupOldTempFiles();
// 启动服务器
app.listen(PORT, '0.0.0.0', () => {
console.log(`\n========================================`);
console.log(`玩玩云已启动`);
console.log(`服务器地址: http://localhost:${PORT}`);
console.log(`外网访问地址: http://0.0.0.0:${PORT}`);
console.log(`========================================\n`);
});