fix: harden cloud storage security
This commit is contained in:
@@ -1778,8 +1778,8 @@
|
||||
<input type="email" class="form-input" v-model="registerForm.email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">密码 (至少6字符)</label>
|
||||
<input type="password" class="form-input" v-model="registerForm.password" required minlength="6">
|
||||
<label class="form-label">密码 (8-128字符,至少两类字符)</label>
|
||||
<input type="password" class="form-input" v-model="registerForm.password" required minlength="8" maxlength="128">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">验证码</label>
|
||||
@@ -1986,7 +1986,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 拖拽提示层 -->
|
||||
<div v-if="isDragging && storageType === 'local'" class="drag-drop-overlay">
|
||||
<div v-if="isDragging" class="drag-drop-overlay">
|
||||
<div class="drag-drop-content">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size: 64px; color: #667eea; margin-bottom: 20px;"></i>
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px;">拖放文件到这里上传</div>
|
||||
@@ -3041,8 +3041,8 @@
|
||||
<input type="password" class="form-input" v-model="changePasswordForm.current_password" placeholder="输入当前密码" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">新密码 (至少6字符)</label>
|
||||
<input type="password" class="form-input" v-model="changePasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
|
||||
<label class="form-label">新密码 (8-128字符,至少两类字符)</label>
|
||||
<input type="password" class="form-input" v-model="changePasswordForm.new_password" placeholder="输入新密码" minlength="8" maxlength="128" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="passwordChanging">
|
||||
<i :class="passwordChanging ? 'fas fa-spinner fa-spin' : 'fas fa-key'"></i> {{ passwordChanging ? '修改中...' : '修改密码' }}
|
||||
@@ -4252,8 +4252,8 @@
|
||||
重置链接已验证,请输入新密码
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">新密码 (至少6字符)</label>
|
||||
<input type="password" class="form-input" v-model="resetPasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
|
||||
<label class="form-label">新密码 (8-128字符,至少两类字符)</label>
|
||||
<input type="password" class="form-input" v-model="resetPasswordForm.new_password" placeholder="输入新密码" minlength="8" maxlength="128" required>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||
<button class="btn btn-primary" @click="submitResetPassword" :disabled="passwordResetting" style="flex: 1;">
|
||||
@@ -4392,12 +4392,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 上传进度条 -->
|
||||
<div v-if="uploadProgress > 0 && uploadProgress < 100"
|
||||
<div v-if="uploadingFileName && uploadProgress > 0"
|
||||
class="upload-progress-panel">
|
||||
<div class="upload-progress-header">
|
||||
<i class="fas fa-cloud-upload-alt upload-progress-icon"></i>
|
||||
<div class="upload-progress-meta">
|
||||
<div class="upload-progress-title">正在上传文件</div>
|
||||
<div class="upload-progress-title">{{ uploadPhase || '正在上传文件' }}</div>
|
||||
<div class="upload-progress-name">{{ uploadingFileName }}</div>
|
||||
<div v-if="totalBytes > 0" class="upload-progress-size">{{ formatFileSize(uploadedBytes) }} / {{ formatFileSize(totalBytes) }}</div>
|
||||
</div>
|
||||
|
||||
270
frontend/app.js
270
frontend/app.js
@@ -163,7 +163,10 @@ createApp({
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
uploadingFileName: '',
|
||||
uploadPhase: '',
|
||||
isDragging: false,
|
||||
fileLoadRequestId: 0,
|
||||
inspectionLoadRequestId: 0,
|
||||
modalMouseDownTarget: null, // 模态框鼠标按下的目标
|
||||
|
||||
// 全局搜索(文件页)
|
||||
@@ -723,6 +726,36 @@ createApp({
|
||||
};
|
||||
},
|
||||
|
||||
validateAccountPassword(password) {
|
||||
const value = String(password || '');
|
||||
if (value.length < 8) {
|
||||
return { valid: false, message: '密码至少8个字符' };
|
||||
}
|
||||
if (value.length > 128) {
|
||||
return { valid: false, message: '密码不能超过128个字符' };
|
||||
}
|
||||
|
||||
const typeCount = [
|
||||
/[a-zA-Z]/.test(value),
|
||||
/[0-9]/.test(value),
|
||||
/[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]/.test(value)
|
||||
].filter(Boolean).length;
|
||||
|
||||
if (typeCount < 2) {
|
||||
return { valid: false, message: '密码必须包含字母、数字、特殊字符中的至少两种' };
|
||||
}
|
||||
|
||||
const weakPasswords = [
|
||||
'password', '12345678', '123456789', 'qwerty123', 'admin123',
|
||||
'letmein', 'welcome', 'monkey', 'dragon', 'master'
|
||||
];
|
||||
if (weakPasswords.includes(value.toLowerCase())) {
|
||||
return { valid: false, message: '密码过于简单,请使用更复杂的密码' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
// 创建防抖版本的 loadUserProfile(延迟2秒,避免频繁请求)
|
||||
debouncedLoadUserProfile() {
|
||||
if (!this._debouncedLoadUserProfile) {
|
||||
@@ -1001,8 +1034,7 @@ handleDragLeave(e) {
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
await this.uploadFile(file);
|
||||
await this.uploadFiles(files);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1031,6 +1063,27 @@ handleDragLeave(e) {
|
||||
return generated;
|
||||
},
|
||||
|
||||
getPersistableUser(user) {
|
||||
if (!user || typeof user !== 'object') return null;
|
||||
const {
|
||||
oss_access_key_id,
|
||||
oss_access_key_secret,
|
||||
oss_provider,
|
||||
oss_region,
|
||||
oss_bucket,
|
||||
oss_endpoint,
|
||||
...safeUser
|
||||
} = user;
|
||||
return safeUser;
|
||||
},
|
||||
|
||||
persistUser(user = this.user) {
|
||||
const safeUser = this.getPersistableUser(user);
|
||||
if (safeUser) {
|
||||
localStorage.setItem('user', JSON.stringify(safeUser));
|
||||
}
|
||||
},
|
||||
|
||||
buildLoginClientMeta() {
|
||||
const platform = navigator.platform || '未知平台';
|
||||
const name = `${navigator.userAgent?.includes('Mobile') ? '移动端网页' : '网页端'} · ${platform || '未知平台'}`;
|
||||
@@ -1121,7 +1174,7 @@ handleDragLeave(e) {
|
||||
|
||||
// 保存用户信息到localStorage(非敏感信息,用于页面刷新后恢复)
|
||||
// 注意:token 通过 HttpOnly Cookie 传递,不再存储在 localStorage
|
||||
localStorage.setItem('user', JSON.stringify(this.user));
|
||||
this.persistUser();
|
||||
|
||||
// 启动token自动刷新(在过期前5分钟刷新)
|
||||
const expiresIn = response.data.expiresIn || (2 * 60 * 60 * 1000);
|
||||
@@ -1152,29 +1205,26 @@ handleDragLeave(e) {
|
||||
this.loadUserTheme();
|
||||
// 管理员直接跳转到管理后台
|
||||
if (this.user.is_admin) {
|
||||
this.currentView = 'admin';
|
||||
this.switchView('admin', true);
|
||||
}
|
||||
// 普通用户:检查存储权限
|
||||
else {
|
||||
// 如果用户可以使用本地存储,直接进入文件页面
|
||||
if (this.storagePermission === 'local_only' || this.storagePermission === 'user_choice') {
|
||||
this.currentView = 'files';
|
||||
this.loadFiles('/');
|
||||
this.switchView('files', true);
|
||||
}
|
||||
// 如果仅OSS模式,需要检查是否配置了OSS(包括系统级统一配置)
|
||||
else if (this.storagePermission === 'oss_only') {
|
||||
if (this.user?.oss_config_source !== 'none') {
|
||||
this.currentView = 'files';
|
||||
this.loadFiles('/');
|
||||
this.switchView('files', true);
|
||||
} else {
|
||||
this.currentView = 'settings';
|
||||
this.switchView('settings', true);
|
||||
this.showToast('info', '欢迎', '请先配置您的OSS服务');
|
||||
this.openOssConfigModal();
|
||||
}
|
||||
} else {
|
||||
// 默认行为:跳转到文件页面
|
||||
this.currentView = 'files';
|
||||
this.loadFiles('/');
|
||||
this.switchView('files', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1298,6 +1348,13 @@ handleDragLeave(e) {
|
||||
async handleRegister() {
|
||||
this.errorMessage = '';
|
||||
this.successMessage = '';
|
||||
|
||||
const passwordCheck = this.validateAccountPassword(this.registerForm.password);
|
||||
if (!passwordCheck.valid) {
|
||||
this.errorMessage = passwordCheck.message;
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerLoading = true;
|
||||
|
||||
try {
|
||||
@@ -1350,7 +1407,7 @@ handleDragLeave(e) {
|
||||
this.showToast('error', '配置错误', 'Access Key ID 不能为空');
|
||||
return;
|
||||
}
|
||||
if (!this.ossConfigForm.oss_access_key_secret || this.ossConfigForm.oss_access_key_secret.trim() === '') {
|
||||
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;
|
||||
}
|
||||
@@ -1392,8 +1449,7 @@ handleDragLeave(e) {
|
||||
this.showOssConfigModal = false;
|
||||
|
||||
// 刷新到文件页面
|
||||
this.currentView = 'files';
|
||||
this.loadFiles('/');
|
||||
this.switchView('files', true);
|
||||
|
||||
// 显示成功提示
|
||||
this.showToast('success', '配置成功', 'OSS存储配置已保存!');
|
||||
@@ -1470,7 +1526,7 @@ handleDragLeave(e) {
|
||||
// 更新用户信息(后端已通过 Cookie 更新 token)
|
||||
if (response.data.user) {
|
||||
this.user = response.data.user;
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user));
|
||||
this.persistUser(response.data.user);
|
||||
}
|
||||
|
||||
// 延迟后重新登录
|
||||
@@ -1487,8 +1543,9 @@ handleDragLeave(e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.changePasswordForm.new_password.length < 6) {
|
||||
this.showToast('warning', '提示', '新密码至少6个字符');
|
||||
const passwordCheck = this.validateAccountPassword(this.changePasswordForm.new_password);
|
||||
if (!passwordCheck.valid) {
|
||||
this.showToast('warning', '提示', passwordCheck.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1554,7 +1611,7 @@ handleDragLeave(e) {
|
||||
this.showToast('success', '成功', '用户名修改成功!');
|
||||
// 更新本地用户信息
|
||||
this.user.username = this.usernameForm.newUsername;
|
||||
localStorage.setItem('user', JSON.stringify(this.user));
|
||||
this.persistUser();
|
||||
this.usernameForm.newUsername = '';
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1576,7 +1633,7 @@ handleDragLeave(e) {
|
||||
// 更新本地用户信息
|
||||
if (response.data.user) {
|
||||
this.user = response.data.user;
|
||||
localStorage.setItem('user', JSON.stringify(this.user));
|
||||
this.persistUser();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1637,7 +1694,7 @@ handleDragLeave(e) {
|
||||
this.isLoggedIn = true;
|
||||
|
||||
// 更新localStorage中的用户信息(非敏感信息)
|
||||
localStorage.setItem('user', JSON.stringify(this.user));
|
||||
this.persistUser();
|
||||
|
||||
// 从最新的用户信息初始化存储相关字段
|
||||
this.storagePermission = this.user.storage_permission || 'oss_only';
|
||||
@@ -1774,16 +1831,22 @@ handleDragLeave(e) {
|
||||
// ===== 文件管理 =====
|
||||
|
||||
async loadFiles(path) {
|
||||
const requestId = ++this.fileLoadRequestId;
|
||||
const targetPath = path || '/';
|
||||
this.loading = true;
|
||||
// 确保路径不为undefined
|
||||
this.currentPath = path || '/';
|
||||
this.currentPath = targetPath;
|
||||
this.globalSearchVisible = false;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${this.apiBase}/api/files`, {
|
||||
params: { path }
|
||||
params: { path: targetPath }
|
||||
});
|
||||
|
||||
if (requestId !== this.fileLoadRequestId || this.currentPath !== targetPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
this.files = response.data.items;
|
||||
this.thumbnailLoadErrors = {};
|
||||
@@ -1805,6 +1868,9 @@ handleDragLeave(e) {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== this.fileLoadRequestId) {
|
||||
return;
|
||||
}
|
||||
console.error('加载文件失败:', error);
|
||||
this.showToast('error', '加载失败', error.response?.data?.message || error.message);
|
||||
|
||||
@@ -1812,7 +1878,9 @@ handleDragLeave(e) {
|
||||
this.logout();
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
if (requestId === this.fileLoadRequestId) {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2246,11 +2314,17 @@ handleDragLeave(e) {
|
||||
|
||||
// 长按取消(移动端)
|
||||
handleLongPressEnd() {
|
||||
const wasTriggered = this.longPressTriggered;
|
||||
if (this.longPressTimer) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
this.longPressFile = null;
|
||||
if (wasTriggered) {
|
||||
setTimeout(() => {
|
||||
this.longPressTriggered = false;
|
||||
}, 350);
|
||||
}
|
||||
},
|
||||
|
||||
// 从菜单执行操作
|
||||
@@ -2381,14 +2455,7 @@ handleDragLeave(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 本地存储模式:返回同步的下载 URL
|
||||
// OSS 模式下缩略图功能暂不支持(需要预签名 URL,建议点击文件预览)
|
||||
if (this.storageType !== 'oss') {
|
||||
const filePath = this.getCurrentFilePath(file);
|
||||
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
// OSS 模式暂不支持同步缩略图,返回 null
|
||||
// 不用下载接口做缩略图,避免浏览网格时消耗下载流量配额。
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -2482,6 +2549,8 @@ handleDragLeave(e) {
|
||||
const link = document.createElement('a');
|
||||
link.href = this.currentMediaUrl;
|
||||
link.setAttribute('download', this.currentMediaName);
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -2521,7 +2590,7 @@ handleDragLeave(e) {
|
||||
openShareFileModal(file) {
|
||||
this.shareFileForm.fileName = file.name;
|
||||
this.shareFileForm.filePath = this.currentPath === '/'
|
||||
? file.name
|
||||
? `/${file.name}`
|
||||
: `${this.currentPath}/${file.name}`;
|
||||
this.shareFileForm.isDirectory = file.isDirectory; // 设置是否为文件夹
|
||||
this.shareFileForm.enablePassword = false;
|
||||
@@ -2746,23 +2815,26 @@ handleDragLeave(e) {
|
||||
|
||||
// ===== 文件上传 =====
|
||||
|
||||
handleFileSelect(event) {
|
||||
async handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
// 支持多文件上传
|
||||
Array.from(files).forEach(file => {
|
||||
this.uploadFile(file);
|
||||
});
|
||||
await this.uploadFiles(files);
|
||||
// 清空input,允许重复上传相同文件
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
|
||||
handleFileDrop(event) {
|
||||
async handleFileDrop(event) {
|
||||
this.isDragging = false;
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
this.uploadFile(file);
|
||||
await this.uploadFiles(event.dataTransfer.files);
|
||||
},
|
||||
|
||||
async uploadFiles(fileList) {
|
||||
const files = Array.from(fileList || []).filter(Boolean);
|
||||
if (files.length === 0) return;
|
||||
|
||||
for (const file of files) {
|
||||
await this.uploadFile(file);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2784,6 +2856,7 @@ handleDragLeave(e) {
|
||||
this.uploadProgress = 0;
|
||||
this.uploadedBytes = 0;
|
||||
this.totalBytes = file.size;
|
||||
this.uploadPhase = '准备上传';
|
||||
|
||||
try {
|
||||
const fileHash = await this.computeQuickFileHash(file);
|
||||
@@ -2793,6 +2866,7 @@ handleDragLeave(e) {
|
||||
this.uploadedBytes = 0;
|
||||
this.totalBytes = 0;
|
||||
this.uploadingFileName = '';
|
||||
this.uploadPhase = '';
|
||||
await this.loadFiles(this.currentPath);
|
||||
await this.refreshStorageUsage();
|
||||
return;
|
||||
@@ -2816,6 +2890,7 @@ handleDragLeave(e) {
|
||||
this.uploadedBytes = 0;
|
||||
this.totalBytes = 0;
|
||||
this.uploadingFileName = '';
|
||||
this.uploadPhase = '';
|
||||
|
||||
this.showToast('error', '上传失败', error.message || '上传失败,请重试');
|
||||
}
|
||||
@@ -2934,6 +3009,7 @@ handleDragLeave(e) {
|
||||
'Content-Type': file.type || 'application/octet-stream'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
this.uploadPhase = '上传中';
|
||||
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
this.uploadedBytes = progressEvent.loaded;
|
||||
this.totalBytes = progressEvent.total;
|
||||
@@ -2942,6 +3018,7 @@ handleDragLeave(e) {
|
||||
});
|
||||
|
||||
// 3. 通知后端上传完成
|
||||
this.uploadPhase = '服务端确认中';
|
||||
await axios.post(`${this.apiBase}/api/files/upload-complete`, {
|
||||
objectKey: signData.objectKey,
|
||||
size: file.size,
|
||||
@@ -2957,6 +3034,7 @@ handleDragLeave(e) {
|
||||
this.uploadedBytes = 0;
|
||||
this.totalBytes = 0;
|
||||
this.uploadingFileName = '';
|
||||
this.uploadPhase = '';
|
||||
|
||||
// 6. 刷新文件列表和空间统计
|
||||
await this.loadFiles(this.currentPath);
|
||||
@@ -3006,6 +3084,7 @@ handleDragLeave(e) {
|
||||
if (uploadedBytes > 0 && file.size > 0) {
|
||||
this.uploadedBytes = uploadedBytes;
|
||||
this.uploadProgress = Math.min(100, Math.round((uploadedBytes / file.size) * 100));
|
||||
this.uploadPhase = '上传中';
|
||||
}
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
|
||||
@@ -3037,8 +3116,10 @@ handleDragLeave(e) {
|
||||
this.uploadProgress = file.size > 0
|
||||
? Math.min(100, Math.round((uploadedBytes / file.size) * 100))
|
||||
: 0;
|
||||
this.uploadPhase = this.uploadProgress >= 100 ? '服务端合并中' : '上传中';
|
||||
}
|
||||
|
||||
this.uploadPhase = '服务端合并中';
|
||||
const completeResp = await axios.post(`${this.apiBase}/api/upload/resumable/complete`, {
|
||||
session_id: sessionId
|
||||
});
|
||||
@@ -3052,6 +3133,7 @@ handleDragLeave(e) {
|
||||
this.uploadedBytes = 0;
|
||||
this.totalBytes = 0;
|
||||
this.uploadingFileName = '';
|
||||
this.uploadPhase = '';
|
||||
await this.loadFiles(this.currentPath);
|
||||
await this.refreshStorageUsage();
|
||||
return true;
|
||||
@@ -3078,6 +3160,7 @@ handleDragLeave(e) {
|
||||
this.uploadedBytes = 0;
|
||||
this.totalBytes = 0;
|
||||
this.uploadingFileName = '';
|
||||
this.uploadPhase = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3092,6 +3175,7 @@ handleDragLeave(e) {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30 * 60 * 1000,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
this.uploadPhase = '上传中';
|
||||
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
this.uploadedBytes = progressEvent.loaded;
|
||||
this.totalBytes = progressEvent.total;
|
||||
@@ -3104,8 +3188,16 @@ handleDragLeave(e) {
|
||||
this.uploadedBytes = 0;
|
||||
this.totalBytes = 0;
|
||||
this.uploadingFileName = '';
|
||||
this.uploadPhase = '';
|
||||
await this.loadFiles(this.currentPath);
|
||||
await this.refreshStorageUsage();
|
||||
} else {
|
||||
this.uploadProgress = 0;
|
||||
this.uploadedBytes = 0;
|
||||
this.totalBytes = 0;
|
||||
this.uploadingFileName = '';
|
||||
this.uploadPhase = '';
|
||||
throw new Error(response.data.message || '上传失败');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -3702,8 +3794,14 @@ handleDragLeave(e) {
|
||||
},
|
||||
|
||||
async submitResetPassword() {
|
||||
if (!this.resetPasswordForm.token || !this.resetPasswordForm.new_password || this.resetPasswordForm.new_password.length < 6) {
|
||||
this.showToast('error', '错误', '请输入有效的重置链接和新密码(至少6位)');
|
||||
if (!this.resetPasswordForm.token || !this.resetPasswordForm.new_password) {
|
||||
this.showToast('error', '错误', '请输入有效的重置链接和新密码');
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordCheck = this.validateAccountPassword(this.resetPasswordForm.new_password);
|
||||
if (!passwordCheck.valid) {
|
||||
this.showToast('error', '错误', passwordCheck.message);
|
||||
return;
|
||||
}
|
||||
this.passwordResetting = true;
|
||||
@@ -3735,25 +3833,36 @@ handleDragLeave(e) {
|
||||
},
|
||||
|
||||
async loadUserFiles(path) {
|
||||
const requestId = ++this.inspectionLoadRequestId;
|
||||
const targetPath = path || '/';
|
||||
this.inspectionLoading = true;
|
||||
this.inspectionPath = path;
|
||||
this.inspectionPath = targetPath;
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${this.apiBase}/api/admin/users/${this.inspectionUser.id}/files`,
|
||||
{
|
||||
params: { path }
|
||||
params: { path: targetPath }
|
||||
}
|
||||
);
|
||||
|
||||
if (requestId !== this.inspectionLoadRequestId || this.inspectionPath !== targetPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
this.inspectionFiles = response.data.items;
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== this.inspectionLoadRequestId) {
|
||||
return;
|
||||
}
|
||||
console.error('加载用户文件失败:', error);
|
||||
this.showToast('error', '错误', error.response?.data?.message || '加载文件失败');
|
||||
} finally {
|
||||
this.inspectionLoading = false;
|
||||
if (requestId === this.inspectionLoadRequestId) {
|
||||
this.inspectionLoading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4023,16 +4132,13 @@ handleDragLeave(e) {
|
||||
},
|
||||
|
||||
openOssConfigModal() {
|
||||
// 只有管理员才能配置OSS
|
||||
if (!this.user?.is_admin) {
|
||||
this.showToast('error', '权限不足', '只有管理员才能配置OSS服务');
|
||||
if (!this.user) {
|
||||
this.showToast('error', '请先登录', '登录后才能配置 OSS');
|
||||
return;
|
||||
}
|
||||
this.showOssGuideModal = false;
|
||||
this.showOssConfigModal = true;
|
||||
if (this.user && !this.user.is_admin) {
|
||||
this.loadOssConfig();
|
||||
}
|
||||
this.loadOssConfig();
|
||||
},
|
||||
|
||||
closeOssConfigModal() {
|
||||
@@ -4075,6 +4181,7 @@ handleDragLeave(e) {
|
||||
// 切换到管理后台时,重新加载用户列表、健康检测和系统日志
|
||||
if (this.user && this.user.is_admin) {
|
||||
this.loadUsers();
|
||||
this.loadSystemSettings();
|
||||
this.loadServerStorageStats();
|
||||
if (this.adminTab === 'monitor') {
|
||||
this.initMonitorTab();
|
||||
@@ -4087,6 +4194,7 @@ handleDragLeave(e) {
|
||||
case 'settings':
|
||||
this.loadOnlineDevices();
|
||||
if (this.user && !this.user.is_admin) {
|
||||
this.loadOssConfig();
|
||||
this.loadDownloadTrafficReport();
|
||||
}
|
||||
break;
|
||||
@@ -4360,11 +4468,25 @@ handleDragLeave(e) {
|
||||
},
|
||||
|
||||
getHighlightedText(value, keyword) {
|
||||
const text = this.escapeHtml(value || '-');
|
||||
const rawText = String(value || '-');
|
||||
const search = String(keyword || '').trim();
|
||||
if (!search) return text;
|
||||
const reg = new RegExp(this.escapeRegExp(search), 'ig');
|
||||
return text.replace(reg, (match) => `<mark class="admin-search-hit">${match}</mark>`);
|
||||
if (!search) return this.escapeHtml(rawText);
|
||||
|
||||
const lowerText = rawText.toLowerCase();
|
||||
const lowerSearch = search.toLowerCase();
|
||||
let cursor = 0;
|
||||
let output = '';
|
||||
let index = lowerText.indexOf(lowerSearch, cursor);
|
||||
|
||||
while (index !== -1) {
|
||||
output += this.escapeHtml(rawText.slice(cursor, index));
|
||||
output += `<mark class="admin-search-hit">${this.escapeHtml(rawText.slice(index, index + search.length))}</mark>`;
|
||||
cursor = index + search.length;
|
||||
index = lowerText.indexOf(lowerSearch, cursor);
|
||||
}
|
||||
|
||||
output += this.escapeHtml(rawText.slice(cursor));
|
||||
return output;
|
||||
},
|
||||
|
||||
formatBytes(bytes) {
|
||||
@@ -4566,9 +4688,14 @@ handleDragLeave(e) {
|
||||
async updateSystemSettings() {
|
||||
try {
|
||||
const maxUploadSize = parseInt(this.systemSettings.maxUploadSizeMB) * 1024 * 1024;
|
||||
const currentPassword = window.prompt('请输入当前管理员密码以确认修改系统设置');
|
||||
if (currentPassword === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
max_upload_size: maxUploadSize,
|
||||
current_password: currentPassword,
|
||||
download_security: {
|
||||
enabled: !!this.systemSettings.downloadSecurity.enabled,
|
||||
same_ip_same_file: {
|
||||
@@ -4610,7 +4737,7 @@ handleDragLeave(e) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新系统设置失败:', error);
|
||||
this.showToast('error', '错误', '更新系统设置失败');
|
||||
this.showToast('error', '错误', error.response?.data?.message || '更新系统设置失败');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4966,7 +5093,7 @@ handleDragLeave(e) {
|
||||
const csrfToken = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
?.substring('csrf_token='.length);
|
||||
|
||||
if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken;
|
||||
@@ -5017,12 +5144,18 @@ handleDragLeave(e) {
|
||||
// 设置axios响应拦截器,处理401错误(token过期/失效)
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
async error => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
// 排除登录接口本身的401(密码错误等)
|
||||
const isLoginApi = error.config?.url?.includes('/api/login');
|
||||
if (!isLoginApi && this.isLoggedIn) {
|
||||
console.warn('[认证] 收到401响应,Token已失效');
|
||||
const isRefreshApi = error.config?.url?.includes('/api/refresh');
|
||||
if (!isLoginApi && !isRefreshApi && this.isLoggedIn && !error.config?._retry) {
|
||||
console.warn('[认证] 收到401响应,尝试刷新Token');
|
||||
error.config._retry = true;
|
||||
const refreshed = await this.doRefreshToken();
|
||||
if (refreshed) {
|
||||
return axios(error.config);
|
||||
}
|
||||
this.handleTokenExpired();
|
||||
this.showToast('warning', '登录已过期', '请重新登录');
|
||||
}
|
||||
@@ -5049,21 +5182,6 @@ handleDragLeave(e) {
|
||||
|
||||
watch: {
|
||||
currentView(newView) {
|
||||
if (newView === 'shares') {
|
||||
this.refreshShareResources();
|
||||
} else if (newView === 'admin' && this.user?.is_admin) {
|
||||
this.loadUsers();
|
||||
this.loadSystemSettings();
|
||||
this.loadServerStorageStats();
|
||||
} else if (newView === 'settings' && this.user && !this.user.is_admin) {
|
||||
// 普通用户进入设置页面时加载OSS配置
|
||||
this.loadOssConfig();
|
||||
this.loadDownloadTrafficReport();
|
||||
this.loadOnlineDevices();
|
||||
} else if (newView === 'settings') {
|
||||
this.loadOnlineDevices();
|
||||
}
|
||||
|
||||
// 记住最后停留的视图(需合法且已登录)
|
||||
if (this.isLoggedIn && this.isViewAllowed(newView)) {
|
||||
localStorage.setItem('lastView', newView);
|
||||
|
||||
BIN
frontend/downloads/wanwan-cloud-desktop_v0.1.31_x64-setup.exe
Normal file
BIN
frontend/downloads/wanwan-cloud-desktop_v0.1.31_x64-setup.exe
Normal file
Binary file not shown.
@@ -292,8 +292,8 @@
|
||||
<div class="form-group">
|
||||
<label class="form-label">新密码</label>
|
||||
<input type="password" id="password" class="form-input"
|
||||
placeholder="请输入新密码" required minlength="6">
|
||||
<div class="password-hint">密码长度至少6位</div>
|
||||
placeholder="请输入新密码" required minlength="8" maxlength="128">
|
||||
<div class="password-hint">密码长度8-128位,且包含字母、数字、特殊字符中的至少两种</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -347,6 +347,28 @@
|
||||
return url.searchParams.get(name);
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
const prefix = `${name}=`;
|
||||
const row = document.cookie
|
||||
.split('; ')
|
||||
.find(item => item.startsWith(prefix));
|
||||
return row ? decodeURIComponent(row.substring(prefix.length)) : '';
|
||||
}
|
||||
|
||||
async function ensureCsrfToken() {
|
||||
let token = getCookie('csrf_token');
|
||||
if (token) return token;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/csrf-token', { credentials: 'include' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
token = data.csrfToken || data.token || getCookie('csrf_token');
|
||||
} catch (error) {
|
||||
console.warn('[CSRF] 获取 token 失败:', error.message);
|
||||
}
|
||||
return token || getCookie('csrf_token');
|
||||
}
|
||||
|
||||
// 显示指定区块
|
||||
function showSection(id) {
|
||||
['loading', 'error', 'form', 'success'].forEach(s => {
|
||||
@@ -363,6 +385,35 @@
|
||||
alert.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function validateAccountPassword(password) {
|
||||
const value = String(password || '');
|
||||
if (value.length < 8) {
|
||||
return { valid: false, message: '密码至少8个字符' };
|
||||
}
|
||||
if (value.length > 128) {
|
||||
return { valid: false, message: '密码不能超过128个字符' };
|
||||
}
|
||||
|
||||
const typeCount = [
|
||||
/[a-zA-Z]/.test(value),
|
||||
/[0-9]/.test(value),
|
||||
/[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]/.test(value)
|
||||
].filter(Boolean).length;
|
||||
if (typeCount < 2) {
|
||||
return { valid: false, message: '密码必须包含字母、数字、特殊字符中的至少两种' };
|
||||
}
|
||||
|
||||
const weakPasswords = [
|
||||
'password', '12345678', '123456789', 'qwerty123', 'admin123',
|
||||
'letmein', 'welcome', 'monkey', 'dragon', 'master'
|
||||
];
|
||||
if (weakPasswords.includes(value.toLowerCase())) {
|
||||
return { valid: false, message: '密码过于简单,请使用更复杂的密码' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// 验证token
|
||||
async function validateToken() {
|
||||
resetToken = getParam('resetToken') || getParam('token');
|
||||
@@ -373,6 +424,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanUrl = new URL(window.location.href);
|
||||
cleanUrl.searchParams.delete('resetToken');
|
||||
cleanUrl.searchParams.delete('token');
|
||||
window.history.replaceState({}, document.title, cleanUrl.pathname + cleanUrl.search + cleanUrl.hash);
|
||||
|
||||
// Token存在,显示表单
|
||||
showSection('form');
|
||||
}
|
||||
@@ -386,8 +442,9 @@
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
// 验证
|
||||
if (password.length < 6) {
|
||||
showFormAlert('error', '密码长度至少6位');
|
||||
const passwordCheck = validateAccountPassword(password);
|
||||
if (!passwordCheck.valid) {
|
||||
showFormAlert('error', passwordCheck.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,9 +458,14 @@
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
|
||||
|
||||
try {
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
const res = await fetch('/api/password/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: resetToken,
|
||||
new_password: password
|
||||
|
||||
@@ -1040,6 +1040,13 @@
|
||||
<span v-else> | 有效期: <strong class="share-expire-time valid">永久有效</strong></span>
|
||||
</p>
|
||||
|
||||
<div v-if="shareInfo.share_type !== 'file'" class="share-meta-bar" style="justify-content: space-between;">
|
||||
<span>当前位置:/{{ currentPath || '' }}</span>
|
||||
<button v-if="currentPath" class="btn btn-secondary" style="width: auto; padding: 8px 14px;" @click="goParentDirectory">
|
||||
<i class="fas fa-arrow-left"></i> 返回上级
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 视图切换按钮 (多文件时才显示) -->
|
||||
<div v-if="files.length > 1" class="view-controls">
|
||||
<button class="btn" :class="viewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'grid'">
|
||||
@@ -1061,6 +1068,9 @@
|
||||
<i class="single-file-icon fas" :class="getFileIcon(viewingFile || files[0])" :style="getIconColor(viewingFile || files[0])"></i>
|
||||
<div class="single-file-name">{{ (viewingFile || files[0]).name }}</div>
|
||||
<div class="single-file-size">{{ (viewingFile || files[0]).sizeFormatted }}</div>
|
||||
<button v-if="(viewingFile || files[0]).isDirectory" class="btn single-file-download" @click="enterDirectory(viewingFile || files[0])">
|
||||
<i class="fas fa-folder-open"></i> 进入文件夹
|
||||
</button>
|
||||
<button v-if="!(viewingFile || files[0]).isDirectory" class="btn single-file-download" @click="downloadFile(viewingFile || files[0])">
|
||||
<i class="fas fa-download"></i> 下载文件
|
||||
</button>
|
||||
@@ -1083,7 +1093,7 @@
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<ul v-else-if="!viewingFile" class="file-list">
|
||||
<li v-for="file in files" :key="file.name" class="file-item">
|
||||
<li v-for="file in files" :key="file.name" class="file-item" @click="handleFileClick(file)" style="cursor: pointer;">
|
||||
<div class="file-info">
|
||||
<i class="file-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
|
||||
<div class="file-name-container">
|
||||
@@ -1126,6 +1136,7 @@
|
||||
shareNotFound: false,
|
||||
shareInfo: null,
|
||||
files: [],
|
||||
currentPath: '',
|
||||
loading: true,
|
||||
errorMessage: '',
|
||||
downloadAlertMessage: '',
|
||||
@@ -1196,6 +1207,8 @@
|
||||
async verifyShare() {
|
||||
this.errorMessage = '';
|
||||
this.downloadAlertMessage = '';
|
||||
this.currentPath = '';
|
||||
this.viewingFile = null;
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
@@ -1210,40 +1223,45 @@
|
||||
// 如果是单文件分享且后端已返回文件信息,直接使用,无需再次请求
|
||||
if (response.data.file) {
|
||||
this.files = [response.data.file];
|
||||
this.loading = false;
|
||||
} else {
|
||||
// 目录分享,需要加载文件列表
|
||||
await this.loadFiles();
|
||||
}
|
||||
} else {
|
||||
this.errorMessage = response.data.message || '验证失败';
|
||||
}
|
||||
} catch (error) {
|
||||
// 404错误 - 分享不存在
|
||||
if (error.response?.status === 404) {
|
||||
this.shareNotFound = true;
|
||||
this.loading = false;
|
||||
}
|
||||
// 需要密码
|
||||
else if (error.response?.data?.needPassword) {
|
||||
this.needPassword = true;
|
||||
this.loading = false;
|
||||
}
|
||||
// 其他错误
|
||||
else {
|
||||
this.errorMessage = error.response?.data?.message || '验证失败';
|
||||
this.loading = false;
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadFiles() {
|
||||
async loadFiles(path = this.currentPath || '') {
|
||||
this.loading = true;
|
||||
this.currentPath = String(path || '').replace(/^\/+|\/+$/g, '');
|
||||
this.viewingFile = null;
|
||||
try {
|
||||
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/list`, {
|
||||
password: this.password,
|
||||
path: ''
|
||||
path: this.currentPath
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
this.files = response.data.items;
|
||||
} else {
|
||||
this.errorMessage = response.data.message || '加载文件失败';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文件失败:', error);
|
||||
@@ -1255,10 +1273,32 @@
|
||||
|
||||
// 处理文件点击 - 显示文件详情页面
|
||||
handleFileClick(file) {
|
||||
// 所有文件类型都显示详情页面(分享页面不提供媒体预览)
|
||||
if (file?.isDirectory) {
|
||||
this.enterDirectory(file);
|
||||
return;
|
||||
}
|
||||
this.viewFileDetail(file);
|
||||
},
|
||||
|
||||
joinSharePath(basePath, name) {
|
||||
return [basePath, name]
|
||||
.map(part => String(part || '').replace(/^\/+|\/+$/g, ''))
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
},
|
||||
|
||||
enterDirectory(file) {
|
||||
if (!file?.isDirectory) return;
|
||||
this.loadFiles(this.joinSharePath(this.currentPath, file.name));
|
||||
},
|
||||
|
||||
goParentDirectory() {
|
||||
if (!this.currentPath) return;
|
||||
const parts = this.currentPath.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
this.loadFiles(parts.join('/'));
|
||||
},
|
||||
|
||||
// 查看文件详情(放大显示)
|
||||
viewFileDetail(file) {
|
||||
this.viewingFile = file;
|
||||
@@ -1279,8 +1319,9 @@
|
||||
filePath = this.shareInfo.share_path;
|
||||
} else {
|
||||
// 目录分享,组合路径
|
||||
const basePath = this.shareInfo.share_path;
|
||||
filePath = basePath === '/' ? `/${file.name}` : `${basePath}/${file.name}`;
|
||||
const basePath = String(this.shareInfo.share_path || '/').replace(/\/+$/g, '') || '/';
|
||||
const relativePath = this.joinSharePath(this.currentPath, file.name);
|
||||
filePath = basePath === '/' ? `/${relativePath}` : `${basePath}/${relativePath}`;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1295,11 +1336,10 @@
|
||||
// OSS 直连下载:新窗口打开
|
||||
console.log("[分享下载] OSS 直连下载");
|
||||
|
||||
// 仅直连下载需要单独记录下载次数(本地代理下载在后端接口内已计数)
|
||||
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
|
||||
.catch(err => console.error('记录下载次数失败:', err));
|
||||
|
||||
window.open(data.downloadUrl, '_blank');
|
||||
const downloadWindow = window.open(data.downloadUrl, '_blank', 'noopener,noreferrer');
|
||||
if (downloadWindow) {
|
||||
downloadWindow.opener = null;
|
||||
}
|
||||
} else {
|
||||
// 本地存储:通过后端下载
|
||||
console.log("[分享下载] 后端代理下载");
|
||||
@@ -1474,7 +1514,7 @@
|
||||
const csrfToken = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
?.substring('csrf_token='.length);
|
||||
|
||||
if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken;
|
||||
|
||||
@@ -265,6 +265,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanUrl = new URL(window.location.href);
|
||||
cleanUrl.searchParams.delete('verifyToken');
|
||||
cleanUrl.searchParams.delete('token');
|
||||
window.history.replaceState({}, document.title, cleanUrl.pathname + cleanUrl.search + cleanUrl.hash);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/verify-email?token=${encodeURIComponent(token)}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user