feat: unify cloud workspace and release desktop v0.1.38
This commit is contained in:
Generated
+8
-8
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+321
-84
@@ -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);
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user