Initial commit - 玩玩云文件管理系统 v1.0.0
- 完整的前后端代码 - 支持本地存储和SFTP存储 - 文件分享功能 - 上传工具源代码 - 完整的部署文档 - Nginx配置模板 技术栈: - 后端: Node.js + Express + SQLite - 前端: Vue.js 3 + Axios - 存储: 本地存储 / SFTP远程存储
This commit is contained in:
8
backend/.env.example
Normal file
8
backend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# FTP服务器配置
|
||||
FTP_HOST=your-ftp-host.com
|
||||
FTP_PORT=21
|
||||
FTP_USER=your-username
|
||||
FTP_PASSWORD=your-password
|
||||
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装编译工具
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# 复制package文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install --production
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 40001
|
||||
|
||||
# 启动应用
|
||||
CMD ["node", "server.js"]
|
||||
108
backend/auth.js
Normal file
108
backend/auth.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { UserDB } = require('./database');
|
||||
|
||||
// JWT密钥(生产环境应该放在环境变量中)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
// 生成JWT Token
|
||||
function generateToken(user) {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证Token中间件
|
||||
function authMiddleware(req, res, next) {
|
||||
// 从请求头、cookie或URL参数中获取token
|
||||
const token = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token || req.query?.token;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '未提供认证令牌'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const user = UserDB.findById(decoded.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
if (user.is_banned) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '账号已被封禁'
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '账号未激活'
|
||||
});
|
||||
}
|
||||
|
||||
// 将用户信息附加到请求对象(包含所有存储相关字段)
|
||||
req.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
is_admin: user.is_admin,
|
||||
has_ftp_config: user.has_ftp_config,
|
||||
ftp_host: user.ftp_host,
|
||||
ftp_port: user.ftp_port,
|
||||
ftp_user: user.ftp_user,
|
||||
ftp_password: user.ftp_password,
|
||||
http_download_base_url: user.http_download_base_url,
|
||||
// 存储相关字段(v2.0新增)
|
||||
storage_permission: user.storage_permission || 'sftp_only',
|
||||
current_storage_type: user.current_storage_type || 'sftp',
|
||||
local_storage_quota: user.local_storage_quota || 1073741824,
|
||||
local_storage_used: user.local_storage_used || 0
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '令牌已过期'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '无效的令牌'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员权限中间件
|
||||
function adminMiddleware(req, res, next) {
|
||||
if (!req.user || !req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '需要管理员权限'
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
JWT_SECRET,
|
||||
generateToken,
|
||||
authMiddleware,
|
||||
adminMiddleware
|
||||
};
|
||||
52
backend/backup.bat
Normal file
52
backend/backup.bat
Normal file
@@ -0,0 +1,52 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ========================================
|
||||
echo 数据库备份工具
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
cd /d %~dp0
|
||||
|
||||
REM 创建备份目录
|
||||
if not exist backup mkdir backup
|
||||
|
||||
REM 生成时间戳
|
||||
set YEAR=%date:~0,4%
|
||||
set MONTH=%date:~5,2%
|
||||
set DAY=%date:~8,2%
|
||||
set HOUR=%time:~0,2%
|
||||
set MINUTE=%time:~3,2%
|
||||
set SECOND=%time:~6,2%
|
||||
|
||||
REM 去掉小时前面的空格
|
||||
if "%HOUR:~0,1%" == " " set HOUR=0%HOUR:~1,1%
|
||||
|
||||
set TIMESTAMP=%YEAR%%MONTH%%DAY%_%HOUR%%MINUTE%%SECOND%
|
||||
|
||||
REM 备份数据库
|
||||
copy ftp-manager.db backup\ftp-manager-%TIMESTAMP%.db >nul
|
||||
|
||||
if %errorlevel% == 0 (
|
||||
echo [成功] 备份完成!
|
||||
echo 文件: backup\ftp-manager-%TIMESTAMP%.db
|
||||
|
||||
REM 获取文件大小
|
||||
for %%A in (backup\ftp-manager-%TIMESTAMP%.db) do echo 大小: %%~zA 字节
|
||||
) else (
|
||||
echo [错误] 备份失败!
|
||||
)
|
||||
|
||||
echo.
|
||||
|
||||
REM 清理30天前的备份
|
||||
echo 清理30天前的旧备份...
|
||||
forfiles /P backup /M ftp-manager-*.db /D -30 /C "cmd /c del @path" 2>nul
|
||||
if %errorlevel% == 0 (
|
||||
echo [成功] 旧备份已清理
|
||||
) else (
|
||||
echo [提示] 没有需要清理的旧备份
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
pause
|
||||
554
backend/database.js
Normal file
554
backend/database.js
Normal file
@@ -0,0 +1,554 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
|
||||
// 创建或连接数据库
|
||||
const db = new Database(path.join(__dirname, 'ftp-manager.db'));
|
||||
|
||||
// 启用外键约束
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// 初始化数据库表
|
||||
function initDatabase() {
|
||||
// 用户表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
|
||||
-- FTP配置(可选)
|
||||
ftp_host TEXT,
|
||||
ftp_port INTEGER DEFAULT 22,
|
||||
ftp_user TEXT,
|
||||
ftp_password TEXT,
|
||||
http_download_base_url TEXT,
|
||||
|
||||
-- 上传工具API密钥
|
||||
upload_api_key TEXT,
|
||||
|
||||
-- 用户状态
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_banned INTEGER DEFAULT 0,
|
||||
has_ftp_config INTEGER DEFAULT 0,
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 分享链接表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
share_code TEXT UNIQUE NOT NULL,
|
||||
share_path TEXT NOT NULL,
|
||||
share_type TEXT DEFAULT 'file',
|
||||
share_password TEXT,
|
||||
|
||||
-- 分享统计
|
||||
view_count INTEGER DEFAULT 0,
|
||||
download_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// 系统设置表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 密码重置请求表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS password_reset_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
new_password TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- pending, approved, rejected
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_at DATETIME,
|
||||
reviewed_by INTEGER,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (reviewed_by) REFERENCES users (id)
|
||||
)
|
||||
`);
|
||||
|
||||
// 创建索引
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_code ON shares(share_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reset_requests_user ON password_reset_requests(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reset_requests_status ON password_reset_requests(status);
|
||||
`);
|
||||
|
||||
// 数据库迁移:添加upload_api_key字段(如果不存在)
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||
const hasUploadApiKey = columns.some(col => col.name === 'upload_api_key');
|
||||
|
||||
if (!hasUploadApiKey) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN upload_api_key TEXT`);
|
||||
console.log('数据库迁移:添加upload_api_key字段完成');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('数据库迁移失败:', error);
|
||||
}
|
||||
|
||||
// 数据库迁移:添加share_type字段(如果不存在)
|
||||
try {
|
||||
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
|
||||
const hasShareType = shareColumns.some(col => col.name === 'share_type');
|
||||
|
||||
if (!hasShareType) {
|
||||
db.exec(`ALTER TABLE shares ADD COLUMN share_type TEXT DEFAULT 'file'`);
|
||||
console.log('数据库迁移:添加share_type字段完成');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('数据库迁移(share_type)失败:', error);
|
||||
}
|
||||
|
||||
console.log('数据库初始化完成');
|
||||
}
|
||||
|
||||
// 创建默认管理员账号
|
||||
function createDefaultAdmin() {
|
||||
const adminExists = db.prepare('SELECT id FROM users WHERE is_admin = 1').get();
|
||||
|
||||
if (!adminExists) {
|
||||
const hashedPassword = bcrypt.hashSync('admin123', 10);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (
|
||||
username, email, password,
|
||||
is_admin, is_active, has_ftp_config
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'admin',
|
||||
'admin@example.com',
|
||||
hashedPassword,
|
||||
1,
|
||||
1,
|
||||
0 // 管理员不需要FTP配置
|
||||
);
|
||||
|
||||
console.log('默认管理员账号已创建');
|
||||
console.log('用户名: admin');
|
||||
console.log('密码: admin123');
|
||||
console.log('⚠️ 请登录后立即修改密码!');
|
||||
}
|
||||
}
|
||||
|
||||
// 用户相关操作
|
||||
const UserDB = {
|
||||
// 创建用户
|
||||
create(userData) {
|
||||
const hashedPassword = bcrypt.hashSync(userData.password, 10);
|
||||
|
||||
const hasFtpConfig = userData.ftp_host && userData.ftp_user && userData.ftp_password ? 1 : 0;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (
|
||||
username, email, password,
|
||||
ftp_host, ftp_port, ftp_user, ftp_password, http_download_base_url,
|
||||
has_ftp_config
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
userData.username,
|
||||
userData.email,
|
||||
hashedPassword,
|
||||
userData.ftp_host || null,
|
||||
userData.ftp_port || 22,
|
||||
userData.ftp_user || null,
|
||||
userData.ftp_password || null,
|
||||
userData.http_download_base_url || null,
|
||||
hasFtpConfig
|
||||
);
|
||||
|
||||
return result.lastInsertRowid;
|
||||
},
|
||||
|
||||
// 根据用户名查找
|
||||
findByUsername(username) {
|
||||
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
},
|
||||
|
||||
// 根据邮箱查找
|
||||
findByEmail(email) {
|
||||
return db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||
},
|
||||
|
||||
// 根据ID查找
|
||||
findById(id) {
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
// 验证密码
|
||||
verifyPassword(plainPassword, hashedPassword) {
|
||||
return bcrypt.compareSync(plainPassword, hashedPassword);
|
||||
},
|
||||
|
||||
// 更新用户
|
||||
update(id, updates) {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key === 'password') {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(bcrypt.hashSync(value, 10));
|
||||
} else {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(id);
|
||||
|
||||
const stmt = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`);
|
||||
return stmt.run(...values);
|
||||
},
|
||||
|
||||
// 获取所有用户
|
||||
getAll(filters = {}) {
|
||||
let query = 'SELECT * FROM users WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (filters.is_admin !== undefined) {
|
||||
query += ' AND is_admin = ?';
|
||||
params.push(filters.is_admin);
|
||||
}
|
||||
|
||||
if (filters.is_banned !== undefined) {
|
||||
query += ' AND is_banned = ?';
|
||||
params.push(filters.is_banned);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
return db.prepare(query).all(...params);
|
||||
},
|
||||
|
||||
// 删除用户
|
||||
delete(id) {
|
||||
return db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
},
|
||||
|
||||
// 封禁/解封用户
|
||||
setBanStatus(id, isBanned) {
|
||||
return db.prepare('UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.run(isBanned ? 1 : 0, id);
|
||||
}
|
||||
};
|
||||
|
||||
// 分享链接相关操作
|
||||
const ShareDB = {
|
||||
// 生成随机分享码
|
||||
generateShareCode(length = 8) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return code;
|
||||
},
|
||||
|
||||
// 创建分享链接
|
||||
// 创建分享链接
|
||||
create(userId, options = {}) {
|
||||
const {
|
||||
share_type = 'file',
|
||||
file_path = '',
|
||||
file_name = '',
|
||||
password = null,
|
||||
expiry_days = null
|
||||
} = options;
|
||||
|
||||
let shareCode;
|
||||
let attempts = 0;
|
||||
|
||||
// 尝试生成唯一的分享码
|
||||
do {
|
||||
shareCode = this.generateShareCode();
|
||||
attempts++;
|
||||
if (attempts > 10) {
|
||||
shareCode = this.generateShareCode(10); // 增加长度
|
||||
}
|
||||
} while (this.findByCode(shareCode) && attempts < 20);
|
||||
|
||||
// 计算过期时间
|
||||
let expiresAt = null;
|
||||
if (expiry_days) {
|
||||
const expireDate = new Date();
|
||||
expireDate.setDate(expireDate.getDate() + parseInt(expiry_days));
|
||||
expiresAt = expireDate.toISOString();
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const hashedPassword = password ? bcrypt.hashSync(password, 10) : null;
|
||||
const sharePath = share_type === 'file' ? file_path : '/';
|
||||
|
||||
const result = stmt.run(
|
||||
userId,
|
||||
shareCode,
|
||||
sharePath,
|
||||
share_type,
|
||||
hashedPassword,
|
||||
expiresAt
|
||||
);
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid,
|
||||
share_code: shareCode,
|
||||
share_type: share_type
|
||||
};
|
||||
},
|
||||
|
||||
// 根据分享码查找
|
||||
findByCode(shareCode) {
|
||||
return db.prepare(`
|
||||
SELECT s.*, u.username, u.ftp_host, u.ftp_port, u.ftp_user, u.ftp_password, u.http_download_base_url
|
||||
FROM shares s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.share_code = ?
|
||||
`).get(shareCode);
|
||||
},
|
||||
|
||||
// 根据ID查找
|
||||
findById(id) {
|
||||
return db.prepare('SELECT * FROM shares WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
// 验证分享密码
|
||||
verifyPassword(plainPassword, hashedPassword) {
|
||||
return bcrypt.compareSync(plainPassword, hashedPassword);
|
||||
},
|
||||
|
||||
// 获取用户的所有分享
|
||||
getUserShares(userId) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM shares
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`).all(userId);
|
||||
},
|
||||
|
||||
// 增加查看次数
|
||||
incrementViewCount(shareCode) {
|
||||
return db.prepare(`
|
||||
UPDATE shares
|
||||
SET view_count = view_count + 1
|
||||
WHERE share_code = ?
|
||||
`).run(shareCode);
|
||||
},
|
||||
|
||||
// 增加下载次数
|
||||
incrementDownloadCount(shareCode) {
|
||||
return db.prepare(`
|
||||
UPDATE shares
|
||||
SET download_count = download_count + 1
|
||||
WHERE share_code = ?
|
||||
`).run(shareCode);
|
||||
},
|
||||
|
||||
// 删除分享
|
||||
delete(id, userId = null) {
|
||||
if (userId) {
|
||||
return db.prepare('DELETE FROM shares WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
return db.prepare('DELETE FROM shares WHERE id = ?').run(id);
|
||||
},
|
||||
|
||||
// 获取所有分享(管理员)
|
||||
getAll() {
|
||||
return db.prepare(`
|
||||
SELECT s.*, u.username
|
||||
FROM shares s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
ORDER BY s.created_at DESC
|
||||
`).all();
|
||||
}
|
||||
};
|
||||
|
||||
// 系统设置管理
|
||||
const SettingsDB = {
|
||||
// 获取设置
|
||||
get(key) {
|
||||
const row = db.prepare('SELECT value FROM system_settings WHERE key = ?').get(key);
|
||||
return row ? row.value : null;
|
||||
},
|
||||
|
||||
// 设置值
|
||||
set(key, value) {
|
||||
db.prepare(`
|
||||
INSERT INTO system_settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`).run(key, value);
|
||||
},
|
||||
|
||||
// 获取所有设置
|
||||
getAll() {
|
||||
return db.prepare('SELECT key, value FROM system_settings').all();
|
||||
}
|
||||
};
|
||||
|
||||
// 密码重置请求管理
|
||||
const PasswordResetDB = {
|
||||
// 创建密码重置请求
|
||||
create(userId, newPassword) {
|
||||
const hashedPassword = bcrypt.hashSync(newPassword, 10);
|
||||
|
||||
// 删除该用户之前的pending请求
|
||||
db.prepare('DELETE FROM password_reset_requests WHERE user_id = ? AND status = ?')
|
||||
.run(userId, 'pending');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO password_reset_requests (user_id, new_password, status)
|
||||
VALUES (?, ?, 'pending')
|
||||
`);
|
||||
|
||||
const result = stmt.run(userId, hashedPassword);
|
||||
return result.lastInsertRowid;
|
||||
},
|
||||
|
||||
// 获取待审核的请求
|
||||
getPending() {
|
||||
return db.prepare(`
|
||||
SELECT r.*, u.username, u.email
|
||||
FROM password_reset_requests r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.status = 'pending'
|
||||
ORDER BY r.created_at DESC
|
||||
`).all();
|
||||
},
|
||||
|
||||
// 审核请求(批准或拒绝)
|
||||
review(requestId, adminId, approved) {
|
||||
const request = db.prepare('SELECT * FROM password_reset_requests WHERE id = ?').get(requestId);
|
||||
|
||||
if (!request || request.status !== 'pending') {
|
||||
throw new Error('请求不存在或已被处理');
|
||||
}
|
||||
|
||||
const newStatus = approved ? 'approved' : 'rejected';
|
||||
|
||||
db.prepare(`
|
||||
UPDATE password_reset_requests
|
||||
SET status = ?, reviewed_at = CURRENT_TIMESTAMP, reviewed_by = ?
|
||||
WHERE id = ?
|
||||
`).run(newStatus, adminId, requestId);
|
||||
|
||||
// 如果批准,更新用户密码
|
||||
if (approved) {
|
||||
db.prepare('UPDATE users SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.run(request.new_password, request.user_id);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// 获取用户的所有请求
|
||||
getUserRequests(userId) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM password_reset_requests
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`).all(userId);
|
||||
},
|
||||
|
||||
// 检查用户是否有待处理的请求
|
||||
hasPendingRequest(userId) {
|
||||
const request = db.prepare(`
|
||||
SELECT id FROM password_reset_requests
|
||||
WHERE user_id = ? AND status = 'pending'
|
||||
`).get(userId);
|
||||
return !!request;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化默认设置
|
||||
function initDefaultSettings() {
|
||||
// 默认上传限制为100MB
|
||||
if (!SettingsDB.get('max_upload_size')) {
|
||||
SettingsDB.set('max_upload_size', '104857600'); // 100MB in bytes
|
||||
}
|
||||
}
|
||||
|
||||
// 数据库版本迁移 - v2.0 本地存储功能
|
||||
function migrateToV2() {
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||
const hasStoragePermission = columns.some(col => col.name === 'storage_permission');
|
||||
|
||||
if (!hasStoragePermission) {
|
||||
console.log('[数据库迁移] 检测到旧版本数据库,开始升级到 v2.0...');
|
||||
|
||||
// 添加本地存储相关字段
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN storage_permission TEXT DEFAULT 'sftp_only';
|
||||
ALTER TABLE users ADD COLUMN current_storage_type TEXT DEFAULT 'sftp';
|
||||
ALTER TABLE users ADD COLUMN local_storage_quota INTEGER DEFAULT 1073741824;
|
||||
ALTER TABLE users ADD COLUMN local_storage_used INTEGER DEFAULT 0;
|
||||
`);
|
||||
|
||||
// 更新现有用户为SFTP模式(保持兼容)
|
||||
const updateStmt = db.prepare("UPDATE users SET current_storage_type = 'sftp' WHERE has_ftp_config = 1");
|
||||
updateStmt.run();
|
||||
|
||||
console.log('[数据库迁移] ✓ 用户表已升级');
|
||||
|
||||
// 为分享表添加存储类型字段
|
||||
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
|
||||
const hasShareStorageType = shareColumns.some(col => col.name === 'storage_type');
|
||||
|
||||
if (!hasShareStorageType) {
|
||||
db.exec(`ALTER TABLE shares ADD COLUMN storage_type TEXT DEFAULT 'sftp';`);
|
||||
console.log('[数据库迁移] ✓ 分享表已升级');
|
||||
}
|
||||
|
||||
console.log('[数据库迁移] ✅ 数据库升级到 v2.0 完成!本地存储功能已启用');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[数据库迁移] 迁移失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
initDatabase();
|
||||
createDefaultAdmin();
|
||||
initDefaultSettings();
|
||||
migrateToV2(); // 执行数据库迁移
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
UserDB,
|
||||
ShareDB,
|
||||
SettingsDB,
|
||||
PasswordResetDB
|
||||
};
|
||||
3010
backend/package-lock.json
generated
Normal file
3010
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
backend/package.json
Normal file
34
backend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "ftp-web-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "FTP Web Manager Backend",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"keywords": [
|
||||
"ftp",
|
||||
"web",
|
||||
"file-manager"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"basic-ftp": "^5.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-validator": "^7.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"ssh2-sftp-client": "^12.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
2113
backend/server.js
Normal file
2113
backend/server.js
Normal file
File diff suppressed because it is too large
Load Diff
10
backend/start.bat
Normal file
10
backend/start.bat
Normal file
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo FTP 网盘管理平台 - 启动脚本
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
cd /d %~dp0
|
||||
node server.js
|
||||
|
||||
pause
|
||||
321
backend/storage.js
Normal file
321
backend/storage.js
Normal file
@@ -0,0 +1,321 @@
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { UserDB } = require('./database');
|
||||
|
||||
// ===== 统一存储接口 =====
|
||||
|
||||
/**
|
||||
* 存储接口工厂
|
||||
* 根据用户的存储类型返回对应的存储客户端
|
||||
*/
|
||||
class StorageInterface {
|
||||
constructor(user) {
|
||||
this.user = user;
|
||||
this.type = user.current_storage_type || 'sftp';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并返回存储客户端
|
||||
*/
|
||||
async connect() {
|
||||
if (this.type === 'local') {
|
||||
const client = new LocalStorageClient(this.user);
|
||||
await client.init();
|
||||
return client;
|
||||
} else {
|
||||
const client = new SftpStorageClient(this.user);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 本地存储客户端 =====
|
||||
|
||||
class LocalStorageClient {
|
||||
constructor(user) {
|
||||
this.user = user;
|
||||
// 使用环境变量或默认路径(不硬编码)
|
||||
const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage');
|
||||
this.basePath = path.join(storageRoot, `user_${user.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化用户存储目录
|
||||
*/
|
||||
async init() {
|
||||
if (!fs.existsSync(this.basePath)) {
|
||||
fs.mkdirSync(this.basePath, { recursive: true, mode: 0o755 });
|
||||
console.log(`[本地存储] 创建用户目录: ${this.basePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
async list(dirPath) {
|
||||
const fullPath = this.getFullPath(dirPath);
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.mkdirSync(fullPath, { recursive: true });
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(fullPath, { withFileTypes: true });
|
||||
|
||||
return items.map(item => {
|
||||
const itemPath = path.join(fullPath, item.name);
|
||||
const stats = fs.statSync(itemPath);
|
||||
return {
|
||||
name: item.name,
|
||||
type: item.isDirectory() ? 'd' : '-',
|
||||
size: stats.size,
|
||||
modifyTime: stats.mtimeMs
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async put(localPath, remotePath) {
|
||||
const destPath = this.getFullPath(remotePath);
|
||||
|
||||
// 检查配额
|
||||
const fileSize = fs.statSync(localPath).size;
|
||||
this.checkQuota(fileSize);
|
||||
|
||||
// 确保目标目录存在
|
||||
const destDir = path.dirname(destPath);
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 使用临时文件+重命名模式,避免文件被占用问题
|
||||
const tempPath = `${destPath}.uploading_${Date.now()}`;
|
||||
|
||||
try {
|
||||
// 复制到临时文件
|
||||
fs.copyFileSync(localPath, tempPath);
|
||||
|
||||
// 如果目标文件存在,先删除
|
||||
if (fs.existsSync(destPath)) {
|
||||
fs.unlinkSync(destPath);
|
||||
}
|
||||
|
||||
// 重命名临时文件为目标文件
|
||||
fs.renameSync(tempPath, destPath);
|
||||
|
||||
// 更新已使用空间
|
||||
this.updateUsedSpace(fileSize);
|
||||
} catch (error) {
|
||||
// 清理临时文件
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async delete(filePath) {
|
||||
const fullPath = this.getFullPath(filePath);
|
||||
const stats = fs.statSync(fullPath);
|
||||
|
||||
fs.unlinkSync(fullPath);
|
||||
|
||||
// 更新已使用空间
|
||||
this.updateUsedSpace(-stats.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件
|
||||
*/
|
||||
async rename(oldPath, newPath) {
|
||||
const oldFullPath = this.getFullPath(oldPath);
|
||||
const newFullPath = this.getFullPath(newPath);
|
||||
|
||||
// 确保新路径的目录存在
|
||||
const newDir = path.dirname(newFullPath);
|
||||
if (!fs.existsSync(newDir)) {
|
||||
fs.mkdirSync(newDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.renameSync(oldFullPath, newFullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
*/
|
||||
async stat(filePath) {
|
||||
const fullPath = this.getFullPath(filePath);
|
||||
return fs.statSync(fullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件读取流
|
||||
*/
|
||||
createReadStream(filePath) {
|
||||
const fullPath = this.getFullPath(filePath);
|
||||
return fs.createReadStream(fullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接(本地存储无需关闭)
|
||||
*/
|
||||
async end() {
|
||||
// 本地存储无需关闭连接
|
||||
}
|
||||
|
||||
// ===== 辅助方法 =====
|
||||
|
||||
/**
|
||||
* 获取完整路径(带安全检查)
|
||||
*/
|
||||
getFullPath(relativePath) {
|
||||
// 1. 规范化路径,移除 ../ 等危险路径
|
||||
const normalized = path.normalize(relativePath).replace(/^(\.\.[\/\\])+/, '');
|
||||
|
||||
// 2. 拼接完整路径
|
||||
const fullPath = path.join(this.basePath, normalized);
|
||||
|
||||
// 3. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
|
||||
if (!fullPath.startsWith(this.basePath)) {
|
||||
throw new Error('非法路径访问');
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查配额
|
||||
*/
|
||||
checkQuota(additionalSize) {
|
||||
const newUsed = (this.user.local_storage_used || 0) + additionalSize;
|
||||
if (newUsed > this.user.local_storage_quota) {
|
||||
const used = this.formatSize(this.user.local_storage_used);
|
||||
const quota = this.formatSize(this.user.local_storage_quota);
|
||||
const need = this.formatSize(additionalSize);
|
||||
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新已使用空间
|
||||
*/
|
||||
updateUsedSpace(delta) {
|
||||
const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta);
|
||||
UserDB.update(this.user.id, { local_storage_used: newUsed });
|
||||
// 更新内存中的值
|
||||
this.user.local_storage_used = newUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
formatSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SFTP存储客户端 =====
|
||||
|
||||
class SftpStorageClient {
|
||||
constructor(user) {
|
||||
this.user = user;
|
||||
this.sftp = new SftpClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接SFTP服务器
|
||||
*/
|
||||
async connect() {
|
||||
await this.sftp.connect({
|
||||
host: this.user.ftp_host,
|
||||
port: this.user.ftp_port || 22,
|
||||
username: this.user.ftp_user,
|
||||
password: this.user.ftp_password
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
async list(dirPath) {
|
||||
return await this.sftp.list(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async put(localPath, remotePath) {
|
||||
// 使用临时文件+重命名模式(与upload_tool保持一致)
|
||||
const tempRemotePath = `${remotePath}.uploading_${Date.now()}`;
|
||||
|
||||
// 第一步:上传到临时文件
|
||||
await this.sftp.put(localPath, tempRemotePath);
|
||||
|
||||
// 第二步:检查目标文件是否存在,如果存在先删除
|
||||
try {
|
||||
await this.sftp.stat(remotePath);
|
||||
await this.sftp.delete(remotePath);
|
||||
} catch (err) {
|
||||
// 文件不存在,无需删除
|
||||
}
|
||||
|
||||
// 第三步:重命名临时文件为目标文件
|
||||
await this.sftp.rename(tempRemotePath, remotePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async delete(filePath) {
|
||||
return await this.sftp.delete(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件
|
||||
*/
|
||||
async rename(oldPath, newPath) {
|
||||
return await this.sftp.rename(oldPath, newPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
*/
|
||||
async stat(filePath) {
|
||||
return await this.sftp.stat(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件读取流
|
||||
*/
|
||||
createReadStream(filePath) {
|
||||
return this.sftp.createReadStream(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
async end() {
|
||||
if (this.sftp) {
|
||||
await this.sftp.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
StorageInterface,
|
||||
LocalStorageClient,
|
||||
SftpStorageClient
|
||||
};
|
||||
Reference in New Issue
Block a user