feat: add independent direct-link sharing flow

This commit is contained in:
2026-02-17 21:57:38 +08:00
parent d236a790a1
commit 6242622f1a
4 changed files with 842 additions and 6 deletions

View File

@@ -236,6 +236,28 @@ function initDatabase() {
)
`);
// 分享直链表(与 shares 独立,互不影响)
db.exec(`
CREATE TABLE IF NOT EXISTS direct_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
link_code TEXT UNIQUE NOT NULL,
file_path TEXT NOT NULL,
file_name TEXT,
storage_type TEXT DEFAULT 'oss',
-- 直链统计
download_count INTEGER DEFAULT 0,
last_accessed_at DATETIME,
-- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// 系统设置表
db.exec(`
CREATE TABLE IF NOT EXISTS system_settings (
@@ -254,6 +276,9 @@ function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_shares_code ON shares(share_code);
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at);
CREATE INDEX IF NOT EXISTS idx_direct_links_code ON direct_links(link_code);
CREATE INDEX IF NOT EXISTS idx_direct_links_user ON direct_links(user_id);
CREATE INDEX IF NOT EXISTS idx_direct_links_expires ON direct_links(expires_at);
-- ===== 性能优化复合索引P0 优先级修复) =====
@@ -262,6 +287,10 @@ function initDatabase() {
-- 使用场景ShareDB.findByCode, 分享访问验证
CREATE INDEX IF NOT EXISTS idx_shares_code_expires ON shares(share_code, expires_at);
-- 直链复合索引link_code + expires_at
-- 使用场景DirectLinkDB.findByCode
CREATE INDEX IF NOT EXISTS idx_direct_links_code_expires ON direct_links(link_code, expires_at);
-- 注意system_logs 表的复合索引在表创建后创建第372行之后
-- 2. 活动日志复合索引user_id + created_at
-- 优势:快速查询用户最近的活动记录,支持时间范围过滤
@@ -276,6 +305,7 @@ function initDatabase() {
console.log('[数据库性能优化] ✓ 基础索引已创建');
console.log(' - idx_shares_code_expires: 分享码+过期时间');
console.log(' - idx_direct_links_code_expires: 直链码+过期时间');
// 数据库迁移添加upload_api_key字段如果不存在
try {
@@ -1265,6 +1295,127 @@ const ShareDB = {
}
};
// 分享直链相关操作(与 ShareDB 独立)
const DirectLinkDB = {
// 生成随机直链码
generateLinkCode(length = 10) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const bytes = crypto.randomBytes(length);
let code = '';
for (let i = 0; i < length; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
},
create(userId, options = {}) {
const {
file_path = '',
file_name = '',
storage_type = 'oss',
expiry_days = null
} = options;
let linkCode;
let attempts = 0;
do {
linkCode = this.generateLinkCode();
attempts++;
if (attempts > 10) {
linkCode = this.generateLinkCode(14);
}
} while (
db.prepare('SELECT 1 FROM direct_links WHERE link_code = ?').get(linkCode)
&& attempts < 20
);
let expiresAt = null;
if (expiry_days) {
const expireDate = new Date();
expireDate.setDate(expireDate.getDate() + parseInt(expiry_days, 10));
const year = expireDate.getFullYear();
const month = String(expireDate.getMonth() + 1).padStart(2, '0');
const day = String(expireDate.getDate()).padStart(2, '0');
const hours = String(expireDate.getHours()).padStart(2, '0');
const minutes = String(expireDate.getMinutes()).padStart(2, '0');
const seconds = String(expireDate.getSeconds()).padStart(2, '0');
expiresAt = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
const result = db.prepare(`
INSERT INTO direct_links (user_id, link_code, file_path, file_name, storage_type, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
userId,
linkCode,
file_path,
file_name || null,
storage_type || 'oss',
expiresAt
);
return {
id: result.lastInsertRowid,
link_code: linkCode,
file_path,
file_name: file_name || null,
storage_type: storage_type || 'oss',
expires_at: expiresAt
};
},
findByCode(linkCode) {
return db.prepare(`
SELECT
dl.*,
u.username,
u.is_banned
FROM direct_links dl
JOIN users u ON dl.user_id = u.id
WHERE dl.link_code = ?
AND (dl.expires_at IS NULL OR dl.expires_at > datetime('now', 'localtime'))
AND u.is_banned = 0
`).get(linkCode);
},
findById(id) {
return db.prepare('SELECT * FROM direct_links WHERE id = ?').get(id);
},
getUserLinks(userId) {
return db.prepare(`
SELECT *
FROM direct_links
WHERE user_id = ?
ORDER BY created_at DESC
`).all(userId);
},
incrementDownloadCount(linkCode) {
return db.prepare(`
UPDATE direct_links
SET download_count = download_count + 1,
last_accessed_at = CURRENT_TIMESTAMP
WHERE link_code = ?
`).run(linkCode);
},
touchAccess(linkCode) {
return db.prepare(`
UPDATE direct_links
SET last_accessed_at = CURRENT_TIMESTAMP
WHERE link_code = ?
`).run(linkCode);
},
delete(id, userId = null) {
if (userId) {
return db.prepare('DELETE FROM direct_links WHERE id = ? AND user_id = ?').run(id, userId);
}
return db.prepare('DELETE FROM direct_links WHERE id = ?').run(id);
}
};
// 系统设置管理
const SettingsDB = {
// 获取设置
@@ -2091,17 +2242,21 @@ const TransactionDB = {
// 1. 删除用户的所有分享
const sharesDeleted = db.prepare('DELETE FROM shares WHERE user_id = ?').run(userId);
// 2. 删除密码重置令牌
// 2. 删除用户的所有直链
const directLinksDeleted = db.prepare('DELETE FROM direct_links WHERE user_id = ?').run(userId);
// 3. 删除密码重置令牌
const tokensDeleted = db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);
// 3. 更新日志中的用户引用(设为 NULL保留日志记录
// 4. 更新日志中的用户引用(设为 NULL保留日志记录
db.prepare('UPDATE system_logs SET user_id = NULL WHERE user_id = ?').run(userId);
// 4. 删除用户记录
// 5. 删除用户记录
const userDeleted = db.prepare('DELETE FROM users WHERE id = ?').run(userId);
return {
sharesDeleted: sharesDeleted.changes,
directLinksDeleted: directLinksDeleted.changes,
tokensDeleted: tokensDeleted.changes,
userDeleted: userDeleted.changes
};
@@ -2123,6 +2278,7 @@ module.exports = {
db,
UserDB,
ShareDB,
DirectLinkDB,
SettingsDB,
VerificationDB,
PasswordResetTokenDB,

View File

@@ -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;