const express = require('express');
|
const multer = require('multer');
|
const { nanoid } = require('nanoid');
|
const QRCode = require('qrcode');
|
const path = require('path');
|
const fs = require('fs');
|
const os = require('os');
|
const session = require('express-session');
|
const crypto = require('crypto');
|
const Database = require('better-sqlite3');
|
const svgCaptcha = require('svg-captcha');
|
const Iconv = require('iconv-lite');
|
const bcrypt = require('bcrypt');
|
const rateLimit = require('express-rate-limit');
|
|
const app = express();
|
const PORT = process.env.PORT || 3344;
|
const BASE_URL = process.env.BASE_URL || `http://${getLocalIP()}:${PORT}`;
|
const UPLOAD_DIR = path.join(__dirname, 'uploads');
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'data.db');
|
|
// === 安全配置 ===
|
const BCRYPT_ROUNDS = 12;
|
const UPLOAD_EXTENSIONS = new Set([
|
// 文档类
|
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt',
|
// 图片类
|
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg',
|
// 压缩类
|
'.zip', '.rar', '.7z', '.tar', '.gz'
|
]);
|
|
// === 初始化 SQLite ===
|
const db = new Database(DB_PATH);
|
db.pragma('journal_mode = WAL');
|
db.pragma('foreign_keys = ON');
|
|
// 用户表
|
db.exec(`
|
CREATE TABLE IF NOT EXISTS users (
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
username TEXT NOT NULL UNIQUE,
|
password_hash TEXT NOT NULL,
|
role TEXT NOT NULL DEFAULT 'admin',
|
created_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL
|
)
|
`);
|
|
// 文件表
|
db.exec(`
|
CREATE TABLE IF NOT EXISTS files (
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
code TEXT NOT NULL UNIQUE,
|
original_name TEXT NOT NULL,
|
saved_name TEXT NOT NULL,
|
size INTEGER NOT NULL,
|
mime_type TEXT,
|
uploaded_at INTEGER NOT NULL,
|
downloads INTEGER NOT NULL DEFAULT 0,
|
uploaded_by TEXT
|
)
|
`);
|
|
// 索引
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_files_code ON files(code)`);
|
|
// === 初始化默认用户 ===
|
const SALT = process.env.SALT || 'docmgr-salt-2026';
|
|
// 密码哈希函数(使用 bcrypt)
|
async function hashPassword(pwd) {
|
return await bcrypt.hash(pwd, BCRYPT_ROUNDS);
|
}
|
|
function verifyPassword(pwd, hash) {
|
return bcrypt.compareSync(pwd, hash);
|
}
|
|
// 默认管理员(超级管理员,拥有所有权限)
|
const DEFAULT_ADMIN_USER = 'gwalrusadmin';
|
const DEFAULT_ADMIN_PASS = process.env.ADMIN_PASSWORD || 'admin123';
|
|
const USERS = parseUsers(process.env.USERS || `${DEFAULT_ADMIN_USER}:${DEFAULT_ADMIN_PASS}`);
|
function parseUsers(str) {
|
const map = {};
|
str.split(',').forEach(pair => {
|
const [u, p] = pair.split(':');
|
if (u && p) map[u.trim()] = p.trim();
|
});
|
return map;
|
}
|
|
async function initUsers() {
|
const now = Date.now();
|
const insert = db.prepare(
|
'INSERT OR IGNORE INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
);
|
const updateRole = db.prepare(
|
'UPDATE users SET role = ?, updated_at = ? WHERE username = ?'
|
);
|
const updatePassword = db.prepare(
|
'UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?'
|
);
|
|
// 确保超级管理员存在
|
const adminExists = db.prepare('SELECT id FROM users WHERE username = ?').get(DEFAULT_ADMIN_USER);
|
if (!adminExists) {
|
const hash = await hashPassword(DEFAULT_ADMIN_PASS);
|
insert.run(DEFAULT_ADMIN_USER, hash, 'super_admin', now, now);
|
} else {
|
// 确保超级管理员的角色正确
|
updateRole.run('super_admin', now, DEFAULT_ADMIN_USER);
|
}
|
|
// 初始化其他用户
|
for (const [username, password] of Object.entries(USERS)) {
|
if (username === DEFAULT_ADMIN_USER) continue;
|
const existing = db.prepare('SELECT id, password_hash FROM users WHERE username = ?').get(username);
|
if (!existing) {
|
const hash = await hashPassword(password);
|
insert.run(username, hash, 'admin', now, now);
|
}
|
}
|
}
|
|
initUsers().then(() => {
|
console.log('用户初始化完成');
|
}).catch(err => {
|
console.error('用户初始化失败:', err);
|
process.exit(1);
|
});
|
|
// 确保目录存在
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
const DB_DIR = path.dirname(DB_PATH);
|
if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true });
|
|
// 获取本机局域网 IP
|
function getLocalIP() {
|
const nets = os.networkInterfaces();
|
for (const name of Object.keys(nets)) {
|
for (const net of nets[name]) {
|
if (net.family === 'IPv4' && !net.internal) return net.address;
|
}
|
}
|
return 'localhost';
|
}
|
|
// Session 配置
|
app.use(session({
|
secret: process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'),
|
resave: false,
|
saveUninitialized: false,
|
name: 'docmgr.sid',
|
cookie: {
|
httpOnly: true,
|
secure: false, // 生产环境配合 nginx 应设为 true
|
sameSite: 'lax',
|
maxAge: 24 * 60 * 60 * 1000
|
}
|
}));
|
|
// === 安全响应头 ===
|
app.use((req, res, next) => {
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Frame-Options', 'DENY');
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: blob:;");
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
res.removeHeader('X-Powered-By'); // 隐藏技术栈
|
next();
|
});
|
|
// === 请求体解析 ===
|
app.use(express.urlencoded({ extended: true }));
|
app.use(express.json());
|
|
// 登录锁定记录(内存)
|
const LOGIN_MAX_ATTEMPTS = 3;
|
const LOGIN_LOCK_MINUTES = 10;
|
const loginAttempts = new Map();
|
|
function checkLoginLock(username, ip) {
|
const key = `${username}:${ip}`;
|
const record = loginAttempts.get(key);
|
if (!record) return null;
|
const elapsed = Date.now() - record.lockedAt;
|
const remaining = LOGIN_LOCK_MINUTES * 60 * 1000 - elapsed;
|
if (remaining > 0) {
|
return Math.ceil(remaining / 1000 / 60);
|
}
|
loginAttempts.delete(key);
|
return null;
|
}
|
|
function recordLoginAttempt(username, ip, success) {
|
const key = `${username}:${ip}`;
|
if (success) {
|
loginAttempts.delete(key);
|
return;
|
}
|
const record = loginAttempts.get(key) || { count: 0, lockedAt: 0 };
|
record.count++;
|
if (record.count >= LOGIN_MAX_ATTEMPTS) {
|
record.lockedAt = Date.now();
|
}
|
loginAttempts.set(key, record);
|
}
|
|
// Multer 配置
|
const storage = multer.diskStorage({
|
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
filename: (req, file, cb) => {
|
const code = nanoid(10);
|
const ext = path.extname(file.originalname).toLowerCase();
|
cb(null, code + ext);
|
}
|
});
|
|
const upload = multer({
|
storage,
|
limits: { fileSize: 500 * 1024 * 1024 },
|
fileFilter: (req, file, cb) => {
|
const ext = path.extname(file.originalname).toLowerCase();
|
if (!UPLOAD_EXTENSIONS.has(ext)) {
|
return cb(new Error(`不支持的文件类型:${ext},允许的类型:${Array.from(UPLOAD_EXTENSIONS).join(', ')}`));
|
}
|
cb(null, true);
|
}
|
});
|
|
// EJS 模板引擎
|
app.set('view engine', 'ejs');
|
app.set('views', path.join(__dirname, 'views'));
|
|
// 模板辅助函数
|
app.locals.formatSize = (bytes) => {
|
if (!bytes) return '0 B';
|
const units = ['B', 'KB', 'MB', 'GB'];
|
let i = 0;
|
let size = bytes;
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
return size.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
};
|
app.locals.formatDate = (ts) => {
|
const d = new Date(ts);
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
};
|
app.locals.getFileIconClass = (name) => {
|
const ext = path.extname(name).toLowerCase();
|
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) return 'archive';
|
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'].includes(ext)) return 'image';
|
if (['.pdf'].includes(ext)) return 'pdf';
|
if (['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt'].includes(ext)) return 'doc';
|
return 'other';
|
};
|
app.locals.getFileIcon = (name) => {
|
const ext = path.extname(name).toLowerCase();
|
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) return 'fa-regular fa-file-zipper';
|
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'].includes(ext)) return 'fa-regular fa-file-image';
|
if (['.pdf'].includes(ext)) return 'fa-regular fa-file-pdf';
|
if (['.doc', '.docx'].includes(ext)) return 'fa-regular fa-file-word';
|
if (['.xls', '.xlsx'].includes(ext)) return 'fa-regular fa-file-excel';
|
if (['.ppt', '.pptx'].includes(ext)) return 'fa-regular fa-file-powerpoint';
|
if (['.txt'].includes(ext)) return 'fa-regular fa-file-lines';
|
if (['.mp4', '.avi', '.mov', '.mkv'].includes(ext)) return 'fa-regular fa-file-video';
|
if (['.mp3', '.wav', '.flac'].includes(ext)) return 'fa-regular fa-file-audio';
|
return 'fa-regular fa-file';
|
};
|
|
// 静态文件(防止目录遍历攻击)
|
app.use('/uploads', (req, res, next) => {
|
// 只允许访问文件,禁止目录浏览
|
if (req.path.includes('..')) {
|
return res.status(403).send('禁止访问');
|
}
|
next();
|
}, express.static(UPLOAD_DIR, {
|
dotfiles: 'ignore',
|
index: false // 禁止列出目录
|
}));
|
app.use('/static', express.static(path.join(__dirname, 'public')));
|
|
// ========== 登录中间件 ==========
|
function requireAuth(req, res, next) {
|
if (req.session && req.session.user) return next();
|
if (req.xhr || req.headers.accept?.includes('json')) {
|
return res.status(401).json({ error: '未登录', loginUrl: '/login' });
|
}
|
res.redirect('/login');
|
}
|
|
// 超级管理员权限中间件(只有 gwalrusadmin 可访问)
|
function requireSuperAdmin(req, res, next) {
|
if (!req.session || !req.session.user) {
|
return res.status(401).json({ error: '未登录' });
|
}
|
if (req.session.user.username !== 'gwalrusadmin') {
|
return res.status(403).json({ error: '权限不足:仅超级管理员可访问' });
|
}
|
next();
|
}
|
|
// === 速率限制配置 ===
|
const loginLimiter = rateLimit({
|
windowMs: 15 * 60 * 1000, // 15 分钟
|
max: 10, // 最多 10 次请求
|
message: { error: '操作太频繁,请稍后再试' },
|
standardHeaders: true,
|
legacyHeaders: false
|
});
|
|
const apiLimiter = rateLimit({
|
windowMs: 15 * 60 * 1000,
|
max: 100, // API 最多 100 次请求
|
message: { error: '操作太频繁,请稍后再试' },
|
standardHeaders: true,
|
legacyHeaders: false
|
});
|
|
// ========== 生成验证码 ==========
|
app.get('/captcha', (req, res) => {
|
const captcha = svgCaptcha.create({
|
size: 4,
|
noise: 2,
|
color: true,
|
background: '#f0f4ff',
|
width: 120,
|
height: 40
|
});
|
req.session.captcha = captcha.text.toLowerCase();
|
res.type('svg');
|
res.send(captcha.data);
|
});
|
|
// ========== 登录 ==========
|
app.get('/login', (req, res) => {
|
if (req.session && req.session.user) return res.redirect('/');
|
const clientIp = req.ip || req.connection.remoteAddress || '';
|
const lockRemaining = checkLoginLock(req.query.u || '', clientIp);
|
res.render('login', {
|
error: req.query.error || null,
|
username: req.query.u || '',
|
password: req.query.p || '',
|
lockedUsername: req.query.u || '',
|
lockRemaining,
|
baseUrl: BASE_URL
|
});
|
});
|
|
app.post('/login', loginLimiter, async (req, res) => {
|
const { username, password, captcha } = req.body;
|
const clientIp = req.ip || req.connection.remoteAddress || '';
|
|
// 检查是否被锁定
|
const lockRemaining = checkLoginLock(username, clientIp);
|
if (lockRemaining !== null) {
|
return res.redirect('/login?error=' + encodeURIComponent('账户已锁定,请稍后再试') + '&u=' + encodeURIComponent(username));
|
}
|
|
if (!username || !password) {
|
return res.redirect('/login?error=' + encodeURIComponent('请输入用户名和密码') + '&u=' + encodeURIComponent(username || ''));
|
}
|
|
// 验证验证码
|
const sessionCaptcha = req.session.captcha || '';
|
delete req.session.captcha;
|
if (!captcha || captcha.toLowerCase() !== sessionCaptcha) {
|
return res.redirect('/login?error=' + encodeURIComponent('验证码错误') + '&u=' + encodeURIComponent(username) + '&p=' + encodeURIComponent(password || ''));
|
}
|
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
if (!user || !verifyPassword(password, user.password_hash)) {
|
recordLoginAttempt(username, clientIp, false);
|
return res.redirect('/login?error=' + encodeURIComponent('用户名或密码错误') + '&u=' + encodeURIComponent(username) + '&p=' + encodeURIComponent(password || ''));
|
}
|
|
// 登录成功,重新生成 Session ID(防止 Session 固定攻击)
|
recordLoginAttempt(username, clientIp, true);
|
|
return new Promise((resolve) => {
|
req.session.regenerate((err) => {
|
if (err) {
|
console.error('Session 重新生成失败:', err);
|
return res.redirect('/login?error=' + encodeURIComponent('登录失败,请重试'));
|
}
|
req.session.user = { username: user.username, role: user.role };
|
res.redirect(req.session.returnTo || '/');
|
resolve();
|
});
|
});
|
});
|
|
app.get('/logout', (req, res) => {
|
req.session.destroy(() => res.redirect('/login'));
|
});
|
|
// ========== 管理后台首页 ==========
|
app.get('/', requireAuth, (req, res) => {
|
const files = db.prepare('SELECT * FROM files ORDER BY uploaded_at DESC').all();
|
const list = files.map(f => ({
|
code: f.code,
|
originalName: f.original_name,
|
savedName: f.saved_name,
|
size: f.size,
|
mimeType: f.mime_type,
|
uploadedAt: f.uploaded_at,
|
downloads: f.downloads,
|
uploadedBy: f.uploaded_by,
|
downloadUrl: `${BASE_URL}/d/${f.code}`,
|
qrUrl: `${BASE_URL}/qr/${f.code}`
|
}));
|
res.render('index', { files: list, baseUrl: BASE_URL, user: req.session.user });
|
});
|
|
// ========== 账号管理页面 ==========
|
app.get('/users', requireAuth, (req, res) => {
|
if (req.session.user.username !== 'gwalrusadmin') {
|
return res.status(403).send('权限不足:仅超级管理员可访问');
|
}
|
res.render('users', { user: req.session.user, baseUrl: BASE_URL });
|
});
|
|
// ========== 上传文件 ==========
|
app.post('/api/upload', requireAuth, upload.single('file'), (req, res) => {
|
if (!req.file) return res.status(400).json({ error: '请选择文件' });
|
|
const code = path.parse(req.file.filename).name;
|
const now = Date.now();
|
|
// 修复 multer 接收的中文文件名乱码
|
// 浏览器 multipart/form-data 中文件名是 UTF-8 编码
|
// 但有时被错误地按 latin1 解析,导致中文显示为 丠这类乱码
|
let originalName = req.file.originalname;
|
const latin1Buf = Buffer.from(originalName, 'latin1');
|
const utf8Candidate = Iconv.decode(latin1Buf, 'utf-8');
|
// 如果 latin1→utf8 解码后有明显的 CJK 字符(一-鿿),就用它
|
if (/[\u4e00-\u9fff]/.test(utf8Candidate)) {
|
originalName = utf8Candidate;
|
}
|
|
db.prepare(`
|
INSERT INTO files (code, original_name, saved_name, size, mime_type, uploaded_at, downloads, uploaded_by)
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?)
|
`).run(code, originalName, req.file.filename, req.file.size, req.file.mimetype, now, req.session.user.username);
|
req.file.originalname = originalName;
|
|
res.json({
|
code,
|
downloadUrl: `${BASE_URL}/d/${code}`,
|
qrUrl: `${BASE_URL}/qr/${code}`,
|
originalName: req.file.originalname
|
});
|
});
|
|
// ========== 删除文件 ==========
|
app.post('/api/delete/:code', requireAuth, (req, res) => {
|
const file = db.prepare('SELECT * FROM files WHERE code = ?').get(req.params.code);
|
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
const filePath = path.join(UPLOAD_DIR, file.saved_name);
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
db.prepare('DELETE FROM files WHERE code = ?').run(req.params.code);
|
res.json({ success: true });
|
});
|
|
// ========== 获取文件列表(API) ==========
|
app.get('/api/files', requireAuth, (req, res) => {
|
const files = db.prepare('SELECT * FROM files ORDER BY uploaded_at DESC').all();
|
res.json(files.map(f => ({
|
code: f.code,
|
originalName: f.original_name,
|
savedName: f.saved_name,
|
size: f.size,
|
mimeType: f.mime_type,
|
uploadedAt: f.uploaded_at,
|
downloads: f.downloads,
|
uploadedBy: f.uploaded_by,
|
downloadUrl: `${BASE_URL}/d/${f.code}`,
|
qrUrl: `${BASE_URL}/qr/${f.code}`
|
})));
|
});
|
|
// ========== 二维码数据 API ==========
|
app.get('/api/qr/:code', requireAuth, async (req, res) => {
|
const file = db.prepare('SELECT * FROM files WHERE code = ?').get(req.params.code);
|
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
const downloadUrl = `${BASE_URL}/d/${req.params.code}`;
|
try {
|
const qrDataUrl = await QRCode.toDataURL(downloadUrl, { width: 400, margin: 2 });
|
res.json({ downloadUrl, qrDataUrl });
|
} catch (err) {
|
res.status(500).json({ error: '生成二维码失败' });
|
}
|
});
|
|
// ========== 修改密码 ==========
|
app.get('/settings', requireAuth, (req, res) => {
|
res.render('settings', {
|
success: req.query.success || null,
|
error: req.query.error || null,
|
user: req.session.user,
|
baseUrl: BASE_URL
|
});
|
});
|
|
app.post('/api/change-password', requireAuth, async (req, res) => {
|
const { oldPassword, newPassword, confirmPassword } = req.body;
|
const username = req.session.user.username;
|
|
if (!oldPassword || !newPassword || !confirmPassword) {
|
return res.redirect('/settings?error=' + encodeURIComponent('请填写所有字段'));
|
}
|
if (newPassword.length < 6) {
|
return res.redirect('/settings?error=' + encodeURIComponent('新密码至少 6 位'));
|
}
|
if (newPassword !== confirmPassword) {
|
return res.redirect('/settings?error=' + encodeURIComponent('两次密码输入不一致'));
|
}
|
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
if (!user || !verifyPassword(oldPassword, user.password_hash)) {
|
return res.redirect('/settings?error=' + encodeURIComponent('原密码错误'));
|
}
|
|
const newHash = await hashPassword(newPassword);
|
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?')
|
.run(newHash, Date.now(), username);
|
|
res.redirect('/settings?success=' + encodeURIComponent('密码修改成功'));
|
});
|
|
// ========== 检查登录状态 ==========
|
app.get('/api/me', (req, res) => {
|
if (req.session && req.session.user) {
|
return res.json({ loggedIn: true, user: req.session.user });
|
}
|
res.json({ loggedIn: false });
|
});
|
|
// ========== 账号管理 API(仅 gwalrusadmin) ==========
|
|
// 获取用户列表
|
app.get('/api/users', requireAuth, requireSuperAdmin, apiLimiter, (req, res) => {
|
const users = db.prepare('SELECT id, username, role, created_at, updated_at FROM users ORDER BY created_at DESC').all();
|
res.json({ success: true, users });
|
});
|
|
// 创建用户
|
app.post('/api/users', requireAuth, requireSuperAdmin, apiLimiter, async (req, res) => {
|
const { username, password, role } = req.body;
|
|
if (!username || !password) {
|
return res.status(400).json({ error: '用户名和密码不能为空' });
|
}
|
if (username.length < 3 || username.length > 30) {
|
return res.status(400).json({ error: '用户名长度必须在 3-30 个字符之间' });
|
}
|
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
return res.status(400).json({ error: '用户名只能包含字母、数字和下划线' });
|
}
|
if (password.length < 6) {
|
return res.status(400).json({ error: '密码长度至少 6 位' });
|
}
|
if (!['admin', 'viewer'].includes(role)) {
|
return res.status(400).json({ error: '角色必须是 admin 或 viewer' });
|
}
|
|
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
if (existing) {
|
return res.status(409).json({ error: '用户名已存在' });
|
}
|
|
const hash = await hashPassword(password);
|
const now = Date.now();
|
db.prepare('INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
|
.run(username, hash, role, now, now);
|
|
res.json({ success: true, message: '用户创建成功' });
|
});
|
|
// 更新用户
|
app.put('/api/users/:id', requireAuth, requireSuperAdmin, apiLimiter, async (req, res) => {
|
const userId = parseInt(req.params.id);
|
const { password, role } = req.body;
|
|
const user = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(userId);
|
if (!user) {
|
return res.status(404).json({ error: '用户不存在' });
|
}
|
|
// 不允许修改超级管理员
|
if (user.username === 'gwalrusadmin') {
|
return res.status(403).json({ error: '不能修改超级管理员账号' });
|
}
|
|
const updates = [];
|
const params = [];
|
|
if (password) {
|
if (password.length < 6) {
|
return res.status(400).json({ error: '密码长度至少 6 位' });
|
}
|
const hash = await hashPassword(password);
|
updates.push('password_hash = ?');
|
params.push(hash);
|
}
|
|
if (role) {
|
if (!['admin', 'viewer'].includes(role)) {
|
return res.status(400).json({ error: '角色必须是 admin 或 viewer' });
|
}
|
updates.push('role = ?');
|
params.push(role);
|
}
|
|
if (updates.length === 0) {
|
return res.status(400).json({ error: '没有要更新的字段' });
|
}
|
|
updates.push('updated_at = ?');
|
params.push(Date.now());
|
params.push(userId);
|
|
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
res.json({ success: true, message: '用户更新成功' });
|
});
|
|
// 删除用户
|
app.delete('/api/users/:id', requireAuth, requireSuperAdmin, apiLimiter, (req, res) => {
|
const userId = parseInt(req.params.id);
|
|
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(userId);
|
if (!user) {
|
return res.status(404).json({ error: '用户不存在' });
|
}
|
|
// 不允许删除超级管理员
|
if (user.username === 'gwalrusadmin') {
|
return res.status(403).json({ error: '不能删除超级管理员账号' });
|
}
|
|
// 不允许删除自己
|
if (user.username === req.session.user.username) {
|
return res.status(403).json({ error: '不能删除当前登录的账号' });
|
}
|
|
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
res.json({ success: true, message: '用户删除成功' });
|
});
|
|
// ========== 公开下载 / 二维码 ==========
|
|
app.get('/d/:code', (req, res) => {
|
const file = db.prepare('SELECT * FROM files WHERE code = ?').get(req.params.code);
|
if (!file) return res.status(404).send('文件不存在或已过期');
|
|
const filePath = path.join(UPLOAD_DIR, file.saved_name);
|
if (!fs.existsSync(filePath)) {
|
db.prepare('DELETE FROM files WHERE code = ?').run(req.params.code);
|
return res.status(404).send('文件已被删除');
|
}
|
|
db.prepare('UPDATE files SET downloads = downloads + 1 WHERE code = ?').run(req.params.code);
|
|
// 中文文件名编码处理
|
const encodedName = encodeURIComponent(file.original_name);
|
res.setHeader('Content-Disposition', `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
|
// 设置正确的 Content-Type 和 Content-Length
|
const stat = fs.statSync(filePath);
|
res.setHeader('Content-Length', stat.size);
|
res.setHeader('Content-Type', file.mime_type || 'application/octet-stream');
|
|
// 使用流式传输,添加完整的错误处理
|
const fileStream = fs.createReadStream(filePath);
|
fileStream.on('error', (err) => {
|
console.error(`下载文件时出错: ${err.message}`);
|
if (!res.headersSent) {
|
res.status(500).send('文件读取失败');
|
}
|
});
|
fileStream.on('open', () => {
|
fileStream.pipe(res);
|
});
|
});
|
|
app.get('/qr/:code', async (req, res) => {
|
const file = db.prepare('SELECT * FROM files WHERE code = ?').get(req.params.code);
|
if (!file) return res.status(404).send('文件不存在');
|
|
const downloadUrl = `${BASE_URL}/d/${req.params.code}`;
|
try {
|
const qrDataUrl = await QRCode.toDataURL(downloadUrl, { width: 400, margin: 2 });
|
const base64 = qrDataUrl.replace(/^data:image\/png;base64,/, '');
|
const img = Buffer.from(base64, 'base64');
|
res.writeHead(200, {
|
'Content-Type': 'image/png',
|
'Content-Length': img.length
|
});
|
res.end(img);
|
} catch (err) {
|
res.status(500).send('生成二维码失败');
|
}
|
});
|
|
// ========== 启动 ==========
|
app.listen(PORT, '0.0.0.0', () => {
|
console.log(`📄 文档管理系统启动成功!`);
|
console.log(`🌐 管理后台: ${BASE_URL}`);
|
console.log(`📂 上传目录: ${UPLOAD_DIR}`);
|
console.log(`🗄️ 数据库: ${DB_PATH} (SQLite)`);
|
console.log(`👤 默认超级管理员: gwalrusadmin / ${process.env.ADMIN_PASSWORD || 'admin123'}`);
|
console.log(`🔒 安全加固:bcrypt 密码加密 + 速率限制 + 文件类型白名单`);
|
});
|