fix: 全面修复和优化 OSS 功能
## 安全修复 - 修复 /api/user/profile 接口泄露 OSS 密钥的安全漏洞 - 增强 getObjectKey 路径安全检查(空字节注入、URL 编码绕过) - 修复 storage.end() 重复调用问题 - 增强上传签名接口的安全检查 ## Bug 修复 - 修复 rename 使用错误的 PutObjectCommand,改为 CopyObjectCommand - 修复 CopySource 编码问题,正确处理特殊字符 - 修复签名 URL 生成功能(添加 @aws-sdk/s3-request-presigner) - 修复 S3Client 配置(阿里云 region 格式、endpoint 处理) - 修复分页删除和列表功能(超过 1000 文件的处理) - 修复分享下载使用错误的存储类型字段 - 修复前端媒体预览异步处理错误 - 修复 OSS 直传 objectKey 格式不一致问题 - 修复包名错误 @aws-sdk/request-presigner -> @aws-sdk/s3-request-presigner - 修复前端下载错误处理不完善 ## 新增功能 - 添加 OSS 连接测试 API (/api/user/test-oss) - 添加重命名失败回滚机制 - 添加 OSS 配置前端验证 ## 其他改进 - 更新 install.sh 仓库地址为 git.workyai.cn - 添加 crypto 模块导入 - 修复代码格式和重复定义问题 - 添加缺失的表单对象定义 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
190
frontend/app.js
190
frontend/app.js
@@ -59,6 +59,7 @@ createApp({
|
||||
},
|
||||
showOssConfigModal: false,
|
||||
ossConfigSaving: false, // OSS 配置保存中状态
|
||||
ossConfigTesting: false, // OSS 配置测试中状态
|
||||
|
||||
// 修改密码表单
|
||||
changePasswordForm: {
|
||||
@@ -69,6 +70,20 @@ createApp({
|
||||
usernameForm: {
|
||||
newUsername: ''
|
||||
},
|
||||
// 用户资料表单
|
||||
profileForm: {
|
||||
email: ''
|
||||
},
|
||||
// 管理员资料表单
|
||||
adminProfileForm: {
|
||||
username: ''
|
||||
},
|
||||
// 分享表单(通用)
|
||||
shareForm: {
|
||||
path: '',
|
||||
password: '',
|
||||
expiryDays: null
|
||||
},
|
||||
currentPath: '/',
|
||||
files: [],
|
||||
loading: false,
|
||||
@@ -275,7 +290,6 @@ createApp({
|
||||
|
||||
// OSS配置引导弹窗
|
||||
showOssGuideModal: false,
|
||||
showOssConfigModal: false,
|
||||
|
||||
// OSS空间使用统计
|
||||
ossUsage: null, // { totalSize, totalSizeFormatted, fileCount, dirCount }
|
||||
@@ -499,7 +513,6 @@ createApp({
|
||||
},
|
||||
|
||||
// 模态框点击外部关闭优化 - 防止拖动选择文本时误关闭
|
||||
modalMouseDownTarget: null,
|
||||
handleModalMouseDown(e) {
|
||||
// 记录鼠标按下时的目标
|
||||
this.modalMouseDownTarget = e.target;
|
||||
@@ -816,6 +829,28 @@ handleDragLeave(e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 前端验证
|
||||
if (!this.ossConfigForm.oss_provider || !['aliyun', 'tencent', 'aws'].includes(this.ossConfigForm.oss_provider)) {
|
||||
this.showToast('error', '配置错误', '请选择有效的 OSS 服务商');
|
||||
return;
|
||||
}
|
||||
if (!this.ossConfigForm.oss_region || this.ossConfigForm.oss_region.trim() === '') {
|
||||
this.showToast('error', '配置错误', '地域/Region 不能为空');
|
||||
return;
|
||||
}
|
||||
if (!this.ossConfigForm.oss_access_key_id || this.ossConfigForm.oss_access_key_id.trim() === '') {
|
||||
this.showToast('error', '配置错误', 'Access Key ID 不能为空');
|
||||
return;
|
||||
}
|
||||
if (!this.ossConfigForm.oss_access_key_secret || this.ossConfigForm.oss_access_key_secret.trim() === '') {
|
||||
this.showToast('error', '配置错误', 'Access Key Secret 不能为空');
|
||||
return;
|
||||
}
|
||||
if (!this.ossConfigForm.oss_bucket || this.ossConfigForm.oss_bucket.trim() === '') {
|
||||
this.showToast('error', '配置错误', 'Bucket 名称不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
this.ossConfigSaving = true;
|
||||
|
||||
try {
|
||||
@@ -863,6 +898,55 @@ handleDragLeave(e) {
|
||||
}
|
||||
},
|
||||
|
||||
// 测试 OSS 连接(不保存配置)
|
||||
async testOssConnection() {
|
||||
// 防止重复提交
|
||||
if (this.ossConfigTesting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 前端验证
|
||||
if (!this.ossConfigForm.oss_provider || !['aliyun', 'tencent', 'aws'].includes(this.ossConfigForm.oss_provider)) {
|
||||
this.showToast('error', '配置错误', '请选择有效的 OSS 服务商');
|
||||
return;
|
||||
}
|
||||
if (!this.ossConfigForm.oss_region || this.ossConfigForm.oss_region.trim() === '') {
|
||||
this.showToast('error', '配置错误', '地域/Region 不能为空');
|
||||
return;
|
||||
}
|
||||
if (!this.ossConfigForm.oss_access_key_id || this.ossConfigForm.oss_access_key_id.trim() === '') {
|
||||
this.showToast('error', '配置错误', 'Access Key ID 不能为空');
|
||||
return;
|
||||
}
|
||||
// 如果用户已有配置,Secret 可以为空(使用现有密钥)
|
||||
if (!this.user?.has_oss_config && (!this.ossConfigForm.oss_access_key_secret || this.ossConfigForm.oss_access_key_secret.trim() === '')) {
|
||||
this.showToast('error', '配置错误', 'Access Key Secret 不能为空');
|
||||
return;
|
||||
}
|
||||
if (!this.ossConfigForm.oss_bucket || this.ossConfigForm.oss_bucket.trim() === '') {
|
||||
this.showToast('error', '配置错误', 'Bucket 名称不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
this.ossConfigTesting = true;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.apiBase}/api/user/test-oss`,
|
||||
this.ossConfigForm,
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
this.showToast('success', '连接成功', 'OSS 配置验证通过,可以保存');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OSS连接测试失败:', error);
|
||||
this.showToast('error', '连接失败', error.response?.data?.message || error.message || '请检查配置信息');
|
||||
} finally {
|
||||
this.ossConfigTesting = false;
|
||||
}
|
||||
},
|
||||
|
||||
async updateAdminProfile() {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
@@ -1200,20 +1284,20 @@ handleDragLeave(e) {
|
||||
}
|
||||
},
|
||||
|
||||
handleFileClick(file) {
|
||||
async handleFileClick(file) {
|
||||
if (file.isDirectory) {
|
||||
const newPath = this.currentPath === '/'
|
||||
? `/${file.name}`
|
||||
: `${this.currentPath}/${file.name}`;
|
||||
this.loadFiles(newPath);
|
||||
} else {
|
||||
// 检查文件类型,打开相应的预览
|
||||
// 检查文件类型,打开相应的预览(异步)
|
||||
if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) {
|
||||
this.openImageViewer(file);
|
||||
await this.openImageViewer(file);
|
||||
} else if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)) {
|
||||
this.openVideoPlayer(file);
|
||||
await this.openVideoPlayer(file);
|
||||
} else if (file.name.match(/\.(mp3|wav|flac|aac|ogg|m4a)$/i)) {
|
||||
this.openAudioPlayer(file);
|
||||
await this.openAudioPlayer(file);
|
||||
}
|
||||
// 其他文件类型不做任何操作,用户可以通过右键菜单下载
|
||||
}
|
||||
@@ -1261,10 +1345,15 @@ handleDragLeave(e) {
|
||||
if (data.success) {
|
||||
// 直连 OSS 下载
|
||||
window.open(data.downloadUrl, '_blank');
|
||||
} else {
|
||||
// 处理后端返回的错误
|
||||
console.error('获取下载链接失败:', data.message);
|
||||
this.showToast('error', '下载失败', data.message || '获取下载链接失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取下载链接失败:', error);
|
||||
this.showToast('error', '错误', '获取下载链接失败');
|
||||
const errorMsg = error.response?.data?.message || error.message || '获取下载链接失败';
|
||||
this.showToast('error', '下载失败', errorMsg);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1457,18 +1546,18 @@ handleDragLeave(e) {
|
||||
},
|
||||
|
||||
// 从菜单执行操作
|
||||
contextMenuAction(action) {
|
||||
async contextMenuAction(action) {
|
||||
if (!this.contextMenuFile) return;
|
||||
|
||||
switch (action) {
|
||||
case 'preview':
|
||||
// 根据文件类型打开对应的预览
|
||||
// 根据文件类型打开对应的预览(异步)
|
||||
if (this.contextMenuFile.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) {
|
||||
this.openImageViewer(this.contextMenuFile);
|
||||
await this.openImageViewer(this.contextMenuFile);
|
||||
} else if (this.contextMenuFile.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)) {
|
||||
this.openVideoPlayer(this.contextMenuFile);
|
||||
await this.openVideoPlayer(this.contextMenuFile);
|
||||
} else if (this.contextMenuFile.name.match(/\.(mp3|wav|flac|aac|ogg|m4a)$/i)) {
|
||||
this.openAudioPlayer(this.contextMenuFile);
|
||||
await this.openAudioPlayer(this.contextMenuFile);
|
||||
}
|
||||
break;
|
||||
case 'download':
|
||||
@@ -1516,41 +1605,67 @@ handleDragLeave(e) {
|
||||
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
|
||||
},
|
||||
|
||||
// 获取文件缩略图URL
|
||||
// 获取文件缩略图URL(同步方法,用于本地存储模式)
|
||||
// 注意:OSS 模式下缩略图需要单独处理,此处返回本地存储的直接URL
|
||||
getThumbnailUrl(file) {
|
||||
if (!file || file.isDirectory) return null;
|
||||
|
||||
|
||||
// 检查是否是图片或视频
|
||||
const isImage = file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i);
|
||||
const isVideo = file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i);
|
||||
|
||||
|
||||
if (!isImage && !isVideo) return null;
|
||||
|
||||
return this.getMediaUrl(file);
|
||||
|
||||
// 本地存储模式:返回同步的下载 URL
|
||||
// OSS 模式下缩略图功能暂不支持(需要预签名 URL,建议点击文件预览)
|
||||
if (this.storageType !== 'oss') {
|
||||
const filePath = this.currentPath === '/'
|
||||
? `/${file.name}`
|
||||
: `${this.currentPath}/${file.name}`;
|
||||
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
// OSS 模式暂不支持同步缩略图,返回 null
|
||||
return null;
|
||||
},
|
||||
|
||||
// 打开图片预览
|
||||
openImageViewer(file) {
|
||||
this.currentMediaUrl = this.getMediaUrl(file);
|
||||
this.currentMediaName = file.name;
|
||||
this.currentMediaType = 'image';
|
||||
this.showImageViewer = true;
|
||||
async openImageViewer(file) {
|
||||
const url = await this.getMediaUrl(file);
|
||||
if (url) {
|
||||
this.currentMediaUrl = url;
|
||||
this.currentMediaName = file.name;
|
||||
this.currentMediaType = 'image';
|
||||
this.showImageViewer = true;
|
||||
} else {
|
||||
this.showToast('error', '错误', '无法获取文件预览链接');
|
||||
}
|
||||
},
|
||||
|
||||
// 打开视频播放器
|
||||
openVideoPlayer(file) {
|
||||
this.currentMediaUrl = this.getMediaUrl(file);
|
||||
this.currentMediaName = file.name;
|
||||
this.currentMediaType = 'video';
|
||||
this.showVideoPlayer = true;
|
||||
async openVideoPlayer(file) {
|
||||
const url = await this.getMediaUrl(file);
|
||||
if (url) {
|
||||
this.currentMediaUrl = url;
|
||||
this.currentMediaName = file.name;
|
||||
this.currentMediaType = 'video';
|
||||
this.showVideoPlayer = true;
|
||||
} else {
|
||||
this.showToast('error', '错误', '无法获取文件预览链接');
|
||||
}
|
||||
},
|
||||
|
||||
// 打开音频播放器
|
||||
openAudioPlayer(file) {
|
||||
this.currentMediaUrl = this.getMediaUrl(file);
|
||||
this.currentMediaName = file.name;
|
||||
this.currentMediaType = 'audio';
|
||||
this.showAudioPlayer = true;
|
||||
async openAudioPlayer(file) {
|
||||
const url = await this.getMediaUrl(file);
|
||||
if (url) {
|
||||
this.currentMediaUrl = url;
|
||||
this.currentMediaName = file.name;
|
||||
this.currentMediaType = 'audio';
|
||||
this.showAudioPlayer = true;
|
||||
} else {
|
||||
this.showToast('error', '错误', '无法获取文件预览链接');
|
||||
}
|
||||
},
|
||||
|
||||
// 关闭媒体预览
|
||||
@@ -1769,10 +1884,11 @@ handleDragLeave(e) {
|
||||
// OSS 直连上传
|
||||
async uploadToOSSDirect(file) {
|
||||
try {
|
||||
// 1. 获取签名 URL
|
||||
// 1. 获取签名 URL(传递当前路径)
|
||||
const { data: signData } = await axios.get(`${this.apiBase}/api/files/upload-signature`, {
|
||||
params: {
|
||||
filename: file.name,
|
||||
path: this.currentPath,
|
||||
contentType: file.type || 'application/octet-stream'
|
||||
}
|
||||
});
|
||||
@@ -2349,7 +2465,8 @@ handleDragLeave(e) {
|
||||
|
||||
// 每30秒检查一次用户配置是否有更新
|
||||
this.profileCheckInterval = setInterval(() => {
|
||||
if (this.isLoggedIn && this.token) {
|
||||
// 注意:token 通过 HttpOnly Cookie 管理,仅检查 isLoggedIn
|
||||
if (this.isLoggedIn) {
|
||||
this.loadUserProfile();
|
||||
}
|
||||
}, 30000); // 30秒
|
||||
@@ -2677,8 +2794,7 @@ handleDragLeave(e) {
|
||||
console.error('更新系统设置失败:', error);
|
||||
this.showToast('error', '错误', '更新系统设置失败');
|
||||
}
|
||||
}
|
||||
,
|
||||
},
|
||||
|
||||
async testSmtp() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user