Files
vue-driven-cloud-storage/backend/storage.js
WanWanYun e5c932a351 feat: 新增本地存储文件夹管理功能
新功能概述:
- 支持在本地存储模式下创建文件夹
- 支持删除文件夹(递归删除)
- 支持重命名文件夹(已有功能,天然支持)
- 文件夹配额计算正确

后端改动:

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>
2025-11-15 23:46:20 +08:00

389 lines
9.3 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.
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
};