feat: MC Report System - MySQL + Express + Vanilla JS SPA
This commit is contained in:
125
backend/server.js
Normal file
125
backend/server.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { isInstalled, getConfig } = require('./db');
|
||||
const {
|
||||
sanitizeBody, generalLimiter, loginLimiter, registerLimiter,
|
||||
ticketLimiter, captchaLimiter, apiKeyGuard, methodGuard
|
||||
} = require('./middleware/security');
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
const PORT = process.env.PORT || 3100;
|
||||
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
||||
const HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
|
||||
|
||||
let cachedHtml = null;
|
||||
let htmlTimestamp = 0;
|
||||
|
||||
function getHtmlWithKey() {
|
||||
const stat = fs.statSync(HTML_PATH);
|
||||
const mtime = stat.mtimeMs;
|
||||
if (cachedHtml && htmlTimestamp >= mtime) return cachedHtml;
|
||||
let html = fs.readFileSync(HTML_PATH, 'utf-8');
|
||||
const cfg = getConfig();
|
||||
const key = cfg?.api_key || '';
|
||||
const redirect = isInstalled() ? '' : '<script>location.hash="#/install"</script>';
|
||||
html = html.replace('</head>', `<script>window.__API_KEY__=${JSON.stringify(key)}</script>${redirect}</head>`);
|
||||
cachedHtml = html;
|
||||
htmlTimestamp = mtime;
|
||||
return html;
|
||||
}
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
|
||||
fontSrc: ["'self'", "https://cdnjs.cloudflare.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
mediaSrc: ["'self'"],
|
||||
connectSrc: ["'self'"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
|
||||
app.use(cors({
|
||||
origin: (origin, cb) => {
|
||||
const allowed = process.env.CORS_ORIGIN;
|
||||
if (!allowed || allowed === '*') return cb(null, true);
|
||||
if (allowed === origin) return cb(null, true);
|
||||
if (!allowed) {
|
||||
const host = origin || '';
|
||||
if (host.startsWith('http://localhost:') || host.startsWith('https://localhost:')) return cb(null, true);
|
||||
}
|
||||
cb(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET','POST','PUT','DELETE'],
|
||||
allowedHeaders: ['Content-Type','Authorization','x-api-key'],
|
||||
}));
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
app.use(sanitizeBody);
|
||||
app.use(generalLimiter);
|
||||
app.use('/api', apiKeyGuard);
|
||||
|
||||
const staticOpts = { maxAge: '1h', setHeaders: (res, p) => { if (p.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); } };
|
||||
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), staticOpts));
|
||||
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), staticOpts));
|
||||
|
||||
const installRoutes = require('./routes/install');
|
||||
app.use('/api/install', installRoutes);
|
||||
|
||||
if (isInstalled()) {
|
||||
const authRoutes = require('./routes/auth');
|
||||
app.use('/api/auth/login', methodGuard(['POST']), loginLimiter);
|
||||
app.use('/api/auth/register', methodGuard(['POST']), registerLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
app.use('/api/captcha', methodGuard(['GET']), captchaLimiter, require('./routes/captcha'));
|
||||
|
||||
app.use('/api/tickets', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets'));
|
||||
app.use('/api/reports', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets'));
|
||||
|
||||
app.use('/api/users', methodGuard(['GET','POST','PUT']), generalLimiter, require('./routes/users'));
|
||||
app.use('/api/settings', methodGuard(['GET','PUT']), generalLimiter, require('./routes/settings'));
|
||||
app.use('/api/export', methodGuard(['GET']), generalLimiter, require('./routes/export'));
|
||||
app.use('/api/uploads', methodGuard(['GET']), generalLimiter, require('./routes/uploads'));
|
||||
app.use('/api/notifications', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/notifications'));
|
||||
|
||||
app.get('/api/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));
|
||||
}
|
||||
|
||||
app.get('/api/health', (req, res) => res.json({ status: 'ok', installed: isInstalled() }));
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.type('html').send(getHtmlWithKey());
|
||||
});
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api')) return res.status(404).json({ error: '接口不存在' });
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.type('html').send(getHtmlWithKey());
|
||||
});
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
if (err.message === 'SYSTEM_NOT_INSTALLED') return res.status(503).json({ error: '系统未安装' });
|
||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: '文件大小超过限制(50MB)' });
|
||||
if (err.message?.includes('不支持的文件类型')) return res.status(400).json({ error: err.message });
|
||||
if (err.message?.includes('文件内容与声明类型不符')) return res.status(400).json({ error: '文件类型校验失败' });
|
||||
console.error(err);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
});
|
||||
|
||||
if (isInstalled()) console.log(`Server running at http://localhost:${PORT}`);
|
||||
else console.log(`System not installed. Visit http://localhost:${PORT}/#/install`);
|
||||
|
||||
app.listen(PORT);
|
||||
Reference in New Issue
Block a user