From 465be72579ac1064dc20886ca8fabe2610d684c4 Mon Sep 17 00:00:00 2001
From: 237899745 <237899745@users.noreply.git.workyai.cn>
Date: Tue, 21 Jul 2026 16:16:17 +0800
Subject: [PATCH] feat: unify cloud workspace and release desktop v0.1.38
---
.gitignore | 8 +
backend/package-lock.json | 16 +-
backend/package.json | 9 +-
backend/server.js | 405 ++-
backend/storage.js | 4 +-
backend/tests/full-audit-regression.js | 77 +-
desktop-client/index.html | 6 +-
desktop-client/package-lock.json | 27 +-
desktop-client/package.json | 4 +-
desktop-client/src-tauri/Cargo.lock | 2 +-
desktop-client/src-tauri/Cargo.toml | 2 +-
desktop-client/src-tauri/src/lib.rs | 514 +++-
desktop-client/src-tauri/tauri.conf.json | 2 +-
desktop-client/src/App.vue | 3376 ++++++++++++++++++++--
desktop-client/src/main.ts | 1 +
frontend/app.html | 480 +--
frontend/app.js | 33 +-
frontend/workspace.css | 1839 ++++++++++++
install.sh | 6 +-
19 files changed, 6145 insertions(+), 666 deletions(-)
create mode 100644 frontend/workspace.css
diff --git a/.gitignore b/.gitignore
index 02f9f93..580703d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,3 +129,11 @@ backend/*最终*.json
backend/fix-env.js
backend/create-admin.js
backend/*.backup.*
+
+# 本地协作与设计验收产物
+/work/
+/desktop-client/design-qa.md
+
+# 桌面端历史安装包仅保留在发布服务器,仓库只跟踪当前版本
+/frontend/downloads/*.exe
+!/frontend/downloads/wanwan-cloud-desktop_v0.1.38_x64-setup.exe
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 16ba067..98c5b40 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -21,8 +21,8 @@
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.23",
- "multer": "^2.0.2",
- "nodemailer": "^8.0.1",
+ "multer": "^2.2.0",
+ "nodemailer": "^9.0.3",
"svg-captcha": "^1.4.0"
},
"devDependencies": {
@@ -3312,9 +3312,9 @@
"license": "MIT"
},
"node_modules/multer": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
- "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
+ "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -3358,9 +3358,9 @@
}
},
"node_modules/nodemailer": {
- "version": "8.0.11",
- "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
- "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
+ "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
diff --git a/backend/package.json b/backend/package.json
index 2e7678e..fde3d11 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -5,7 +5,10 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
- "dev": "nodemon server.js"
+ "dev": "nodemon server.js",
+ "test": "npm run test:unit && npm run test:integration && node test_download_quota_defaults.js",
+ "test:unit": "node tests/run-all-tests.js",
+ "test:integration": "node tests/full-audit-regression.js"
},
"keywords": [
"cloud-storage",
@@ -30,8 +33,8 @@
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.23",
- "multer": "^2.0.2",
- "nodemailer": "^8.0.1",
+ "multer": "^2.2.0",
+ "nodemailer": "^9.0.3",
"svg-captcha": "^1.4.0"
},
"devDependencies": {
diff --git a/backend/server.js b/backend/server.js
index b349d24..7c608ee 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -112,7 +112,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max(
10,
Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30))
);
-const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.31';
+const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.38';
const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || '';
const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SHA256 || '').trim().toLowerCase();
const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0));
@@ -2562,7 +2562,13 @@ const MULTER_UPLOAD_MAX_BYTES = Math.max(
);
const upload = multer({
dest: path.join(__dirname, 'uploads'),
- limits: { fileSize: MULTER_UPLOAD_MAX_BYTES }
+ limits: {
+ fileSize: MULTER_UPLOAD_MAX_BYTES,
+ fields: 20,
+ parts: 22,
+ fieldNameSize: 100,
+ fieldSize: 1024 * 1024
+ }
});
// ===== TTL缓存类 =====
@@ -5719,11 +5725,63 @@ app.get('/api/files/search', authMiddleware, async (req, res) => {
}
});
+function rewritePersistedPathsAfterRename(userId, storageType, oldPath, newPath, isDirectory) {
+ const oldPrefix = `${oldPath}/`;
+ const rewritePath = (value) => {
+ const current = normalizeVirtualPath(value || '');
+ if (!current) return null;
+ if (current === oldPath) return newPath;
+ if (isDirectory && current.startsWith(oldPrefix)) {
+ return `${newPath}${current.slice(oldPath.length)}`;
+ }
+ return null;
+ };
+
+ db.transaction(() => {
+ const shares = db.prepare(`
+ SELECT id, share_path FROM shares
+ WHERE user_id = ? AND COALESCE(storage_type, 'oss') = ?
+ AND (share_path = ? OR share_path LIKE ?)
+ `).all(userId, storageType, oldPath, `${oldPrefix}%`);
+ const updateShare = db.prepare('UPDATE shares SET share_path = ? WHERE id = ?');
+ for (const share of shares) {
+ const rewritten = rewritePath(share.share_path);
+ if (rewritten) updateShare.run(rewritten, share.id);
+ }
+
+ const links = db.prepare(`
+ SELECT id, file_path FROM direct_links
+ WHERE user_id = ? AND COALESCE(storage_type, 'oss') = ?
+ AND (file_path = ? OR file_path LIKE ?)
+ `).all(userId, storageType, oldPath, `${oldPrefix}%`);
+ const updateLink = db.prepare('UPDATE direct_links SET file_path = ?, file_name = ? WHERE id = ?');
+ for (const link of links) {
+ const rewritten = rewritePath(link.file_path);
+ if (rewritten) updateLink.run(rewritten, rewritten.split('/').pop() || null, link.id);
+ }
+
+ const hashes = db.prepare(`
+ SELECT id, file_path FROM user_file_hash_index
+ WHERE user_id = ? AND storage_type = ?
+ AND (file_path = ? OR file_path LIKE ?)
+ `).all(userId, storageType, oldPath, `${oldPrefix}%`);
+ const updateHash = db.prepare(`
+ UPDATE user_file_hash_index
+ SET file_path = ?, object_key = NULL, updated_at = datetime('now', 'localtime')
+ WHERE id = ?
+ `);
+ for (const row of hashes) {
+ const rewritten = rewritePath(row.file_path);
+ if (rewritten) updateHash.run(rewritten, row.id);
+ }
+ })();
+}
+
// 重命名文件
app.post('/api/files/rename', authMiddleware, async (req, res) => {
const oldName = decodeHtmlEntities(req.body.oldName);
const newName = decodeHtmlEntities(req.body.newName);
- const path = decodeHtmlEntities(req.body.path) || '/';
+ const parentPath = normalizeVirtualPath(decodeHtmlEntities(req.body.path) || '/');
let storage;
if (!oldName || !newName) {
@@ -5733,14 +5791,22 @@ app.post('/api/files/rename', authMiddleware, async (req, res) => {
});
}
+ if (!parentPath || !isSafePathSegment(oldName) || !isSafePathSegment(newName)) {
+ return res.status(400).json({
+ success: false,
+ message: '文件名或路径包含非法字符'
+ });
+ }
+
try {
// 使用统一存储接口
const { StorageInterface } = require('./storage');
const storageInterface = new StorageInterface(req.user);
storage = await storageInterface.connect();
- const oldPath = path === '/' ? `/${oldName}` : `${path}/${oldName}`;
- const newPath = path === '/' ? `/${newName}` : `${path}/${newName}`;
+ const oldPath = parentPath === '/' ? `/${oldName}` : `${parentPath}/${oldName}`;
+ const newPath = parentPath === '/' ? `/${newName}` : `${parentPath}/${newName}`;
+ const sourceStats = await storage.stat(oldPath);
await storage.rename(oldPath, newPath);
@@ -5748,18 +5814,22 @@ app.post('/api/files/rename', authMiddleware, async (req, res) => {
const normalizedOldPath = normalizeVirtualPath(oldPath);
const normalizedNewPath = normalizeVirtualPath(newPath);
if (normalizedOldPath && normalizedNewPath) {
- const oldHashRow = FileHashIndexDB.getByPath(req.user.id, normalizedStorageType, normalizedOldPath);
- if (oldHashRow) {
- FileHashIndexDB.upsert({
- userId: req.user.id,
- storageType: normalizedStorageType,
- fileHash: oldHashRow.file_hash,
- fileSize: oldHashRow.file_size,
- filePath: normalizedNewPath,
- objectKey: oldHashRow.object_key || null
- });
+ try {
+ rewritePersistedPathsAfterRename(
+ req.user.id,
+ normalizedStorageType,
+ normalizedOldPath,
+ normalizedNewPath,
+ !!sourceStats?.isDirectory
+ );
+ } catch (metadataError) {
+ try {
+ await storage.rename(newPath, oldPath);
+ } catch (rollbackError) {
+ console.error('重命名元数据失败且存储回滚失败:', rollbackError);
+ }
+ throw metadataError;
}
- FileHashIndexDB.deleteByPath(req.user.id, normalizedStorageType, normalizedOldPath);
}
// 清除 OSS 使用情况缓存(如果用户使用 OSS)
@@ -7140,6 +7210,28 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
const trafficState = getDownloadTrafficState(latestUser);
+ if ((latestUser.current_storage_type || 'oss') === 'local') {
+ const localMode = isPreviewMode ? 'preview' : 'download';
+ const downloadToken = signEphemeralToken({
+ type: 'local_download',
+ userId: latestUser.id,
+ path: normalizedPath,
+ mode: localMode
+ }, 15 * 60);
+ const downloadUrl = `${getSecureBaseUrl(req)}/api/files/download` +
+ `?path=${encodeURIComponent(normalizedPath)}` +
+ `&mode=${encodeURIComponent(localMode)}` +
+ `&token=${encodeURIComponent(downloadToken)}`;
+ return res.json({
+ success: true,
+ downloadUrl,
+ expiresIn: 15 * 60,
+ direct: false,
+ storageType: 'local',
+ quotaLimited: !trafficState.isUnlimited
+ });
+ }
+
// 检查用户是否配置了 OSS(包括个人配置和系统级统一配置)
const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig();
if (!req.user.has_oss_config && !hasUnifiedConfig) {
@@ -7469,8 +7561,50 @@ app.get('/api/files/download-check', authMiddleware, async (req, res) => {
}
});
+function parseSingleByteRange(rangeHeader, fileSize) {
+ if (!rangeHeader) return null;
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader).trim());
+ if (!match || (!match[1] && !match[2]) || fileSize <= 0) return { invalid: true };
+
+ let start;
+ let end;
+ if (!match[1]) {
+ const suffixLength = Number(match[2]);
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return { invalid: true };
+ start = Math.max(0, fileSize - suffixLength);
+ end = fileSize - 1;
+ } else {
+ start = Number(match[1]);
+ end = match[2] ? Number(match[2]) : fileSize - 1;
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= fileSize || end < start) {
+ return { invalid: true };
+ }
+ end = Math.min(end, fileSize - 1);
+ }
+
+ return { invalid: false, start, end, length: end - start + 1 };
+}
+
+function authOrLocalDownloadToken(req, res, next) {
+ const tokenResult = verifyEphemeralToken(req.query?.token, 'local_download');
+ if (tokenResult.valid) {
+ const payload = tokenResult.payload || {};
+ const requestedPath = normalizeVirtualPath(req.query?.path || '');
+ const requestedMode = req.query?.mode === 'preview' ? 'preview' : 'download';
+ if (requestedPath && payload.path === requestedPath && payload.mode === requestedMode) {
+ const user = UserDB.findById(Number(payload.userId));
+ if (user && user.is_active && !user.is_banned) {
+ req.user = user;
+ req.localDownloadTokenAuthenticated = true;
+ return next();
+ }
+ }
+ }
+ return authMiddleware(req, res, next);
+}
+
// 下载文件
-app.get('/api/files/download', authMiddleware, async (req, res) => {
+app.get('/api/files/download', authOrLocalDownloadToken, async (req, res) => {
const filePath = req.query.path;
let storage;
let storageEnded = false; // 防止重复关闭
@@ -7545,6 +7679,37 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
});
}
+ // Legacy pages download with the session cookie only. Redirect once to a
+ // short-lived URL so Android's external download manager can resume without it.
+ if (!req.localDownloadTokenAuthenticated) {
+ const securityResult = evaluateDownloadSecurityPolicy(req, {
+ ownerUserId: req.user.id,
+ filePath: normalizedPath,
+ source: 'user_download_legacy_redirect'
+ });
+ if (!securityResult.allowed) {
+ return handleDownloadSecurityBlock(req, res, securityResult, {
+ statusCode: 503,
+ ownerUserId: req.user.id,
+ filePath: normalizedPath,
+ source: 'user_download_legacy_redirect'
+ });
+ }
+
+ const downloadMode = req.query?.mode === 'preview' ? 'preview' : 'download';
+ const downloadToken = signEphemeralToken({
+ type: 'local_download',
+ userId: req.user.id,
+ path: normalizedPath,
+ mode: downloadMode
+ }, 15 * 60);
+ const redirectUrl = '/api/files/download' +
+ `?path=${encodeURIComponent(normalizedPath)}` +
+ `&mode=${encodeURIComponent(downloadMode)}` +
+ `&token=${encodeURIComponent(downloadToken)}`;
+ return res.redirect(302, redirectUrl);
+ }
+
try {
const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download');
const latestUser = policyState?.user || UserDB.findById(req.user.id);
@@ -7555,18 +7720,22 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
});
}
- const securityResult = evaluateDownloadSecurityPolicy(req, {
- ownerUserId: latestUser.id,
- filePath: normalizedPath,
- source: 'user_download_stream'
- });
- if (!securityResult.allowed) {
- return handleDownloadSecurityBlock(req, res, securityResult, {
- statusCode: 503,
+ // The security policy is evaluated when the short-lived token is issued.
+ // Do not count the browser probes and DownloadManager requests again.
+ if (req.localDownloadTokenAuthenticated !== true) {
+ const securityResult = evaluateDownloadSecurityPolicy(req, {
ownerUserId: latestUser.id,
filePath: normalizedPath,
source: 'user_download_stream'
});
+ if (!securityResult.allowed) {
+ return handleDownloadSecurityBlock(req, res, securityResult, {
+ statusCode: 503,
+ ownerUserId: latestUser.id,
+ filePath: normalizedPath,
+ source: 'user_download_stream'
+ });
+ }
}
const trafficState = getDownloadTrafficState(latestUser);
@@ -7582,29 +7751,49 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
// 先获取文件信息(获取文件大小)
const fileStats = await storage.stat(normalizedPath);
const fileSize = Math.max(0, Number(fileStats?.size) || 0);
+ const isLocalStorage = (latestUser.current_storage_type || 'oss') === 'local';
+ const byteRange = isLocalStorage ? parseSingleByteRange(req.headers.range, fileSize) : null;
console.log('[下载] 文件: ' + fileName + ', 大小: ' + fileSize + ' 字节');
- if (!trafficState.isUnlimited && fileSize > trafficState.remaining) {
+ if (byteRange?.invalid) {
+ res.setHeader('Content-Range', `bytes */${fileSize}`);
+ await safeEndStorage();
+ return res.status(416).end();
+ }
+
+ const responseSize = byteRange ? byteRange.length : fileSize;
+ if (!trafficState.isUnlimited && responseSize > trafficState.remaining) {
await safeEndStorage();
return res.status(403).json({
success: false,
- message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(trafficState.remaining)}`
+ message: `下载流量不足:本次需要 ${formatFileSize(responseSize)},剩余 ${formatFileSize(trafficState.remaining)}`
});
}
// 设置响应头(包含文件大小,浏览器可显示下载进度)
res.setHeader('Content-Type', 'application/octet-stream');
- res.setHeader('Content-Length', fileSize);
+ res.setHeader('Content-Length', responseSize);
+ if (isLocalStorage) {
+ res.setHeader('Accept-Ranges', 'bytes');
+ }
+ if (byteRange) {
+ res.status(206);
+ res.setHeader('Content-Range', `bytes ${byteRange.start}-${byteRange.end}/${fileSize}`);
+ }
// 关闭 Nginx 代理缓冲,避免上游提前读完整文件导致流量计量失真
res.setHeader('X-Accel-Buffering', 'no');
- res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(fileName) + '"; filename*=UTF-8\'\'' + encodeURIComponent(fileName));
+ const disposition = req.query?.mode === 'preview' ? 'inline' : 'attachment';
+ res.setHeader('Content-Disposition', disposition + '; filename="' + encodeURIComponent(fileName) + '"; filename*=UTF-8\'\'' + encodeURIComponent(fileName));
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0;
// 创建文件流并传输(流式下载,服务器不保存临时文件)
- const stream = await storage.createReadStream(normalizedPath);
+ const stream = await storage.createReadStream(
+ normalizedPath,
+ byteRange ? { start: byteRange.start, end: byteRange.end } : undefined
+ );
stream.on('data', (chunk) => {
if (!chunk) return;
@@ -8865,7 +9054,7 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
});
}
- const normalizedFilePath = normalizeVirtualPath(filePath);
+ let normalizedFilePath = normalizeVirtualPath(filePath);
if (!normalizedFilePath) {
return res.status(400).json({
success: false,
@@ -8901,6 +9090,12 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
}
}
+ // Older cached share pages may request "/" for single-file shares.
+ // Map that back to the immutable share path before the strict scope check.
+ if (share.share_type === 'file' && normalizedFilePath === '/') {
+ normalizedFilePath = normalizeVirtualPath(share.share_path);
+ }
+
// 安全验证:检查请求路径是否在分享范围内
if (!isPathWithinShare(normalizedFilePath, share)) {
return res.status(403).json({
@@ -8909,9 +9104,10 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
});
}
+ const isResumeRequest = /^bytes=[1-9]\d*-/i.test(String(req.headers.range || '').trim());
const accessPolicy = evaluateShareSecurityPolicy(share, req, {
action: 'download',
- enforceDownloadLimit: true
+ enforceDownloadLimit: !isResumeRequest
});
if (!accessPolicy.allowed) {
return res.status(403).json({
@@ -8934,20 +9130,21 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
const storageType = share.storage_type || 'oss';
const ownerTrafficState = getDownloadTrafficState(shareOwner);
- if (storageType === 'oss') {
- const securityResult = evaluateDownloadSecurityPolicy(req, {
+ const downloadSecuritySource = storageType === 'oss'
+ ? 'share_download_url_oss'
+ : 'share_download_url_local';
+ const securityResult = evaluateDownloadSecurityPolicy(req, {
+ ownerUserId: shareOwner.id,
+ filePath: normalizedFilePath,
+ source: downloadSecuritySource
+ });
+ if (!securityResult.allowed) {
+ return handleDownloadSecurityBlock(req, res, securityResult, {
+ statusCode: 503,
ownerUserId: shareOwner.id,
filePath: normalizedFilePath,
- source: 'share_download_url_oss'
+ source: downloadSecuritySource
});
- if (!securityResult.allowed) {
- return handleDownloadSecurityBlock(req, res, securityResult, {
- statusCode: 503,
- ownerUserId: shareOwner.id,
- filePath: normalizedFilePath,
- source: 'share_download_url_oss'
- });
- }
}
// 本地存储:继续走后端下载
@@ -8990,16 +9187,17 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
}
}
- let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(normalizedFilePath)}`;
+ const downloadToken = signEphemeralToken({
+ type: 'share_download',
+ code,
+ path: normalizedFilePath
+ }, 15 * 60);
+ const downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file` +
+ `?path=${encodeURIComponent(normalizedFilePath)}` +
+ `&token=${encodeURIComponent(downloadToken)}`;
- if (share.share_password) {
- const downloadToken = signEphemeralToken({
- type: 'share_download',
- code,
- path: normalizedFilePath
- }, 15 * 60);
- downloadUrl += `&token=${encodeURIComponent(downloadToken)}`;
- }
+ // Count one user-initiated download, not every browser probe or range request.
+ ShareDB.incrementDownloadCount(code);
return res.json({
success: true,
@@ -9105,7 +9303,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
const { code } = req.params;
const rawFilePath = typeof req.query?.path === 'string' ? req.query.path : '';
const { password, token } = req.query;
- const filePath = normalizeVirtualPath(rawFilePath);
+ let filePath = normalizeVirtualPath(rawFilePath);
let storage;
let storageEnded = false; // 防止重复关闭
let transferFinalized = false; // 防止重复结算
@@ -9181,21 +9379,24 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
});
}
+ // Older cached share pages may request "/" for single-file shares.
+ // Map that back to the immutable share path before validating the token.
+ if (share.share_type === 'file' && filePath === '/') {
+ filePath = normalizeVirtualPath(share.share_path);
+ }
+
+ let verifiedByShareToken = false;
+ if (token) {
+ const tokenResult = verifyEphemeralToken(token, 'share_download');
+ if (tokenResult.valid) {
+ const tokenPayload = tokenResult.payload || {};
+ verifiedByShareToken = tokenPayload.code === code && tokenPayload.path === filePath;
+ }
+ }
+
// 验证密码(如果需要),支持短期下载 token(避免密码出现在 URL)
if (share.share_password) {
- let verifiedByToken = false;
-
- if (token) {
- const tokenResult = verifyEphemeralToken(token, 'share_download');
- if (tokenResult.valid) {
- const tokenPayload = tokenResult.payload || {};
- if (tokenPayload.code === code && tokenPayload.path === filePath) {
- verifiedByToken = true;
- }
- }
- }
-
- if (!verifiedByToken) {
+ if (!verifiedByShareToken) {
if (!password || !ShareDB.verifyPassword(password, share.share_password)) {
// 只在密码错误时记录失败
if (req.shareRateLimitKey) {
@@ -9224,9 +9425,10 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
});
}
+ const isResumeRequest = /^bytes=[1-9]\d*-/i.test(String(req.headers.range || '').trim());
const accessPolicy = evaluateShareSecurityPolicy(share, req, {
action: 'download',
- enforceDownloadLimit: true
+ enforceDownloadLimit: !verifiedByShareToken && !isResumeRequest
});
if (!accessPolicy.allowed) {
return res.status(403).json({
@@ -9246,18 +9448,20 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
}
shareOwnerId = shareOwner.id;
- const securityResult = evaluateDownloadSecurityPolicy(req, {
- ownerUserId: shareOwner.id,
- filePath,
- source: 'share_download_stream'
- });
- if (!securityResult.allowed) {
- return handleDownloadSecurityBlock(req, res, securityResult, {
- statusCode: 503,
+ if (!verifiedByShareToken) {
+ const securityResult = evaluateDownloadSecurityPolicy(req, {
ownerUserId: shareOwner.id,
filePath,
source: 'share_download_stream'
});
+ if (!securityResult.allowed) {
+ return handleDownloadSecurityBlock(req, res, securityResult, {
+ statusCode: 503,
+ ownerUserId: shareOwner.id,
+ filePath,
+ source: 'share_download_stream'
+ });
+ }
}
const ownerTrafficState = getDownloadTrafficState(shareOwner);
@@ -9283,22 +9487,36 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
// 获取文件信息(获取文件大小)
const fileStats = await storage.stat(filePath);
const fileSize = fileStats.size;
+ const isLocalStorage = storageType === 'local';
+ const byteRange = isLocalStorage ? parseSingleByteRange(req.headers.range, fileSize) : null;
console.log(`[分享下载] 文件: ${fileName}, 大小: ${fileSize} 字节`);
- if (!ownerTrafficState.isUnlimited && fileSize > ownerTrafficState.remaining) {
+ if (byteRange?.invalid) {
+ res.setHeader('Content-Range', `bytes */${fileSize}`);
+ await safeEndStorage();
+ return res.status(416).end();
+ }
+
+ const responseSize = byteRange ? byteRange.length : fileSize;
+ if (!ownerTrafficState.isUnlimited && responseSize > ownerTrafficState.remaining) {
await safeEndStorage();
return res.status(403).json({
success: false,
- message: `分享者下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(ownerTrafficState.remaining)}`
+ message: `分享者下载流量不足:本次需要 ${formatFileSize(responseSize)},剩余 ${formatFileSize(ownerTrafficState.remaining)}`
});
}
// 增加下载次数
- ShareDB.incrementDownloadCount(code);
+ if (!verifiedByShareToken && !isResumeRequest) ShareDB.incrementDownloadCount(code);
// 设置响应头(包含文件大小,浏览器可显示下载进度)
res.setHeader('Content-Type', 'application/octet-stream');
- res.setHeader('Content-Length', fileSize);
+ res.setHeader('Content-Length', responseSize);
+ if (isLocalStorage) res.setHeader('Accept-Ranges', 'bytes');
+ if (byteRange) {
+ res.status(206);
+ res.setHeader('Content-Range', `bytes ${byteRange.start}-${byteRange.end}/${fileSize}`);
+ }
// 关闭 Nginx 代理缓冲,减少代理预读导致的计量偏差
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
@@ -9308,7 +9526,10 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0;
// 创建文件流并传输(流式下载,服务器不保存临时文件)
- const stream = await storage.createReadStream(filePath);
+ const stream = await storage.createReadStream(
+ filePath,
+ byteRange ? { start: byteRange.start, end: byteRange.end } : undefined
+ );
stream.on('data', (chunk) => {
if (!chunk) return;
@@ -11466,6 +11687,7 @@ app.get('/d/:code', async (req, res) => {
const fileStats = await storage.stat(normalizedPath);
const fileSize = Number(fileStats?.size || 0);
+ const byteRange = parseSingleByteRange(req.headers.range, fileSize);
if (fileStats?.isDirectory) {
await safeEndStorage();
@@ -11477,15 +11699,27 @@ app.get('/d/:code', async (req, res) => {
return sendPlainTextError(res, 404, '文件不存在');
}
- if (!ownerTrafficState.isUnlimited && fileSize > ownerTrafficState.remaining) {
+ if (byteRange?.invalid) {
+ res.setHeader('Content-Range', `bytes */${fileSize}`);
+ await safeEndStorage();
+ return res.status(416).end();
+ }
+
+ const responseSize = byteRange ? byteRange.length : fileSize;
+ if (!ownerTrafficState.isUnlimited && responseSize > ownerTrafficState.remaining) {
await safeEndStorage();
return sendPlainTextError(res, 503, getBusyDownloadMessage());
}
- DirectLinkDB.incrementDownloadCount(code);
+ if (!byteRange || byteRange.start === 0) DirectLinkDB.incrementDownloadCount(code);
res.setHeader('Content-Type', 'application/octet-stream');
- res.setHeader('Content-Length', fileSize);
+ res.setHeader('Content-Length', responseSize);
+ res.setHeader('Accept-Ranges', 'bytes');
+ if (byteRange) {
+ res.status(206);
+ res.setHeader('Content-Range', `bytes ${byteRange.start}-${byteRange.end}/${fileSize}`);
+ }
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(directFileName)}"; filename*=UTF-8''${encodeURIComponent(directFileName)}`);
if (typeof res.flushHeaders === 'function') {
@@ -11493,7 +11727,10 @@ app.get('/d/:code', async (req, res) => {
}
responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0;
- const stream = await storage.createReadStream(normalizedPath);
+ const stream = await storage.createReadStream(
+ normalizedPath,
+ byteRange ? { start: byteRange.start, end: byteRange.end } : undefined
+ );
stream.on('data', (chunk) => {
if (!chunk) return;
downloadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
diff --git a/backend/storage.js b/backend/storage.js
index 6e7da5d..c441ad4 100644
--- a/backend/storage.js
+++ b/backend/storage.js
@@ -433,14 +433,14 @@ class LocalStorageClient {
* @param {string} filePath - 文件路径
* @returns {ReadStream} 文件读取流
*/
- createReadStream(filePath) {
+ createReadStream(filePath, options = undefined) {
const fullPath = this.getFullPath(filePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`文件不存在: ${filePath}`);
}
- return fs.createReadStream(fullPath);
+ return fs.createReadStream(fullPath, options);
}
/**
diff --git a/backend/tests/full-audit-regression.js b/backend/tests/full-audit-regression.js
index ce43271..4471c38 100644
--- a/backend/tests/full-audit-regression.js
+++ b/backend/tests/full-audit-regression.js
@@ -181,6 +181,8 @@ async function run() {
let shareId = null;
let shareCode = '';
let directLinkId = null;
+ let directLinkCode = '';
+ let artifactDirName = AUDIT_PREFIX;
test('public health/config/csrf endpoints are reachable', async () => {
const health = await request(baseUrl, jar, 'GET', '/api/health');
@@ -288,12 +290,30 @@ async function run() {
assert.strictEqual(check.data.success, true);
const url = await request(baseUrl, jar, 'GET', `/api/files/download-url?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt&mode=download`);
- assert.strictEqual(url.status, 400);
- assert.match(url.data.message, /OSS/);
+ assert.strictEqual(url.status, 200);
+ assert.strictEqual(url.data.storageType, 'local');
+ assert.match(url.data.downloadUrl, /\/api\/files\/download/);
- const download = await request(baseUrl, jar, 'GET', `/api/files/download?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
+ const tokenDownload = await request(baseUrl, null, 'GET', url.data.downloadUrl);
+ assert.strictEqual(tokenDownload.status, 200);
+ assert.ok(tokenDownload.raw.toString('utf8').includes(AUDIT_PREFIX));
+
+ const legacyDownload = await request(baseUrl, jar, 'GET', `/api/files/download?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
+ assert.strictEqual(legacyDownload.status, 302);
+ const legacyLocation = legacyDownload.headers.get('location') || '';
+ assert.match(legacyLocation, /\/api\/files\/download\?.*token=/);
+
+ const download = await request(baseUrl, null, 'GET', legacyLocation);
assert.strictEqual(download.status, 200);
assert.ok(download.raw.toString('utf8').includes(AUDIT_PREFIX));
+
+ const partialUrl = new URL(url.data.downloadUrl);
+ const partial = await request(baseUrl, null, 'GET', partialUrl.toString(), {
+ headers: { Range: 'bytes=6-' }
+ });
+ assert.strictEqual(partial.status, 206);
+ assert.match(partial.headers.get('content-range') || '', /^bytes 6-/);
+ assert.strictEqual(partial.raw.toString('utf8'), download.raw.subarray(6).toString('utf8'));
});
test('share and direct-link flows preserve path boundaries', async () => {
@@ -337,6 +357,29 @@ async function run() {
});
assert.ok([400, 403, 404].includes(traversal.status));
+ const shareUrl = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/download-url`, {
+ json: {
+ path: `/${AUDIT_PREFIX}/hello.txt`,
+ mode: 'download',
+ password: `${AUDIT_PREFIX}_pw`
+ }
+ });
+ assert.strictEqual(shareUrl.status, 200);
+ assert.match(shareUrl.data.downloadUrl, /\/api\/share\/[^/]+\/download-file\?.*token=/);
+
+ const shareDownload = await request(baseUrl, null, 'GET', shareUrl.data.downloadUrl);
+ assert.strictEqual(shareDownload.status, 200);
+ assert.ok(shareDownload.raw.toString('utf8').includes(AUDIT_PREFIX));
+
+ const sharePartial = await request(baseUrl, null, 'GET', shareUrl.data.downloadUrl, {
+ headers: { Range: 'bytes=6-' }
+ });
+ assert.strictEqual(sharePartial.status, 206);
+ assert.strictEqual(
+ sharePartial.raw.toString('utf8'),
+ shareDownload.raw.subarray(6).toString('utf8')
+ );
+
const direct = await request(baseUrl, jar, 'POST', '/api/direct-link/create', {
json: {
file_path: `/${AUDIT_PREFIX}/hello.txt`,
@@ -347,6 +390,32 @@ async function run() {
assert.strictEqual(direct.status, 200);
assert.strictEqual(direct.data.success, true);
directLinkId = direct.data.link_id;
+ directLinkCode = direct.data.link_code;
+ });
+
+ test('renaming a directory preserves shares and direct links', async () => {
+ const renamedDir = `${AUDIT_PREFIX}_renamed`;
+ const rename = await request(baseUrl, jar, 'POST', '/api/files/rename', {
+ json: { path: '/', oldName: AUDIT_PREFIX, newName: renamedDir }
+ });
+ assert.strictEqual(rename.status, 200);
+ assert.strictEqual(rename.data.success, true);
+ artifactDirName = renamedDir;
+
+ const shares = await request(baseUrl, jar, 'GET', '/api/share/my');
+ const share = shares.data.shares.find(item => item.share_code === shareCode);
+ assert.strictEqual(share.share_path, `/${renamedDir}`);
+
+ const publicJar = new CookieJar();
+ const list = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/list`, {
+ json: { path: '', password: `${AUDIT_PREFIX}_pw` }
+ });
+ assert.strictEqual(list.status, 200);
+ assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
+
+ const directDownload = await request(baseUrl, null, 'GET', `/d/${directLinkCode}`);
+ assert.strictEqual(directDownload.status, 200);
+ assert.ok(directDownload.raw.toString('utf8').includes(AUDIT_PREFIX));
});
test('admin listing/logging endpoints are authenticated and sanitized', async () => {
@@ -376,7 +445,7 @@ async function run() {
assert.ok([200, 404].includes(res.status));
}
const del = await request(baseUrl, jar, 'POST', '/api/files/delete', {
- json: { path: '/', fileName: AUDIT_PREFIX }
+ json: { path: '/', fileName: artifactDirName }
});
assert.strictEqual(del.status, 200);
assert.strictEqual(del.data.success, true);
diff --git a/desktop-client/index.html b/desktop-client/index.html
index 99f203f..70ed0d8 100644
--- a/desktop-client/index.html
+++ b/desktop-client/index.html
@@ -1,10 +1,10 @@
-
+
-
- Tauri + Vue + Typescript App
+
+ 玩玩云 Desktop
diff --git a/desktop-client/package-lock.json b/desktop-client/package-lock.json
index 0ff5126..0eea82a 100644
--- a/desktop-client/package-lock.json
+++ b/desktop-client/package-lock.json
@@ -1,13 +1,15 @@
{
"name": "desktop-client",
- "version": "0.1.31",
+ "version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "desktop-client",
- "version": "0.1.31",
+ "version": "0.1.38",
"dependencies": {
+ "@fontsource-variable/noto-sans-sc": "^5.3.0",
+ "@phosphor-icons/vue": "^2.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
@@ -509,12 +511,33 @@
"node": ">=18"
}
},
+ "node_modules/@fontsource-variable/noto-sans-sc": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.3.0.tgz",
+ "integrity": "sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
+ "node_modules/@phosphor-icons/vue": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@phosphor-icons/vue/-/vue-2.2.1.tgz",
+ "integrity": "sha512-3RNg1utc2Z5RwPKWFkW3eXI/0BfQAwXgtFxPUPeSzi55jGYUq16b+UqcgbKLazWFlwg5R92OCLKjDiJjeiJcnA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "vue": ">=3.2.39"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
diff --git a/desktop-client/package.json b/desktop-client/package.json
index c8c3084..e6cf2c6 100644
--- a/desktop-client/package.json
+++ b/desktop-client/package.json
@@ -1,7 +1,7 @@
{
"name": "desktop-client",
"private": true,
- "version": "0.1.31",
+ "version": "0.1.38",
"type": "module",
"scripts": {
"dev": "vite",
@@ -10,6 +10,8 @@
"tauri": "tauri"
},
"dependencies": {
+ "@fontsource-variable/noto-sans-sc": "^5.3.0",
+ "@phosphor-icons/vue": "^2.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
diff --git a/desktop-client/src-tauri/Cargo.lock b/desktop-client/src-tauri/Cargo.lock
index f44bb47..c1d778c 100644
--- a/desktop-client/src-tauri/Cargo.lock
+++ b/desktop-client/src-tauri/Cargo.lock
@@ -693,7 +693,7 @@ dependencies = [
[[package]]
name = "desktop-client"
-version = "0.1.31"
+version = "0.1.38"
dependencies = [
"reqwest 0.12.28",
"rusqlite",
diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml
index 55a16e8..3bc48c2 100644
--- a/desktop-client/src-tauri/Cargo.toml
+++ b/desktop-client/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "desktop-client"
-version = "0.1.31"
+version = "0.1.38"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs
index c21d6fe..097375e 100644
--- a/desktop-client/src-tauri/src/lib.rs
+++ b/desktop-client/src-tauri/src/lib.rs
@@ -1,5 +1,5 @@
-use reqwest::{Method, Url};
use reqwest::StatusCode;
+use reqwest::{Method, Url};
use rusqlite::{params, Connection};
use serde::Serialize;
use serde_json::{Map, Value};
@@ -18,19 +18,15 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
use tokio::time::sleep;
+#[cfg(target_os = "windows")]
+use windows_sys::Win32::Foundation::LocalFree;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
};
-#[cfg(target_os = "windows")]
-use windows_sys::Win32::Foundation::LocalFree;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
-#[cfg(target_os = "windows")]
-const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
-#[cfg(target_os = "windows")]
-const DETACHED_PROCESS: u32 = 0x00000008;
const RESUMABLE_CHUNK_MAX_RETRIES: u32 = 3;
const RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS: u64 = 900;
@@ -182,22 +178,6 @@ fn build_desktop_client_meta() -> (String, String, String) {
(platform, device_name, device_id)
}
-fn build_upload_file_fingerprint(meta: &fs::Metadata) -> Option {
- let size = meta.len();
- let modified_ms = meta
- .modified()
- .ok()
- .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
- .map(|duration| duration.as_millis())
- .unwrap_or(0);
- let fingerprint = format!("v1:size:{}:mtime:{}", size, modified_ms);
- if fingerprint.len() > 120 {
- None
- } else {
- Some(fingerprint)
- }
-}
-
fn is_retryable_upload_status(status: u16) -> bool {
matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
}
@@ -208,7 +188,9 @@ fn is_retryable_transport_error(err: &reqwest::Error) -> bool {
fn build_chunk_retry_delay(attempt: u32) -> Duration {
let multiplier = 2_u64.saturating_pow(attempt.min(5));
- let ms = RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS.saturating_mul(multiplier).min(15_000);
+ let ms = RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS
+ .saturating_mul(multiplier)
+ .min(15_000);
Duration::from_millis(ms)
}
@@ -263,7 +245,10 @@ fn dpapi_protect_bytes(input: &[u8]) -> Result, String> {
)
};
if ok == 0 {
- return Err(format!("加密登录状态失败: {}", std::io::Error::last_os_error()));
+ return Err(format!(
+ "加密登录状态失败: {}",
+ std::io::Error::last_os_error()
+ ));
}
let data = unsafe {
@@ -302,7 +287,10 @@ fn dpapi_unprotect_bytes(input: &[u8]) -> Result, String> {
)
};
if ok == 0 {
- return Err(format!("解密登录状态失败: {}", std::io::Error::last_os_error()));
+ return Err(format!(
+ "解密登录状态失败: {}",
+ std::io::Error::last_os_error()
+ ));
}
let data = unsafe {
@@ -341,8 +329,8 @@ fn decode_login_password(stored_password: &str) -> Result {
while index < bytes.len() {
let part = std::str::from_utf8(&bytes[index..index + 2])
.map_err(|_| "登录状态密文格式无效".to_string())?;
- let value = u8::from_str_radix(part, 16)
- .map_err(|_| "登录状态密文格式无效".to_string())?;
+ let value =
+ u8::from_str_radix(part, 16).map_err(|_| "登录状态密文格式无效".to_string())?;
encrypted.push(value);
index += 2;
}
@@ -377,7 +365,10 @@ fn sanitize_file_name(name: &str) -> String {
let raw = name.trim();
let mut cleaned = String::with_capacity(raw.len());
for ch in raw.chars() {
- if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0') {
+ if matches!(
+ ch,
+ '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
+ ) {
cleaned.push('_');
} else {
cleaned.push(ch);
@@ -435,11 +426,25 @@ fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf {
first
}
-fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, url: &str) -> PathBuf {
+fn build_download_resume_temp_path(
+ download_dir: &Path,
+ preferred_name: &str,
+ url: &str,
+) -> PathBuf {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
preferred_name.hash(&mut hasher);
- url.hash(&mut hasher);
+ let resume_identity = Url::parse(url)
+ .map(|parsed| {
+ let host = parsed.host_str().unwrap_or_default();
+ let port = parsed
+ .port()
+ .map(|value| format!(":{}", value))
+ .unwrap_or_default();
+ format!("{}://{}{}{}", parsed.scheme(), host, port, parsed.path())
+ })
+ .unwrap_or_else(|_| url.to_string());
+ resume_identity.hash(&mut hasher);
let digest = format!("{:016x}", hasher.finish());
let safe_name = sanitize_file_name(preferred_name);
let temp_name = format!(".{}.{}.part", safe_name, digest);
@@ -504,7 +509,8 @@ fn cleanup_old_update_installers(
) -> Result<(), String> {
let mut entries: Vec<(PathBuf, SystemTime)> = Vec::new();
let normalized_keep = keep_file_name.trim();
- for entry in fs::read_dir(download_dir).map_err(|err| format!("扫描下载目录失败: {}", err))? {
+ for entry in fs::read_dir(download_dir).map_err(|err| format!("扫描下载目录失败: {}", err))?
+ {
let path = match entry {
Ok(item) => item.path(),
Err(_) => continue,
@@ -544,6 +550,163 @@ fn cleanup_old_update_installers(
Ok(())
}
+#[cfg(target_os = "windows")]
+fn powershell_single_quoted_path(path: &Path) -> String {
+ path.to_string_lossy().replace('\'', "''")
+}
+
+#[cfg(target_os = "windows")]
+fn build_windows_update_script(
+ installer: &Path,
+ app_exe: &Path,
+ app_pid: u32,
+ log_file: &Path,
+) -> String {
+ let template = r#"$ErrorActionPreference = 'Continue'
+$Installer = '__INSTALLER__'
+$AppExe = '__APP_EXE__'
+$AppPid = __APP_PID__
+$LogFile = '__LOG_FILE__'
+
+function Write-UpdateLog([string]$Message) {
+ Add-Content -LiteralPath $LogFile -Encoding UTF8 -Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Message)
+}
+
+Write-UpdateLog 'update script started'
+if (-not (Test-Path -LiteralPath $Installer -PathType Leaf)) {
+ Write-UpdateLog ('installer not found: ' + $Installer)
+ exit 2
+}
+
+Start-Sleep -Milliseconds 700
+Stop-Process -Id $AppPid -Force -ErrorAction SilentlyContinue
+for ($Attempt = 0; $Attempt -lt 20; $Attempt++) {
+ if (-not (Get-Process -Id $AppPid -ErrorAction SilentlyContinue)) { break }
+ Start-Sleep -Milliseconds 250
+}
+
+$InstallExit = 1
+try {
+ $InstallerProcess = Start-Process -FilePath $Installer -ArgumentList '/S' -WindowStyle Hidden -Wait -PassThru -ErrorAction Stop
+ $InstallExit = $InstallerProcess.ExitCode
+ Write-UpdateLog ('installer exit code: ' + $InstallExit)
+} catch {
+ Write-UpdateLog ('installer failed: ' + $_.Exception.Message)
+}
+
+$Candidates = @(
+ $AppExe,
+ (Join-Path $env:LOCALAPPDATA '玩玩云\desktop-client.exe')
+)
+$TargetApp = $null
+for ($Attempt = 0; $Attempt -lt 40 -and -not $TargetApp; $Attempt++) {
+ foreach ($Candidate in $Candidates) {
+ if ($Candidate -and (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
+ $TargetApp = $Candidate
+ break
+ }
+ }
+ if (-not $TargetApp) { Start-Sleep -Milliseconds 500 }
+}
+
+if ($TargetApp) {
+ try {
+ Start-Process -FilePath $TargetApp -ErrorAction Stop
+ Write-UpdateLog ('application restarted: ' + $TargetApp)
+ } catch {
+ Write-UpdateLog ('application restart failed: ' + $_.Exception.Message)
+ }
+} else {
+ Write-UpdateLog 'application executable not found after install'
+}
+
+Start-Sleep -Milliseconds 300
+Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
+exit $InstallExit
+"#;
+
+ let script = template
+ .replace("__INSTALLER__", &powershell_single_quoted_path(installer))
+ .replace("__APP_EXE__", &powershell_single_quoted_path(app_exe))
+ .replace("__APP_PID__", &app_pid.to_string())
+ .replace("__LOG_FILE__", &powershell_single_quoted_path(log_file))
+ .replace('\n', "\r\n");
+ format!("\u{feff}{}", script)
+}
+
+#[cfg(target_os = "windows")]
+fn spawn_windows_update_script(
+ script_path: &Path,
+ working_dir: &Path,
+) -> std::io::Result {
+ Command::new("powershell.exe")
+ .arg("-NoLogo")
+ .arg("-NoProfile")
+ .arg("-NonInteractive")
+ .arg("-ExecutionPolicy")
+ .arg("Bypass")
+ .arg("-WindowStyle")
+ .arg("Hidden")
+ .arg("-File")
+ .arg(script_path)
+ .current_dir(working_dir)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .creation_flags(CREATE_NO_WINDOW)
+ .spawn()
+}
+
+#[cfg(all(test, target_os = "windows"))]
+mod windows_update_tests {
+ use super::*;
+
+ #[test]
+ fn update_script_is_utf8_and_does_not_spawn_timeout_processes() {
+ let script = build_windows_update_script(
+ Path::new(r"C:\downloads\release's setup.exe"),
+ Path::new(r"C:\Users\tester\AppData\Local\玩玩云\desktop-client.exe"),
+ 4242,
+ Path::new(r"C:\Temp\silent-update.log"),
+ );
+
+ assert!(script.starts_with('\u{feff}'));
+ assert!(!script.to_ascii_lowercase().contains("timeout /t"));
+ assert!(script.contains("Start-Sleep"));
+ assert!(script.contains("-WindowStyle Hidden -Wait -PassThru"));
+ assert!(script.contains(r"$Installer = 'C:\downloads\release''s setup.exe'"));
+ assert!(script.contains(r"玩玩云\desktop-client.exe"));
+ }
+
+ #[test]
+ fn updater_powershell_process_runs_hidden_script() {
+ let stamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or_default();
+ let test_dir = env::temp_dir().join(format!("玩玩云-updater-test-{}", stamp));
+ fs::create_dir_all(&test_dir).expect("create updater test directory");
+ let script_path = test_dir.join("probe.ps1");
+ let marker_path = test_dir.join("probe-ok.txt");
+ let script = format!(
+ "\u{feff}Start-Sleep -Milliseconds 120\r\nSet-Content -LiteralPath '{}' -Value 'ok' -Encoding UTF8\r\n",
+ powershell_single_quoted_path(&marker_path)
+ );
+ fs::write(&script_path, script.as_bytes()).expect("write updater probe script");
+
+ let mut child = spawn_windows_update_script(&script_path, &test_dir)
+ .expect("spawn hidden updater powershell");
+ let status = child.wait().expect("wait for updater powershell");
+ assert!(status.success(), "powershell exited with {status}");
+ assert!(
+ marker_path.is_file(),
+ "updater script did not create marker"
+ );
+
+ let _ = fs::remove_dir_all(test_dir);
+ }
+}
+
fn resolve_local_state_dir() -> PathBuf {
if let Some(appdata) = env::var_os("APPDATA") {
return PathBuf::from(appdata).join("wanwan-cloud-desktop");
@@ -558,7 +721,8 @@ fn open_local_state_db() -> Result {
let state_dir = resolve_local_state_dir();
fs::create_dir_all(&state_dir).map_err(|err| format!("创建本地状态目录失败: {}", err))?;
let db_path = state_dir.join("client_state.db");
- let conn = Connection::open(db_path).map_err(|err| format!("打开本地状态数据库失败: {}", err))?;
+ let conn =
+ Connection::open(db_path).map_err(|err| format!("打开本地状态数据库失败: {}", err))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS login_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -608,16 +772,14 @@ fn load_login_state_record() -> Result