feat: add independent direct-link sharing flow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2198,6 +2198,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成直链模态框 -->
|
||||
<div v-if="showDirectLinkModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showDirectLinkModal', $event)">
|
||||
<div class="modal-content" @click.stop>
|
||||
<h3 style="margin-bottom: 20px;">生成文件直链</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 15px;">文件: <strong>{{ directLinkForm.fileName }}</strong></p>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 15px; word-break: break-all;">路径: {{ directLinkForm.filePath }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">有效期</label>
|
||||
<select class="form-input" v-model="directLinkForm.expiryType">
|
||||
<option value="never">永久</option>
|
||||
<option value="7">7天</option>
|
||||
<option value="30">30天</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="directLinkForm.expiryType === 'custom'" class="form-group">
|
||||
<label class="form-label">自定义天数</label>
|
||||
<input type="number" class="form-input" v-model.number="directLinkForm.customDays" min="1" max="365">
|
||||
</div>
|
||||
|
||||
<div v-if="directLinkResult" class="share-success-panel" style="margin-top: 15px;">
|
||||
<div class="share-success-head">
|
||||
<i class="fas fa-circle-check"></i>
|
||||
<div>
|
||||
<div class="share-success-title">直链创建成功</div>
|
||||
<div class="share-success-subtitle">{{ directLinkResult.target_name || directLinkForm.fileName }} · 直链下载</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-success-link" :title="directLinkResult.direct_url">{{ directLinkResult.direct_url }}</div>
|
||||
|
||||
<div class="share-success-actions">
|
||||
<button class="btn btn-primary" @click="copyDirectLink(directLinkResult.direct_url)">
|
||||
<i class="fas fa-copy"></i> 复制直链
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="openShare(directLinkResult.direct_url)">
|
||||
<i class="fas fa-up-right-from-square"></i> 打开直链
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="share-success-meta">
|
||||
<span class="share-chip info" v-if="directLinkResult.link_code">
|
||||
<i class="fas fa-hashtag"></i> {{ directLinkResult.link_code }}
|
||||
</span>
|
||||
<span class="share-chip" :class="directLinkResult.expires_at ? (isExpiringSoon(directLinkResult.expires_at) ? 'warn' : 'success') : 'info'">
|
||||
<i class="fas fa-clock"></i>
|
||||
{{ directLinkResult.expires_at ? formatExpireTime(directLinkResult.expires_at) : '永久有效' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||
<button class="btn btn-primary" @click="createDirectLink()" :disabled="creatingDirectLink" style="flex: 1;">
|
||||
<i class="fas" :class="creatingDirectLink ? 'fa-spinner fa-spin' : 'fa-link'"></i> {{ creatingDirectLink ? '生成中...' : '生成直链' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="showDirectLinkModal = false; directLinkResult = null" style="flex: 1;">
|
||||
<i class="fas fa-times"></i> 关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OSS 配置引导弹窗 -->
|
||||
<div v-if="showOssGuideModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showOssGuideModal', $event)">
|
||||
<div class="modal-content" @click.stop style="max-width: 520px; border-radius: 16px; overflow: hidden;">
|
||||
@@ -2814,7 +2877,7 @@
|
||||
<button class="btn" :class="shareViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="shareViewMode = 'list'">
|
||||
<i class="fas fa-list"></i> 列表
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="loadShares">
|
||||
<button class="btn btn-secondary" @click="refreshShareResources">
|
||||
<i class="fas fa-sync-alt"></i> 刷新
|
||||
</button>
|
||||
</div>
|
||||
@@ -2934,6 +2997,60 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 直链管理(独立于普通分享) -->
|
||||
<div style="margin-top: 24px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px;">
|
||||
<h4 style="margin: 0; display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-link"></i> 我的直链
|
||||
</h4>
|
||||
<small style="color: var(--text-secondary);">提示:可在文件右键菜单中生成直链</small>
|
||||
</div>
|
||||
|
||||
<div v-if="directLinksLoading" class="alert alert-info">
|
||||
正在加载直链列表...
|
||||
</div>
|
||||
<div v-else-if="directLinks.length === 0" class="alert alert-info">
|
||||
还没有创建直链
|
||||
</div>
|
||||
<div v-else-if="filteredDirectLinks.length === 0" class="alert alert-warning">
|
||||
没有符合搜索条件的直链
|
||||
</div>
|
||||
<table v-else class="share-list-table">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid #ddd;">
|
||||
<th style="padding: 10px; text-align: left; width: 26%;">文件路径</th>
|
||||
<th style="padding: 10px; text-align: left; width: 36%;">直链地址</th>
|
||||
<th style="padding: 10px; text-align: center; width: 8%;">下载</th>
|
||||
<th style="padding: 10px; text-align: center; width: 15%;">到期时间</th>
|
||||
<th style="padding: 10px; text-align: center; width: 15%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="link in filteredDirectLinks" :key="`direct-${link.id}`" style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="link.file_path">{{ link.file_path }}</td>
|
||||
<td style="padding: 10px; overflow: hidden;">
|
||||
<a :href="link.direct_url" target="_blank" style="color: #667eea; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="link.direct_url">{{ link.direct_url }}</a>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center;">{{ link.download_count || 0 }}</td>
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<span v-if="!link.expires_at" style="color: #22c55e;"><i class="fas fa-infinity"></i> 永久有效</span>
|
||||
<span v-else :style="{color: isExpiringSoon(link.expires_at) ? '#ffc107' : isExpired(link.expires_at) ? '#dc3545' : '#667eea'}" :title="link.expires_at"><i class="fas fa-clock"></i> {{ formatExpireTime(link.expires_at) }}</span>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<div style="display: inline-flex; gap: 6px;">
|
||||
<button class="btn btn-secondary" @click="copyDirectLink(link.direct_url)">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
<button class="btn" style="background: #ef4444; color: white;" @click="deleteDirectLink(link.id)">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3949,6 +4066,9 @@
|
||||
<div class="context-menu-item" @click="contextMenuAction('share')">
|
||||
<i class="fas fa-share"></i> 分享
|
||||
</div>
|
||||
<div v-if="!contextMenuFile.isDirectory" class="context-menu-item" @click="contextMenuAction('direct_link')">
|
||||
<i class="fas fa-link"></i> 生成直链
|
||||
</div>
|
||||
<div class="context-menu-divider"></div>
|
||||
<div class="context-menu-item context-menu-item-danger" @click="contextMenuAction('delete')">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
@@ -3978,6 +4098,9 @@
|
||||
<button class="mobile-file-sheet-btn" @click="mobileFileAction('share')">
|
||||
<i class="fas fa-share"></i> 分享
|
||||
</button>
|
||||
<button v-if="!mobileActionFile.isDirectory" class="mobile-file-sheet-btn" @click="mobileFileAction('direct_link')">
|
||||
<i class="fas fa-link"></i> 生成直链
|
||||
</button>
|
||||
<button class="mobile-file-sheet-btn danger" @click="mobileFileAction('delete')">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
|
||||
127
frontend/app.js
127
frontend/app.js
@@ -92,6 +92,8 @@ createApp({
|
||||
|
||||
// 分享管理
|
||||
shares: [],
|
||||
directLinks: [],
|
||||
directLinksLoading: false,
|
||||
showShareFileModal: false,
|
||||
creatingShare: false, // 创建分享中状态
|
||||
shareFileForm: {
|
||||
@@ -104,6 +106,15 @@ createApp({
|
||||
customDays: 7
|
||||
},
|
||||
shareResult: null,
|
||||
showDirectLinkModal: false,
|
||||
creatingDirectLink: false,
|
||||
directLinkForm: {
|
||||
fileName: '',
|
||||
filePath: '',
|
||||
expiryType: 'never',
|
||||
customDays: 7
|
||||
},
|
||||
directLinkResult: null,
|
||||
shareFilters: {
|
||||
keyword: '',
|
||||
type: 'all', // all/file/directory/all_files
|
||||
@@ -580,6 +591,28 @@ createApp({
|
||||
return list;
|
||||
},
|
||||
|
||||
filteredDirectLinks() {
|
||||
let list = [...this.directLinks];
|
||||
const keyword = this.shareFilters.keyword.trim().toLowerCase();
|
||||
|
||||
if (keyword) {
|
||||
list = list.filter((link) =>
|
||||
(link.file_path || '').toLowerCase().includes(keyword)
|
||||
|| (link.file_name || '').toLowerCase().includes(keyword)
|
||||
|| (link.link_code || '').toLowerCase().includes(keyword)
|
||||
|| (link.direct_url || '').toLowerCase().includes(keyword)
|
||||
);
|
||||
}
|
||||
|
||||
list.sort((a, b) => {
|
||||
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||
const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
|
||||
return list;
|
||||
},
|
||||
|
||||
adminUsersFilteredCount() {
|
||||
return Math.max(0, Number(this.adminUsersTotalCount) || 0);
|
||||
},
|
||||
@@ -1940,6 +1973,9 @@ handleDragLeave(e) {
|
||||
case 'share':
|
||||
this.openShareFileModal(this.contextMenuFile);
|
||||
break;
|
||||
case 'direct_link':
|
||||
this.openDirectLinkModal(this.contextMenuFile);
|
||||
break;
|
||||
case 'delete':
|
||||
this.confirmDeleteFile(this.contextMenuFile);
|
||||
break;
|
||||
@@ -2293,6 +2329,55 @@ handleDragLeave(e) {
|
||||
}
|
||||
},
|
||||
|
||||
openDirectLinkModal(file) {
|
||||
if (!file || file.isDirectory) {
|
||||
this.showToast('warning', '提示', '目录不支持生成直链,请选择文件');
|
||||
return;
|
||||
}
|
||||
|
||||
this.directLinkForm.fileName = file.name;
|
||||
this.directLinkForm.filePath = this.currentPath === '/'
|
||||
? `/${file.name}`
|
||||
: `${this.currentPath}/${file.name}`;
|
||||
this.directLinkForm.expiryType = 'never';
|
||||
this.directLinkForm.customDays = 7;
|
||||
this.directLinkResult = null;
|
||||
this.showDirectLinkModal = true;
|
||||
},
|
||||
|
||||
async createDirectLink() {
|
||||
if (this.creatingDirectLink) return;
|
||||
this.creatingDirectLink = true;
|
||||
|
||||
try {
|
||||
const expiryCheck = this.resolveShareExpiry(this.directLinkForm.expiryType, this.directLinkForm.customDays);
|
||||
if (!expiryCheck.valid) {
|
||||
this.showToast('warning', '提示', expiryCheck.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.post(`${this.apiBase}/api/direct-link/create`, {
|
||||
file_path: this.directLinkForm.filePath,
|
||||
file_name: this.directLinkForm.fileName,
|
||||
expiry_days: expiryCheck.value
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
this.directLinkResult = {
|
||||
...response.data,
|
||||
target_name: this.directLinkForm.fileName
|
||||
};
|
||||
this.showToast('success', '成功', '直链已创建');
|
||||
this.loadDirectLinks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建直链失败:', error);
|
||||
this.showToast('error', '错误', error.response?.data?.message || '创建直链失败');
|
||||
} finally {
|
||||
this.creatingDirectLink = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ===== 文件上传 =====
|
||||
|
||||
@@ -2485,6 +2570,40 @@ handleDragLeave(e) {
|
||||
}
|
||||
},
|
||||
|
||||
async loadDirectLinks() {
|
||||
this.directLinksLoading = true;
|
||||
try {
|
||||
const response = await axios.get(`${this.apiBase}/api/direct-link/my`);
|
||||
if (response.data?.success) {
|
||||
this.directLinks = response.data.links || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载直链列表失败:', error);
|
||||
this.showToast('error', '加载失败', error.response?.data?.message || '加载直链列表失败');
|
||||
} finally {
|
||||
this.directLinksLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDirectLink(id) {
|
||||
if (!confirm('确定要删除这个直链吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await axios.delete(`${this.apiBase}/api/direct-link/${id}`);
|
||||
if (response.data?.success) {
|
||||
this.showToast('success', '成功', '直链已删除');
|
||||
this.loadDirectLinks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除直链失败:', error);
|
||||
this.showToast('error', '删除失败', error.response?.data?.message || '删除直链失败');
|
||||
}
|
||||
},
|
||||
|
||||
async refreshShareResources() {
|
||||
await Promise.all([this.loadShares(), this.loadDirectLinks()]);
|
||||
},
|
||||
|
||||
async createShare() {
|
||||
this.shareForm.path = this.currentPath;
|
||||
|
||||
@@ -2728,6 +2847,10 @@ handleDragLeave(e) {
|
||||
this.copyTextToClipboard(url, '分享链接已复制到剪贴板');
|
||||
},
|
||||
|
||||
copyDirectLink(url) {
|
||||
this.copyTextToClipboard(url, '直链已复制到剪贴板');
|
||||
},
|
||||
|
||||
copySharePassword(password) {
|
||||
this.copyTextToClipboard(password, '访问密码已复制到剪贴板');
|
||||
},
|
||||
@@ -3286,7 +3409,7 @@ handleDragLeave(e) {
|
||||
break;
|
||||
case 'shares':
|
||||
// 切换到分享视图时,重新加载分享列表
|
||||
this.loadShares();
|
||||
this.refreshShareResources();
|
||||
break;
|
||||
case 'admin':
|
||||
// 切换到管理后台时,重新加载用户列表、健康检测和系统日志
|
||||
@@ -4110,7 +4233,7 @@ handleDragLeave(e) {
|
||||
watch: {
|
||||
currentView(newView) {
|
||||
if (newView === 'shares') {
|
||||
this.loadShares();
|
||||
this.refreshShareResources();
|
||||
} else if (newView === 'admin' && this.user?.is_admin) {
|
||||
this.loadUsers();
|
||||
this.loadSystemSettings();
|
||||
|
||||
Reference in New Issue
Block a user