新功能概述:
- 支持在本地存储模式下创建文件夹
- 支持删除文件夹(递归删除)
- 支持重命名文件夹(已有功能,天然支持)
- 文件夹配额计算正确
后端改动:
1. 新增创建文件夹API (backend/server.js)
- POST /api/files/mkdir
- 参数: path(当前路径), folderName(文件夹名称)
- 安全检查: 禁止特殊字符(/ \ .. :),防止路径遍历攻击
- 仅限本地存储使用
- 创建前检查文件夹是否已存在
2. 改进删除功能 (backend/storage.js)
- LocalStorageClient.delete() 现在支持删除文件夹
- 使用 fs.rmSync(path, { recursive: true }) 递归删除
- 新增 calculateFolderSize() 方法计算文件夹总大小
- 删除文件夹时正确更新配额使用情况
前端改动:
1. 新建文件夹按钮 (frontend/app.html)
- 在"上传文件"按钮旁新增"新建文件夹"按钮
- 仅本地存储模式显示
2. 新建文件夹弹窗 (frontend/app.html)
- 简洁的创建表单
- 支持回车键快速创建
- 使用优化的弹窗关闭逻辑(防止拖动选择文本时误关闭)
3. 前端API调用 (frontend/app.js)
- 新增 createFolderForm 状态
- 新增 createFolder() 方法
- 前端参数验证
- 创建成功后自动刷新文件列表
4. 右键菜单优化 (frontend/app.html)
- 文件夹不显示"下载"按钮(文件夹暂不支持打包下载)
- 文件夹不显示"分享"按钮(分享单个文件夹暂不支持)
- 文件夹支持"重命名"和"删除"操作
安全性:
- 文件夹名称严格验证,禁止包含 / \ .. : 等特殊字符
- 路径安全检查,防止目录遍历攻击
- 仅限本地存储模式使用(SFTP存储使用上传工具管理)
配额管理:
- 空文件夹不占用配额
- 删除文件夹时正确释放配额(计算所有子文件大小)
- 删除非空文件夹会递归删除所有内容
使用方式:
1. 登录后切换到本地存储模式
2. 点击"新建文件夹"按钮
3. 输入文件夹名称,点击创建
4. 双击文件夹进入,支持多级目录
5. 右键文件夹可重命名或删除
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
389 lines
9.3 KiB
JavaScript
389 lines
9.3 KiB
JavaScript
const SftpClient = require('ssh2-sftp-client');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { UserDB } = require('./database');
|
||
|
||
// ===== 统一存储接口 =====
|
||
|
||
/**
|
||
* 存储接口工厂
|
||
* 根据用户的存储类型返回对应的存储客户端
|
||
*/
|
||
class StorageInterface {
|
||
constructor(user) {
|
||
this.user = user;
|
||
this.type = user.current_storage_type || 'sftp';
|
||
}
|
||
|
||
/**
|
||
* 创建并返回存储客户端
|
||
*/
|
||
async connect() {
|
||
if (this.type === 'local') {
|
||
const client = new LocalStorageClient(this.user);
|
||
await client.init();
|
||
return client;
|
||
} else {
|
||
const client = new SftpStorageClient(this.user);
|
||
await client.connect();
|
||
return client;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 本地存储客户端 =====
|
||
|
||
class LocalStorageClient {
|
||
constructor(user) {
|
||
this.user = user;
|
||
// 使用环境变量或默认路径(不硬编码)
|
||
const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage');
|
||
this.basePath = path.join(storageRoot, `user_${user.id}`);
|
||
}
|
||
|
||
/**
|
||
* 初始化用户存储目录
|
||
*/
|
||
async init() {
|
||
if (!fs.existsSync(this.basePath)) {
|
||
fs.mkdirSync(this.basePath, { recursive: true, mode: 0o755 });
|
||
console.log(`[本地存储] 创建用户目录: ${this.basePath}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 列出目录内容
|
||
*/
|
||
async list(dirPath) {
|
||
const fullPath = this.getFullPath(dirPath);
|
||
|
||
// 确保目录存在
|
||
if (!fs.existsSync(fullPath)) {
|
||
fs.mkdirSync(fullPath, { recursive: true });
|
||
return [];
|
||
}
|
||
|
||
const items = fs.readdirSync(fullPath, { withFileTypes: true });
|
||
|
||
return items.map(item => {
|
||
const itemPath = path.join(fullPath, item.name);
|
||
const stats = fs.statSync(itemPath);
|
||
return {
|
||
name: item.name,
|
||
type: item.isDirectory() ? 'd' : '-',
|
||
size: stats.size,
|
||
modifyTime: stats.mtimeMs
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 上传文件
|
||
*/
|
||
async put(localPath, remotePath) {
|
||
const destPath = this.getFullPath(remotePath);
|
||
|
||
// 获取新文件大小
|
||
const newFileSize = fs.statSync(localPath).size;
|
||
|
||
// 如果目标文件存在,计算实际需要的额外空间
|
||
let oldFileSize = 0;
|
||
if (fs.existsSync(destPath)) {
|
||
try {
|
||
oldFileSize = fs.statSync(destPath).size;
|
||
} catch (err) {
|
||
// 文件可能已被删除,忽略错误
|
||
}
|
||
}
|
||
|
||
// 检查配额:只检查净增量(新文件大小 - 旧文件大小)
|
||
const netIncrease = newFileSize - oldFileSize;
|
||
if (netIncrease > 0) {
|
||
this.checkQuota(netIncrease);
|
||
}
|
||
|
||
// 确保目标目录存在
|
||
const destDir = path.dirname(destPath);
|
||
if (!fs.existsSync(destDir)) {
|
||
fs.mkdirSync(destDir, { recursive: true });
|
||
}
|
||
|
||
// 使用临时文件+重命名模式,避免文件被占用问题
|
||
const tempPath = `${destPath}.uploading_${Date.now()}`;
|
||
|
||
try {
|
||
// 复制到临时文件
|
||
fs.copyFileSync(localPath, tempPath);
|
||
|
||
// 如果目标文件存在,先删除
|
||
if (fs.existsSync(destPath)) {
|
||
fs.unlinkSync(destPath);
|
||
}
|
||
|
||
// 重命名临时文件为目标文件
|
||
fs.renameSync(tempPath, destPath);
|
||
|
||
// 更新已使用空间(使用净增量)
|
||
if (netIncrease !== 0) {
|
||
this.updateUsedSpace(netIncrease);
|
||
}
|
||
} catch (error) {
|
||
// 清理临时文件
|
||
if (fs.existsSync(tempPath)) {
|
||
fs.unlinkSync(tempPath);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除文件或文件夹
|
||
*/
|
||
async delete(filePath) {
|
||
const fullPath = this.getFullPath(filePath);
|
||
const stats = fs.statSync(fullPath);
|
||
|
||
if (stats.isDirectory()) {
|
||
// 删除文件夹 - 递归删除
|
||
// 先计算文件夹内所有文件的总大小
|
||
const folderSize = this.calculateFolderSize(fullPath);
|
||
|
||
// 删除文件夹及其内容
|
||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||
|
||
// 更新已使用空间
|
||
if (folderSize > 0) {
|
||
this.updateUsedSpace(-folderSize);
|
||
}
|
||
} else {
|
||
// 删除文件
|
||
fs.unlinkSync(fullPath);
|
||
|
||
// 更新已使用空间
|
||
this.updateUsedSpace(-stats.size);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算文件夹大小
|
||
*/
|
||
calculateFolderSize(folderPath) {
|
||
let totalSize = 0;
|
||
|
||
const items = fs.readdirSync(folderPath, { withFileTypes: true });
|
||
|
||
for (const item of items) {
|
||
const itemPath = path.join(folderPath, item.name);
|
||
|
||
if (item.isDirectory()) {
|
||
// 递归计算子文件夹
|
||
totalSize += this.calculateFolderSize(itemPath);
|
||
} else {
|
||
// 累加文件大小
|
||
const stats = fs.statSync(itemPath);
|
||
totalSize += stats.size;
|
||
}
|
||
}
|
||
|
||
return totalSize;
|
||
}
|
||
|
||
/**
|
||
* 重命名文件
|
||
*/
|
||
async rename(oldPath, newPath) {
|
||
const oldFullPath = this.getFullPath(oldPath);
|
||
const newFullPath = this.getFullPath(newPath);
|
||
|
||
// 确保新路径的目录存在
|
||
const newDir = path.dirname(newFullPath);
|
||
if (!fs.existsSync(newDir)) {
|
||
fs.mkdirSync(newDir, { recursive: true });
|
||
}
|
||
|
||
fs.renameSync(oldFullPath, newFullPath);
|
||
}
|
||
|
||
/**
|
||
* 获取文件信息
|
||
*/
|
||
async stat(filePath) {
|
||
const fullPath = this.getFullPath(filePath);
|
||
return fs.statSync(fullPath);
|
||
}
|
||
|
||
/**
|
||
* 创建文件读取流
|
||
*/
|
||
createReadStream(filePath) {
|
||
const fullPath = this.getFullPath(filePath);
|
||
return fs.createReadStream(fullPath);
|
||
}
|
||
|
||
/**
|
||
* 关闭连接(本地存储无需关闭)
|
||
*/
|
||
async end() {
|
||
// 本地存储无需关闭连接
|
||
}
|
||
|
||
// ===== 辅助方法 =====
|
||
|
||
/**
|
||
* 获取完整路径(带安全检查)
|
||
*/
|
||
getFullPath(relativePath) {
|
||
// 1. 规范化路径,移除 ../ 等危险路径
|
||
let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, '');
|
||
|
||
// 2. ✅ 修复:将绝对路径转换为相对路径(解决Linux环境下的问题)
|
||
if (path.isAbsolute(normalized)) {
|
||
// 移除开头的 / 或 Windows 盘符,转为相对路径
|
||
normalized = normalized.replace(/^[\/\\]+/, '').replace(/^[a-zA-Z]:/, '');
|
||
}
|
||
|
||
// 3. 空字符串或 . 表示根目录
|
||
if (normalized === '' || normalized === '.') {
|
||
return this.basePath;
|
||
}
|
||
|
||
// 4. 拼接完整路径
|
||
const fullPath = path.join(this.basePath, normalized);
|
||
|
||
// 5. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
|
||
if (!fullPath.startsWith(this.basePath)) {
|
||
throw new Error('非法路径访问');
|
||
}
|
||
|
||
return fullPath;
|
||
}
|
||
|
||
/**
|
||
* 检查配额
|
||
*/
|
||
checkQuota(additionalSize) {
|
||
const newUsed = (this.user.local_storage_used || 0) + additionalSize;
|
||
if (newUsed > this.user.local_storage_quota) {
|
||
const used = this.formatSize(this.user.local_storage_used);
|
||
const quota = this.formatSize(this.user.local_storage_quota);
|
||
const need = this.formatSize(additionalSize);
|
||
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新已使用空间
|
||
*/
|
||
updateUsedSpace(delta) {
|
||
const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta);
|
||
UserDB.update(this.user.id, { local_storage_used: newUsed });
|
||
// 更新内存中的值
|
||
this.user.local_storage_used = newUsed;
|
||
}
|
||
|
||
/**
|
||
* 格式化文件大小
|
||
*/
|
||
formatSize(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];
|
||
}
|
||
}
|
||
|
||
// ===== SFTP存储客户端 =====
|
||
|
||
class SftpStorageClient {
|
||
constructor(user) {
|
||
this.user = user;
|
||
this.sftp = new SftpClient();
|
||
}
|
||
|
||
/**
|
||
* 连接SFTP服务器
|
||
*/
|
||
async connect() {
|
||
await this.sftp.connect({
|
||
host: this.user.ftp_host,
|
||
port: this.user.ftp_port || 22,
|
||
username: this.user.ftp_user,
|
||
password: this.user.ftp_password
|
||
});
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 列出目录内容
|
||
*/
|
||
async list(dirPath) {
|
||
return await this.sftp.list(dirPath);
|
||
}
|
||
|
||
/**
|
||
* 上传文件
|
||
*/
|
||
async put(localPath, remotePath) {
|
||
// 使用临时文件+重命名模式(与upload_tool保持一致)
|
||
const tempRemotePath = `${remotePath}.uploading_${Date.now()}`;
|
||
|
||
// 第一步:上传到临时文件
|
||
await this.sftp.put(localPath, tempRemotePath);
|
||
|
||
// 第二步:检查目标文件是否存在,如果存在先删除
|
||
try {
|
||
await this.sftp.stat(remotePath);
|
||
await this.sftp.delete(remotePath);
|
||
} catch (err) {
|
||
// 文件不存在,无需删除
|
||
}
|
||
|
||
// 第三步:重命名临时文件为目标文件
|
||
await this.sftp.rename(tempRemotePath, remotePath);
|
||
}
|
||
|
||
/**
|
||
* 删除文件
|
||
*/
|
||
async delete(filePath) {
|
||
return await this.sftp.delete(filePath);
|
||
}
|
||
|
||
/**
|
||
* 重命名文件
|
||
*/
|
||
async rename(oldPath, newPath) {
|
||
return await this.sftp.rename(oldPath, newPath);
|
||
}
|
||
|
||
/**
|
||
* 获取文件信息
|
||
*/
|
||
async stat(filePath) {
|
||
return await this.sftp.stat(filePath);
|
||
}
|
||
|
||
/**
|
||
* 创建文件读取流
|
||
*/
|
||
createReadStream(filePath) {
|
||
return this.sftp.createReadStream(filePath);
|
||
}
|
||
|
||
/**
|
||
* 关闭连接
|
||
*/
|
||
async end() {
|
||
if (this.sftp) {
|
||
await this.sftp.end();
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
StorageInterface,
|
||
LocalStorageClient,
|
||
SftpStorageClient
|
||
};
|