Compare commits
72 Commits
b171b41599
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d8cd7fd514 | |||
| b161a3e3e7 | |||
| b179cae14e | |||
| c8f63d6fc9 | |||
| fe544efc91 | |||
| 01384a2215 | |||
| 6618de1aed | |||
| e2318ac42e | |||
| cdfe45b3a2 | |||
| 7563664733 | |||
| 77799ef819 | |||
| 374238d15f | |||
| 099ba3e3e0 | |||
| 71a19e9e87 | |||
| 9c3ced5c44 | |||
| 5082a5ed04 | |||
| d604b8dc7b | |||
| 50d41cb7ae | |||
| 19f53875c9 | |||
| 365ada1a4a | |||
| 3329ff10cf | |||
| 9d600a2d5c | |||
| bb8a4ea386 | |||
| fd236e6949 | |||
| b931f36bde | |||
| e4098bfda9 | |||
| fec2bd37a4 | |||
| af51d74a9f | |||
| ada7986669 | |||
| 74032fe497 | |||
| f96a9ccaa9 | |||
| c83d9304ea | |||
| 4b3a113285 | |||
| c81b5395ac | |||
| 5f91fd925d | |||
| 5c484e33a6 | |||
| c668c88f7f | |||
| 32a66e6c77 | |||
| d4818a78d3 | |||
| 09043e8059 | |||
| 2b36275c4a | |||
| 8736a127a5 | |||
| 24ac734503 | |||
| 9da90f38cc | |||
| 3c483d2093 | |||
| e343f6ac2a | |||
| 3ea17db971 | |||
| 751428a29a | |||
| 5eab1de03e | |||
| 7ee727bd3a | |||
| 96ff46aa4a | |||
| 8956270a60 | |||
| 1a1c64c0e7 | |||
| 3c75986566 | |||
| aad1202d5e | |||
| 5f7599bd0d | |||
| b261d2750c | |||
| e909d9917a | |||
| 6242622f1a | |||
| d236a790a1 | |||
| aed5dfdcb2 | |||
| 1eae645bfd | |||
| c506cf83be | |||
| 0885195cb5 | |||
| f0e7381c1d | |||
| 2b700978ad | |||
| dd6c439eb3 | |||
| 978ae545e1 | |||
| 53e77ebf4e | |||
| 3ab92d672d | |||
| 19d3f29f6b | |||
| 10a3f09952 |
6
.gitignore
vendored
@@ -15,9 +15,11 @@ __pycache__/
|
||||
|
||||
# 临时文件
|
||||
backend/uploads/
|
||||
backend/storage/ # 本地存储数据
|
||||
# 本地存储数据目录(保留 .gitkeep)
|
||||
backend/storage/*
|
||||
!backend/storage/.gitkeep
|
||||
backend/data/ # 数据库目录
|
||||
# 数据库目录(保留 .gitkeep)
|
||||
backend/data/*
|
||||
!backend/data/.gitkeep
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { UserDB } = require('./database');
|
||||
const { UserDB, DeviceSessionDB } = require('./database');
|
||||
const { decryptSecret } = require('./utils/encryption');
|
||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
@@ -64,13 +64,16 @@ if (JWT_SECRET.length < 32) {
|
||||
console.log('[安全] ✓ JWT密钥验证通过');
|
||||
|
||||
// 生成Access Token(短期)
|
||||
function generateToken(user) {
|
||||
function generateToken(user, sessionId = null) {
|
||||
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
type: 'access'
|
||||
type: 'access',
|
||||
sid: safeSessionId,
|
||||
jti: crypto.randomBytes(12).toString('hex')
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: ACCESS_TOKEN_EXPIRES }
|
||||
@@ -78,11 +81,13 @@ function generateToken(user) {
|
||||
}
|
||||
|
||||
// 生成Refresh Token(长期)
|
||||
function generateRefreshToken(user) {
|
||||
function generateRefreshToken(user, sessionId = null) {
|
||||
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
type: 'refresh',
|
||||
sid: safeSessionId,
|
||||
// 添加随机标识,使每次生成的refresh token不同
|
||||
jti: crypto.randomBytes(16).toString('hex')
|
||||
},
|
||||
@@ -91,8 +96,26 @@ function generateRefreshToken(user) {
|
||||
);
|
||||
}
|
||||
|
||||
function decodeAccessToken(token) {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRefreshToken(token) {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
try {
|
||||
return jwt.verify(token, REFRESH_SECRET);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证Refresh Token并返回新的Access Token
|
||||
function refreshAccessToken(refreshToken) {
|
||||
function refreshAccessToken(refreshToken, context = {}) {
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
|
||||
|
||||
@@ -100,6 +123,18 @@ function refreshAccessToken(refreshToken) {
|
||||
return { success: false, message: '无效的刷新令牌类型' };
|
||||
}
|
||||
|
||||
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
|
||||
if (sessionId) {
|
||||
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
|
||||
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
|
||||
return { success: false, message: '当前设备会话已失效,请重新登录' };
|
||||
}
|
||||
DeviceSessionDB.touch(sessionId, {
|
||||
ipAddress: context.ipAddress,
|
||||
userAgent: context.userAgent
|
||||
});
|
||||
}
|
||||
|
||||
const user = UserDB.findById(decoded.id);
|
||||
|
||||
if (!user) {
|
||||
@@ -115,11 +150,12 @@ function refreshAccessToken(refreshToken) {
|
||||
}
|
||||
|
||||
// 生成新的access token
|
||||
const newAccessToken = generateToken(user);
|
||||
const newAccessToken = generateToken(user, sessionId || null);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token: newAccessToken,
|
||||
sessionId: sessionId || null,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -148,6 +184,29 @@ function authMiddleware(req, res, next) {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.type !== 'access') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '无效的令牌类型'
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
|
||||
if (sessionId) {
|
||||
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
|
||||
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '当前设备已下线,请重新登录'
|
||||
});
|
||||
}
|
||||
DeviceSessionDB.touch(sessionId, {
|
||||
ipAddress: req.ip || req.socket?.remoteAddress || '',
|
||||
userAgent: req.get('user-agent') || ''
|
||||
});
|
||||
}
|
||||
|
||||
const user = UserDB.findById(decoded.id);
|
||||
|
||||
if (!user) {
|
||||
@@ -217,6 +276,8 @@ function authMiddleware(req, res, next) {
|
||||
// 主题偏好
|
||||
theme_preference: user.theme_preference || null
|
||||
};
|
||||
req.authSessionId = sessionId || null;
|
||||
req.authTokenJti = typeof decoded.jti === 'string' ? decoded.jti : null;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -329,6 +390,8 @@ module.exports = {
|
||||
JWT_SECRET,
|
||||
generateToken,
|
||||
generateRefreshToken,
|
||||
decodeAccessToken,
|
||||
decodeRefreshToken,
|
||||
refreshAccessToken,
|
||||
authMiddleware,
|
||||
adminMiddleware,
|
||||
|
||||
1505
backend/database.js
4033
backend/server.js
@@ -1,4 +1,16 @@
|
||||
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
HeadObjectCommand,
|
||||
CopyObjectCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand
|
||||
} = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -18,6 +30,25 @@ function formatFileSize(bytes) {
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
const OSS_MULTIPART_MAX_PARTS = 10000;
|
||||
const OSS_MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024; // S3 协议要求最小 5MB(最后一片除外)
|
||||
const OSS_MULTIPART_DEFAULT_PART_SIZE = Math.max(
|
||||
OSS_MULTIPART_MIN_PART_SIZE,
|
||||
parsePositiveInteger(process.env.OSS_MULTIPART_PART_SIZE_BYTES, 16 * 1024 * 1024)
|
||||
);
|
||||
const OSS_SINGLE_UPLOAD_THRESHOLD = Math.max(
|
||||
OSS_MULTIPART_MIN_PART_SIZE,
|
||||
parsePositiveInteger(process.env.OSS_SINGLE_UPLOAD_THRESHOLD_BYTES, 64 * 1024 * 1024)
|
||||
);
|
||||
|
||||
/**
|
||||
* 将 OSS/网络错误转换为友好的错误信息
|
||||
* @param {Error} error - 原始错误
|
||||
@@ -1090,9 +1121,13 @@ class OssStorageClient {
|
||||
* @param {string} remotePath - 远程文件路径
|
||||
*/
|
||||
async put(localPath, remotePath) {
|
||||
let bucket = '';
|
||||
let key = '';
|
||||
let uploadId = null;
|
||||
let fileHandle = null;
|
||||
try {
|
||||
const key = this.getObjectKey(remotePath);
|
||||
const bucket = this.getBucket();
|
||||
key = this.getObjectKey(remotePath);
|
||||
bucket = this.getBucket();
|
||||
|
||||
// 检查本地文件是否存在
|
||||
if (!fs.existsSync(localPath)) {
|
||||
@@ -1102,30 +1137,107 @@ class OssStorageClient {
|
||||
const fileStats = fs.statSync(localPath);
|
||||
const fileSize = fileStats.size;
|
||||
|
||||
// 检查文件大小(AWS S3 单次上传最大 5GB)
|
||||
const MAX_SINGLE_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024; // 5GB
|
||||
if (fileSize > MAX_SINGLE_UPLOAD_SIZE) {
|
||||
throw new Error(`文件过大 (${formatFileSize(fileSize)}),单次上传最大支持 5GB,请使用分片上传`);
|
||||
if (fileSize <= OSS_SINGLE_UPLOAD_THRESHOLD) {
|
||||
// 小文件保持单请求上传,兼容性更高。
|
||||
const fileContent = fs.readFileSync(localPath);
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: fileContent,
|
||||
ContentLength: fileSize,
|
||||
ChecksumAlgorithm: undefined
|
||||
});
|
||||
await this.s3Client.send(command);
|
||||
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用Buffer上传而非流式上传,避免AWS SDK使用aws-chunked编码
|
||||
// 这样可以确保与阿里云OSS的兼容性
|
||||
const fileContent = fs.readFileSync(localPath);
|
||||
// 大文件自动切换到分片上传,避免整文件读入内存和 5GB 单请求上限。
|
||||
const minPartSizeByCount = Math.ceil(fileSize / OSS_MULTIPART_MAX_PARTS);
|
||||
const alignedMinPartSize = Math.ceil(
|
||||
Math.max(minPartSizeByCount, OSS_MULTIPART_MIN_PART_SIZE) / (1024 * 1024)
|
||||
) * 1024 * 1024;
|
||||
const partSize = Math.max(OSS_MULTIPART_DEFAULT_PART_SIZE, alignedMinPartSize);
|
||||
const estimatedParts = Math.ceil(fileSize / partSize);
|
||||
if (estimatedParts > OSS_MULTIPART_MAX_PARTS) {
|
||||
throw new Error(`文件过大,分片数量超限 (${estimatedParts}/${OSS_MULTIPART_MAX_PARTS})`);
|
||||
}
|
||||
|
||||
// 直接上传
|
||||
const command = new PutObjectCommand({
|
||||
const createResp = await this.s3Client.send(new CreateMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key
|
||||
}));
|
||||
uploadId = createResp?.UploadId || null;
|
||||
if (!uploadId) {
|
||||
throw new Error('创建分片上传会话失败:缺少 UploadId');
|
||||
}
|
||||
|
||||
const uploadedParts = [];
|
||||
let offset = 0;
|
||||
let partNumber = 1;
|
||||
fileHandle = await fs.promises.open(localPath, 'r');
|
||||
|
||||
while (offset < fileSize) {
|
||||
const remaining = fileSize - offset;
|
||||
const currentPartSize = Math.min(partSize, remaining);
|
||||
const buffer = Buffer.allocUnsafe(currentPartSize);
|
||||
const { bytesRead } = await fileHandle.read(buffer, 0, currentPartSize, offset);
|
||||
if (bytesRead <= 0) {
|
||||
throw new Error('读取本地文件失败,分片上传中断');
|
||||
}
|
||||
|
||||
const body = bytesRead === currentPartSize ? buffer : buffer.subarray(0, bytesRead);
|
||||
const partResp = await this.s3Client.send(new UploadPartCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
Body: body,
|
||||
ContentLength: bytesRead
|
||||
}));
|
||||
|
||||
if (!partResp?.ETag) {
|
||||
throw new Error(`分片 ${partNumber} 上传失败:服务端未返回 ETag`);
|
||||
}
|
||||
uploadedParts.push({
|
||||
ETag: partResp.ETag,
|
||||
PartNumber: partNumber
|
||||
});
|
||||
|
||||
offset += bytesRead;
|
||||
if (partNumber === 1 || partNumber % 10 === 0 || offset >= fileSize) {
|
||||
console.log(
|
||||
`[OSS存储] 上传分片进度: ${key} ${partNumber}/${estimatedParts} (${formatFileSize(offset)}/${formatFileSize(fileSize)})`
|
||||
);
|
||||
}
|
||||
partNumber += 1;
|
||||
}
|
||||
|
||||
await this.s3Client.send(new CompleteMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: fileContent,
|
||||
ContentLength: fileSize, // 明确指定内容长度,避免某些服务端问题
|
||||
// 禁用checksum算法(阿里云OSS不完全支持AWS的x-amz-content-sha256头)
|
||||
ChecksumAlgorithm: undefined
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: { Parts: uploadedParts }
|
||||
}));
|
||||
uploadId = null;
|
||||
console.log(
|
||||
`[OSS存储] 分片上传完成: ${key} (${formatFileSize(fileSize)}, ${uploadedParts.length} 片)`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
if (uploadId && bucket && key) {
|
||||
try {
|
||||
await this.s3Client.send(new AbortMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId
|
||||
}));
|
||||
console.warn(`[OSS存储] 已中止失败的分片上传: ${key}`);
|
||||
} catch (abortError) {
|
||||
console.error(`[OSS存储] 中止分片上传失败: ${key}`, abortError.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message);
|
||||
|
||||
// 判断错误类型并给出友好的错误信息
|
||||
@@ -1139,6 +1251,10 @@ class OssStorageClient {
|
||||
throw new Error(`本地文件不存在: ${localPath}`);
|
||||
}
|
||||
throw new Error(`文件上传失败: ${error.message}`);
|
||||
} finally {
|
||||
if (fileHandle) {
|
||||
await fileHandle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,69 +1,163 @@
|
||||
/**
|
||||
* 管理员功能完整性测试脚本
|
||||
* 测试范围:
|
||||
* 1. 用户管理 - 用户列表、搜索、封禁/解封、删除、修改存储权限、查看用户文件
|
||||
* 2. 系统设置 - SMTP邮件配置、存储配置、注册开关、主题设置
|
||||
* 3. 分享管理 - 查看所有分享、删除分享
|
||||
* 4. 系统监控 - 健康检查、存储统计、操作日志
|
||||
* 5. 安全检查 - 管理员权限验证、敏感操作确认
|
||||
* 管理员功能完整性测试脚本(Cookie + CSRF 认证模型)
|
||||
*
|
||||
* 覆盖范围:
|
||||
* 1. 鉴权与权限校验
|
||||
* 2. 用户管理(列表、封禁/删除自保护、存储权限)
|
||||
* 3. 系统设置(获取/更新/参数校验)
|
||||
* 4. 分享管理
|
||||
* 5. 系统监控(健康、存储、日志)
|
||||
* 6. 上传工具接口
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { UserDB } = require('./database');
|
||||
|
||||
const BASE_URL = 'http://localhost:40001';
|
||||
let adminToken = '';
|
||||
let testUserId = null;
|
||||
let testShareId = null;
|
||||
const BASE_URL = process.env.TEST_BASE_URL || 'http://127.0.0.1:40001';
|
||||
|
||||
const state = {
|
||||
adminSession: {
|
||||
cookies: {},
|
||||
csrfToken: ''
|
||||
},
|
||||
adminUserId: null,
|
||||
testUserId: null,
|
||||
latestSettings: null
|
||||
};
|
||||
|
||||
// 测试结果收集
|
||||
const testResults = {
|
||||
passed: [],
|
||||
failed: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
// 辅助函数:发送HTTP请求
|
||||
function request(method, path, data = null, token = null) {
|
||||
function makeCookieHeader(cookies) {
|
||||
return Object.entries(cookies || {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function storeSetCookies(session, setCookieHeader) {
|
||||
if (!session || !setCookieHeader) return;
|
||||
const list = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const raw of list) {
|
||||
const first = String(raw || '').split(';')[0];
|
||||
const idx = first.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
const key = first.slice(0, idx).trim();
|
||||
const value = first.slice(idx + 1).trim();
|
||||
session.cookies[key] = value;
|
||||
if (key === 'csrf_token') {
|
||||
session.csrfToken = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeMethod(method) {
|
||||
const upper = String(method || '').toUpperCase();
|
||||
return upper === 'GET' || upper === 'HEAD' || upper === 'OPTIONS';
|
||||
}
|
||||
|
||||
function request(method, path, options = {}) {
|
||||
const {
|
||||
data = null,
|
||||
session = null,
|
||||
headers = {},
|
||||
requireCsrf = true
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, BASE_URL);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname + url.search,
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
|
||||
if (token) {
|
||||
options.headers['Authorization'] = `Bearer ${token}`;
|
||||
const requestHeaders = { ...headers };
|
||||
if (session) {
|
||||
const cookieHeader = makeCookieHeader(session.cookies);
|
||||
if (cookieHeader) {
|
||||
requestHeaders.Cookie = cookieHeader;
|
||||
}
|
||||
|
||||
if (requireCsrf && !isSafeMethod(method)) {
|
||||
const csrfToken = session.csrfToken || session.cookies.csrf_token;
|
||||
if (csrfToken) {
|
||||
requestHeaders['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let payload = null;
|
||||
if (data !== null && data !== undefined) {
|
||||
payload = JSON.stringify(data);
|
||||
requestHeaders['Content-Type'] = 'application/json';
|
||||
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
|
||||
}
|
||||
|
||||
const req = transport.request({
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method,
|
||||
headers: requestHeaders
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
resolve({ status: res.statusCode, data: json });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, data: body });
|
||||
if (session) {
|
||||
storeSetCookies(session, res.headers['set-cookie']);
|
||||
}
|
||||
|
||||
let parsed = body;
|
||||
try {
|
||||
parsed = body ? JSON.parse(body) : {};
|
||||
} catch (e) {
|
||||
// keep raw text
|
||||
}
|
||||
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
data: parsed,
|
||||
headers: res.headers
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (data) {
|
||||
req.write(JSON.stringify(data));
|
||||
if (payload) {
|
||||
req.write(payload);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 测试函数包装器
|
||||
async function initCsrf(session) {
|
||||
const res = await request('GET', '/api/csrf-token', {
|
||||
session,
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data && res.data.csrfToken) {
|
||||
session.csrfToken = res.data.csrfToken;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function warn(message) {
|
||||
testResults.warnings.push(message);
|
||||
console.log(`[WARN] ${message}`);
|
||||
}
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
@@ -75,432 +169,355 @@ async function test(name, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// 警告记录
|
||||
function warn(message) {
|
||||
testResults.warnings.push(message);
|
||||
console.log(`[WARN] ${message}`);
|
||||
function ensureTestUser() {
|
||||
if (state.testUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = Date.now();
|
||||
const username = `admin_test_user_${suffix}`;
|
||||
const email = `${username}@test.local`;
|
||||
const password = `AdminTest#${suffix}`;
|
||||
|
||||
const id = UserDB.create({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
is_verified: 1
|
||||
});
|
||||
|
||||
UserDB.update(id, {
|
||||
is_active: 1,
|
||||
is_banned: 0,
|
||||
storage_permission: 'user_choice',
|
||||
current_storage_type: 'oss'
|
||||
});
|
||||
|
||||
state.testUserId = id;
|
||||
}
|
||||
|
||||
// 断言函数
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
function cleanupTestUser() {
|
||||
if (!state.testUserId) return;
|
||||
try {
|
||||
UserDB.delete(state.testUserId);
|
||||
} catch (error) {
|
||||
warn(`清理测试用户失败: ${error.message}`);
|
||||
} finally {
|
||||
state.testUserId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 测试用例 ============
|
||||
|
||||
// 1. 安全检查:未认证访问应被拒绝
|
||||
async function testUnauthorizedAccess() {
|
||||
const res = await request('GET', '/api/admin/users');
|
||||
assert(res.status === 401, `未认证访问应返回401,实际返回: ${res.status}`);
|
||||
}
|
||||
|
||||
// 2. 管理员登录
|
||||
async function testAdminLogin() {
|
||||
const res = await request('POST', '/api/login', {
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
captcha: '' // 开发环境可能不需要验证码
|
||||
});
|
||||
|
||||
// 登录可能因为验证码失败,这是预期的
|
||||
if (res.status === 400 && res.data.message && res.data.message.includes('验证码')) {
|
||||
warn('登录需要验证码,跳过登录测试,使用模拟token');
|
||||
// 使用JWT库生成一个测试token(需要知道JWT_SECRET)
|
||||
// 或者直接查询数据库
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.data.success) {
|
||||
adminToken = res.data.token;
|
||||
console.log(' - 获取到管理员token');
|
||||
} else {
|
||||
throw new Error(`登录失败: ${res.data.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 用户列表获取
|
||||
async function testGetUsers() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过用户列表测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/users', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data.users), 'users应为数组');
|
||||
|
||||
// 记录测试用户ID
|
||||
if (res.data.users.length > 1) {
|
||||
const nonAdminUser = res.data.users.find(u => !u.is_admin);
|
||||
if (nonAdminUser) {
|
||||
testUserId = nonAdminUser.id;
|
||||
// 2. 安全检查:无效 Token 应被拒绝
|
||||
async function testInvalidTokenAccess() {
|
||||
const res = await request('GET', '/api/admin/users', {
|
||||
headers: {
|
||||
Authorization: 'Bearer invalid-token'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 系统设置获取
|
||||
async function testGetSettings() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过系统设置测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/settings', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data.settings !== undefined, '应包含settings对象');
|
||||
assert(res.data.settings.smtp !== undefined, '应包含smtp配置');
|
||||
assert(res.data.settings.global_theme !== undefined, '应包含全局主题设置');
|
||||
}
|
||||
|
||||
// 5. 更新系统设置
|
||||
async function testUpdateSettings() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过更新系统设置测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/settings', {
|
||||
global_theme: 'dark',
|
||||
max_upload_size: 10737418240
|
||||
}, adminToken);
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
}
|
||||
|
||||
// 6. 健康检查
|
||||
async function testHealthCheck() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过健康检查测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/health-check', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data.checks !== undefined, '应包含checks数组');
|
||||
assert(res.data.overallStatus !== undefined, '应包含overallStatus');
|
||||
assert(res.data.summary !== undefined, '应包含summary');
|
||||
|
||||
// 检查各项检测项目
|
||||
const checkNames = res.data.checks.map(c => c.name);
|
||||
assert(checkNames.includes('JWT密钥'), '应包含JWT密钥检查');
|
||||
assert(checkNames.includes('数据库连接'), '应包含数据库连接检查');
|
||||
assert(checkNames.includes('存储目录'), '应包含存储目录检查');
|
||||
}
|
||||
|
||||
// 7. 存储统计
|
||||
async function testStorageStats() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过存储统计测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/storage-stats', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data.stats !== undefined, '应包含stats对象');
|
||||
assert(typeof res.data.stats.totalDisk === 'number', 'totalDisk应为数字');
|
||||
}
|
||||
|
||||
// 8. 系统日志获取
|
||||
async function testGetLogs() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过系统日志测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/logs?page=1&pageSize=10', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data.logs), 'logs应为数组');
|
||||
assert(typeof res.data.total === 'number', 'total应为数字');
|
||||
}
|
||||
|
||||
// 9. 日志统计
|
||||
async function testLogStats() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过日志统计测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/logs/stats', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data.stats !== undefined, '应包含stats对象');
|
||||
}
|
||||
|
||||
// 10. 分享列表获取
|
||||
async function testGetShares() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过分享列表测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/shares', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data.shares), 'shares应为数组');
|
||||
|
||||
// 记录测试分享ID
|
||||
if (res.data.shares.length > 0) {
|
||||
testShareId = res.data.shares[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 安全检查:普通用户不能访问管理员API
|
||||
async function testNonAdminAccess() {
|
||||
// 使用一个无效的token模拟普通用户
|
||||
const fakeToken = 'invalid-token';
|
||||
const res = await request('GET', '/api/admin/users', null, fakeToken);
|
||||
});
|
||||
assert(res.status === 401, `无效token应返回401,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 12. 安全检查:不能封禁自己
|
||||
async function testCannotBanSelf() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过封禁自己测试');
|
||||
return;
|
||||
}
|
||||
// 3. 管理员登录(基于 Cookie)
|
||||
async function testAdminLogin() {
|
||||
await initCsrf(state.adminSession);
|
||||
|
||||
// 获取当前管理员ID
|
||||
const usersRes = await request('GET', '/api/admin/users', null, adminToken);
|
||||
const adminUser = usersRes.data.users.find(u => u.is_admin);
|
||||
const res = await request('POST', '/api/login', {
|
||||
data: {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
},
|
||||
session: state.adminSession,
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
warn('未找到管理员用户');
|
||||
return;
|
||||
}
|
||||
assert(res.status === 200, `登录应返回200,实际: ${res.status}`);
|
||||
assert(res.data && res.data.success === true, `登录失败: ${res.data?.message || 'unknown'}`);
|
||||
assert(!!state.adminSession.cookies.token, '登录后应写入 token Cookie');
|
||||
|
||||
const res = await request('POST', `/api/admin/users/${adminUser.id}/ban`, {
|
||||
banned: true
|
||||
}, adminToken);
|
||||
await initCsrf(state.adminSession);
|
||||
|
||||
assert(res.status === 400, `封禁自己应返回400,实际: ${res.status}`);
|
||||
assert(res.data.message.includes('不能封禁自己'), '应提示不能封禁自己');
|
||||
const profileRes = await request('GET', '/api/user/profile', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(profileRes.status === 200, `读取profile应返回200,实际: ${profileRes.status}`);
|
||||
assert(profileRes.data?.success === true, '读取profile应成功');
|
||||
assert(profileRes.data?.user?.is_admin === 1, '登录账号应为管理员');
|
||||
|
||||
state.adminUserId = profileRes.data.user.id;
|
||||
assert(Number.isInteger(state.adminUserId) && state.adminUserId > 0, '应获取管理员ID');
|
||||
}
|
||||
|
||||
// 13. 安全检查:不能删除自己
|
||||
// 4. 用户列表获取(分页)
|
||||
async function testGetUsers() {
|
||||
const res = await request('GET', '/api/admin/users?paged=1&page=1&pageSize=20&sort=created_desc', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data?.users), 'users应为数组');
|
||||
assert(!!res.data?.pagination, '分页模式应返回pagination');
|
||||
}
|
||||
|
||||
// 5. 系统设置获取
|
||||
async function testGetSettings() {
|
||||
const res = await request('GET', '/api/admin/settings', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(res.data?.settings && typeof res.data.settings === 'object', '应包含settings对象');
|
||||
assert(res.data?.settings?.smtp && typeof res.data.settings.smtp === 'object', '应包含smtp配置');
|
||||
assert(res.data?.settings?.global_theme !== undefined, '应包含全局主题设置');
|
||||
|
||||
state.latestSettings = res.data.settings;
|
||||
}
|
||||
|
||||
// 6. 更新系统设置(写回当前值,避免影响测试环境)
|
||||
async function testUpdateSettings() {
|
||||
const current = state.latestSettings || {};
|
||||
const payload = {
|
||||
global_theme: current.global_theme || 'dark',
|
||||
max_upload_size: Number(current.max_upload_size || 10737418240)
|
||||
};
|
||||
|
||||
const res = await request('POST', '/api/admin/settings', {
|
||||
data: payload,
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
}
|
||||
|
||||
// 7. 健康检查
|
||||
async function testHealthCheck() {
|
||||
const res = await request('GET', '/api/admin/health-check', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data?.checks), '应包含checks数组');
|
||||
assert(res.data?.summary && typeof res.data.summary === 'object', '应包含summary');
|
||||
}
|
||||
|
||||
// 8. 存储统计
|
||||
async function testStorageStats() {
|
||||
const res = await request('GET', '/api/admin/storage-stats', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(res.data?.stats && typeof res.data.stats === 'object', '应包含stats对象');
|
||||
}
|
||||
|
||||
// 9. 系统日志获取
|
||||
async function testGetLogs() {
|
||||
const res = await request('GET', '/api/admin/logs?page=1&pageSize=10', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data?.logs), 'logs应为数组');
|
||||
}
|
||||
|
||||
// 10. 日志统计
|
||||
async function testLogStats() {
|
||||
const res = await request('GET', '/api/admin/logs/stats', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(res.data?.stats && typeof res.data.stats === 'object', '应包含stats对象');
|
||||
}
|
||||
|
||||
// 11. 分享列表获取
|
||||
async function testGetShares() {
|
||||
const res = await request('GET', '/api/admin/shares', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data?.shares), 'shares应为数组');
|
||||
}
|
||||
|
||||
// 12. 不能封禁自己
|
||||
async function testCannotBanSelf() {
|
||||
assert(state.adminUserId, '管理员ID未初始化');
|
||||
|
||||
const res = await request('POST', `/api/admin/users/${state.adminUserId}/ban`, {
|
||||
data: { banned: true },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `封禁自己应返回400,实际: ${res.status}`);
|
||||
assert(String(res.data?.message || '').includes('不能封禁自己'), '应提示不能封禁自己');
|
||||
}
|
||||
|
||||
// 13. 不能删除自己
|
||||
async function testCannotDeleteSelf() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过删除自己测试');
|
||||
return;
|
||||
}
|
||||
assert(state.adminUserId, '管理员ID未初始化');
|
||||
|
||||
const usersRes = await request('GET', '/api/admin/users', null, adminToken);
|
||||
const adminUser = usersRes.data.users.find(u => u.is_admin);
|
||||
const res = await request('DELETE', `/api/admin/users/${state.adminUserId}`, {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
warn('未找到管理员用户');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('DELETE', `/api/admin/users/${adminUser.id}`, null, adminToken);
|
||||
assert(res.status === 400, `删除自己应返回400,实际: ${res.status}`);
|
||||
assert(res.data.message.includes('不能删除自己'), '应提示不能删除自己');
|
||||
assert(String(res.data?.message || '').includes('不能删除自己'), '应提示不能删除自己');
|
||||
}
|
||||
|
||||
// 14. 参数验证:无效用户ID
|
||||
async function testInvalidUserId() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过无效用户ID测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/users/invalid/ban', {
|
||||
banned: true
|
||||
}, adminToken);
|
||||
data: { banned: true },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 15. 参数验证:无效分享ID
|
||||
async function testInvalidShareId() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过无效分享ID测试');
|
||||
return;
|
||||
}
|
||||
const res = await request('DELETE', '/api/admin/shares/invalid', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
const res = await request('DELETE', '/api/admin/shares/invalid', null, adminToken);
|
||||
assert(res.status === 400, `无效分享ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 16. 存储权限设置
|
||||
async function testSetStoragePermission() {
|
||||
if (!adminToken || !testUserId) {
|
||||
warn('无admin token或测试用户,跳过存储权限测试');
|
||||
return;
|
||||
}
|
||||
ensureTestUser();
|
||||
|
||||
const res = await request('POST', `/api/admin/users/${testUserId}/storage-permission`, {
|
||||
storage_permission: 'local_only',
|
||||
local_storage_quota: 2147483648 // 2GB
|
||||
}, adminToken);
|
||||
const res = await request('POST', `/api/admin/users/${state.testUserId}/storage-permission`, {
|
||||
data: {
|
||||
storage_permission: 'local_only',
|
||||
local_storage_quota: 2147483648,
|
||||
download_traffic_quota: 3221225472
|
||||
},
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
}
|
||||
|
||||
// 17. 参数验证:无效的存储权限值
|
||||
async function testInvalidStoragePermission() {
|
||||
if (!adminToken || !testUserId) {
|
||||
warn('无admin token或测试用户,跳过无效存储权限测试');
|
||||
return;
|
||||
}
|
||||
ensureTestUser();
|
||||
|
||||
const res = await request('POST', `/api/admin/users/${testUserId}/storage-permission`, {
|
||||
storage_permission: 'invalid_permission'
|
||||
}, adminToken);
|
||||
const res = await request('POST', `/api/admin/users/${state.testUserId}/storage-permission`, {
|
||||
data: { storage_permission: 'invalid_permission' },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `无效存储权限应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 18. 主题设置验证
|
||||
async function testInvalidTheme() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过无效主题测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/settings', {
|
||||
global_theme: 'invalid_theme'
|
||||
}, adminToken);
|
||||
data: { global_theme: 'invalid_theme' },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `无效主题应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 19. 日志清理测试
|
||||
async function testLogCleanup() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过日志清理测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/logs/cleanup', {
|
||||
keepDays: 90
|
||||
}, adminToken);
|
||||
data: { keepDays: 90 },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(typeof res.data.deletedCount === 'number', 'deletedCount应为数字');
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(typeof res.data?.deletedCount === 'number', 'deletedCount应为数字');
|
||||
}
|
||||
|
||||
// 20. SMTP测试(预期失败因为未配置)
|
||||
// 20. SMTP测试(未配置时返回400,配置后可能200/500)
|
||||
async function testSmtpTest() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过SMTP测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/settings/test-smtp', {
|
||||
to: 'test@example.com'
|
||||
}, adminToken);
|
||||
data: { to: 'test@example.com' },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
// SMTP未配置时应返回400
|
||||
if (res.status === 400 && res.data.message && res.data.message.includes('SMTP未配置')) {
|
||||
if (res.status === 400 && String(res.data?.message || '').includes('SMTP未配置')) {
|
||||
console.log(' - SMTP未配置,这是预期的');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果SMTP已配置,可能成功或失败
|
||||
assert(res.status === 200 || res.status === 500, `应返回200或500,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 21. 上传工具检查
|
||||
async function testCheckUploadTool() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过上传工具检查测试');
|
||||
return;
|
||||
}
|
||||
// 21. 上传工具配置生成(替代已移除的 /api/admin/check-upload-tool)
|
||||
async function testGenerateUploadToolConfig() {
|
||||
const res = await request('POST', '/api/upload/generate-tool', {
|
||||
data: {},
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
const res = await request('GET', '/api/admin/check-upload-tool', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(typeof res.data.exists === 'boolean', 'exists应为布尔值');
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(res.data?.config && typeof res.data.config === 'object', '应包含config对象');
|
||||
assert(typeof res.data?.config?.api_key === 'string' && res.data.config.api_key.length > 0, '应返回有效api_key');
|
||||
}
|
||||
|
||||
// 22. 用户文件查看 - 无效用户ID验证
|
||||
async function testInvalidUserIdForFiles() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过用户文件查看无效ID测试');
|
||||
return;
|
||||
}
|
||||
const res = await request('GET', '/api/admin/users/invalid/files', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
const res = await request('GET', '/api/admin/users/invalid/files', null, adminToken);
|
||||
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 23. 删除用户 - 无效用户ID验证
|
||||
async function testInvalidUserIdForDelete() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过删除用户无效ID测试');
|
||||
return;
|
||||
}
|
||||
const res = await request('DELETE', '/api/admin/users/invalid', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
const res = await request('DELETE', '/api/admin/users/invalid', null, adminToken);
|
||||
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 24. 存储权限设置 - 无效用户ID验证
|
||||
async function testInvalidUserIdForPermission() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过存储权限无效ID测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/users/invalid/storage-permission', {
|
||||
storage_permission: 'local_only'
|
||||
}, adminToken);
|
||||
data: {
|
||||
storage_permission: 'local_only'
|
||||
},
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 主测试函数
|
||||
async function runTests() {
|
||||
console.log('========================================');
|
||||
console.log('管理员功能完整性测试');
|
||||
console.log('管理员功能完整性测试(Cookie + CSRF)');
|
||||
console.log('========================================\n');
|
||||
|
||||
// 先尝试直接使用数据库获取token
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { UserDB } = require('./database');
|
||||
require('dotenv').config();
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
const adminUser = UserDB.findByUsername('admin');
|
||||
|
||||
if (adminUser) {
|
||||
adminToken = jwt.sign(
|
||||
{
|
||||
id: adminUser.id,
|
||||
username: adminUser.username,
|
||||
is_admin: adminUser.is_admin,
|
||||
type: 'access'
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '2h' }
|
||||
);
|
||||
console.log('[INFO] 已通过数据库直接生成管理员token\n');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[INFO] 无法直接生成token,将尝试登录: ' + e.message + '\n');
|
||||
}
|
||||
|
||||
// 安全检查测试
|
||||
console.log('\n--- 安全检查 ---');
|
||||
await test('未认证访问应被拒绝', testUnauthorizedAccess);
|
||||
await test('无效token应被拒绝', testNonAdminAccess);
|
||||
await test('无效token应被拒绝', testInvalidTokenAccess);
|
||||
|
||||
// 如果还没有token,尝试登录
|
||||
if (!adminToken) {
|
||||
await test('管理员登录', testAdminLogin);
|
||||
}
|
||||
await test('管理员登录', testAdminLogin);
|
||||
|
||||
// 用户管理测试
|
||||
console.log('\n--- 用户管理 ---');
|
||||
await test('获取用户列表', testGetUsers);
|
||||
await test('不能封禁自己', testCannotBanSelf);
|
||||
@@ -509,19 +526,16 @@ async function runTests() {
|
||||
await test('设置存储权限', testSetStoragePermission);
|
||||
await test('无效存储权限验证', testInvalidStoragePermission);
|
||||
|
||||
// 系统设置测试
|
||||
console.log('\n--- 系统设置 ---');
|
||||
await test('获取系统设置', testGetSettings);
|
||||
await test('更新系统设置', testUpdateSettings);
|
||||
await test('无效主题验证', testInvalidTheme);
|
||||
await test('SMTP测试', testSmtpTest);
|
||||
|
||||
// 分享管理测试
|
||||
console.log('\n--- 分享管理 ---');
|
||||
await test('获取分享列表', testGetShares);
|
||||
await test('无效分享ID验证', testInvalidShareId);
|
||||
|
||||
// 系统监控测试
|
||||
console.log('\n--- 系统监控 ---');
|
||||
await test('健康检查', testHealthCheck);
|
||||
await test('存储统计', testStorageStats);
|
||||
@@ -529,17 +543,16 @@ async function runTests() {
|
||||
await test('日志统计', testLogStats);
|
||||
await test('日志清理', testLogCleanup);
|
||||
|
||||
// 其他功能测试
|
||||
console.log('\n--- 其他功能 ---');
|
||||
await test('上传工具检查', testCheckUploadTool);
|
||||
await test('上传工具配置生成', testGenerateUploadToolConfig);
|
||||
|
||||
// 参数验证增强测试
|
||||
console.log('\n--- 参数验证增强 ---');
|
||||
await test('用户文件查看无效ID验证', testInvalidUserIdForFiles);
|
||||
await test('删除用户无效ID验证', testInvalidUserIdForDelete);
|
||||
await test('存储权限设置无效ID验证', testInvalidUserIdForPermission);
|
||||
|
||||
// 输出测试结果
|
||||
cleanupTestUser();
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('测试结果汇总');
|
||||
console.log('========================================');
|
||||
@@ -549,26 +562,25 @@ async function runTests() {
|
||||
|
||||
if (testResults.failed.length > 0) {
|
||||
console.log('\n失败的测试:');
|
||||
testResults.failed.forEach(f => {
|
||||
for (const f of testResults.failed) {
|
||||
console.log(` - ${f.name}: ${f.error}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (testResults.warnings.length > 0) {
|
||||
console.log('\n警告:');
|
||||
testResults.warnings.forEach(w => {
|
||||
for (const w of testResults.warnings) {
|
||||
console.log(` - ${w}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
|
||||
// 返回退出码
|
||||
process.exit(testResults.failed.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests().catch(err => {
|
||||
console.error('测试执行错误:', err);
|
||||
runTests().catch((error) => {
|
||||
cleanupTestUser();
|
||||
console.error('测试执行异常:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* 分享功能边界条件深度测试
|
||||
* 分享功能边界条件深度测试(兼容 Cookie + CSRF)
|
||||
*
|
||||
* 测试场景:
|
||||
* 测试场景:
|
||||
* 1. 已过期的分享
|
||||
* 2. 分享者被删除
|
||||
* 3. 存储类型切换后的分享
|
||||
* 2. 分享文件不存在
|
||||
* 3. 被封禁用户的分享
|
||||
* 4. 路径遍历攻击
|
||||
* 5. 并发访问限流
|
||||
* 5. 特殊字符路径
|
||||
* 6. 并发密码尝试
|
||||
* 7. 分享统计
|
||||
* 8. 分享码唯一性
|
||||
* 9. 过期时间格式
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { db, ShareDB, UserDB } = require('./database');
|
||||
|
||||
const BASE_URL = process.env.TEST_BASE_URL || 'http://127.0.0.1:40001';
|
||||
@@ -20,45 +26,129 @@ const results = {
|
||||
errors: []
|
||||
};
|
||||
|
||||
// HTTP 请求工具
|
||||
function request(method, path, data = null, headers = {}) {
|
||||
const adminSession = {
|
||||
cookies: {},
|
||||
csrfToken: ''
|
||||
};
|
||||
|
||||
let adminUserId = 1;
|
||||
|
||||
function makeCookieHeader(cookies) {
|
||||
return Object.entries(cookies || {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function storeSetCookies(session, setCookieHeader) {
|
||||
if (!session || !setCookieHeader) return;
|
||||
const list = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const raw of list) {
|
||||
const first = String(raw || '').split(';')[0];
|
||||
const idx = first.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
const key = first.slice(0, idx).trim();
|
||||
const value = first.slice(idx + 1).trim();
|
||||
session.cookies[key] = value;
|
||||
if (key === 'csrf_token') {
|
||||
session.csrfToken = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeMethod(method) {
|
||||
const upper = String(method || '').toUpperCase();
|
||||
return upper === 'GET' || upper === 'HEAD' || upper === 'OPTIONS';
|
||||
}
|
||||
|
||||
function request(method, path, options = {}) {
|
||||
const {
|
||||
data = null,
|
||||
session = null,
|
||||
headers = {},
|
||||
requireCsrf = true
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, BASE_URL);
|
||||
const port = url.port ? parseInt(url.port, 10) : 80;
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: port,
|
||||
path: url.pathname + url.search,
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
const requestHeaders = { ...headers };
|
||||
|
||||
if (session) {
|
||||
const cookieHeader = makeCookieHeader(session.cookies);
|
||||
if (cookieHeader) {
|
||||
requestHeaders.Cookie = cookieHeader;
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
resolve({ status: res.statusCode, data: json, headers: res.headers });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, data: body, headers: res.headers });
|
||||
if (requireCsrf && !isSafeMethod(method)) {
|
||||
const csrfToken = session.csrfToken || session.cookies.csrf_token;
|
||||
if (csrfToken) {
|
||||
requestHeaders['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let payload = null;
|
||||
if (data !== null && data !== undefined) {
|
||||
payload = JSON.stringify(data);
|
||||
requestHeaders['Content-Type'] = 'application/json';
|
||||
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
|
||||
}
|
||||
|
||||
const req = transport.request({
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method,
|
||||
headers: requestHeaders
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (session) {
|
||||
storeSetCookies(session, res.headers['set-cookie']);
|
||||
}
|
||||
|
||||
let parsed = body;
|
||||
try {
|
||||
parsed = body ? JSON.parse(body) : {};
|
||||
} catch (e) {
|
||||
// keep raw text
|
||||
}
|
||||
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
data: parsed,
|
||||
headers: res.headers
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (data) {
|
||||
req.write(JSON.stringify(data));
|
||||
if (payload) {
|
||||
req.write(payload);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function initCsrf(session) {
|
||||
const res = await request('GET', '/api/csrf-token', {
|
||||
session,
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data && res.data.csrfToken) {
|
||||
session.csrfToken = res.data.csrfToken;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
results.passed++;
|
||||
@@ -70,41 +160,48 @@ function assert(condition, message) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 测试用例 =====
|
||||
function createValidShareCode(prefix = '') {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const generated = ShareDB.generateShareCode();
|
||||
const code = (prefix + generated).replace(/[^A-Za-z0-9]/g, '').slice(0, 16);
|
||||
if (!code || code.length < 6) continue;
|
||||
const exists = db.prepare('SELECT 1 FROM shares WHERE share_code = ?').get(code);
|
||||
if (!exists) return code;
|
||||
}
|
||||
|
||||
return ShareDB.generateShareCode();
|
||||
}
|
||||
|
||||
async function testExpiredShare() {
|
||||
console.log('\n[测试] 已过期的分享...');
|
||||
|
||||
// 直接在数据库中创建一个已过期的分享
|
||||
const expiredShareCode = 'expired_' + Date.now();
|
||||
const expiredShareCode = createValidShareCode('E');
|
||||
|
||||
try {
|
||||
// 插入一个已过期的分享(过期时间设为昨天)
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const expiresAt = yesterday.toISOString().replace('T', ' ').substring(0, 19);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(1, expiredShareCode, '/expired-test.txt', 'file', expiresAt);
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(adminUserId, expiredShareCode, '/expired-test.txt', 'file', 'local', expiresAt);
|
||||
|
||||
console.log(` 创建过期分享: ${expiredShareCode}, 过期时间: ${expiresAt}`);
|
||||
|
||||
// 尝试访问过期分享
|
||||
const res = await request('POST', `/api/share/${expiredShareCode}/verify`, {});
|
||||
const res = await request('POST', `/api/share/${expiredShareCode}/verify`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
assert(res.status === 404, `过期分享应返回 404, 实际: ${res.status}`);
|
||||
assert(res.data.message === '分享不存在', '应提示分享不存在');
|
||||
assert(res.data && res.data.message === '分享不存在', '应提示分享不存在');
|
||||
|
||||
// 清理测试数据
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(expiredShareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
results.failed++;
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(expiredShareCode);
|
||||
return false;
|
||||
}
|
||||
@@ -113,32 +210,27 @@ async function testExpiredShare() {
|
||||
async function testShareWithDeletedFile() {
|
||||
console.log('\n[测试] 分享的文件不存在...');
|
||||
|
||||
// 创建一个指向不存在文件的分享
|
||||
const shareCode = 'nofile_' + Date.now();
|
||||
const shareCode = createValidShareCode('N');
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(1, shareCode, '/non_existent_file_xyz.txt', 'file', 'local');
|
||||
`).run(adminUserId, shareCode, '/non_existent_file_xyz.txt', 'file', 'local');
|
||||
|
||||
console.log(` 创建分享: ${shareCode}, 路径: /non_existent_file_xyz.txt`);
|
||||
|
||||
// 访问分享
|
||||
const res = await request('POST', `/api/share/${shareCode}/verify`, {});
|
||||
const res = await request('POST', `/api/share/${shareCode}/verify`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
// 应该返回错误(文件不存在)
|
||||
// 注意:verify 接口在缓存未命中时会查询存储
|
||||
assert(res.status === 500 || res.status === 200, `应返回500或200,实际: ${res.status}`);
|
||||
if (res.status === 500) {
|
||||
assert(res.data.message && res.data.message.includes('不存在'), '应提示文件不存在');
|
||||
} else if (res.status === 200) {
|
||||
// 如果成功返回,file 字段应该没有正确的文件信息
|
||||
console.log(` [INFO] verify 返回 200,检查文件信息`);
|
||||
assert(!!res.data?.message, '500时应返回错误消息');
|
||||
}
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -150,20 +242,17 @@ async function testShareWithDeletedFile() {
|
||||
async function testShareByBannedUser() {
|
||||
console.log('\n[测试] 被封禁用户的分享...');
|
||||
|
||||
// 创建测试用户
|
||||
let testUserId = null;
|
||||
const shareCode = 'banned_' + Date.now();
|
||||
const shareCode = createValidShareCode('B');
|
||||
|
||||
try {
|
||||
// 创建测试用户
|
||||
testUserId = UserDB.create({
|
||||
username: 'test_banned_' + Date.now(),
|
||||
username: `test_banned_${Date.now()}`,
|
||||
email: `test_banned_${Date.now()}@test.com`,
|
||||
password: 'test123',
|
||||
is_verified: 1
|
||||
});
|
||||
|
||||
// 创建分享
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
@@ -172,21 +261,16 @@ async function testShareByBannedUser() {
|
||||
console.log(` 创建测试用户 ID: ${testUserId}`);
|
||||
console.log(` 创建分享: ${shareCode}`);
|
||||
|
||||
// 封禁用户
|
||||
UserDB.setBanStatus(testUserId, true);
|
||||
console.log(` 封禁用户: ${testUserId}`);
|
||||
|
||||
// 访问分享
|
||||
const res = await request('POST', `/api/share/${shareCode}/verify`, {});
|
||||
const res = await request('POST', `/api/share/${shareCode}/verify`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
// 当前实现:被封禁用户的分享仍然可以访问
|
||||
// 如果需要阻止,应该在 ShareDB.findByCode 中检查用户状态
|
||||
console.log(` 被封禁用户分享访问状态码: ${res.status}`);
|
||||
|
||||
// 注意:这里可能是一个潜在的功能增强点
|
||||
// 如果希望被封禁用户的分享也被禁止访问,需要修改代码
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
UserDB.delete(testUserId);
|
||||
|
||||
@@ -203,16 +287,14 @@ async function testShareByBannedUser() {
|
||||
async function testPathTraversalAttacks() {
|
||||
console.log('\n[测试] 路径遍历攻击防护...');
|
||||
|
||||
// 创建测试分享
|
||||
const shareCode = 'traverse_' + Date.now();
|
||||
const shareCode = createValidShareCode('T');
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(1, shareCode, '/allowed-folder', 'directory', 'local');
|
||||
`).run(adminUserId, shareCode, '/allowed-folder', 'directory', 'local');
|
||||
|
||||
// 测试各种路径遍历攻击
|
||||
const attackPaths = [
|
||||
'../../../etc/passwd',
|
||||
'..\\..\\..\\etc\\passwd',
|
||||
@@ -225,7 +307,10 @@ async function testPathTraversalAttacks() {
|
||||
|
||||
let blocked = 0;
|
||||
for (const attackPath of attackPaths) {
|
||||
const res = await request('POST', `/api/share/${shareCode}/download-url`, { path: attackPath });
|
||||
const res = await request('POST', `/api/share/${shareCode}/download-url`, {
|
||||
data: { path: attackPath },
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (res.status === 403 || res.status === 400) {
|
||||
blocked++;
|
||||
@@ -237,9 +322,7 @@ async function testPathTraversalAttacks() {
|
||||
|
||||
assert(blocked >= attackPaths.length - 1, `路径遍历攻击应被阻止 (${blocked}/${attackPaths.length})`);
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -251,7 +334,12 @@ async function testPathTraversalAttacks() {
|
||||
async function testSpecialCharactersInPath() {
|
||||
console.log('\n[测试] 特殊字符路径处理...');
|
||||
|
||||
// 测试创建包含特殊字符的分享
|
||||
if (!adminSession.cookies.token) {
|
||||
console.log(' [WARN] 未登录,跳过特殊字符路径测试');
|
||||
assert(true, '未登录时跳过特殊字符路径测试');
|
||||
return true;
|
||||
}
|
||||
|
||||
const specialPaths = [
|
||||
'/文件夹/中文文件.txt',
|
||||
'/folder with spaces/file.txt',
|
||||
@@ -262,28 +350,35 @@ async function testSpecialCharactersInPath() {
|
||||
|
||||
let handled = 0;
|
||||
|
||||
for (const path of specialPaths) {
|
||||
for (const virtualPath of specialPaths) {
|
||||
try {
|
||||
const res = await request('POST', '/api/share/create', {
|
||||
share_type: 'file',
|
||||
file_path: path
|
||||
}, { Cookie: authCookie });
|
||||
const createRes = await request('POST', '/api/share/create', {
|
||||
data: {
|
||||
share_type: 'file',
|
||||
file_path: virtualPath
|
||||
},
|
||||
session: adminSession
|
||||
});
|
||||
|
||||
if (res.status === 200 || res.status === 400) {
|
||||
if (createRes.status === 200 || createRes.status === 400) {
|
||||
handled++;
|
||||
console.log(` [OK] ${path.substring(0, 30)}... - 状态: ${res.status}`);
|
||||
console.log(` [OK] ${virtualPath.substring(0, 30)}... - 状态: ${createRes.status}`);
|
||||
|
||||
// 如果创建成功,清理
|
||||
if (res.data.share_code) {
|
||||
const myShares = await request('GET', '/api/share/my', null, { Cookie: authCookie });
|
||||
const share = myShares.data.shares?.find(s => s.share_code === res.data.share_code);
|
||||
if (createRes.status === 200 && createRes.data?.share_code) {
|
||||
const mySharesRes = await request('GET', '/api/share/my', {
|
||||
session: adminSession
|
||||
});
|
||||
|
||||
const share = mySharesRes.data?.shares?.find(s => s.share_code === createRes.data.share_code);
|
||||
if (share) {
|
||||
await request('DELETE', `/api/share/${share.id}`, null, { Cookie: authCookie });
|
||||
await request('DELETE', `/api/share/${share.id}`, {
|
||||
session: adminSession
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${path}: ${error.message}`);
|
||||
console.log(` [ERROR] ${virtualPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,36 +389,33 @@ async function testSpecialCharactersInPath() {
|
||||
async function testConcurrentPasswordAttempts() {
|
||||
console.log('\n[测试] 并发密码尝试限流...');
|
||||
|
||||
// 创建一个带密码的分享
|
||||
const shareCode = 'concurrent_' + Date.now();
|
||||
const shareCode = createValidShareCode('C');
|
||||
|
||||
try {
|
||||
// 使用 bcrypt 哈希密码
|
||||
const bcrypt = require('bcryptjs');
|
||||
const hashedPassword = bcrypt.hashSync('correct123', 10);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, storage_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(1, shareCode, '/test.txt', 'file', hashedPassword, 'local');
|
||||
`).run(adminUserId, shareCode, '/test.txt', 'file', hashedPassword, 'local');
|
||||
|
||||
// 发送大量并发错误密码请求
|
||||
const promises = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
promises.push(request('POST', `/api/share/${shareCode}/verify`, {
|
||||
password: 'wrong' + i
|
||||
}));
|
||||
promises.push(
|
||||
request('POST', `/api/share/${shareCode}/verify`, {
|
||||
data: { password: `wrong${i}` },
|
||||
requireCsrf: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const responses = await Promise.all(promises);
|
||||
|
||||
// 检查是否有请求被限流
|
||||
const rateLimited = results.filter(r => r.status === 429).length;
|
||||
const unauthorized = results.filter(r => r.status === 401).length;
|
||||
const rateLimited = responses.filter(r => r.status === 429).length;
|
||||
const unauthorized = responses.filter(r => r.status === 401).length;
|
||||
|
||||
console.log(` 并发请求: 20, 限流: ${rateLimited}, 401错误: ${unauthorized}`);
|
||||
|
||||
// 注意:限流是否触发取决于配置
|
||||
if (rateLimited > 0) {
|
||||
assert(true, '限流机制生效');
|
||||
} else {
|
||||
@@ -331,9 +423,7 @@ async function testConcurrentPasswordAttempts() {
|
||||
assert(true, '并发测试完成');
|
||||
}
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -345,25 +435,28 @@ async function testConcurrentPasswordAttempts() {
|
||||
async function testShareStatistics() {
|
||||
console.log('\n[测试] 分享统计功能...');
|
||||
|
||||
const shareCode = 'stats_' + Date.now();
|
||||
const shareCode = createValidShareCode('S');
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type, view_count, download_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(1, shareCode, '/test.txt', 'file', 'local', 0, 0);
|
||||
`).run(adminUserId, shareCode, '/test.txt', 'file', 'local', 0, 0);
|
||||
|
||||
// 验证多次(增加查看次数)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request('POST', `/api/share/${shareCode}/verify`, {});
|
||||
await request('POST', `/api/share/${shareCode}/verify`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
}
|
||||
|
||||
// 记录下载次数
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await request('POST', `/api/share/${shareCode}/download`, {});
|
||||
await request('POST', `/api/share/${shareCode}/download`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
}
|
||||
|
||||
// 检查统计数据
|
||||
const share = db.prepare('SELECT view_count, download_count FROM shares WHERE share_code = ?').get(shareCode);
|
||||
|
||||
assert(share.view_count === 3, `查看次数应为 3, 实际: ${share.view_count}`);
|
||||
@@ -371,9 +464,7 @@ async function testShareStatistics() {
|
||||
|
||||
console.log(` 查看次数: ${share.view_count}, 下载次数: ${share.download_count}`);
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -386,12 +477,10 @@ async function testShareCodeUniqueness() {
|
||||
console.log('\n[测试] 分享码唯一性...');
|
||||
|
||||
try {
|
||||
// 创建多个分享,检查分享码是否唯一
|
||||
const codes = new Set();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const code = ShareDB.generateShareCode();
|
||||
|
||||
if (codes.has(code)) {
|
||||
console.log(` [WARN] 发现重复分享码: ${code}`);
|
||||
}
|
||||
@@ -401,7 +490,6 @@ async function testShareCodeUniqueness() {
|
||||
assert(codes.size === 10, `应生成 10 个唯一分享码, 实际: ${codes.size}`);
|
||||
console.log(` 生成了 ${codes.size} 个唯一分享码`);
|
||||
|
||||
// 检查分享码长度和字符
|
||||
const sampleCode = ShareDB.generateShareCode();
|
||||
assert(sampleCode.length === 8, `分享码长度应为 8, 实际: ${sampleCode.length}`);
|
||||
assert(/^[a-zA-Z0-9]+$/.test(sampleCode), '分享码应只包含字母数字');
|
||||
@@ -417,11 +505,10 @@ async function testExpiryTimeFormat() {
|
||||
console.log('\n[测试] 过期时间格式...');
|
||||
|
||||
try {
|
||||
// 测试不同的过期天数
|
||||
const testDays = [1, 7, 30, 365];
|
||||
|
||||
for (const days of testDays) {
|
||||
const result = ShareDB.create(1, {
|
||||
const result = ShareDB.create(adminUserId, {
|
||||
share_type: 'file',
|
||||
file_path: `/test_${days}_days.txt`,
|
||||
expiry_days: days
|
||||
@@ -429,15 +516,12 @@ async function testExpiryTimeFormat() {
|
||||
|
||||
const share = db.prepare('SELECT expires_at FROM shares WHERE share_code = ?').get(result.share_code);
|
||||
|
||||
// 验证过期时间格式
|
||||
const expiresAt = new Date(share.expires_at);
|
||||
const now = new Date();
|
||||
const diffDays = Math.round((expiresAt - now) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// 允许1天的误差(由于时区等因素)
|
||||
assert(Math.abs(diffDays - days) <= 1, `${days}天过期应正确设置, 实际差异: ${diffDays}天`);
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(result.share_code);
|
||||
}
|
||||
|
||||
@@ -448,28 +532,37 @@ async function testExpiryTimeFormat() {
|
||||
}
|
||||
}
|
||||
|
||||
// 全局认证 Cookie
|
||||
let authCookie = '';
|
||||
|
||||
async function login() {
|
||||
console.log('\n[准备] 登录获取认证...');
|
||||
|
||||
try {
|
||||
await initCsrf(adminSession);
|
||||
|
||||
const res = await request('POST', '/api/login', {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
data: {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
},
|
||||
session: adminSession,
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data.success) {
|
||||
const setCookie = res.headers['set-cookie'];
|
||||
if (setCookie) {
|
||||
authCookie = setCookie.map(c => c.split(';')[0]).join('; ');
|
||||
console.log(' 认证成功');
|
||||
return true;
|
||||
if (res.status === 200 && res.data?.success && adminSession.cookies.token) {
|
||||
await initCsrf(adminSession);
|
||||
|
||||
const profileRes = await request('GET', '/api/user/profile', {
|
||||
session: adminSession
|
||||
});
|
||||
|
||||
if (profileRes.status === 200 && profileRes.data?.user?.id) {
|
||||
adminUserId = profileRes.data.user.id;
|
||||
}
|
||||
|
||||
console.log(' 认证成功');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(' 认证失败');
|
||||
console.log(` 认证失败: status=${res.status}, message=${res.data?.message || 'unknown'}`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -477,20 +570,16 @@ async function login() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 主测试流程 =====
|
||||
|
||||
async function runTests() {
|
||||
console.log('========================================');
|
||||
console.log(' 分享功能边界条件深度测试');
|
||||
console.log(' 分享功能边界条件深度测试(Cookie + CSRF)');
|
||||
console.log('========================================');
|
||||
|
||||
// 登录
|
||||
const loggedIn = await login();
|
||||
if (!loggedIn) {
|
||||
console.log('\n[WARN] 登录失败,部分测试可能无法执行');
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
await testExpiredShare();
|
||||
await testShareWithDeletedFile();
|
||||
await testShareByBannedUser();
|
||||
@@ -501,7 +590,6 @@ async function runTests() {
|
||||
await testShareCodeUniqueness();
|
||||
await testExpiryTimeFormat();
|
||||
|
||||
// 结果统计
|
||||
console.log('\n========================================');
|
||||
console.log(' 测试结果统计');
|
||||
console.log('========================================');
|
||||
|
||||
24
desktop-client/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
118
desktop-client/README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 玩玩云桌面客户端(Tauri + Vue + TypeScript)
|
||||
|
||||
这是玩玩云网盘的桌面端,基于 `Tauri 2 + Vue 3 + TypeScript` 开发。
|
||||
|
||||
默认对接地址:`https://cs.workyai.cn`。
|
||||
|
||||
## 功能概览
|
||||
|
||||
- 账号登录(复用网页端账号体系)
|
||||
- 文件列表浏览、目录切换、全局搜索
|
||||
- 新建文件夹、重命名、删除
|
||||
- 分享链接 / 直链创建与管理
|
||||
- 下载(支持断点续传)
|
||||
- 本地目录同步(手动/定时)
|
||||
- 客户端版本检测与更新下载
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 18+(建议 20 LTS)
|
||||
- Rust stable(建议通过 `rustup` 安装)
|
||||
- Tauri CLI(项目已在 `package.json` 中声明)
|
||||
|
||||
可选:Linux 交叉编译 Windows 时还需要:
|
||||
|
||||
- `mingw-w64`
|
||||
- `nsis`
|
||||
|
||||
## 开发运行
|
||||
|
||||
```bash
|
||||
cd desktop-client
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
## 对接你的网盘后端
|
||||
|
||||
### 1. 修改 API 基础地址
|
||||
|
||||
当前默认地址在 `desktop-client/src/App.vue`:
|
||||
|
||||
- `appConfig.baseUrl`(默认 `https://cs.workyai.cn`)
|
||||
|
||||
改成你的服务地址后重新运行/打包。
|
||||
|
||||
### 2. 后端需提供的核心接口
|
||||
|
||||
桌面端通过现有 Web API 工作,至少需要这些接口:
|
||||
|
||||
- `POST /api/login`
|
||||
- `POST /api/logout`
|
||||
- `GET /api/user/profile`
|
||||
- `GET /api/files`
|
||||
- `GET /api/files/search`
|
||||
- `POST /api/files/mkdir`
|
||||
- `POST /api/files/rename`
|
||||
- `POST /api/files/delete`
|
||||
- `POST /api/share/create`
|
||||
- `GET /api/share/my`
|
||||
- `DELETE /api/share/:id`
|
||||
- `POST /api/direct-link/create`
|
||||
- `GET /api/client/desktop-update`
|
||||
- 分片上传相关接口(客户端走 `api_upload_file_resumable`)
|
||||
|
||||
### 3. 鉴权与跨域要求
|
||||
|
||||
- 使用 Cookie / Session 鉴权
|
||||
- 服务端允许桌面端跨域并携带凭据(credentials)
|
||||
- 建议启用 HTTPS
|
||||
|
||||
## 构建打包
|
||||
|
||||
```bash
|
||||
cd desktop-client
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
构建产物示例:
|
||||
|
||||
- `desktop-client/src-tauri/target/release/bundle/nsis/*.exe`
|
||||
|
||||
## Linux 交叉编译 Windows(x64)
|
||||
|
||||
```bash
|
||||
cd desktop-client
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
npm run tauri build -- --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
如需生成安装包,请确保系统安装 `mingw-w64` 与 `nsis`。
|
||||
|
||||
## 更新发布接入(与本项目后端配合)
|
||||
|
||||
1. 把新安装包上传到服务端下载目录(示例:`frontend/downloads/`)。
|
||||
2. 在后台设置中更新:
|
||||
- `desktop_update.latest_version`
|
||||
- `desktop_update.installer_url`
|
||||
- `desktop_update.release_notes`
|
||||
3. 客户端会通过 `GET /api/client/desktop-update` 检查更新并下载新包。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1:改了接口地址但还是连旧地址?
|
||||
|
||||
确认修改的是 `desktop-client/src/App.vue` 中的 `appConfig.baseUrl`,并重新构建。
|
||||
|
||||
### Q2:为什么更新接口返回有新版本,但客户端不弹更新?
|
||||
|
||||
检查:
|
||||
|
||||
- `latest_version` 是否确实大于客户端当前版本
|
||||
- `installer_url` 是否可访问
|
||||
- 客户端能否访问后端 `desktop-update` 接口
|
||||
|
||||
### Q3:分享/直链重复创建问题
|
||||
|
||||
当前后端已支持“同一用户同一路径复用已有链接”。若要生成新链接,请先删除旧链接再创建。
|
||||
14
desktop-client/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + Typescript App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1753
desktop-client/package-lock.json
generated
Normal file
25
desktop-client/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "desktop-client",
|
||||
"private": true,
|
||||
"version": "0.1.28",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
6
desktop-client/public/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
desktop-client/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
7
desktop-client/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5843
desktop-client/src-tauri/Cargo.lock
generated
Normal file
34
desktop-client/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "desktop-client"
|
||||
version = "0.1.28"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "desktop_client_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] }
|
||||
urlencoding = "2.1"
|
||||
walkdir = "2.5"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
|
||||
3
desktop-client/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
desktop-client/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
BIN
desktop-client/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
desktop-client/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
desktop-client/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
desktop-client/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
desktop-client/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
desktop-client/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
desktop-client/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
desktop-client/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
desktop-client/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
desktop-client/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
desktop-client/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
desktop-client/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
desktop-client/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
desktop-client/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
desktop-client/src-tauri/icons/icon.icns
Normal file
BIN
desktop-client/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
desktop-client/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
desktop-client/src-tauri/icons/wanwan-dog-source.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
2091
desktop-client/src-tauri/src/lib.rs
Normal file
6
desktop-client/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
desktop_client_lib::run()
|
||||
}
|
||||
37
desktop-client/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "玩玩云",
|
||||
"version": "0.1.28",
|
||||
"identifier": "cn.workyai.wanwancloud.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "玩玩云",
|
||||
"width": 1360,
|
||||
"height": 860,
|
||||
"minWidth": 1120,
|
||||
"minHeight": 720
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
4682
desktop-client/src/App.vue
Normal file
1
desktop-client/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
4
desktop-client/src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
7
desktop-client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
25
desktop-client/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
desktop-client/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
32
desktop-client/vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [vue()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
1456
frontend/app.html
1435
frontend/app.js
@@ -791,6 +791,11 @@
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .download-alert {
|
||||
margin-bottom: 12px;
|
||||
animation: fadeInOut 0.2s ease;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .view-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -970,6 +975,17 @@
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="enterprise-netdisk-share">
|
||||
@@ -1016,6 +1032,7 @@
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-else-if="verified">
|
||||
<div v-if="downloadAlertMessage" class="alert alert-error download-alert">{{ downloadAlertMessage }}</div>
|
||||
<p class="share-meta-bar">
|
||||
分享者: <strong style="color: var(--text-primary);">{{ shareInfo.username }}</strong> |
|
||||
创建时间: {{ formatDate(shareInfo.created_at) }}
|
||||
@@ -1111,6 +1128,8 @@
|
||||
files: [],
|
||||
loading: true,
|
||||
errorMessage: '',
|
||||
downloadAlertMessage: '',
|
||||
downloadAlertTimer: null,
|
||||
viewMode: "grid", // 视图模式: grid 大图标, list 列表(默认大图标)
|
||||
// 主题
|
||||
currentTheme: 'dark',
|
||||
@@ -1176,6 +1195,7 @@
|
||||
|
||||
async verifyShare() {
|
||||
this.errorMessage = '';
|
||||
this.downloadAlertMessage = '';
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
@@ -1288,10 +1308,28 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[分享下载] 获取下载链接失败:', error);
|
||||
this.errorMessage = '获取下载链接失败: ' + (error.response?.data?.message || error.message);
|
||||
const message = error.response?.data?.message || '当前网络繁忙,请稍后再试';
|
||||
this.showDownloadAlert(message);
|
||||
}
|
||||
},
|
||||
|
||||
showDownloadAlert(message) {
|
||||
const safeMessage = typeof message === 'string' && message.trim()
|
||||
? message.trim()
|
||||
: '当前网络繁忙,请稍后再试';
|
||||
|
||||
this.downloadAlertMessage = safeMessage;
|
||||
if (this.downloadAlertTimer) {
|
||||
clearTimeout(this.downloadAlertTimer);
|
||||
this.downloadAlertTimer = null;
|
||||
}
|
||||
|
||||
this.downloadAlertTimer = setTimeout(() => {
|
||||
this.downloadAlertMessage = '';
|
||||
this.downloadAlertTimer = null;
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
// 触发下载(使用隐藏的a标签,避免页面闪动)
|
||||
triggerDownload(url, filename) {
|
||||
const link = document.createElement('a');
|
||||
@@ -1331,17 +1369,36 @@
|
||||
return 'color: #9E9E9E;';
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
// SQLite 返回的是 UTC 时间字符串,需要显式处理
|
||||
let dateStr = dateString;
|
||||
if (!dateStr.includes('Z') && !dateStr.includes('+') && !dateStr.includes('T')) {
|
||||
// SQLite 格式: "2025-11-13 16:37:19" -> ISO格式: "2025-11-13T16:37:19Z"
|
||||
dateStr = dateStr.replace(' ', 'T') + 'Z';
|
||||
parseDateValue(value) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const localMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/);
|
||||
if (localMatch) {
|
||||
const year = Number(localMatch[1]);
|
||||
const month = Number(localMatch[2]);
|
||||
const day = Number(localMatch[3]);
|
||||
const hour = Number(localMatch[4] || 0);
|
||||
const minute = Number(localMatch[5] || 0);
|
||||
const second = Number(localMatch[6] || 0);
|
||||
const localDate = new Date(year, month - 1, day, hour, minute, second);
|
||||
return Number.isNaN(localDate.getTime()) ? null : localDate;
|
||||
}
|
||||
|
||||
const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T');
|
||||
const parsed = new Date(normalized);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = this.parseDateValue(dateString);
|
||||
if (!date) return String(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@@ -1357,7 +1414,8 @@
|
||||
formatExpireTime(expiresAt) {
|
||||
if (!expiresAt) return '永久有效';
|
||||
|
||||
const expireDate = new Date(expiresAt);
|
||||
const expireDate = this.parseDateValue(expiresAt);
|
||||
if (!expireDate) return String(expiresAt);
|
||||
const now = new Date();
|
||||
const diffMs = expireDate - now;
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
@@ -1391,7 +1449,8 @@
|
||||
// 判断是否即将过期(3天内)
|
||||
isExpiringSoon(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
const expireDate = new Date(expiresAt);
|
||||
const expireDate = this.parseDateValue(expiresAt);
|
||||
if (!expireDate) return false;
|
||||
const now = new Date();
|
||||
const diffMs = expireDate - now;
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
@@ -1401,7 +1460,8 @@
|
||||
// 判断是否已过期
|
||||
isExpired(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
const expireDate = new Date(expiresAt);
|
||||
const expireDate = this.parseDateValue(expiresAt);
|
||||
if (!expireDate) return false;
|
||||
const now = new Date();
|
||||
return expireDate <= now;
|
||||
}
|
||||
@@ -1430,6 +1490,13 @@
|
||||
}
|
||||
|
||||
this.init();
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
if (this.downloadAlertTimer) {
|
||||
clearTimeout(this.downloadAlertTimer);
|
||||
this.downloadAlertTimer = null;
|
||||
}
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
@@ -1449,57 +1516,17 @@
|
||||
// 禁用F12和常见开发者工具快捷键(调试模式下不禁用)
|
||||
if (!isDebugMode) {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// F12
|
||||
if (e.key === 'F12' || e.keyCode === 123) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+I (开发者工具)
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.keyCode === 73)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+J (控制台)
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'J' || e.keyCode === 74)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+U (查看源代码)
|
||||
if (e.ctrlKey && (e.key === 'U' || e.keyCode === 85)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+C (元素选择器)
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.keyCode === 67)) {
|
||||
const key = String(e.key || '').toLowerCase();
|
||||
const blocked = e.keyCode === 123
|
||||
|| (e.ctrlKey && e.shiftKey && ['i', 'j', 'c'].includes(key))
|
||||
|| (e.ctrlKey && key === 'u');
|
||||
if (blocked) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检测开发者工具是否打开(调试模式下不检测)
|
||||
if (!isDebugMode) {
|
||||
(function() {
|
||||
const threshold = 160;
|
||||
let isDevToolsOpen = false;
|
||||
|
||||
setInterval(function() {
|
||||
const widthThreshold = window.outerWidth - window.innerWidth > threshold;
|
||||
const heightThreshold = window.outerHeight - window.innerHeight > threshold;
|
||||
|
||||
if (!(heightThreshold && widthThreshold) &&
|
||||
((window.Firebug && window.Firebug.chrome && window.Firebug.chrome.isInitialized) || widthThreshold || heightThreshold)) {
|
||||
if (!isDevToolsOpen) {
|
||||
isDevToolsOpen = true;
|
||||
console.clear();
|
||||
}
|
||||
} else {
|
||||
isDevToolsOpen = false;
|
||||
}
|
||||
}, 500);
|
||||
})();
|
||||
}
|
||||
|
||||
// 禁用console输出(调试模式下不禁用)
|
||||
if (!isDebugMode && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
||||
console.log = function() {};
|
||||
|
||||