diff --git a/backend/database.js b/backend/database.js index 0ed11f9..b805844 100644 --- a/backend/database.js +++ b/backend/database.js @@ -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, diff --git a/backend/server.js b/backend/server.js index 364a10c..a3b72ff 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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; diff --git a/frontend/app.html b/frontend/app.html index 49c53e4..5e6c064 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -2198,6 +2198,69 @@ + +
+ @@ -3949,6 +4066,9 @@ +