- app.js: set #logo-text (top bar) from window.__SITE_NAME__ - the top-right site name stayed default because only #site-title and #sidebar-title were set - security.js: apiKeyGuard whitelist /api/polls/server-list so the ticket create page can load sub-servers (was 401 without api key) - external-api.js: management page now only creates/manages clients, removed inline endpoint table - new external-api-docs.js: full API doc page (auth flow, all 11 endpoints, request/response examples, Node sample) at #/external-api-docs, linked from the management page - index.html: load external-api-docs.js
195 lines
5.6 KiB
JavaScript
195 lines
5.6 KiB
JavaScript
/*
|
||
* MC Report System
|
||
* Copyright (C) 2026 Sea Network Technology Studio
|
||
* Author: CangLan <admin@sea-studio.top>
|
||
*
|
||
* This program is free software: you can redistribute it and/or modify
|
||
* it under the terms of the GNU Affero General Public License as published
|
||
* by the Free Software Foundation, either version 3 of the License, or
|
||
* (at your option) any later version.
|
||
*
|
||
* This program is distributed in the hope that it will be useful,
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
* GNU Affero General Public License for more details.
|
||
*
|
||
* You should have received a copy of the GNU Affero General Public License
|
||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
*/
|
||
|
||
const xss = require('xss');
|
||
const rateLimit = require('express-rate-limit');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const CONFIG_PATH = path.join(__dirname, '..', '..', 'data', 'config.json');
|
||
|
||
function readApiKey() {
|
||
try {
|
||
if (fs.existsSync(CONFIG_PATH)) {
|
||
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')).api_key || null;
|
||
}
|
||
} catch {}
|
||
return null;
|
||
}
|
||
|
||
let cachedApiKey = null;
|
||
let cachedApiKeyMtime = 0;
|
||
function getApiKey() {
|
||
try {
|
||
const stat = fs.statSync(CONFIG_PATH);
|
||
if (cachedApiKeyMtime === stat.mtimeMs) return cachedApiKey;
|
||
cachedApiKey = readApiKey();
|
||
cachedApiKeyMtime = stat.mtimeMs;
|
||
} catch {
|
||
cachedApiKey = null;
|
||
cachedApiKeyMtime = 0;
|
||
}
|
||
return cachedApiKey;
|
||
}
|
||
function clearApiKeyCache() { cachedApiKey = null; cachedApiKeyMtime = 0; }
|
||
|
||
const xssOptions = {
|
||
whiteList: {
|
||
p: [], br: [], strong: [], b: [], em: [], i: [], u: [], s: [], strike: [],
|
||
a: ['href', 'title', 'target', 'rel'], span: [], div: [],
|
||
ul: [], ol: [], li: [], h1: [], h2: [], h3: [], h4: [], h5: [], h6: [],
|
||
table: [], thead: [], tbody: [], tfoot: [], tr: [], th: [], td: [], caption: [],
|
||
code: [], pre: [], blockquote: [], hr: [], img: ['src', 'alt', 'title', 'width', 'height'],
|
||
font: ['color', 'size', 'face'], small: [], sub: [], sup: [],
|
||
},
|
||
stripIgnoreTag: true,
|
||
stripIgnoreTagBody: ['script', 'style', 'xml', 'iframe', 'object', 'embed'],
|
||
onTagAttr: (tag, name, value) => {
|
||
if ((name === 'href' || name === 'src') && /^\s*(javascript|data):/i.test(value)) return '';
|
||
return;
|
||
},
|
||
};
|
||
|
||
function sanitize(value) {
|
||
if (typeof value !== 'string') return value;
|
||
return xss(value, xssOptions).trim();
|
||
}
|
||
|
||
function sanitizeBody(req, res, next) {
|
||
function walk(obj) {
|
||
if (!obj || typeof obj !== 'object') return;
|
||
for (const key of Object.keys(obj)) {
|
||
if (typeof obj[key] === 'string') obj[key] = sanitize(obj[key]);
|
||
else if (typeof obj[key] === 'object') walk(obj[key]);
|
||
}
|
||
}
|
||
walk(req.body);
|
||
next();
|
||
}
|
||
|
||
function validateLengths(rules) {
|
||
return (req, res, next) => {
|
||
for (const [field, maxLen] of Object.entries(rules)) {
|
||
const val = req.body[field];
|
||
if (val && typeof val === 'string' && val.length > maxLen) {
|
||
return res.status(400).json({ error: `${field} 长度不能超过 ${maxLen} 个字符` });
|
||
}
|
||
}
|
||
next();
|
||
};
|
||
}
|
||
|
||
const loginLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 8,
|
||
message: { error: '登录尝试过于频繁,请1分钟后再试' },
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
keyGenerator: (req) => req.ip,
|
||
});
|
||
|
||
const registerLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 3,
|
||
message: { error: '注册请求过于频繁,请1分钟后再试' },
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
const ticketLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 500,
|
||
skip: (req) => !!(req.headers.authorization),
|
||
message: { error: '提交过于频繁,请稍后再试' },
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
const ticketAnonLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 5,
|
||
skip: (req) => !!(req.headers.authorization),
|
||
message: { error: '匿名提交过于频繁,请登录后再试或稍后重试' },
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
const uploadLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 5,
|
||
message: { error: '上传请求过于频繁' },
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
const generalLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 300,
|
||
message: { error: '请求过于频繁' },
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
const captchaLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 30,
|
||
message: { error: '验证码请求过于频繁' },
|
||
});
|
||
|
||
function apiKeyGuard(req, res, next) {
|
||
const p = req.originalUrl;
|
||
if (p === '/api/health' || p.startsWith('/api/install') || p.startsWith('/api/polls/server-list')) return next();
|
||
const key = getApiKey();
|
||
if (!key) return next();
|
||
if (!req.headers['x-api-key'] || req.headers['x-api-key'].length !== key.length) return res.status(401).json({ error: '无效的 API 密钥' });
|
||
if (!timingSafeEqual(req.headers['x-api-key'], key)) return res.status(401).json({ error: '无效的 API 密钥' });
|
||
next();
|
||
}
|
||
|
||
function timingSafeEqual(a, b) {
|
||
let diff = a.length ^ b.length;
|
||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||
return diff === 0;
|
||
}
|
||
|
||
function methodGuard(allowed) {
|
||
return (req, res, next) => {
|
||
if (!allowed.includes(req.method)) {
|
||
return res.status(405).json({ error: `不允许 ${req.method} 方法` });
|
||
}
|
||
next();
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
sanitize,
|
||
sanitizeBody,
|
||
validateLengths,
|
||
loginLimiter,
|
||
registerLimiter,
|
||
ticketLimiter,
|
||
ticketAnonLimiter,
|
||
uploadLimiter,
|
||
generalLimiter,
|
||
captchaLimiter,
|
||
apiKeyGuard,
|
||
methodGuard,
|
||
clearApiKeyCache,
|
||
};
|