feat: add share security, resumable upload, global search and reservation ops panel

This commit is contained in:
2026-02-17 23:36:30 +08:00
parent 3c75986566
commit 1a1c64c0e7
4 changed files with 2745 additions and 22 deletions

View File

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

File diff suppressed because it is too large Load Diff