diff --git a/backend/database.js b/backend/database.js index b805844..2eb78da 100644 --- a/backend/database.js +++ b/backend/database.js @@ -223,6 +223,11 @@ function initDatabase() { share_path TEXT NOT NULL, share_type TEXT DEFAULT 'file', share_password TEXT, + max_downloads INTEGER DEFAULT NULL, + ip_whitelist TEXT, + device_limit TEXT DEFAULT 'all', + access_time_start TEXT, + access_time_end TEXT, -- 分享统计 view_count INTEGER DEFAULT 0, @@ -333,6 +338,39 @@ function initDatabase() { console.error('数据库迁移(share_type)失败:', error); } + // 数据库迁移:分享高级安全策略字段 + try { + const shareColumns = db.prepare("PRAGMA table_info(shares)").all(); + const hasMaxDownloads = shareColumns.some(col => col.name === 'max_downloads'); + const hasIpWhitelist = shareColumns.some(col => col.name === 'ip_whitelist'); + const hasDeviceLimit = shareColumns.some(col => col.name === 'device_limit'); + const hasAccessTimeStart = shareColumns.some(col => col.name === 'access_time_start'); + const hasAccessTimeEnd = shareColumns.some(col => col.name === 'access_time_end'); + + if (!hasMaxDownloads) { + db.exec('ALTER TABLE shares ADD COLUMN max_downloads INTEGER DEFAULT NULL'); + console.log('数据库迁移:添加 shares.max_downloads 字段完成'); + } + if (!hasIpWhitelist) { + db.exec('ALTER TABLE shares ADD COLUMN ip_whitelist TEXT'); + console.log('数据库迁移:添加 shares.ip_whitelist 字段完成'); + } + if (!hasDeviceLimit) { + db.exec('ALTER TABLE shares ADD COLUMN device_limit TEXT DEFAULT "all"'); + console.log('数据库迁移:添加 shares.device_limit 字段完成'); + } + if (!hasAccessTimeStart) { + db.exec('ALTER TABLE shares ADD COLUMN access_time_start TEXT'); + console.log('数据库迁移:添加 shares.access_time_start 字段完成'); + } + if (!hasAccessTimeEnd) { + db.exec('ALTER TABLE shares ADD COLUMN access_time_end TEXT'); + console.log('数据库迁移:添加 shares.access_time_end 字段完成'); + } + } catch (error) { + console.error('数据库迁移(分享安全策略)失败:', error); + } + // 数据库迁移:邮箱验证字段 try { const columns = db.prepare("PRAGMA table_info(users)").all(); @@ -445,6 +483,47 @@ function initDatabase() { ) `); + // 本地分片上传会话(断点续传) + db.exec(` + CREATE TABLE IF NOT EXISTS upload_sessions ( + session_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + storage_type TEXT NOT NULL DEFAULT 'local', + target_path TEXT NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + chunk_size INTEGER NOT NULL DEFAULT 0, + total_chunks INTEGER NOT NULL DEFAULT 0, + uploaded_chunks TEXT NOT NULL DEFAULT '', + uploaded_bytes INTEGER NOT NULL DEFAULT 0, + temp_file_path TEXT NOT NULL, + file_hash TEXT, + status TEXT NOT NULL DEFAULT 'active', -- active/completed/expired/cancelled + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `); + + // 文件哈希索引(用于秒传) + db.exec(` + CREATE TABLE IF NOT EXISTS user_file_hash_index ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + storage_type TEXT NOT NULL DEFAULT 'local', + file_hash TEXT NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + file_path TEXT NOT NULL, + object_key TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, storage_type, file_hash, file_size, file_path), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `); + // 日志表索引 db.exec(` CREATE INDEX IF NOT EXISTS idx_logs_created_at ON system_logs(created_at); @@ -468,6 +547,18 @@ function initDatabase() { -- 已处理日志索引(按处理时间回溯) CREATE INDEX IF NOT EXISTS idx_ingested_logs_processed_at ON download_traffic_ingested_logs(processed_at); + + -- 断点上传会话索引 + CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status_expires + ON upload_sessions(user_id, status, expires_at); + CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash + ON upload_sessions(user_id, file_hash, file_size); + + -- 秒传索引 + CREATE INDEX IF NOT EXISTS idx_file_hash_index_lookup + ON user_file_hash_index(user_id, storage_type, file_hash, file_size); + CREATE INDEX IF NOT EXISTS idx_file_hash_index_path + ON user_file_hash_index(user_id, storage_type, file_path); `); console.log('[数据库性能优化] ✓ 日志表复合索引已创建'); @@ -1142,7 +1233,12 @@ const ShareDB = { file_path = '', file_name = '', password = null, - expiry_days = null + expiry_days = null, + max_downloads = null, + ip_whitelist = null, + device_limit = 'all', + access_time_start = null, + access_time_end = null } = options; let shareCode; @@ -1174,8 +1270,11 @@ const ShareDB = { } const stmt = db.prepare(` - INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, expires_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO shares ( + user_id, share_code, share_path, share_type, share_password, expires_at, + max_downloads, ip_whitelist, device_limit, access_time_start, access_time_end + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const hashedPassword = password ? bcrypt.hashSync(password, 10) : null; @@ -1199,7 +1298,12 @@ const ShareDB = { sharePath, share_type, hashedPassword, - expiresAt + expiresAt, + max_downloads, + ip_whitelist, + device_limit || 'all', + access_time_start, + access_time_end ); return { @@ -1207,6 +1311,11 @@ const ShareDB = { share_code: shareCode, share_type: share_type, expires_at: expiresAt, + max_downloads: max_downloads ?? null, + ip_whitelist: ip_whitelist ?? null, + device_limit: device_limit || 'all', + access_time_start: access_time_start ?? null, + access_time_end: access_time_end ?? null }; }, @@ -1221,6 +1330,7 @@ const ShareDB = { s.id, s.user_id, s.share_code, s.share_path, s.share_type, s.storage_type, s.share_password, + s.max_downloads, s.ip_whitelist, s.device_limit, s.access_time_start, s.access_time_end, s.view_count, s.download_count, s.created_at, s.expires_at, u.username, -- OSS 配置(访问分享文件所需) @@ -2029,6 +2139,481 @@ const DownloadTrafficReservationDB = { } return { consumed, finalizedCount }; + }, + + getAdminList(options = {}) { + const where = ['1=1']; + const params = []; + + const status = typeof options.status === 'string' ? options.status.trim() : ''; + const allowedStatus = new Set(['pending', 'confirmed', 'expired', 'cancelled']); + if (allowedStatus.has(status)) { + where.push('r.status = ?'); + params.push(status); + } + + const uid = Number(options.userId); + if (Number.isFinite(uid) && uid > 0) { + where.push('r.user_id = ?'); + params.push(Math.floor(uid)); + } + + const keyword = typeof options.keyword === 'string' ? options.keyword.trim() : ''; + if (keyword) { + const likeValue = `%${keyword}%`; + where.push('(u.username LIKE ? OR COALESCE(r.object_key, \'\') LIKE ? OR r.source LIKE ?)'); + params.push(likeValue, likeValue, likeValue); + } + + const whereSql = where.join(' AND '); + const page = Math.max(1, Math.floor(Number(options.page) || 1)); + const pageSize = Math.min(200, Math.max(1, Math.floor(Number(options.pageSize) || 20))); + const offset = (page - 1) * pageSize; + + const totalRow = db.prepare(` + SELECT COUNT(*) AS total + FROM user_download_traffic_reservations r + LEFT JOIN users u ON u.id = r.user_id + WHERE ${whereSql} + `).get(...params); + const total = Math.max(0, Number(totalRow?.total || 0)); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(page, totalPages); + const safeOffset = (safePage - 1) * pageSize; + + const rows = db.prepare(` + SELECT + r.*, + u.username + FROM user_download_traffic_reservations r + LEFT JOIN users u ON u.id = r.user_id + WHERE ${whereSql} + ORDER BY r.created_at DESC, r.id DESC + LIMIT ? OFFSET ? + `).all(...params, pageSize, safeOffset); + + return { + rows, + pagination: { + page: safePage, + pageSize, + total, + totalPages + } + }; + }, + + getAdminSummary() { + const statusRows = db.prepare(` + SELECT + status, + COUNT(*) AS count, + COALESCE(SUM(remaining_bytes), 0) AS remaining_bytes, + COALESCE(SUM(reserved_bytes), 0) AS reserved_bytes + FROM user_download_traffic_reservations + GROUP BY status + `).all(); + + const summary = { + total: 0, + pending: 0, + confirmed: 0, + expired: 0, + cancelled: 0, + pending_remaining_bytes: 0, + pending_reserved_bytes: 0, + pending_expiring_soon: 0 + }; + + for (const row of statusRows) { + const status = String(row.status || '').trim(); + const count = Number(row.count || 0); + const remaining = Number(row.remaining_bytes || 0); + const reserved = Number(row.reserved_bytes || 0); + + summary.total += count; + if (Object.prototype.hasOwnProperty.call(summary, status)) { + summary[status] = count; + } + if (status === 'pending') { + summary.pending_remaining_bytes = Math.max(0, remaining); + summary.pending_reserved_bytes = Math.max(0, reserved); + } + } + + const expiringSoonRow = db.prepare(` + SELECT COUNT(*) AS count + FROM user_download_traffic_reservations + WHERE status = 'pending' + AND expires_at <= datetime('now', 'localtime', '+5 minutes') + `).get(); + summary.pending_expiring_soon = Number(expiringSoonRow?.count || 0); + + return summary; + }, + + cancelPendingById(id) { + const rid = Number(id); + if (!Number.isFinite(rid) || rid <= 0) { + return { changes: 0, row: null }; + } + + const row = db.prepare(` + SELECT * + FROM user_download_traffic_reservations + WHERE id = ? + AND status = 'pending' + `).get(Math.floor(rid)); + if (!row) { + return { changes: 0, row: null }; + } + + const result = db.prepare(` + UPDATE user_download_traffic_reservations + SET status = 'cancelled', + remaining_bytes = 0, + finalized_at = datetime('now', 'localtime'), + updated_at = datetime('now', 'localtime') + WHERE id = ? + AND status = 'pending' + `).run(Math.floor(rid)); + + return { + changes: Number(result?.changes || 0), + row: db.prepare('SELECT * FROM user_download_traffic_reservations WHERE id = ?').get(Math.floor(rid)) + }; + }, + + cleanupFinalizedHistory(keepDays = 7) { + const days = Math.min(365, Math.max(1, Math.floor(Number(keepDays) || 7))); + return db.prepare(` + DELETE FROM user_download_traffic_reservations + WHERE status IN ('confirmed', 'expired', 'cancelled') + AND updated_at < datetime('now', 'localtime', '-' || ? || ' days') + `).run(days); + } +}; + +const UploadSessionDB = { + _normalizeSessionId(sessionId) { + return typeof sessionId === 'string' ? sessionId.trim() : ''; + }, + + parseUploadedChunks(chunksText) { + const raw = typeof chunksText === 'string' ? chunksText.trim() : ''; + if (!raw) return []; + const set = new Set(); + for (const part of raw.split(',')) { + const idx = Number(part); + if (Number.isFinite(idx) && idx >= 0) { + set.add(Math.floor(idx)); + } + } + return Array.from(set).sort((a, b) => a - b); + }, + + formatUploadedChunks(chunks = []) { + if (!Array.isArray(chunks) || chunks.length === 0) { + return ''; + } + const set = new Set(); + for (const chunk of chunks) { + const idx = Number(chunk); + if (Number.isFinite(idx) && idx >= 0) { + set.add(Math.floor(idx)); + } + } + return Array.from(set).sort((a, b) => a - b).join(','); + }, + + create(data = {}) { + const sessionId = this._normalizeSessionId(data.sessionId); + const userId = Number(data.userId); + const storageType = typeof data.storageType === 'string' ? data.storageType : 'local'; + const targetPath = typeof data.targetPath === 'string' ? data.targetPath : ''; + const fileName = typeof data.fileName === 'string' ? data.fileName : ''; + const fileSize = Math.max(0, Math.floor(Number(data.fileSize) || 0)); + const chunkSize = Math.max(1, Math.floor(Number(data.chunkSize) || 1)); + const totalChunks = Math.max(1, Math.floor(Number(data.totalChunks) || 1)); + const uploadedChunks = this.formatUploadedChunks(data.uploadedChunks || []); + const uploadedBytes = Math.max(0, Math.floor(Number(data.uploadedBytes) || 0)); + const tempFilePath = typeof data.tempFilePath === 'string' ? data.tempFilePath : ''; + const fileHash = typeof data.fileHash === 'string' ? data.fileHash : null; + const expiresAt = typeof data.expiresAt === 'string' ? data.expiresAt : null; + + if (!sessionId || !Number.isFinite(userId) || userId <= 0 || !targetPath || !fileName || !tempFilePath || !expiresAt) { + return null; + } + + db.prepare(` + INSERT INTO upload_sessions ( + session_id, user_id, storage_type, target_path, file_name, + file_size, chunk_size, total_chunks, uploaded_chunks, uploaded_bytes, + temp_file_path, file_hash, status, expires_at, created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, 'active', ?, datetime('now', 'localtime'), datetime('now', 'localtime') + ) + ON CONFLICT(session_id) DO UPDATE SET + storage_type = excluded.storage_type, + target_path = excluded.target_path, + file_name = excluded.file_name, + file_size = excluded.file_size, + chunk_size = excluded.chunk_size, + total_chunks = excluded.total_chunks, + uploaded_chunks = excluded.uploaded_chunks, + uploaded_bytes = excluded.uploaded_bytes, + temp_file_path = excluded.temp_file_path, + file_hash = excluded.file_hash, + status = 'active', + expires_at = excluded.expires_at, + completed_at = NULL, + updated_at = datetime('now', 'localtime') + `).run( + sessionId, + Math.floor(userId), + storageType, + targetPath, + fileName, + fileSize, + chunkSize, + totalChunks, + uploadedChunks, + uploadedBytes, + tempFilePath, + fileHash, + expiresAt + ); + + return this.findBySessionId(sessionId); + }, + + findBySessionId(sessionId) { + const sid = this._normalizeSessionId(sessionId); + if (!sid) return null; + return db.prepare('SELECT * FROM upload_sessions WHERE session_id = ?').get(sid); + }, + + findActiveForResume(userId, targetPath, fileSize, fileHash = null) { + const uid = Number(userId); + const safePath = typeof targetPath === 'string' ? targetPath : ''; + const size = Math.floor(Number(fileSize) || 0); + const hash = typeof fileHash === 'string' ? fileHash.trim() : ''; + if (!Number.isFinite(uid) || uid <= 0 || !safePath || size <= 0) { + return null; + } + + if (hash) { + return db.prepare(` + SELECT * + FROM upload_sessions + WHERE user_id = ? + AND target_path = ? + AND file_size = ? + AND status = 'active' + AND expires_at > datetime('now', 'localtime') + AND COALESCE(file_hash, '') = ? + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + `).get(Math.floor(uid), safePath, size, hash); + } + + return db.prepare(` + SELECT * + FROM upload_sessions + WHERE user_id = ? + AND target_path = ? + AND file_size = ? + AND status = 'active' + AND expires_at > datetime('now', 'localtime') + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + `).get(Math.floor(uid), safePath, size); + }, + + updateProgress(sessionId, uploadedChunks = [], uploadedBytes = 0, expiresAt = null) { + const sid = this._normalizeSessionId(sessionId); + if (!sid) return { changes: 0 }; + const chunksText = this.formatUploadedChunks(uploadedChunks); + const bytes = Math.max(0, Math.floor(Number(uploadedBytes) || 0)); + + if (typeof expiresAt === 'string' && expiresAt) { + return db.prepare(` + UPDATE upload_sessions + SET uploaded_chunks = ?, + uploaded_bytes = ?, + expires_at = ?, + updated_at = datetime('now', 'localtime') + WHERE session_id = ? + `).run(chunksText, bytes, expiresAt, sid); + } + + return db.prepare(` + UPDATE upload_sessions + SET uploaded_chunks = ?, + uploaded_bytes = ?, + updated_at = datetime('now', 'localtime') + WHERE session_id = ? + `).run(chunksText, bytes, sid); + }, + + setStatus(sessionId, status, { completed = false, expiresAt = null } = {}) { + const sid = this._normalizeSessionId(sessionId); + const safeStatus = typeof status === 'string' ? status : ''; + if (!sid || !safeStatus) return { changes: 0 }; + + if (expiresAt && completed) { + return db.prepare(` + UPDATE upload_sessions + SET status = ?, + expires_at = ?, + completed_at = datetime('now', 'localtime'), + updated_at = datetime('now', 'localtime') + WHERE session_id = ? + `).run(safeStatus, expiresAt, sid); + } + + if (expiresAt) { + return db.prepare(` + UPDATE upload_sessions + SET status = ?, + expires_at = ?, + completed_at = CASE WHEN ? = 1 THEN datetime('now', 'localtime') ELSE completed_at END, + updated_at = datetime('now', 'localtime') + WHERE session_id = ? + `).run(safeStatus, expiresAt, completed ? 1 : 0, sid); + } + + return db.prepare(` + UPDATE upload_sessions + SET status = ?, + completed_at = CASE WHEN ? = 1 THEN datetime('now', 'localtime') ELSE completed_at END, + updated_at = datetime('now', 'localtime') + WHERE session_id = ? + `).run(safeStatus, completed ? 1 : 0, sid); + }, + + delete(sessionId) { + const sid = this._normalizeSessionId(sessionId); + if (!sid) return { changes: 0 }; + return db.prepare('DELETE FROM upload_sessions WHERE session_id = ?').run(sid); + }, + + listExpiredActive(limit = 100) { + const safeLimit = Math.min(500, Math.max(1, Math.floor(Number(limit) || 100))); + return db.prepare(` + SELECT * + FROM upload_sessions + WHERE status = 'active' + AND expires_at <= datetime('now', 'localtime') + ORDER BY expires_at ASC + LIMIT ? + `).all(safeLimit); + }, + + expireSession(sessionId) { + const sid = this._normalizeSessionId(sessionId); + if (!sid) return { changes: 0 }; + return db.prepare(` + UPDATE upload_sessions + SET status = 'expired', + updated_at = datetime('now', 'localtime') + WHERE session_id = ? + AND status = 'active' + `).run(sid); + } +}; + +const FileHashIndexDB = { + normalizeStorageType(storageType) { + return storageType === 'oss' ? 'oss' : 'local'; + }, + + upsert(entry = {}) { + const userId = Number(entry.userId); + const storageType = this.normalizeStorageType(entry.storageType); + const fileHash = typeof entry.fileHash === 'string' ? entry.fileHash.trim() : ''; + const fileSize = Math.max(0, Math.floor(Number(entry.fileSize) || 0)); + const filePath = typeof entry.filePath === 'string' ? entry.filePath : ''; + const objectKey = typeof entry.objectKey === 'string' && entry.objectKey ? entry.objectKey : null; + + if (!Number.isFinite(userId) || userId <= 0 || !fileHash || fileSize <= 0 || !filePath) { + return null; + } + + return db.prepare(` + INSERT INTO user_file_hash_index ( + user_id, storage_type, file_hash, file_size, file_path, object_key, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime')) + ON CONFLICT(user_id, storage_type, file_hash, file_size, file_path) + DO UPDATE SET + object_key = excluded.object_key, + updated_at = datetime('now', 'localtime') + `).run( + Math.floor(userId), + storageType, + fileHash, + fileSize, + filePath, + objectKey + ); + }, + + findLatestByHash(userId, storageType, fileHash, fileSize) { + const uid = Number(userId); + const normalizedStorage = this.normalizeStorageType(storageType); + const hash = typeof fileHash === 'string' ? fileHash.trim() : ''; + const size = Math.max(0, Math.floor(Number(fileSize) || 0)); + if (!Number.isFinite(uid) || uid <= 0 || !hash || size <= 0) { + return null; + } + + return db.prepare(` + SELECT * + FROM user_file_hash_index + WHERE user_id = ? + AND storage_type = ? + AND file_hash = ? + AND file_size = ? + ORDER BY updated_at DESC, id DESC + LIMIT 1 + `).get(Math.floor(uid), normalizedStorage, hash, size); + }, + + deleteByPath(userId, storageType, filePath) { + const uid = Number(userId); + const normalizedStorage = this.normalizeStorageType(storageType); + const safePath = typeof filePath === 'string' ? filePath : ''; + if (!Number.isFinite(uid) || uid <= 0 || !safePath) { + return { changes: 0 }; + } + + return db.prepare(` + DELETE FROM user_file_hash_index + WHERE user_id = ? + AND storage_type = ? + AND file_path = ? + `).run(Math.floor(uid), normalizedStorage, safePath); + }, + + getByPath(userId, storageType, filePath) { + const uid = Number(userId); + const normalizedStorage = this.normalizeStorageType(storageType); + const safePath = typeof filePath === 'string' ? filePath : ''; + if (!Number.isFinite(uid) || uid <= 0 || !safePath) { + return null; + } + + return db.prepare(` + SELECT * + FROM user_file_hash_index + WHERE user_id = ? + AND storage_type = ? + AND file_path = ? + ORDER BY updated_at DESC, id DESC + LIMIT 1 + `).get(Math.floor(uid), normalizedStorage, safePath); } }; @@ -2284,6 +2869,8 @@ module.exports = { PasswordResetTokenDB, DownloadTrafficReportDB, DownloadTrafficReservationDB, + UploadSessionDB, + FileHashIndexDB, DownloadTrafficIngestDB, SystemLogDB, TransactionDB, diff --git a/backend/server.js b/backend/server.js index a3b72ff..d13366c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -73,6 +73,8 @@ const { PasswordResetTokenDB, DownloadTrafficReportDB, DownloadTrafficReservationDB, + UploadSessionDB, + FileHashIndexDB, DownloadTrafficIngestDB, SystemLogDB, TransactionDB, @@ -95,6 +97,12 @@ const DOWNLOAD_RESERVATION_TTL_MS = Number(process.env.DOWNLOAD_RESERVATION_TTL_ const DOWNLOAD_LOG_RECONCILE_INTERVAL_MS = Number(process.env.DOWNLOAD_LOG_RECONCILE_INTERVAL_MS || (5 * 60 * 1000)); // 5分钟 const DOWNLOAD_LOG_MAX_FILES_PER_SWEEP = Number(process.env.DOWNLOAD_LOG_MAX_FILES_PER_SWEEP || 40); const DOWNLOAD_LOG_LIST_MAX_KEYS = Number(process.env.DOWNLOAD_LOG_LIST_MAX_KEYS || 200); +const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时 +const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB +const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB +const GLOBAL_SEARCH_DEFAULT_LIMIT = Number(process.env.GLOBAL_SEARCH_DEFAULT_LIMIT || 80); +const GLOBAL_SEARCH_MAX_LIMIT = Number(process.env.GLOBAL_SEARCH_MAX_LIMIT || 200); +const GLOBAL_SEARCH_MAX_SCANNED_NODES = Number(process.env.GLOBAL_SEARCH_MAX_SCANNED_NODES || 4000); const SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/; const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase(); const SHOULD_USE_SECURE_COOKIES = @@ -1418,6 +1426,43 @@ setTimeout(() => { runDownloadTrafficLogReconcile('startup'); }, 30 * 1000); +function cleanupExpiredUploadSessions(trigger = 'interval') { + try { + const expiredRows = UploadSessionDB.listExpiredActive(300); + if (!Array.isArray(expiredRows) || expiredRows.length === 0) { + return; + } + + let cleaned = 0; + for (const session of expiredRows) { + const tempFilePath = typeof session.temp_file_path === 'string' ? session.temp_file_path : ''; + if (tempFilePath) { + safeUnlink(tempFilePath); + } + UploadSessionDB.expireSession(session.session_id); + cleaned += 1; + } + + if (cleaned > 0) { + console.log(`[断点上传] 已清理过期会话 ${cleaned} 个 (trigger=${trigger})`); + } + } catch (error) { + console.error(`[断点上传] 清理过期会话失败 (trigger=${trigger}):`, error); + } +} + +const uploadSessionSweepTimer = setInterval(() => { + cleanupExpiredUploadSessions('interval'); +}, 5 * 60 * 1000); + +if (uploadSessionSweepTimer && typeof uploadSessionSweepTimer.unref === 'function') { + uploadSessionSweepTimer.unref(); +} + +setTimeout(() => { + cleanupExpiredUploadSessions('startup'); +}, 20 * 1000); + // 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret) function buildStorageUserContext(user, overrides = {}) { if (!user) { @@ -2135,6 +2180,509 @@ function isPathWithinShare(requestPath, share) { const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`; return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix); } + +function normalizeClientIp(rawIp) { + const ip = String(rawIp || '').trim(); + if (!ip) return ''; + if (ip.startsWith('::ffff:')) { + return ip.slice(7); + } + if (ip === '::1') { + return '127.0.0.1'; + } + return ip; +} + +function parseShareIpWhitelist(rawValue) { + if (typeof rawValue !== 'string') return []; + return rawValue + .split(/[\s,;]+/) + .map(item => item.trim()) + .filter(Boolean) + .slice(0, 100); +} + +function isShareIpAllowed(clientIp, whitelist = []) { + if (!clientIp || !Array.isArray(whitelist) || whitelist.length === 0) { + return true; + } + + for (const rule of whitelist) { + const normalizedRule = String(rule || '').trim(); + if (!normalizedRule) continue; + + if (normalizedRule === clientIp) { + return true; + } + + if (normalizedRule.endsWith('*')) { + const prefix = normalizedRule.slice(0, -1); + if (prefix && clientIp.startsWith(prefix)) { + return true; + } + } + } + + return false; +} + +function detectDeviceTypeFromUserAgent(userAgent = '') { + const ua = String(userAgent || ''); + const mobilePattern = /(Mobile|Android|iPhone|iPad|iPod|Windows Phone|HarmonyOS|Mobi)/i; + return mobilePattern.test(ua) ? 'mobile' : 'desktop'; +} + +function normalizeTimeHHmm(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + const match = trimmed.match(/^(\d{2}):(\d{2})$/); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null; + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +} + +function toMinutesOfDay(hhmm) { + const normalized = normalizeTimeHHmm(hhmm); + if (!normalized) return null; + const [hh, mm] = normalized.split(':').map(Number); + return hh * 60 + mm; +} + +function isCurrentTimeInWindow(startTime, endTime, now = new Date()) { + const start = toMinutesOfDay(startTime); + const end = toMinutesOfDay(endTime); + if (start === null || end === null) { + return true; + } + + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + if (start === end) { + return true; + } + if (start < end) { + return nowMinutes >= start && nowMinutes < end; + } + // 跨天窗口:如 22:00 - 06:00 + return nowMinutes >= start || nowMinutes < end; +} + +function getSharePolicySummary(share) { + const maxDownloads = Number(share?.max_downloads); + const whitelist = parseShareIpWhitelist(share?.ip_whitelist || ''); + const deviceLimit = ['all', 'mobile', 'desktop'].includes(share?.device_limit) + ? share.device_limit + : 'all'; + const accessTimeStart = normalizeTimeHHmm(share?.access_time_start || ''); + const accessTimeEnd = normalizeTimeHHmm(share?.access_time_end || ''); + + return { + max_downloads: Number.isFinite(maxDownloads) && maxDownloads > 0 ? Math.floor(maxDownloads) : null, + ip_whitelist_count: whitelist.length, + device_limit: deviceLimit, + access_time_start: accessTimeStart, + access_time_end: accessTimeEnd + }; +} + +function evaluateShareSecurityPolicy(share, req, options = {}) { + const action = options.action || 'view'; + const enforceDownloadLimit = options.enforceDownloadLimit === true; + + if (!share) { + return { allowed: false, code: 'share_not_found', message: '分享不存在' }; + } + + const clientIp = normalizeClientIp(req?.ip || req?.socket?.remoteAddress || ''); + const whitelist = parseShareIpWhitelist(share.ip_whitelist || ''); + if (whitelist.length > 0 && !isShareIpAllowed(clientIp, whitelist)) { + return { + allowed: false, + code: 'ip_not_allowed', + message: '当前访问环境受限,请稍后再试' + }; + } + + const deviceLimit = ['all', 'mobile', 'desktop'].includes(share.device_limit) + ? share.device_limit + : 'all'; + if (deviceLimit !== 'all') { + const deviceType = detectDeviceTypeFromUserAgent(req?.get('user-agent') || req?.headers?.['user-agent'] || ''); + if (deviceType !== deviceLimit) { + return { + allowed: false, + code: 'device_not_allowed', + message: '当前访问环境受限,请稍后再试' + }; + } + } + + const accessTimeStart = normalizeTimeHHmm(share.access_time_start || ''); + const accessTimeEnd = normalizeTimeHHmm(share.access_time_end || ''); + if (accessTimeStart && accessTimeEnd && !isCurrentTimeInWindow(accessTimeStart, accessTimeEnd, new Date())) { + return { + allowed: false, + code: 'time_not_allowed', + message: '当前访问环境受限,请稍后再试' + }; + } + + if (enforceDownloadLimit && action === 'download') { + const maxDownloads = Number(share.max_downloads); + const currentDownloads = Number(share.download_count || 0); + if (Number.isFinite(maxDownloads) && maxDownloads > 0 && currentDownloads >= maxDownloads) { + return { + allowed: false, + code: 'download_limit_reached', + message: '下载次数已达到上限' + }; + } + } + + return { allowed: true, code: 'ok', message: '' }; +} + +function normalizeFileHash(rawHash) { + if (typeof rawHash !== 'string') return null; + const trimmed = rawHash.trim(); + if (!trimmed) return null; + if (trimmed.length < 16 || trimmed.length > 128) return null; + if (!/^[A-Za-z0-9:_-]+$/.test(trimmed)) return null; + return trimmed; +} + +function getResumableUploadTempRoot() { + return path.join(__dirname, 'uploads', 'resumable'); +} + +function ensureResumableUploadTempRoot() { + const tempRoot = getResumableUploadTempRoot(); + if (!fs.existsSync(tempRoot)) { + fs.mkdirSync(tempRoot, { recursive: true, mode: 0o755 }); + } + return tempRoot; +} + +function buildResumableUploadExpiresAt(ttlMs = RESUMABLE_UPLOAD_SESSION_TTL_MS) { + const safeTtl = Math.max(10 * 60 * 1000, Number(ttlMs) || RESUMABLE_UPLOAD_SESSION_TTL_MS); + return formatDateTimeForSqlite(new Date(Date.now() + safeTtl)); +} + +function safeUnlink(filePath) { + if (!filePath) return; + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (error) { + console.error('[清理] 删除文件失败:', filePath, error.message); + } +} + +async function searchFilesRecursively(storage, startPath, keyword, options = {}) { + const queue = [startPath || '/']; + const visited = new Set(); + const results = []; + const normalizedKeyword = String(keyword || '').toLowerCase(); + const limit = Math.max(1, Math.min(GLOBAL_SEARCH_MAX_LIMIT, Math.floor(Number(options.limit) || GLOBAL_SEARCH_DEFAULT_LIMIT))); + const maxNodes = Math.max(limit, Math.floor(Number(options.maxNodes) || GLOBAL_SEARCH_MAX_SCANNED_NODES)); + const type = ['all', 'file', 'directory'].includes(options.type) ? options.type : 'all'; + + let scannedNodes = 0; + let scannedDirs = 0; + let truncated = false; + + while (queue.length > 0 && results.length < limit && scannedNodes < maxNodes) { + const currentPath = queue.shift() || '/'; + if (visited.has(currentPath)) continue; + visited.add(currentPath); + + let list; + try { + list = await storage.list(currentPath); + } catch (error) { + // 某个目录无法访问时跳过,不中断全局搜索 + continue; + } + scannedDirs += 1; + + for (const item of (Array.isArray(list) ? list : [])) { + const name = String(item?.name || ''); + if (!name) continue; + const isDirectory = item?.type === 'd'; + const absolutePath = currentPath === '/' + ? `/${name}` + : `${currentPath}/${name}`; + + scannedNodes += 1; + if (scannedNodes > maxNodes) { + truncated = true; + break; + } + + if (isDirectory) { + queue.push(absolutePath); + } + + if (normalizedKeyword && !name.toLowerCase().includes(normalizedKeyword)) { + continue; + } + + if (type === 'file' && isDirectory) { + continue; + } + if (type === 'directory' && !isDirectory) { + continue; + } + + results.push({ + name, + path: absolutePath, + parent_path: currentPath, + isDirectory, + type: isDirectory ? 'directory' : 'file', + size: Number(item?.size || 0), + sizeFormatted: formatFileSize(Number(item?.size || 0)), + modifiedAt: new Date(item?.modifyTime || Date.now()) + }); + + if (results.length >= limit) { + truncated = queue.length > 0 || scannedNodes >= maxNodes; + break; + } + } + } + + return { + results, + meta: { + keyword: keyword || '', + limit, + scanned_nodes: scannedNodes, + scanned_dirs: scannedDirs, + truncated + } + }; +} + +function normalizeUploadPath(rawPath) { + const safeRaw = typeof rawPath === 'string' ? rawPath : '/'; + if (safeRaw.includes('..') || safeRaw.includes('\x00')) { + return null; + } + const normalized = path.posix.normalize(safeRaw || '/'); + if (normalized.includes('..')) { + return null; + } + return normalized === '.' ? '/' : normalized; +} + +function buildVirtualFilePath(basePath, filename) { + const normalizedBasePath = normalizeUploadPath(basePath || '/'); + if (!normalizedBasePath) return null; + const safeName = sanitizeFilename(filename || ''); + return normalizedBasePath === '/' + ? `/${safeName}` + : `${normalizedBasePath}/${safeName}`; +} + +function isTrackableFileHash(fileHash, fileSize) { + return !!normalizeFileHash(fileHash) && Number.isFinite(Number(fileSize)) && Number(fileSize) > 0; +} + +async function trackFileHashIndexForUpload({ + userId, + storageType, + fileHash, + fileSize, + filePath, + objectKey = null +}) { + const normalizedHash = normalizeFileHash(fileHash); + const size = Math.floor(Number(fileSize) || 0); + if (!normalizedHash || !filePath || size <= 0) { + return; + } + FileHashIndexDB.upsert({ + userId, + storageType, + fileHash: normalizedHash, + fileSize: size, + filePath, + objectKey + }); +} + +async function tryInstantUploadByHash(user, { + storageType, + fileHash, + fileSize, + targetPath +} = {}) { + const uid = Number(user?.id); + const normalizedStorageType = storageType === 'oss' ? 'oss' : 'local'; + const normalizedHash = normalizeFileHash(fileHash); + const normalizedTargetPath = normalizeVirtualPath(targetPath || ''); + const normalizedSize = Math.floor(Number(fileSize) || 0); + + if (!Number.isFinite(uid) || uid <= 0 || !normalizedHash || !normalizedTargetPath || normalizedSize <= 0) { + return { instant: false }; + } + + const sourceEntry = FileHashIndexDB.findLatestByHash(uid, normalizedStorageType, normalizedHash, normalizedSize); + if (!sourceEntry) { + return { instant: false }; + } + + if (normalizedStorageType === 'local') { + const latestUser = UserDB.findById(uid); + if (!latestUser) { + return { instant: false }; + } + const localUser = buildStorageUserContext(latestUser, { current_storage_type: 'local' }); + const localStorage = new LocalStorageClient(localUser); + await localStorage.init(); + + const sourcePath = normalizeVirtualPath(sourceEntry.file_path || ''); + if (!sourcePath) { + return { instant: false }; + } + + if (sourcePath === normalizedTargetPath) { + const sourceFullPath = localStorage.getFullPath(sourcePath); + if (!fs.existsSync(sourceFullPath)) { + FileHashIndexDB.deleteByPath(uid, 'local', sourcePath); + return { instant: false }; + } + return { instant: true, alreadyExists: true, path: normalizedTargetPath }; + } + + const sourceFullPath = localStorage.getFullPath(sourcePath); + if (!fs.existsSync(sourceFullPath)) { + FileHashIndexDB.deleteByPath(uid, 'local', sourcePath); + return { instant: false }; + } + + await localStorage.put(sourceFullPath, normalizedTargetPath); + await trackFileHashIndexForUpload({ + userId: uid, + storageType: 'local', + fileHash: normalizedHash, + fileSize: normalizedSize, + filePath: normalizedTargetPath + }); + return { instant: true, alreadyExists: false, path: normalizedTargetPath }; + } + + // OSS 秒传:服务端 CopyObject,不走客户端上传 + const latestUser = UserDB.findById(uid); + if (!latestUser) { + return { instant: false }; + } + + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!latestUser.has_oss_config && !hasUnifiedConfig) { + return { instant: false }; + } + + const { HeadObjectCommand, CopyObjectCommand } = require('@aws-sdk/client-s3'); + const { client, bucket, ossClient } = createS3ClientContextForUser(latestUser); + const sourcePath = normalizeVirtualPath(sourceEntry.file_path || ''); + const sourceObjectKey = sourceEntry.object_key || (sourcePath ? ossClient.getObjectKey(sourcePath) : null); + const targetObjectKey = ossClient.getObjectKey(normalizedTargetPath); + + if (!sourceObjectKey || !targetObjectKey) { + return { instant: false }; + } + + if (sourceObjectKey === targetObjectKey) { + try { + await client.send(new HeadObjectCommand({ Bucket: bucket, Key: sourceObjectKey })); + return { instant: true, alreadyExists: true, path: normalizedTargetPath }; + } catch (error) { + FileHashIndexDB.deleteByPath(uid, 'oss', sourcePath || normalizedTargetPath); + return { instant: false }; + } + } + + let sourceSize = 0; + try { + const headSource = await client.send(new HeadObjectCommand({ + Bucket: bucket, + Key: sourceObjectKey + })); + sourceSize = Number(headSource?.ContentLength || 0); + } catch (error) { + const statusCode = error?.$metadata?.httpStatusCode; + if (error?.name === 'NotFound' || error?.name === 'NoSuchKey' || statusCode === 404) { + if (sourcePath) { + FileHashIndexDB.deleteByPath(uid, 'oss', sourcePath); + } + return { instant: false }; + } + throw error; + } + + if (!Number.isFinite(sourceSize) || sourceSize <= 0) { + return { instant: false }; + } + + let previousTargetSize = 0; + try { + const headTarget = await client.send(new HeadObjectCommand({ + Bucket: bucket, + Key: targetObjectKey + })); + previousTargetSize = Number(headTarget?.ContentLength || 0); + } catch (error) { + const statusCode = error?.$metadata?.httpStatusCode; + if (error?.name !== 'NotFound' && error?.name !== 'NoSuchKey' && statusCode !== 404) { + throw error; + } + } + + const latestUsageUser = UserDB.findById(uid) || latestUser; + const ossQuota = normalizeOssQuota(latestUsageUser?.oss_storage_quota); + const currentUsage = Number(latestUsageUser?.storage_used || 0); + const projectedUsage = Math.max(0, currentUsage + (sourceSize - previousTargetSize)); + if (projectedUsage > ossQuota) { + return { + instant: false, + blocked: true, + message: `OSS 配额不足:剩余 ${formatFileSize(Math.max(0, ossQuota - currentUsage))}` + }; + } + + const encodedSourceKey = sourceObjectKey + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/'); + const copySource = `${bucket}/${encodedSourceKey}`; + await client.send(new CopyObjectCommand({ + Bucket: bucket, + Key: targetObjectKey, + CopySource: copySource + })); + + const deltaSize = sourceSize - previousTargetSize; + if (deltaSize !== 0) { + await StorageUsageCache.updateUsage(uid, deltaSize); + } + clearOssUsageCache(uid); + + await trackFileHashIndexForUpload({ + userId: uid, + storageType: 'oss', + fileHash: normalizedHash, + fileSize: sourceSize, + filePath: normalizedTargetPath, + objectKey: targetObjectKey + }); + + return { instant: true, alreadyExists: false, path: normalizedTargetPath }; +} // 清理旧的临时文件(启动时执行一次) function cleanupOldTempFiles() { const uploadsDir = path.join(__dirname, 'uploads'); @@ -3870,6 +4418,74 @@ app.get('/api/files', authMiddleware, async (req, res) => { } }); +// 全局文件搜索(递归搜索当前存储空间) +app.get('/api/files/search', authMiddleware, async (req, res) => { + const keyword = typeof req.query?.keyword === 'string' ? req.query.keyword.trim() : ''; + const searchType = typeof req.query?.type === 'string' ? req.query.type.trim() : 'all'; + const rawStartPath = typeof req.query?.path === 'string' ? req.query.path : '/'; + const startPath = normalizeVirtualPath(rawStartPath); + const limit = Math.max(1, Math.min(GLOBAL_SEARCH_MAX_LIMIT, parseInt(req.query?.limit, 10) || GLOBAL_SEARCH_DEFAULT_LIMIT)); + let storage; + + if (!keyword || keyword.length < 1) { + return res.status(400).json({ + success: false, + message: '搜索关键词不能为空' + }); + } + + if (keyword.length > 80) { + return res.status(400).json({ + success: false, + message: '搜索关键词过长(最多80个字符)' + }); + } + + if (!startPath) { + return res.status(400).json({ + success: false, + message: '搜索路径非法' + }); + } + + if (!['all', 'file', 'directory'].includes(searchType)) { + return res.status(400).json({ + success: false, + message: '搜索类型无效' + }); + } + + try { + const { StorageInterface } = require('./storage'); + const storageInterface = new StorageInterface(req.user); + storage = await storageInterface.connect(); + + const searchResult = await searchFilesRecursively(storage, startPath, keyword, { + type: searchType, + limit, + maxNodes: GLOBAL_SEARCH_MAX_SCANNED_NODES + }); + + res.json({ + success: true, + keyword, + path: startPath, + type: searchType, + storageType: req.user.current_storage_type || 'oss', + items: searchResult.results, + meta: searchResult.meta + }); + } catch (error) { + console.error('全局搜索失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '全局搜索失败,请稍后重试', '全局搜索') + }); + } finally { + if (storage) await storage.end(); + } +}); + // 重命名文件 app.post('/api/files/rename', authMiddleware, async (req, res) => { const oldName = decodeHtmlEntities(req.body.oldName); @@ -3895,6 +4511,24 @@ app.post('/api/files/rename', authMiddleware, async (req, res) => { await storage.rename(oldPath, newPath); + const normalizedStorageType = req.user.current_storage_type === 'oss' ? 'oss' : 'local'; + 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 + }); + } + FileHashIndexDB.deleteByPath(req.user.id, normalizedStorageType, normalizedOldPath); + } + // 清除 OSS 使用情况缓存(如果用户使用 OSS) if (req.user.current_storage_type === 'oss') { clearOssUsageCache(req.user.id); @@ -4140,6 +4774,7 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => { const path = decodeHtmlEntities(rawPath) || '/'; let storage; let deletedSize = 0; + let deletedTargetPath = null; if (!fileName) { return res.status(400).json({ @@ -4182,6 +4817,7 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => { if (deleteResult && deleteResult.size !== undefined) { deletedSize = deleteResult.size; } + deletedTargetPath = normalizeVirtualPath(targetPath); break; } catch (err) { @@ -4211,6 +4847,14 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => { clearOssUsageCache(req.user.id); } + if (deletedTargetPath) { + FileHashIndexDB.deleteByPath( + req.user.id, + req.user.current_storage_type === 'oss' ? 'oss' : 'local', + deletedTargetPath + ); + } + res.json({ success: true, message: '删除成功' @@ -4226,6 +4870,549 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => { } }); +function calculateResumableUploadedBytes(chunks = [], totalChunks = 0, chunkSize = 0, fileSize = 0) { + if (!Array.isArray(chunks) || chunks.length === 0) return 0; + const safeTotalChunks = Math.max(1, Math.floor(Number(totalChunks) || 1)); + const safeChunkSize = Math.max(1, Math.floor(Number(chunkSize) || 1)); + const safeFileSize = Math.max(0, Math.floor(Number(fileSize) || 0)); + let total = 0; + + for (const chunkIndexRaw of chunks) { + const chunkIndex = Math.floor(Number(chunkIndexRaw)); + if (!Number.isFinite(chunkIndex) || chunkIndex < 0 || chunkIndex >= safeTotalChunks) { + continue; + } + const start = chunkIndex * safeChunkSize; + const end = Math.min(safeFileSize, start + safeChunkSize); + if (end > start) { + total += (end - start); + } + } + + return total; +} + +// 秒传预检查:命中哈希后由服务器直接复制,不走客户端上传流量 +app.post('/api/files/instant-upload/check', authMiddleware, async (req, res) => { + try { + const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''; + const fileHash = normalizeFileHash(req.body?.file_hash); + const fileSize = Math.floor(Number(req.body?.size) || 0); + const uploadPath = normalizeUploadPath(req.body?.path || '/'); + const storageType = req.user.current_storage_type === 'oss' ? 'oss' : 'local'; + + if (!filename || !isSafePathSegment(filename)) { + return res.status(400).json({ + success: false, + message: '文件名无效' + }); + } + + if (!uploadPath) { + return res.status(400).json({ + success: false, + message: '上传路径非法' + }); + } + + if (!fileHash || fileSize <= 0) { + return res.json({ + success: true, + instant: false, + message: '未命中秒传' + }); + } + + const targetPath = buildVirtualFilePath(uploadPath, filename); + if (!targetPath) { + return res.status(400).json({ + success: false, + message: '目标路径非法' + }); + } + + const instantResult = await tryInstantUploadByHash(req.user, { + storageType, + fileHash, + fileSize, + targetPath + }); + + if (instantResult?.blocked) { + return res.status(400).json({ + success: false, + message: instantResult.message || '当前无法执行秒传' + }); + } + + if (instantResult?.instant) { + return res.json({ + success: true, + instant: true, + already_exists: !!instantResult.alreadyExists, + path: instantResult.path || targetPath, + message: instantResult.alreadyExists ? '文件已存在,无需上传' : '秒传成功' + }); + } + + res.json({ + success: true, + instant: false, + message: '未命中秒传' + }); + } catch (error) { + console.error('秒传检查失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '秒传检查失败,请稍后重试', '秒传检查') + }); + } +}); + +// 本地分片上传初始化(断点续传) +app.post('/api/upload/resumable/init', authMiddleware, async (req, res) => { + try { + if ((req.user.current_storage_type || 'oss') !== 'local') { + return res.status(400).json({ + success: false, + message: '当前存储模式不支持分片上传' + }); + } + + const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : ''; + const uploadPath = normalizeUploadPath(req.body?.path || '/'); + const fileSize = Math.floor(Number(req.body?.size) || 0); + const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240', 10); + const requestedChunkSize = Math.floor(Number(req.body?.chunk_size) || RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES); + const chunkSize = Math.min( + RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES, + Math.max(256 * 1024, requestedChunkSize) + ); + const fileHash = normalizeFileHash(req.body?.file_hash); + + if (!filename || !isSafePathSegment(filename)) { + return res.status(400).json({ + success: false, + message: '文件名无效' + }); + } + + if (!isFileExtensionSafe(filename)) { + return res.status(400).json({ + success: false, + message: '不允许上传此类型的文件(安全限制)' + }); + } + + if (!uploadPath) { + return res.status(400).json({ + success: false, + message: '上传路径非法' + }); + } + + if (!Number.isFinite(fileSize) || fileSize <= 0) { + return res.status(400).json({ + success: false, + message: '文件大小无效' + }); + } + + if (Number.isFinite(maxUploadSize) && maxUploadSize > 0 && fileSize > maxUploadSize) { + return res.status(400).json({ + success: false, + message: `文件过大,最大允许 ${formatFileSize(maxUploadSize)}` + }); + } + + const targetPath = buildVirtualFilePath(uploadPath, filename); + if (!targetPath) { + return res.status(400).json({ + success: false, + message: '目标路径非法' + }); + } + + const totalChunks = Math.ceil(fileSize / chunkSize); + const existingSession = UploadSessionDB.findActiveForResume( + req.user.id, + targetPath, + fileSize, + fileHash || null + ); + + if (existingSession) { + if (existingSession.temp_file_path && fs.existsSync(existingSession.temp_file_path)) { + const uploadedChunks = UploadSessionDB.parseUploadedChunks(existingSession.uploaded_chunks); + const uploadedBytes = calculateResumableUploadedBytes( + uploadedChunks, + existingSession.total_chunks, + existingSession.chunk_size, + existingSession.file_size + ); + + UploadSessionDB.updateProgress( + existingSession.session_id, + uploadedChunks, + uploadedBytes, + buildResumableUploadExpiresAt() + ); + + return res.json({ + success: true, + resumed: true, + session_id: existingSession.session_id, + target_path: existingSession.target_path, + file_name: existingSession.file_name, + file_size: existingSession.file_size, + chunk_size: existingSession.chunk_size, + total_chunks: existingSession.total_chunks, + uploaded_chunks: uploadedChunks, + uploaded_bytes: uploadedBytes + }); + } + + // 会话存在但临时文件丢失,标记过期并重建 + UploadSessionDB.expireSession(existingSession.session_id); + } + + const tempRoot = ensureResumableUploadTempRoot(); + const sessionId = crypto.randomBytes(20).toString('hex'); + const tempFilePath = path.join(tempRoot, `${sessionId}.part`); + fs.closeSync(fs.openSync(tempFilePath, 'w')); + + const createdSession = UploadSessionDB.create({ + sessionId, + userId: req.user.id, + storageType: 'local', + targetPath, + fileName: filename, + fileSize, + chunkSize, + totalChunks, + uploadedChunks: [], + uploadedBytes: 0, + tempFilePath, + fileHash: fileHash || null, + expiresAt: buildResumableUploadExpiresAt() + }); + + if (!createdSession) { + safeUnlink(tempFilePath); + return res.status(500).json({ + success: false, + message: '创建分片上传会话失败' + }); + } + + res.json({ + success: true, + resumed: false, + session_id: createdSession.session_id, + target_path: createdSession.target_path, + file_name: createdSession.file_name, + file_size: createdSession.file_size, + chunk_size: createdSession.chunk_size, + total_chunks: createdSession.total_chunks, + uploaded_chunks: [] + }); + } catch (error) { + console.error('初始化分片上传失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '初始化分片上传失败,请稍后重试', '分片上传初始化') + }); + } +}); + +// 查询分片上传会话状态 +app.get('/api/upload/resumable/status', authMiddleware, (req, res) => { + try { + const sessionId = typeof req.query?.session_id === 'string' ? req.query.session_id.trim() : ''; + if (!sessionId) { + return res.status(400).json({ + success: false, + message: '缺少会话ID' + }); + } + + const session = UploadSessionDB.findBySessionId(sessionId); + if (!session || Number(session.user_id) !== Number(req.user.id)) { + return res.status(404).json({ + success: false, + message: '上传会话不存在' + }); + } + + const uploadedChunks = UploadSessionDB.parseUploadedChunks(session.uploaded_chunks); + const uploadedBytes = calculateResumableUploadedBytes( + uploadedChunks, + session.total_chunks, + session.chunk_size, + session.file_size + ); + + res.json({ + success: true, + session_id: session.session_id, + status: session.status, + target_path: session.target_path, + file_name: session.file_name, + file_size: session.file_size, + chunk_size: session.chunk_size, + total_chunks: session.total_chunks, + uploaded_chunks: uploadedChunks, + uploaded_bytes: uploadedBytes + }); + } catch (error) { + console.error('获取分片上传状态失败:', error); + res.status(500).json({ + success: false, + message: '获取上传状态失败' + }); + } +}); + +// 上传分片 +app.post('/api/upload/resumable/chunk', authMiddleware, upload.single('chunk'), async (req, res) => { + const tempChunkPath = req.file?.path; + try { + if ((req.user.current_storage_type || 'oss') !== 'local') { + if (tempChunkPath) safeDeleteFile(tempChunkPath); + return res.status(400).json({ + success: false, + message: '当前存储模式不支持分片上传' + }); + } + + const sessionId = typeof req.body?.session_id === 'string' ? req.body.session_id.trim() : ''; + const chunkIndex = Math.floor(Number(req.body?.chunk_index)); + if (!sessionId) { + return res.status(400).json({ + success: false, + message: '缺少会话ID' + }); + } + if (!Number.isFinite(chunkIndex) || chunkIndex < 0) { + return res.status(400).json({ + success: false, + message: '分片索引无效' + }); + } + if (!req.file) { + return res.status(400).json({ + success: false, + message: '缺少分片文件' + }); + } + + const session = UploadSessionDB.findBySessionId(sessionId); + if (!session || Number(session.user_id) !== Number(req.user.id)) { + return res.status(404).json({ + success: false, + message: '上传会话不存在' + }); + } + + if (session.status !== 'active') { + return res.status(409).json({ + success: false, + message: '上传会话已结束,请重新开始上传' + }); + } + + const sessionExpireAt = parseDateTimeValue(session.expires_at); + if (sessionExpireAt && Date.now() >= sessionExpireAt.getTime()) { + UploadSessionDB.expireSession(session.session_id); + return res.status(409).json({ + success: false, + message: '上传会话已过期,请重新开始上传' + }); + } + + const totalChunks = Math.max(1, Math.floor(Number(session.total_chunks) || 1)); + const chunkSize = Math.max(1, Math.floor(Number(session.chunk_size) || 1)); + const fileSize = Math.max(0, Math.floor(Number(session.file_size) || 0)); + + if (chunkIndex >= totalChunks) { + return res.status(400).json({ + success: false, + message: '分片索引超出范围' + }); + } + + const expectedChunkSize = chunkIndex === totalChunks - 1 + ? Math.max(0, fileSize - (chunkIndex * chunkSize)) + : chunkSize; + const receivedChunkSize = Math.max(0, Number(req.file.size || 0)); + if (receivedChunkSize <= 0 || receivedChunkSize > chunkSize || (expectedChunkSize > 0 && receivedChunkSize > expectedChunkSize)) { + return res.status(400).json({ + success: false, + message: '分片大小无效' + }); + } + + if (!session.temp_file_path || !fs.existsSync(session.temp_file_path)) { + return res.status(409).json({ + success: false, + message: '上传会话文件已丢失,请重新开始上传' + }); + } + + const chunkBuffer = fs.readFileSync(req.file.path); + const offset = chunkIndex * chunkSize; + const fd = fs.openSync(session.temp_file_path, 'r+'); + try { + fs.writeSync(fd, chunkBuffer, 0, chunkBuffer.length, offset); + } finally { + fs.closeSync(fd); + } + + const uploadedChunks = UploadSessionDB.parseUploadedChunks(session.uploaded_chunks); + if (!uploadedChunks.includes(chunkIndex)) { + uploadedChunks.push(chunkIndex); + uploadedChunks.sort((a, b) => a - b); + } + + const uploadedBytes = calculateResumableUploadedBytes( + uploadedChunks, + totalChunks, + chunkSize, + fileSize + ); + + UploadSessionDB.updateProgress( + session.session_id, + uploadedChunks, + uploadedBytes, + buildResumableUploadExpiresAt() + ); + + res.json({ + success: true, + session_id: session.session_id, + chunk_index: chunkIndex, + uploaded_chunks_count: uploadedChunks.length, + total_chunks: totalChunks, + uploaded_bytes: uploadedBytes, + progress: fileSize > 0 ? Math.min(100, Math.round((uploadedBytes / fileSize) * 100)) : 0 + }); + } catch (error) { + console.error('上传分片失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '上传分片失败,请稍后重试', '上传分片') + }); + } finally { + if (tempChunkPath) { + safeDeleteFile(tempChunkPath); + } + } +}); + +// 完成分片上传(写入本地存储) +app.post('/api/upload/resumable/complete', authMiddleware, async (req, res) => { + try { + if ((req.user.current_storage_type || 'oss') !== 'local') { + return res.status(400).json({ + success: false, + message: '当前存储模式不支持分片上传' + }); + } + + const sessionId = typeof req.body?.session_id === 'string' ? req.body.session_id.trim() : ''; + if (!sessionId) { + return res.status(400).json({ + success: false, + message: '缺少会话ID' + }); + } + + const session = UploadSessionDB.findBySessionId(sessionId); + if (!session || Number(session.user_id) !== Number(req.user.id)) { + return res.status(404).json({ + success: false, + message: '上传会话不存在' + }); + } + + if (session.status !== 'active') { + return res.status(409).json({ + success: false, + message: '上传会话已结束,请重新上传' + }); + } + + const uploadedChunks = UploadSessionDB.parseUploadedChunks(session.uploaded_chunks); + const totalChunks = Math.max(1, Math.floor(Number(session.total_chunks) || 1)); + if (uploadedChunks.length < totalChunks) { + return res.status(409).json({ + success: false, + message: `分片未上传完成(${uploadedChunks.length}/${totalChunks})` + }); + } + + if (!session.temp_file_path || !fs.existsSync(session.temp_file_path)) { + return res.status(409).json({ + success: false, + message: '上传临时文件不存在,请重新上传' + }); + } + + const tempFileStats = fs.statSync(session.temp_file_path); + const fileSize = Math.max(0, Math.floor(Number(session.file_size) || 0)); + if (tempFileStats.size < fileSize) { + return res.status(409).json({ + success: false, + message: '分片数据不完整,请重试' + }); + } + + const latestUser = UserDB.findById(req.user.id); + if (!latestUser) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + const localUserContext = buildStorageUserContext(latestUser, { + current_storage_type: 'local' + }); + const localStorage = new LocalStorageClient(localUserContext); + await localStorage.init(); + await localStorage.put(session.temp_file_path, session.target_path); + + UploadSessionDB.setStatus(session.session_id, 'completed', { + completed: true, + expiresAt: buildResumableUploadExpiresAt(60 * 1000) + }); + safeUnlink(session.temp_file_path); + + await trackFileHashIndexForUpload({ + userId: req.user.id, + storageType: 'local', + fileHash: session.file_hash, + fileSize, + filePath: session.target_path + }); + + res.json({ + success: true, + message: '分片上传完成', + path: session.target_path, + file_name: session.file_name, + file_size: fileSize + }); + } catch (error) { + console.error('完成分片上传失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '完成分片上传失败,请稍后重试', '完成分片上传') + }); + } +}); + // ========== OSS 直连相关接口(Presigned URL)========== // 生成 OSS 上传签名 URL(用户直连 OSS 上传,不经过后端) @@ -4234,6 +5421,7 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => { const uploadPath = req.query.path || '/'; // 上传目标路径 const contentType = req.query.contentType || 'application/octet-stream'; const fileSize = Number(req.query.size); + const fileHash = normalizeFileHash(req.query.fileHash); if (!filename) { return res.status(400).json({ @@ -4362,7 +5550,8 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => { userId: req.user.id, objectKey, previousSize, - expectedSize: fileSize + expectedSize: fileSize, + fileHash: fileHash || null }, 30 * 60); // 创建 PutObject 命令 @@ -4459,6 +5648,8 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { const previousObjectSize = Number.isFinite(Number(completionPayload.previousSize)) && Number(completionPayload.previousSize) >= 0 ? Number(completionPayload.previousSize) : 0; + const completionFileHash = normalizeFileHash(completionPayload.fileHash); + const virtualFilePath = `/${normalizedObjectKey.slice(expectedPrefix.length)}`; let ossClient; @@ -4519,6 +5710,15 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { // 同时更新旧的内存缓存(保持兼容性) clearOssUsageCache(req.user.id); + await trackFileHashIndexForUpload({ + userId: req.user.id, + storageType: 'oss', + fileHash: completionFileHash, + fileSize: verifiedSize, + filePath: normalizeVirtualPath(virtualFilePath), + objectKey: normalizedObjectKey + }); + console.log(`[上传完成] 用户 ${req.user.id} 文件大小: ${verifiedSize} 字节, 增量: ${deltaSize} 字节`); res.json({ success: true, @@ -4739,6 +5939,7 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) } const remotePath = req.body.path || '/'; + const fileHash = normalizeFileHash(req.body?.file_hash || req.body?.fileHash); // 修复中文文件名:multer将UTF-8转为了Latin1,需要转回来 const originalFilename = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); @@ -4796,6 +5997,14 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) await storage.put(req.file.path, remoteFilePath); console.log(`[上传] 文件上传成功: ${remoteFilePath}`); + await trackFileHashIndexForUpload({ + userId: req.user.id, + storageType: 'local', + fileHash, + fileSize: req.file.size, + filePath: normalizeVirtualPath(remoteFilePath) + }); + // 清除 OSS 使用情况缓存(如果用户使用 OSS) if (req.user.current_storage_type === 'oss') { clearOssUsageCache(req.user.id); @@ -5298,7 +6507,18 @@ app.post('/api/upload/get-config', async (req, res) => { // 创建分享链接 app.post('/api/share/create', authMiddleware, (req, res) => { try { - const { share_type, file_path, file_name, password, expiry_days } = req.body; + const { + share_type, + file_path, + file_name, + password, + expiry_days, + max_downloads, + ip_whitelist, + device_limit, + access_time_start, + access_time_end + } = req.body; // 参数验证:share_type 只能是 'file' 或 'directory' const validShareTypes = ['file', 'directory']; @@ -5359,6 +6579,59 @@ app.post('/api/share/create', authMiddleware, (req, res) => { } } + let normalizedMaxDownloads = null; + if (max_downloads !== undefined && max_downloads !== null && max_downloads !== '') { + const parsedMaxDownloads = parseInt(max_downloads, 10); + if (!Number.isFinite(parsedMaxDownloads) || parsedMaxDownloads < 1 || parsedMaxDownloads > 1000000) { + return res.status(400).json({ + success: false, + message: '下载次数上限必须是 1 到 1000000 的整数' + }); + } + normalizedMaxDownloads = parsedMaxDownloads; + } + + let normalizedWhitelist = null; + if (ip_whitelist !== undefined && ip_whitelist !== null && ip_whitelist !== '') { + const rawWhitelist = Array.isArray(ip_whitelist) + ? ip_whitelist.join(',') + : String(ip_whitelist); + const whitelistItems = parseShareIpWhitelist(rawWhitelist); + if (whitelistItems.length === 0) { + return res.status(400).json({ + success: false, + message: 'IP 白名单格式无效' + }); + } + normalizedWhitelist = whitelistItems.join(','); + } + + const allowedDeviceLimit = new Set(['all', 'mobile', 'desktop']); + const normalizedDeviceLimit = typeof device_limit === 'string' && device_limit.trim() + ? device_limit.trim() + : 'all'; + if (!allowedDeviceLimit.has(normalizedDeviceLimit)) { + return res.status(400).json({ + success: false, + message: '设备限制参数无效' + }); + } + + const normalizedAccessTimeStart = normalizeTimeHHmm(access_time_start || ''); + const normalizedAccessTimeEnd = normalizeTimeHHmm(access_time_end || ''); + if ((normalizedAccessTimeStart && !normalizedAccessTimeEnd) || (!normalizedAccessTimeStart && normalizedAccessTimeEnd)) { + return res.status(400).json({ + success: false, + message: '访问时间限制需同时提供开始和结束时间' + }); + } + if ((access_time_start && !normalizedAccessTimeStart) || (access_time_end && !normalizedAccessTimeEnd)) { + return res.status(400).json({ + success: false, + message: '访问时间格式必须为 HH:mm' + }); + } + const normalizedSharePath = normalizeVirtualPath(file_path); if (!normalizedSharePath) { return res.status(400).json({ @@ -5372,7 +6645,17 @@ app.post('/api/share/create', authMiddleware, (req, res) => { category: 'share', action: 'create_share', message: '创建分享请求', - details: { share_type: actualShareType, file_path: normalizedSharePath, file_name, expiry_days } + details: { + share_type: actualShareType, + file_path: normalizedSharePath, + file_name, + expiry_days, + max_downloads: normalizedMaxDownloads, + ip_whitelist_count: normalizedWhitelist ? parseShareIpWhitelist(normalizedWhitelist).length : 0, + device_limit: normalizedDeviceLimit, + access_time_start: normalizedAccessTimeStart, + access_time_end: normalizedAccessTimeEnd + } }); const result = ShareDB.create(req.user.id, { @@ -5380,7 +6663,12 @@ app.post('/api/share/create', authMiddleware, (req, res) => { file_path: normalizedSharePath, file_name: file_name || '', password: normalizedPassword || null, - expiry_days: expiry_days || null + expiry_days: expiry_days || null, + max_downloads: normalizedMaxDownloads, + ip_whitelist: normalizedWhitelist, + device_limit: normalizedDeviceLimit, + access_time_start: normalizedAccessTimeStart, + access_time_end: normalizedAccessTimeEnd }); // 更新分享的存储类型 @@ -5392,9 +6680,31 @@ app.post('/api/share/create', authMiddleware, (req, res) => { // 记录分享创建日志 logShare(req, 'create_share', `用户创建分享: ${actualShareType === 'file' ? '文件' : '目录'} ${normalizedSharePath}`, - { shareCode: result.share_code, sharePath: normalizedSharePath, shareType: actualShareType, hasPassword: !!normalizedPassword } + { + shareCode: result.share_code, + sharePath: normalizedSharePath, + shareType: actualShareType, + hasPassword: !!normalizedPassword, + policy: getSharePolicySummary({ + ...result, + max_downloads: normalizedMaxDownloads, + ip_whitelist: normalizedWhitelist, + device_limit: normalizedDeviceLimit, + access_time_start: normalizedAccessTimeStart, + access_time_end: normalizedAccessTimeEnd + }) + } ); + const securityPolicy = getSharePolicySummary({ + ...result, + max_downloads: normalizedMaxDownloads, + ip_whitelist: normalizedWhitelist, + device_limit: normalizedDeviceLimit, + access_time_start: normalizedAccessTimeStart, + access_time_end: normalizedAccessTimeEnd + }); + res.json({ success: true, message: '分享链接创建成功', @@ -5402,7 +6712,8 @@ app.post('/api/share/create', authMiddleware, (req, res) => { share_url: shareUrl, share_type: result.share_type, expires_at: result.expires_at, - has_password: !!normalizedPassword + has_password: !!normalizedPassword, + security_policy: securityPolicy }); } catch (error) { console.error('创建分享链接失败:', error); @@ -5774,6 +7085,18 @@ app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) = shareLimiter.recordSuccess(req.shareRateLimitKey); } + const accessPolicy = evaluateShareSecurityPolicy(share, req, { + action: 'view', + enforceDownloadLimit: false + }); + if (!accessPolicy.allowed) { + return res.status(403).json({ + success: false, + message: accessPolicy.message || '当前访问环境受限,请稍后再试', + policy_code: accessPolicy.code || 'policy_blocked' + }); + } + // 增加查看次数 ShareDB.incrementViewCount(code); @@ -5785,7 +7108,8 @@ app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) = share_type: share.share_type, username: share.username, created_at: share.created_at, - expires_at: share.expires_at // 添加到期时间 + expires_at: share.expires_at, // 添加到期时间 + security_policy: getSharePolicySummary(share) } }; @@ -5928,6 +7252,18 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => shareLimiter.recordSuccess(req.shareRateLimitKey); } + const accessPolicy = evaluateShareSecurityPolicy(share, req, { + action: 'view', + enforceDownloadLimit: false + }); + if (!accessPolicy.allowed) { + return res.status(403).json({ + success: false, + message: accessPolicy.message || '当前访问环境受限,请稍后再试', + policy_code: accessPolicy.code || 'policy_blocked' + }); + } + // 获取分享者的用户信息(查看列表不触发下载流量策略) // 仅在实际下载接口中校验和消耗下载流量,避免“可见性”受配额影响 const shareOwner = UserDB.findById(share.user_id); @@ -6071,6 +7407,18 @@ app.post('/api/share/:code/download', shareRateLimitMiddleware, (req, res) => { }); } + const accessPolicy = evaluateShareSecurityPolicy(share, req, { + action: 'download', + enforceDownloadLimit: true + }); + if (!accessPolicy.allowed) { + return res.status(403).json({ + success: false, + message: accessPolicy.message || '下载已受限', + policy_code: accessPolicy.code || 'policy_blocked' + }); + } + // 增加下载次数 ShareDB.incrementDownloadCount(code); @@ -6144,6 +7492,18 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, }); } + const accessPolicy = evaluateShareSecurityPolicy(share, req, { + action: 'download', + enforceDownloadLimit: true + }); + if (!accessPolicy.allowed) { + return res.status(403).json({ + success: false, + message: accessPolicy.message || '下载已受限', + policy_code: accessPolicy.code || 'policy_blocked' + }); + } + // 获取分享者的用户信息 const ownerPolicyState = enforceDownloadTrafficPolicy(share.user_id, 'share_download'); const shareOwner = ownerPolicyState?.user || UserDB.findById(share.user_id); @@ -6427,6 +7787,18 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, }); } + const accessPolicy = evaluateShareSecurityPolicy(share, req, { + action: 'download', + enforceDownloadLimit: true + }); + if (!accessPolicy.allowed) { + return res.status(403).json({ + success: false, + message: accessPolicy.message || '下载已受限', + policy_code: accessPolicy.code || 'policy_blocked' + }); + } + // 获取分享者的用户信息 const shareOwner = UserDB.findById(share.user_id); if (!shareOwner) { @@ -7399,6 +8771,112 @@ app.post('/api/admin/logs/cleanup', } }); +// 下载流量预扣运维面板:查询 +app.get('/api/admin/download-reservations', authMiddleware, adminMiddleware, (req, res) => { + try { + const queryResult = DownloadTrafficReservationDB.getAdminList({ + status: req.query?.status, + userId: req.query?.user_id, + keyword: req.query?.keyword, + page: req.query?.page, + pageSize: req.query?.pageSize + }); + + res.json({ + success: true, + reservations: queryResult.rows || [], + pagination: queryResult.pagination, + summary: DownloadTrafficReservationDB.getAdminSummary() + }); + } catch (error) { + console.error('获取下载预扣列表失败:', error); + res.status(500).json({ + success: false, + message: '获取下载预扣列表失败' + }); + } +}); + +// 下载流量预扣运维面板:手动释放单条 pending 预扣 +app.post('/api/admin/download-reservations/:id/cancel', authMiddleware, adminMiddleware, (req, res) => { + try { + const reservationId = parseInt(req.params.id, 10); + if (!Number.isFinite(reservationId) || reservationId <= 0) { + return res.status(400).json({ + success: false, + message: '无效的预扣ID' + }); + } + + const cancelResult = DownloadTrafficReservationDB.cancelPendingById(reservationId); + if (!cancelResult?.changes) { + return res.status(404).json({ + success: false, + message: '预扣记录不存在或已完成' + }); + } + + logSystem( + req, + 'download_reservation_cancel', + `管理员手动释放预扣 #${reservationId}`, + { + reservationId, + row: cancelResult.row || null + } + ); + + res.json({ + success: true, + message: '预扣额度已释放', + reservation: cancelResult.row || null + }); + } catch (error) { + console.error('释放下载预扣失败:', error); + res.status(500).json({ + success: false, + message: '释放下载预扣失败' + }); + } +}); + +// 下载流量预扣运维面板:批量清理(过期 pending + 历史 finalized) +app.post('/api/admin/download-reservations/cleanup', authMiddleware, adminMiddleware, (req, res) => { + try { + const keepDays = Math.min(365, Math.max(1, parseInt(req.body?.keep_days, 10) || 7)); + const expireResult = DownloadTrafficReservationDB.expirePendingReservations(); + const cleanupResult = DownloadTrafficReservationDB.cleanupFinalizedHistory(keepDays); + + logSystem( + req, + 'download_reservation_cleanup', + `管理员执行预扣清理(保留${keepDays}天)`, + { + keep_days: keepDays, + expired_pending: Number(expireResult?.changes || 0), + deleted_finalized: Number(cleanupResult?.changes || 0) + } + ); + + res.json({ + success: true, + message: '预扣清理完成', + result: { + keep_days: keepDays, + expired_pending: Number(expireResult?.changes || 0), + deleted_finalized: Number(cleanupResult?.changes || 0), + summary: DownloadTrafficReservationDB.getAdminSummary() + } + }); + } catch (error) { + console.error('清理下载预扣失败:', error); + res.status(500).json({ + success: false, + message: '清理下载预扣失败' + }); + } +}); + // ===== 第二轮修复:存储缓存一致性检查和修复接口 ===== /** diff --git a/frontend/app.html b/frontend/app.html index c51e65c..4f1aecc 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -1969,6 +1969,68 @@ +
+
+ + + + +
+ +
+
+ 正在搜索... +
+
+ {{ globalSearchError }} +
+
+ 暂无匹配结果 +
+
+ +
+ 结果已截断,请缩小关键词范围 +
+
+
+
@@ -2209,6 +2271,49 @@
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+
+ +
+
+

下载预扣运维面板

+
+ + +
+
+ +
+ + + +
+ +
+ + + + + + +
+ +
+ 正在加载预扣数据... +
+ +
+ 暂无预扣记录 +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + {{ reservationMonitor.page }} / {{ reservationMonitor.totalPages }} + + +
+
+
diff --git a/frontend/app.js b/frontend/app.js index 02bffa8..b6249d3 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -103,7 +103,15 @@ createApp({ enablePassword: false, password: "", expiryType: "never", - customDays: 7 + customDays: 7, + enableAdvancedSecurity: false, + maxDownloadsEnabled: false, + maxDownloads: 10, + ipWhitelist: '', + deviceLimit: 'all', + accessTimeEnabled: false, + accessTimeStart: '09:00', + accessTimeEnd: '23:00' }, shareResult: null, showDirectLinkModal: false, @@ -150,6 +158,15 @@ createApp({ isDragging: false, modalMouseDownTarget: null, // 模态框鼠标按下的目标 + // 全局搜索(文件页) + globalSearchKeyword: '', + globalSearchType: 'all', // all/file/directory + globalSearchLoading: false, + globalSearchError: '', + globalSearchResults: [], + globalSearchMeta: null, + globalSearchVisible: false, + // 管理员 adminUsers: [], adminUsersLoading: false, @@ -246,6 +263,23 @@ createApp({ } }, + // 下载预扣运维面板(管理员-监控) + reservationMonitor: { + loading: false, + cleaning: false, + rows: [], + summary: null, + page: 1, + pageSize: 20, + total: 0, + totalPages: 1, + filters: { + status: 'pending', + keyword: '', + userId: '' + } + }, + // 监控页整体加载遮罩(避免刷新时闪一下空态) monitorTabLoading: initialAdminTab === 'monitor', @@ -1542,6 +1576,7 @@ handleDragLeave(e) { this.loading = true; // 确保路径不为undefined this.currentPath = path || '/'; + this.globalSearchVisible = false; try { const response = await axios.get(`${this.apiBase}/api/files`, { @@ -1580,6 +1615,77 @@ handleDragLeave(e) { } }, + triggerGlobalSearch() { + const keyword = String(this.globalSearchKeyword || '').trim(); + if (!keyword) { + this.clearGlobalSearch(false); + return; + } + + if (!this._debouncedGlobalSearch) { + this._debouncedGlobalSearch = this.debounce(() => { + this.runGlobalSearch(); + }, 260); + } + this._debouncedGlobalSearch(); + }, + + async runGlobalSearch() { + const keyword = String(this.globalSearchKeyword || '').trim(); + if (!keyword) { + this.clearGlobalSearch(false); + return; + } + + this.globalSearchLoading = true; + this.globalSearchError = ''; + this.globalSearchVisible = true; + + try { + const response = await axios.get(`${this.apiBase}/api/files/search`, { + params: { + keyword, + path: '/', + type: this.globalSearchType || 'all', + limit: 80 + } + }); + + if (response.data?.success) { + this.globalSearchResults = Array.isArray(response.data.items) ? response.data.items : []; + this.globalSearchMeta = response.data.meta || null; + } else { + this.globalSearchResults = []; + this.globalSearchMeta = null; + this.globalSearchError = response.data?.message || '搜索失败'; + } + } catch (error) { + this.globalSearchResults = []; + this.globalSearchMeta = null; + this.globalSearchError = error.response?.data?.message || '搜索失败'; + } finally { + this.globalSearchLoading = false; + } + }, + + clearGlobalSearch(clearKeyword = true) { + if (clearKeyword) { + this.globalSearchKeyword = ''; + } + this.globalSearchLoading = false; + this.globalSearchError = ''; + this.globalSearchResults = []; + this.globalSearchMeta = null; + this.globalSearchVisible = false; + }, + + async jumpToSearchResult(item) { + if (!item || !item.parent_path) return; + this.clearGlobalSearch(false); + await this.loadFiles(item.parent_path); + this.showToast('info', '已定位', `已定位到 ${item.name}`); + }, + async handleFileClick(file) { // 修复:长按后会触发一次 click,需要忽略避免误打开文件/目录 if (this.longPressTriggered) { @@ -2221,6 +2327,14 @@ handleDragLeave(e) { this.shareFileForm.password = ''; this.shareFileForm.expiryType = 'never'; this.shareFileForm.customDays = 7; + this.shareFileForm.enableAdvancedSecurity = false; + this.shareFileForm.maxDownloadsEnabled = false; + this.shareFileForm.maxDownloads = 10; + this.shareFileForm.ipWhitelist = ''; + this.shareFileForm.deviceLimit = 'all'; + this.shareFileForm.accessTimeEnabled = false; + this.shareFileForm.accessTimeStart = '09:00'; + this.shareFileForm.accessTimeEnd = '23:00'; this.shareResult = null; // 清空上次的分享结果 this.showShareFileModal = true; }, @@ -2267,6 +2381,45 @@ handleDragLeave(e) { return { valid: true, value: password }; }, + buildShareSecurityPayload() { + if (!this.shareFileForm.enableAdvancedSecurity) { + return { valid: true, payload: {} }; + } + + const payload = {}; + + if (this.shareFileForm.maxDownloadsEnabled) { + const maxDownloads = Number(this.shareFileForm.maxDownloads); + if (!Number.isInteger(maxDownloads) || maxDownloads < 1 || maxDownloads > 1000000) { + return { valid: false, message: '下载次数上限需为 1 到 1000000 的整数' }; + } + payload.max_downloads = maxDownloads; + } + + const whitelistText = String(this.shareFileForm.ipWhitelist || '').trim(); + if (whitelistText) { + payload.ip_whitelist = whitelistText; + } + + if (!['all', 'mobile', 'desktop'].includes(this.shareFileForm.deviceLimit)) { + return { valid: false, message: '设备限制参数无效' }; + } + payload.device_limit = this.shareFileForm.deviceLimit; + + if (this.shareFileForm.accessTimeEnabled) { + const start = String(this.shareFileForm.accessTimeStart || '').trim(); + const end = String(this.shareFileForm.accessTimeEnd || '').trim(); + const timeReg = /^([01]\d|2[0-3]):([0-5]\d)$/; + if (!timeReg.test(start) || !timeReg.test(end)) { + return { valid: false, message: '访问时段格式必须为 HH:mm' }; + } + payload.access_time_start = start; + payload.access_time_end = end; + } + + return { valid: true, payload }; + }, + buildShareResult(data, options = {}) { return { ...data, @@ -2295,19 +2448,24 @@ handleDragLeave(e) { const expiryDays = expiryCheck.value; const password = passwordCheck.value; + const securityCheck = this.buildShareSecurityPayload(); + if (!securityCheck.valid) { + this.showToast('warning', '提示', securityCheck.message); + return; + } // 根据是否为文件夹决定share_type const shareType = this.shareFileForm.isDirectory ? 'directory' : 'file'; const response = await axios.post( `${this.apiBase}/api/share/create`, - { + Object.assign({ share_type: shareType, // 修复:文件夹使用directory类型 file_path: this.shareFileForm.filePath, file_name: this.shareFileForm.fileName, password, expiry_days: expiryDays - }, + }, securityCheck.payload), ); if (response.data.success) { @@ -2421,12 +2579,27 @@ handleDragLeave(e) { this.totalBytes = file.size; try { + const fileHash = await this.computeQuickFileHash(file); + const instantUploaded = await this.checkInstantUpload(file, fileHash); + if (instantUploaded) { + this.uploadProgress = 0; + this.uploadedBytes = 0; + this.totalBytes = 0; + this.uploadingFileName = ''; + await this.loadFiles(this.currentPath); + await this.refreshStorageUsage(); + return; + } + if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') { // ===== OSS 直连上传(不经过后端) ===== - await this.uploadToOSSDirect(file); + await this.uploadToOSSDirect(file, fileHash); } else { - // ===== 本地存储上传(经过后端) ===== - await this.uploadToLocal(file); + // ===== 本地存储优先分片上传(断点续传) ===== + const resumableOk = await this.uploadToLocalResumable(file, fileHash); + if (!resumableOk) { + await this.uploadToLocal(file, fileHash); + } } } catch (error) { console.error('上传失败:', error); @@ -2441,8 +2614,85 @@ handleDragLeave(e) { } }, + async computeQuickFileHash(file) { + try { + if (!file || !window.crypto?.subtle) { + return null; + } + + const sampleSize = 2 * 1024 * 1024; // 2MB + const fullHashLimit = 8 * 1024 * 1024; // 8MB + const chunks = []; + + if (file.size <= fullHashLimit) { + chunks.push(new Uint8Array(await file.arrayBuffer())); + } else { + const first = await file.slice(0, sampleSize).arrayBuffer(); + const middleStart = Math.max(0, Math.floor(file.size / 2) - Math.floor(sampleSize / 2)); + const middle = await file.slice(middleStart, middleStart + sampleSize).arrayBuffer(); + const lastStart = Math.max(0, file.size - sampleSize); + const last = await file.slice(lastStart).arrayBuffer(); + const meta = new TextEncoder().encode(`${file.name}|${file.size}|${file.lastModified}`); + + chunks.push( + new Uint8Array(first), + new Uint8Array(middle), + new Uint8Array(last), + meta + ); + } + + const totalLength = chunks.reduce((sum, arr) => sum + arr.length, 0); + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const arr of chunks) { + merged.set(arr, offset); + offset += arr.length; + } + + const digest = await window.crypto.subtle.digest('SHA-256', merged.buffer); + const hashHex = Array.from(new Uint8Array(digest)) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + return hashHex || null; + } catch (error) { + console.warn('计算文件哈希失败,跳过秒传:', error); + return null; + } + }, + + async checkInstantUpload(file, fileHash) { + if (!file || !fileHash) { + return false; + } + + try { + const response = await axios.post(`${this.apiBase}/api/files/instant-upload/check`, { + filename: file.name, + path: this.currentPath, + size: file.size, + file_hash: fileHash + }); + + if (response.data?.success && response.data.instant) { + this.showToast('success', '秒传成功', response.data.message || `文件 ${file.name} 已秒传`); + return true; + } + return false; + } catch (error) { + const status = Number(error.response?.status || 0); + const message = error.response?.data?.message; + // 4xx 视为明确业务失败,直接抛给外层;5xx/网络错误降级为普通上传 + if (status >= 400 && status < 500 && message) { + throw new Error(message); + } + console.warn('秒传检查失败,降级普通上传:', error); + return false; + } + }, + // OSS 直连上传 - async uploadToOSSDirect(file) { + async uploadToOSSDirect(file, fileHash = null) { try { // 预检查 OSS 配额(后端也会做强校验,未配置默认 1GB) const ossQuota = Number(this.user?.oss_storage_quota || DEFAULT_OSS_STORAGE_QUOTA_BYTES); @@ -2458,7 +2708,8 @@ handleDragLeave(e) { filename: file.name, path: this.currentPath, contentType: file.type || 'application/octet-stream', - size: file.size + size: file.size, + fileHash: fileHash || undefined } }); @@ -2513,8 +2764,101 @@ handleDragLeave(e) { } }, + // 本地分片上传(断点续传) + async uploadToLocalResumable(file, fileHash = null) { + // 本地存储配额预检查 + const estimatedUsage = this.localUsed + file.size; + if (estimatedUsage > this.localQuota) { + this.showToast( + 'error', + '配额不足', + `文件大小 ${this.formatBytes(file.size)},剩余配额 ${this.formatBytes(this.localQuota - this.localUsed)}` + ); + return true; + } + + try { + const initResponse = await axios.post(`${this.apiBase}/api/upload/resumable/init`, { + filename: file.name, + path: this.currentPath, + size: file.size, + chunk_size: 4 * 1024 * 1024, + file_hash: fileHash || undefined + }); + + if (!initResponse.data?.success) { + throw new Error(initResponse.data?.message || '初始化分片上传失败'); + } + + const sessionId = initResponse.data.session_id; + const chunkSize = Number(initResponse.data.chunk_size || 4 * 1024 * 1024); + const totalChunks = Number(initResponse.data.total_chunks || Math.ceil(file.size / chunkSize)); + const uploadedSet = new Set(Array.isArray(initResponse.data.uploaded_chunks) ? initResponse.data.uploaded_chunks : []); + let uploadedBytes = Number(initResponse.data.uploaded_bytes || 0); + + if (uploadedBytes > 0 && file.size > 0) { + this.uploadedBytes = uploadedBytes; + this.uploadProgress = Math.min(100, Math.round((uploadedBytes / file.size) * 100)); + } + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { + if (uploadedSet.has(chunkIndex)) { + continue; + } + + const start = chunkIndex * chunkSize; + const end = Math.min(file.size, start + chunkSize); + const chunkBlob = file.slice(start, end); + + const formData = new FormData(); + formData.append('session_id', sessionId); + formData.append('chunk_index', String(chunkIndex)); + formData.append('chunk', chunkBlob, `${file.name}.part${chunkIndex}`); + + const chunkResp = await axios.post(`${this.apiBase}/api/upload/resumable/chunk`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 30 * 60 * 1000 + }); + + if (!chunkResp.data?.success) { + throw new Error(chunkResp.data?.message || '上传分片失败'); + } + + uploadedBytes = Number(chunkResp.data.uploaded_bytes || uploadedBytes + chunkBlob.size); + this.uploadedBytes = uploadedBytes; + this.totalBytes = file.size; + this.uploadProgress = file.size > 0 + ? Math.min(100, Math.round((uploadedBytes / file.size) * 100)) + : 0; + } + + const completeResp = await axios.post(`${this.apiBase}/api/upload/resumable/complete`, { + session_id: sessionId + }); + + if (!completeResp.data?.success) { + throw new Error(completeResp.data?.message || '完成分片上传失败'); + } + + this.showToast('success', '上传成功', `文件 ${file.name} 已上传`); + this.uploadProgress = 0; + this.uploadedBytes = 0; + this.totalBytes = 0; + this.uploadingFileName = ''; + await this.loadFiles(this.currentPath); + await this.refreshStorageUsage(); + return true; + } catch (error) { + if (error.response?.status === 404) { + // 后端未启用分片接口时自动降级 + return false; + } + throw error; + } + }, + // 本地存储上传(经过后端) - async uploadToLocal(file) { + async uploadToLocal(file, fileHash = null) { // 本地存储配额预检查 const estimatedUsage = this.localUsed + file.size; if (estimatedUsage > this.localQuota) { @@ -2527,12 +2871,15 @@ handleDragLeave(e) { this.uploadedBytes = 0; this.totalBytes = 0; this.uploadingFileName = ''; - return; + return true; } const formData = new FormData(); formData.append('file', file); formData.append('path', this.currentPath); + if (fileHash) { + formData.append('file_hash', fileHash); + } const response = await axios.post(`${this.apiBase}/api/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, @@ -2553,6 +2900,7 @@ handleDragLeave(e) { await this.loadFiles(this.currentPath); await this.refreshStorageUsage(); } + return true; }, // ===== 分享管理 ===== @@ -3940,11 +4288,13 @@ handleDragLeave(e) { this.monitorTabLoading = true; this.healthCheck.loading = true; this.systemLogs.loading = true; + this.reservationMonitor.loading = true; try { await Promise.all([ this.loadHealthCheck(), - this.loadSystemLogs(1) + this.loadSystemLogs(1), + this.loadDownloadReservationMonitor(1) ]); } catch (e) { // 子方法内部已处理错误 @@ -4128,6 +4478,106 @@ handleDragLeave(e) { } }, + async loadDownloadReservationMonitor(page = this.reservationMonitor.page) { + this.reservationMonitor.loading = true; + try { + const response = await axios.get(`${this.apiBase}/api/admin/download-reservations`, { + params: { + page: Math.max(1, Number(page) || 1), + pageSize: this.reservationMonitor.pageSize, + status: this.reservationMonitor.filters.status || undefined, + keyword: (this.reservationMonitor.filters.keyword || '').trim() || undefined, + user_id: (this.reservationMonitor.filters.userId || '').trim() || undefined + } + }); + + if (response.data?.success) { + const rows = Array.isArray(response.data.reservations) ? response.data.reservations : []; + const pagination = response.data.pagination || {}; + + this.reservationMonitor.rows = rows; + this.reservationMonitor.summary = response.data.summary || null; + this.reservationMonitor.total = Number(pagination.total || rows.length); + this.reservationMonitor.totalPages = Number(pagination.totalPages || 1); + this.reservationMonitor.page = Number(pagination.page || page || 1); + } + } catch (error) { + console.error('加载预扣监控失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '加载预扣监控失败'); + } finally { + this.reservationMonitor.loading = false; + } + }, + + triggerReservationKeywordSearch() { + this.reservationMonitor.page = 1; + if (!this._debouncedReservationQuery) { + this._debouncedReservationQuery = this.debounce(() => { + this.loadDownloadReservationMonitor(1); + }, 260); + } + this._debouncedReservationQuery(); + }, + + async changeReservationPage(nextPage) { + const page = Math.max(1, Number(nextPage) || 1); + if (page === this.reservationMonitor.page) return; + await this.loadDownloadReservationMonitor(page); + }, + + getReservationStatusText(status) { + if (status === 'pending') return '待确认'; + if (status === 'confirmed') return '已确认'; + if (status === 'expired') return '已过期'; + if (status === 'cancelled') return '已取消'; + return status || '-'; + }, + + getReservationStatusColor(status) { + if (status === 'pending') return '#f59e0b'; + if (status === 'confirmed') return '#22c55e'; + if (status === 'expired') return '#ef4444'; + if (status === 'cancelled') return '#94a3b8'; + return 'var(--text-secondary)'; + }, + + async cancelReservation(row) { + if (!row || !row.id) return; + if (!confirm(`确认释放预扣 #${row.id} 吗?`)) return; + + try { + const response = await axios.post(`${this.apiBase}/api/admin/download-reservations/${row.id}/cancel`); + if (response.data?.success) { + this.showToast('success', '成功', response.data.message || '预扣额度已释放'); + await this.loadDownloadReservationMonitor(this.reservationMonitor.page); + } + } catch (error) { + console.error('释放预扣失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '释放预扣失败'); + } + }, + + async cleanupReservations() { + if (this.reservationMonitor.cleaning) return; + if (!confirm('确认清理过期/历史预扣记录吗?')) return; + + this.reservationMonitor.cleaning = true; + try { + const response = await axios.post(`${this.apiBase}/api/admin/download-reservations/cleanup`, { + keep_days: 7 + }); + if (response.data?.success) { + this.showToast('success', '成功', response.data.message || '预扣清理完成'); + await this.loadDownloadReservationMonitor(1); + } + } catch (error) { + console.error('清理预扣失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '清理预扣失败'); + } finally { + this.reservationMonitor.cleaning = false; + } + }, + // ===== 调试模式管理 ===== // 切换调试模式