feat: add independent direct-link sharing flow
This commit is contained in:
@@ -67,6 +67,7 @@ const {
|
||||
db,
|
||||
UserDB,
|
||||
ShareDB,
|
||||
DirectLinkDB,
|
||||
SettingsDB,
|
||||
VerificationDB,
|
||||
PasswordResetTokenDB,
|
||||
@@ -686,6 +687,10 @@ function getBusyDownloadMessage() {
|
||||
return '当前网络繁忙,请稍后再试';
|
||||
}
|
||||
|
||||
function sendPlainTextError(res, statusCode, message) {
|
||||
return res.status(statusCode).type('text/plain; charset=utf-8').send(message);
|
||||
}
|
||||
|
||||
function parseDateTimeValue(value) {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return null;
|
||||
@@ -5490,6 +5495,192 @@ app.delete('/api/share/:id', authMiddleware, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 直链管理(与分享链接独立) =====
|
||||
|
||||
// 创建文件直链
|
||||
app.post('/api/direct-link/create',
|
||||
authMiddleware,
|
||||
[
|
||||
body('file_path').isString().notEmpty().withMessage('文件路径不能为空'),
|
||||
body('file_name').optional({ nullable: true }).isString().withMessage('文件名格式无效'),
|
||||
body('expiry_days').optional({ nullable: true }).custom((value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return true;
|
||||
}
|
||||
const days = parseInt(value, 10);
|
||||
if (Number.isNaN(days) || days < 1 || days > 365) {
|
||||
throw new Error('有效期必须是1-365之间的整数');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
],
|
||||
async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeVirtualPath(req.body?.file_path || '');
|
||||
if (!normalizedPath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件路径非法'
|
||||
});
|
||||
}
|
||||
|
||||
const expiryDays = req.body?.expiry_days === null || req.body?.expiry_days === undefined || req.body?.expiry_days === ''
|
||||
? null
|
||||
: parseInt(req.body.expiry_days, 10);
|
||||
|
||||
let storage;
|
||||
try {
|
||||
// 创建前校验文件是否存在且为文件
|
||||
const { StorageInterface } = require('./storage');
|
||||
const storageInterface = new StorageInterface(req.user);
|
||||
storage = await storageInterface.connect();
|
||||
|
||||
const fileStats = await storage.stat(normalizedPath);
|
||||
if (fileStats?.isDirectory) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '直链仅支持文件,不支持目录'
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedFileName = (typeof req.body?.file_name === 'string' && req.body.file_name.trim())
|
||||
? req.body.file_name.trim()
|
||||
: (normalizedPath.split('/').pop() || 'download.bin');
|
||||
|
||||
const storageType = req.user.current_storage_type || 'oss';
|
||||
const directLink = DirectLinkDB.create(req.user.id, {
|
||||
file_path: normalizedPath,
|
||||
file_name: resolvedFileName,
|
||||
storage_type: storageType,
|
||||
expiry_days: expiryDays
|
||||
});
|
||||
|
||||
const directUrl = `${getSecureBaseUrl(req)}/d/${directLink.link_code}`;
|
||||
|
||||
logShare(
|
||||
req,
|
||||
'create_direct_link',
|
||||
`用户创建直链: ${normalizedPath}`,
|
||||
{
|
||||
linkId: directLink.id,
|
||||
linkCode: directLink.link_code,
|
||||
filePath: normalizedPath,
|
||||
storageType,
|
||||
expiresAt: directLink.expires_at || null
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '直链创建成功',
|
||||
link_id: directLink.id,
|
||||
link_code: directLink.link_code,
|
||||
file_path: normalizedPath,
|
||||
file_name: resolvedFileName,
|
||||
storage_type: storageType,
|
||||
expires_at: directLink.expires_at || null,
|
||||
direct_url: directUrl
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建直链失败:', error);
|
||||
|
||||
if (String(error?.message || '').includes('不存在')) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '文件不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: getSafeErrorMessage(error, '创建直链失败,请稍后重试', '创建直链')
|
||||
});
|
||||
} finally {
|
||||
if (storage) await storage.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取我的直链列表
|
||||
app.get('/api/direct-link/my', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const links = DirectLinkDB.getUserLinks(req.user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
links: links.map((link) => ({
|
||||
...link,
|
||||
direct_url: `${getSecureBaseUrl(req)}/d/${link.link_code}`
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取直链列表失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: getSafeErrorMessage(error, '获取直链列表失败,请稍后重试', '获取直链列表')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 删除直链
|
||||
app.delete('/api/direct-link/:id', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const linkId = parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(linkId) || linkId <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的直链ID'
|
||||
});
|
||||
}
|
||||
|
||||
const link = DirectLinkDB.findById(linkId);
|
||||
if (!link) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '直链不存在'
|
||||
});
|
||||
}
|
||||
|
||||
if (link.user_id !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权限删除此直链'
|
||||
});
|
||||
}
|
||||
|
||||
DirectLinkDB.delete(linkId, req.user.id);
|
||||
|
||||
logShare(
|
||||
req,
|
||||
'delete_direct_link',
|
||||
`用户删除直链: ${link.file_path}`,
|
||||
{
|
||||
linkId: link.id,
|
||||
linkCode: link.link_code,
|
||||
filePath: link.file_path
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '直链已删除'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除直链失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: getSafeErrorMessage(error, '删除直链失败,请稍后重试', '删除直链')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 分享链接访问(公开) =====
|
||||
|
||||
// 获取公共主题设置(用于分享页面,无需认证)
|
||||
@@ -8059,6 +8250,249 @@ app.delete('/api/admin/shares/:id',
|
||||
}
|
||||
});
|
||||
|
||||
// 直链访问路由(公开,直接下载)
|
||||
app.get('/d/:code', async (req, res) => {
|
||||
const { code } = req.params;
|
||||
let storage;
|
||||
let storageEnded = false;
|
||||
let transferFinalized = false;
|
||||
let downloadedBytes = 0;
|
||||
let responseBodyStartSocketBytes = 0;
|
||||
let linkOwnerId = null;
|
||||
|
||||
// 显式拒绝 HEAD,避免误触发计量
|
||||
if (req.method === 'HEAD') {
|
||||
res.setHeader('Allow', 'GET');
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const safeEndStorage = async () => {
|
||||
if (storage && !storageEnded) {
|
||||
storageEnded = true;
|
||||
try {
|
||||
await storage.end();
|
||||
} catch (err) {
|
||||
console.error('关闭存储连接失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeTransfer = async (reason = '') => {
|
||||
if (transferFinalized) {
|
||||
return;
|
||||
}
|
||||
transferFinalized = true;
|
||||
|
||||
try {
|
||||
if (linkOwnerId && downloadedBytes > 0) {
|
||||
const socketBytesWritten = Number(res.socket?.bytesWritten);
|
||||
const socketBodyBytes = Number.isFinite(socketBytesWritten) && socketBytesWritten > responseBodyStartSocketBytes
|
||||
? Math.floor(socketBytesWritten - responseBodyStartSocketBytes)
|
||||
: 0;
|
||||
const billableBytes = socketBodyBytes > 0
|
||||
? Math.min(downloadedBytes, socketBodyBytes)
|
||||
: downloadedBytes;
|
||||
|
||||
const usageResult = applyDownloadTrafficUsage(linkOwnerId, billableBytes);
|
||||
if (usageResult) {
|
||||
const quotaText = usageResult.quota > 0 ? formatFileSize(usageResult.quota) : '不限';
|
||||
console.log(
|
||||
`[直链下载流量] 用户 ${linkOwnerId} 新增 ${formatFileSize(usageResult.added)},` +
|
||||
`累计 ${formatFileSize(usageResult.usedAfter)} / ${quotaText} ` +
|
||||
`(reason=${reason || 'unknown'}, streamed=${downloadedBytes}, socket=${socketBodyBytes})`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[直链下载流量] 结算失败: user=${linkOwnerId}, bytes=${downloadedBytes}`, error);
|
||||
}
|
||||
|
||||
await safeEndStorage();
|
||||
};
|
||||
|
||||
if (!isValidShareCode(code)) {
|
||||
return sendPlainTextError(res, 404, '直链不存在');
|
||||
}
|
||||
|
||||
try {
|
||||
const directLink = DirectLinkDB.findByCode(code);
|
||||
if (!directLink) {
|
||||
return sendPlainTextError(res, 404, '直链不存在或已过期');
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeVirtualPath(directLink.file_path || '');
|
||||
if (!normalizedPath) {
|
||||
return sendPlainTextError(res, 404, '直链不存在或已失效');
|
||||
}
|
||||
|
||||
const ownerPolicyState = enforceDownloadTrafficPolicy(directLink.user_id, 'direct_link_download');
|
||||
const linkOwner = ownerPolicyState?.user || UserDB.findById(directLink.user_id);
|
||||
if (!linkOwner || linkOwner.is_banned) {
|
||||
return sendPlainTextError(res, 404, '直链不存在或已失效');
|
||||
}
|
||||
linkOwnerId = linkOwner.id;
|
||||
|
||||
const ownerTrafficState = getDownloadTrafficState(linkOwner);
|
||||
const storageType = directLink.storage_type || 'oss';
|
||||
const directFileName = (directLink.file_name && String(directLink.file_name).trim())
|
||||
? String(directLink.file_name).trim()
|
||||
: (normalizedPath.split('/').pop() || 'download.bin');
|
||||
|
||||
// OSS 直链下载:重定向到签名 URL
|
||||
if (storageType === 'oss') {
|
||||
const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig();
|
||||
if (!linkOwner.has_oss_config && !hasUnifiedConfig) {
|
||||
return sendPlainTextError(res, 404, '文件不存在或暂不可用');
|
||||
}
|
||||
|
||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
|
||||
const { client, bucket, ossClient } = createS3ClientContextForUser(linkOwner);
|
||||
const objectKey = ossClient.getObjectKey(normalizedPath);
|
||||
let fileSize = 0;
|
||||
|
||||
try {
|
||||
const headResponse = await client.send(new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey
|
||||
}));
|
||||
const contentLength = Number(headResponse?.ContentLength || 0);
|
||||
fileSize = Number.isFinite(contentLength) && contentLength > 0
|
||||
? Math.floor(contentLength)
|
||||
: 0;
|
||||
} catch (headError) {
|
||||
const statusCode = headError?.$metadata?.httpStatusCode;
|
||||
if (headError?.name === 'NotFound' || headError?.name === 'NoSuchKey' || statusCode === 404) {
|
||||
return sendPlainTextError(res, 404, '文件不存在');
|
||||
}
|
||||
throw headError;
|
||||
}
|
||||
|
||||
if (!ownerTrafficState.isUnlimited) {
|
||||
if (fileSize <= 0 || fileSize > ownerTrafficState.remaining) {
|
||||
return sendPlainTextError(res, 503, getBusyDownloadMessage());
|
||||
}
|
||||
|
||||
const reserveResult = reserveDirectDownloadTraffic(linkOwner.id, fileSize, {
|
||||
source: 'direct_link',
|
||||
objectKey,
|
||||
ttlMs: DOWNLOAD_RESERVATION_TTL_MS
|
||||
});
|
||||
if (!reserveResult?.ok) {
|
||||
return sendPlainTextError(res, 503, getBusyDownloadMessage());
|
||||
}
|
||||
}
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey,
|
||||
ResponseContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(directFileName)}`
|
||||
});
|
||||
|
||||
const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 });
|
||||
DirectLinkDB.incrementDownloadCount(code);
|
||||
|
||||
logShare(
|
||||
req,
|
||||
'direct_link_download',
|
||||
`访问直链下载: ${normalizedPath}`,
|
||||
{
|
||||
linkCode: code,
|
||||
ownerId: linkOwner.id,
|
||||
storageType: 'oss'
|
||||
}
|
||||
);
|
||||
|
||||
return res.redirect(signedUrl);
|
||||
}
|
||||
|
||||
// 本地存储:通过后端流式下载
|
||||
const { StorageInterface } = require('./storage');
|
||||
const userForStorage = buildStorageUserContext(linkOwner, {
|
||||
current_storage_type: storageType
|
||||
});
|
||||
const storageInterface = new StorageInterface(userForStorage);
|
||||
storage = await storageInterface.connect();
|
||||
|
||||
const fileStats = await storage.stat(normalizedPath);
|
||||
const fileSize = Number(fileStats?.size || 0);
|
||||
|
||||
if (fileStats?.isDirectory) {
|
||||
await safeEndStorage();
|
||||
return sendPlainTextError(res, 400, '直链仅支持文件下载');
|
||||
}
|
||||
|
||||
if (!Number.isFinite(fileSize) || fileSize <= 0) {
|
||||
await safeEndStorage();
|
||||
return sendPlainTextError(res, 404, '文件不存在');
|
||||
}
|
||||
|
||||
if (!ownerTrafficState.isUnlimited && fileSize > ownerTrafficState.remaining) {
|
||||
await safeEndStorage();
|
||||
return sendPlainTextError(res, 503, getBusyDownloadMessage());
|
||||
}
|
||||
|
||||
DirectLinkDB.incrementDownloadCount(code);
|
||||
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Length', 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') {
|
||||
res.flushHeaders();
|
||||
}
|
||||
responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0;
|
||||
|
||||
const stream = await storage.createReadStream(normalizedPath);
|
||||
stream.on('data', (chunk) => {
|
||||
if (!chunk) return;
|
||||
downloadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
|
||||
});
|
||||
|
||||
res.on('finish', () => {
|
||||
finalizeTransfer('finish').catch(err => {
|
||||
console.error('直链下载完成后资源释放失败:', err);
|
||||
});
|
||||
});
|
||||
|
||||
res.on('close', () => {
|
||||
finalizeTransfer('close').catch(err => {
|
||||
console.error('直链下载连接关闭后资源释放失败:', err);
|
||||
});
|
||||
});
|
||||
|
||||
stream.on('error', (error) => {
|
||||
console.error('直链文件流错误:', error);
|
||||
if (!res.headersSent) {
|
||||
sendPlainTextError(res, 500, '下载失败,请稍后重试');
|
||||
}
|
||||
finalizeTransfer('stream_error').catch(err => {
|
||||
console.error('直链下载流错误后资源释放失败:', err);
|
||||
});
|
||||
});
|
||||
|
||||
logShare(
|
||||
req,
|
||||
'direct_link_download',
|
||||
`访问直链下载: ${normalizedPath}`,
|
||||
{
|
||||
linkCode: code,
|
||||
ownerId: linkOwner.id,
|
||||
storageType: storageType || 'local'
|
||||
}
|
||||
);
|
||||
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('直链下载失败:', error);
|
||||
if (!res.headersSent) {
|
||||
sendPlainTextError(res, 500, '下载失败,请稍后重试');
|
||||
}
|
||||
await finalizeTransfer('catch_error');
|
||||
}
|
||||
});
|
||||
|
||||
// 分享页面访问路由
|
||||
app.get("/s/:code", (req, res) => {
|
||||
const shareCode = req.params.code;
|
||||
|
||||
Reference in New Issue
Block a user