184 lines
6.6 KiB
JavaScript
184 lines
6.6 KiB
JavaScript
const express = require('express');
|
|
const https = require('https');
|
|
const cors = require('cors');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const morgan = require('morgan');
|
|
const rateLimit = require('express-rate-limit');
|
|
const logger = require('./utils/logger');
|
|
const auditLogger = require('./middleware/auditLogger');
|
|
const { checkDiskSpace } = require('./utils/diskMonitor');
|
|
require('dotenv').config();
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// 信任反向代理 (Nginx)
|
|
// 必须设置此项,否则在 Nginx 后端运行时,req.ip 永远是 127.0.0.1,导致限流策略对所有用户生效(误伤)
|
|
app.set('trust proxy', 1);
|
|
|
|
// 中间件
|
|
app.use(cors({
|
|
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
|
credentials: true
|
|
}));
|
|
app.use(express.json());
|
|
|
|
// 定义脱敏的 URL token
|
|
morgan.token('safe-url', (req) => {
|
|
let url = req.originalUrl || req.url;
|
|
try {
|
|
// 隐藏 token, password, code 等敏感参数
|
|
url = url.replace(/([?&])(token|password|code)=([^&]+)/gi, '$1$2=***');
|
|
} catch (e) {
|
|
// 忽略错误
|
|
}
|
|
return url;
|
|
});
|
|
|
|
// HTTP 请求日志 (使用自定义格式,替换 :url 为 :safe-url)
|
|
app.use(morgan(':remote-addr - :remote-user [:date[clf]] ":method :safe-url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', { stream: logger.stream }));
|
|
|
|
// 全局审计日志
|
|
app.use(auditLogger);
|
|
|
|
// 限流策略配置
|
|
// 1. 全局限流:每个IP每分钟最多100次请求
|
|
const globalLimiter = rateLimit({
|
|
windowMs: 1 * 60 * 1000, // 1分钟
|
|
max: 100, // 限制每个IP 100次请求
|
|
standardHeaders: true, // 返回 `RateLimit-*` 头信息
|
|
legacyHeaders: false, // 禁用 `X-RateLimit-*` 头信息
|
|
message: { message: '请求过于频繁,请稍后再试' }
|
|
});
|
|
|
|
// 2. 认证接口限流:每个IP每分钟最多10次请求 (防暴力破解)
|
|
const authLimiter = rateLimit({
|
|
windowMs: 1 * 60 * 1000, // 1分钟
|
|
max: 10, // 限制每个IP 10次请求
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { message: '登录/注册尝试过于频繁,请1分钟后再试' }
|
|
});
|
|
|
|
// 应用全局限流
|
|
app.use('/api/', globalLimiter);
|
|
|
|
// 安全策略:禁止移动端访问
|
|
app.use((req, res, next) => {
|
|
const userAgent = req.headers['user-agent'] || '';
|
|
// 匹配常见的移动设备标识
|
|
const isMobile = /mobile|android|iphone|ipad|phone/i.test(userAgent);
|
|
|
|
if (isMobile) {
|
|
res.status(403).send(`
|
|
<html>
|
|
<head><title>访问被拒绝</title></head>
|
|
<body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif;background:#f0f2f5;">
|
|
<div style="text-align:center;padding:40px;background:white;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
|
|
<h1 style="color:#ff4d4f;">🚫 访问被拒绝</h1>
|
|
<p style="font-size:18px;color:#333;">根据本单位安全规定,本系统<strong>禁止使用移动设备</strong>访问。</p>
|
|
<p style="color:#666;">请使用办公电脑访问。</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`);
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
// 确保上传目录存在
|
|
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
|
|
let absoluteUploadDir;
|
|
if (path.isAbsolute(uploadDir)) {
|
|
absoluteUploadDir = uploadDir;
|
|
} else {
|
|
absoluteUploadDir = path.join(__dirname, uploadDir);
|
|
}
|
|
|
|
if (!fs.existsSync(absoluteUploadDir)) {
|
|
fs.mkdirSync(absoluteUploadDir, { recursive: true });
|
|
}
|
|
|
|
const DISK_MONITOR_ENABLED = String(process.env.DISK_MONITOR_ENABLED || 'true').toLowerCase() !== 'false';
|
|
const DISK_CHECK_INTERVAL_MINUTES = Number(process.env.DISK_CHECK_INTERVAL_MINUTES || 60);
|
|
const DISK_CHECK_INTERVAL_MS = Number.isFinite(DISK_CHECK_INTERVAL_MINUTES) && DISK_CHECK_INTERVAL_MINUTES > 0
|
|
? DISK_CHECK_INTERVAL_MINUTES * 60 * 1000
|
|
: 60 * 60 * 1000;
|
|
|
|
// 启动磁盘监控(默认每 60 分钟检查一次)
|
|
if (DISK_MONITOR_ENABLED) {
|
|
setInterval(() => {
|
|
checkDiskSpace();
|
|
}, DISK_CHECK_INTERVAL_MS);
|
|
|
|
// 启动时立即检查一次
|
|
checkDiskSpace();
|
|
}
|
|
|
|
// 导入路由
|
|
const authRoutes = require('./routes/auth');
|
|
const achievementRoutes = require('./routes/achievements');
|
|
const userRoutes = require('./routes/users');
|
|
const statisticsRoutes = require('./routes/statistics');
|
|
const dictionaryRoutes = require('./routes/dictionaries');
|
|
const logRoutes = require('./routes/logs');
|
|
const notificationRoutes = require('./routes/notifications');
|
|
const aiAnalysisRoutes = require('./routes/aiAnalysis');
|
|
|
|
// 对认证路由应用更严格的限流
|
|
app.use('/api/auth', authLimiter, authRoutes);
|
|
app.use('/api/achievements', achievementRoutes);
|
|
app.use('/api/users', userRoutes);
|
|
app.use('/api/statistics', statisticsRoutes);
|
|
app.use('/api/dictionaries', dictionaryRoutes);
|
|
app.use('/api/logs', logRoutes);
|
|
app.use('/api/notifications', notificationRoutes);
|
|
app.use('/api/ai-analysis', aiAnalysisRoutes);
|
|
|
|
// 静态文件服务 (用于开发环境查看附件,生产环境建议用 Nginx)
|
|
app.use('/uploads', express.static(absoluteUploadDir, {
|
|
setHeaders: (res, path) => {
|
|
// 强制下载,防止浏览器直接预览 HTML/SVG 等可能包含脚本的文件
|
|
res.setHeader('Content-Disposition', 'attachment');
|
|
}
|
|
}));
|
|
|
|
// 托管前端静态文件 (生产环境)
|
|
const clientBuildPath = path.join(__dirname, '../client/build');
|
|
if (fs.existsSync(clientBuildPath)) {
|
|
app.use(express.static(clientBuildPath));
|
|
// 使用正则表达式排除以 /api/ 开头的路径
|
|
app.get(/^(?!\/api\/).*$/, (req, res) => {
|
|
res.sendFile(path.join(clientBuildPath, 'index.html'));
|
|
});
|
|
}
|
|
|
|
// 尝试读取 SSL 证书 (用于 HTTPS)
|
|
const keyPath = path.join(__dirname, 'server.key');
|
|
const certPath = path.join(__dirname, 'server.cert');
|
|
|
|
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
|
// 如果存在证书,启动 HTTPS 服务器
|
|
const options = {
|
|
key: fs.readFileSync(keyPath),
|
|
cert: fs.readFileSync(certPath)
|
|
};
|
|
https.createServer(options, app).listen(PORT, () => {
|
|
const msg = `HTTPS 安全服务器运行在 https://localhost:${PORT}`;
|
|
console.log(msg);
|
|
logger.info(msg);
|
|
console.log(`局域网访问请使用: https://<你的IP>:${PORT}`);
|
|
});
|
|
} else {
|
|
// 否则启动标准 HTTP 服务器
|
|
app.listen(PORT, () => {
|
|
const msg = `HTTP 服务器运行在 http://localhost:${PORT}`;
|
|
console.log(msg);
|
|
logger.info(msg);
|
|
console.log(`提示: 未检测到 server.key 和 server.cert,已降级为 HTTP 模式。`);
|
|
});
|
|
}
|
|
|