Merge feature/add_skin into master: resolve all conflicts

- GameGlobal.js: keep upstream SERVER_URL with /ws suffix
- en.js/zh.js: merge both settings.nickname and settings.profile keys
- SettingsScene.js: keep both nickname row and profile button
- server/index.js: merge express app + content security proxy with
  noServer WebSocket mode and path validation
- Add .gitignore for node_modules and .codebuddy
This commit is contained in:
jakciehan
2026-05-12 07:05:20 +08:00
parent 38294c040c
commit d263c7bf48
48 changed files with 10480 additions and 25 deletions
+89 -24
View File
@@ -10,8 +10,17 @@
*/
const { WebSocketServer } = require('ws');
const express = require('express');
const http = require('http');
// ============================================================
// Content Security Proxy Configuration// The content security service is now deployed as an independent
// microservice in the "content-security" K8s namespace.
// This proxy forwards /api/content/* requests to that service.
// ============================================================
const CONTENT_SECURITY_SERVICE_URL = process.env.CONTENT_SECURITY_SERVICE_URL
|| 'http://content-security-service.content-security.svc.cluster.local:3000';
const GAME_ID = process.env.GAME_ID || 'tankwar';
// ============================================================
// Configuration
// ============================================================
@@ -22,26 +31,80 @@ const HEARTBEAT_INTERVAL = 10000; // ms
const ROOM_TIMEOUT = 300000; // 5 minutes room idle timeout
// ============================================================
// HTTP Health Check Server
// Express HTTP Server + Content Security API
// ============================================================
const healthServer = http.createServer((req, res) => {
if (req.url === '/health' || req.url === '/tankwar/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'healthy',
timestamp: new Date().toISOString(),
activeConnections: players.size,
activeRooms: rooms.size,
activeTeamRooms: teamRooms.size
}));
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
const app = express();
const server = http.createServer(app);
// Parse JSON bodies
app.use(express.json({ limit: '2mb' }));
// Health check endpoints (for K8s livenessProbe/readinessProbe)
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
activeConnections: players.size,
activeRooms: rooms.size,
activeTeamRooms: teamRooms.size
});
});
app.get('/tankwar/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
activeConnections: players.size,
activeRooms: rooms.size,
activeTeamRooms: teamRooms.size
});
});
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
healthServer.listen(PORT, HOST, () => {
console.log(`[Health Server] Running on ${HOST}:${PORT}`);
// ============================================================
// Content Security Proxy
// Forward /api/content/* requests to the independent
// content-security-service in the content-security namespace.
// This allows the tankwar-server to delegate all content
// moderation to the shared microservice.
// ============================================================
app.use('/api/content', (req, res, next) => {
// Parse target host and port
const urlObj = new URL(CONTENT_SECURITY_SERVICE_URL);
const targetPath = `/api/content${req.path}`;
const qs = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
const finalPath = qs ? `${targetPath}${qs}` : targetPath;
const proxyReq = http.request({
hostname: urlObj.hostname,
port: urlObj.port || 80,
path: finalPath,
method: req.method,
headers: {
...req.headers,
host: `${urlObj.hostname}:${urlObj.port || 80}`,
'X-Game-Id': GAME_ID,
'X-Forwarded-For': req.ip || req.socket.remoteAddress,
},
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
proxyReq.on('error', (err) => {
console.error('[TankWar Proxy] Failed to forward to content-security-service:', err.message);
if (!res.headersSent) {
res.status(502).json({
errcode: -1,
errmsg: '内容安全服务暂时不可用,请稍后再试',
});
}
});
// Pipe request body to proxy
req.pipe(proxyReq, { end: true });
});
// ============================================================
@@ -1594,15 +1657,14 @@ setInterval(() => {
}, 300000); // Every 5 minutes
// ============================================================
// WebSocket Server (noServer mode, shares HTTP server with health check)
// ============================================================
// WebSocket Server (noServer mode, shares HTTP server with express)
// ============================================================
// Use noServer mode so the WS upgrade only fires on the configured path.
// This lets /health stay as plain HTTP on the same port.
const wss = new WebSocketServer({ noServer: true });
healthServer.on('upgrade', (req, socket, head) => {
server.on('upgrade', (req, socket, head) => {
// Only upgrade on the configured WebSocket path; reject any other path.
// We compare by pathname so query strings are tolerated.
const pathname = (req.url || '').split('?')[0];
if (pathname !== WS_PATH) {
console.warn(`[Server] Rejected WebSocket upgrade on unexpected path: ${req.url}`);
@@ -1676,6 +1738,9 @@ setInterval(() => {
// ============================================================
// Startup
// ============================================================
console.log(`[Tank War Server] Running on ${HOST}:${PORT}`);
console.log(`[Tank War Server] WebSocket path: ${WS_PATH}`);
console.log(`[Tank War Server] Health check paths: /health, /tankwar/health`);
server.listen(PORT, HOST, () => {
console.log(`[Tank War Server] Running on ${HOST}:${PORT}`);
console.log(`[Tank War Server] WebSocket path: ${WS_PATH}`);
console.log(`[Tank War Server] Health check paths: /health, /tankwar/health`);
console.log(`[Tank War Server] HTTP API URL: http://${HOST}:${PORT}`);
});
+126
View File
@@ -0,0 +1,126 @@
{"timestamp":"2026-05-11T15:29:48.530Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:29:48.533Z","type":"violation","userId":"test_user_1778513388533","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:48.534Z","type":"violation","userId":"mute_test_1778513388534","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:48.534Z","type":"violation","userId":"mute_test_1778513388534","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"mute_test_1778513388534","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"mute_test_1778513388534","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:29:48.538Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:29:57.828Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:29:57.831Z","type":"violation","userId":"test_user_1778513397831","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:57.832Z","type":"violation","userId":"mute_test_1778513397832","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:57.832Z","type":"violation","userId":"mute_test_1778513397832","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:57.832Z","type":"violation","userId":"mute_test_1778513397832","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"mute_test_1778513397832","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:29:57.836Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:33:57.494Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"test_user_1778513637497","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"mute_test_1778513637497","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"mute_test_1778513637497","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"mute_test_1778513637497","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"mute_test_1778513637497","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:33:57.501Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:34:09.377Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:34:09.380Z","type":"violation","userId":"test_user_1778513649380","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:34:09.381Z","type":"violation","userId":"mute_test_1778513649381","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:34:09.381Z","type":"violation","userId":"mute_test_1778513649381","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:34:09.381Z","type":"violation","userId":"mute_test_1778513649381","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"mute_test_1778513649381","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:34:09.383Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:34:09.383Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:34:09.385Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:38:42.680Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:38:42.683Z","type":"violation","userId":"test_user_1778513922683","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:38:42.683Z","type":"violation","userId":"mute_test_1778513922683","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:38:42.683Z","type":"violation","userId":"mute_test_1778513922683","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:38:42.684Z","type":"violation","userId":"mute_test_1778513922683","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:38:42.684Z","type":"violation","userId":"mute_test_1778513922683","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:38:42.687Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:42:44.943Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:42:44.946Z","type":"violation","userId":"test_user_1778514164946","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:42:44.947Z","type":"violation","userId":"mute_test_1778514164947","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:42:44.947Z","type":"violation","userId":"mute_test_1778514164947","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:42:44.947Z","type":"violation","userId":"mute_test_1778514164947","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"mute_test_1778514164947","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:42:44.949Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:42:44.950Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:45:46.119Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:45:46.122Z","type":"violation","userId":"test_user_1778514346122","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:45:46.122Z","type":"violation","userId":"mute_test_1778514346122","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:45:46.123Z","type":"violation","userId":"mute_test_1778514346122","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:45:46.123Z","type":"violation","userId":"mute_test_1778514346122","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:45:46.123Z","type":"violation","userId":"mute_test_1778514346122","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:45:46.126Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:46:37.383Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:46:37.392Z","type":"violation","userId":"test_user_1778514397392","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:46:37.392Z","type":"violation","userId":"mute_test_1778514397392","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:46:37.392Z","type":"violation","userId":"mute_test_1778514397392","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:46:37.393Z","type":"violation","userId":"mute_test_1778514397392","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:46:37.394Z","type":"violation","userId":"mute_test_1778514397392","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:46:37.395Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:46:37.398Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:46:37.400Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:54:21.418Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"test_user_1778514861422","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"mute_test_1778514861422","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"mute_test_1778514861422","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"mute_test_1778514861422","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:54:21.423Z","type":"violation","userId":"mute_test_1778514861422","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:54:21.426Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
+942
View File
@@ -4,6 +4,948 @@
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://mirrors.tencent.com/npm/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://mirrors.tencent.com/npm/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://mirrors.tencent.com/npm/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://mirrors.tencent.com/npm/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://mirrors.tencent.com/npm/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://mirrors.tencent.com/npm/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://mirrors.tencent.com/npm/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://mirrors.tencent.com/npm/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://mirrors.tencent.com/npm/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://mirrors.tencent.com/npm/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://mirrors.tencent.com/npm/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://mirrors.tencent.com/npm/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://mirrors.tencent.com/npm/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://mirrors.tencent.com/npm/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://mirrors.tencent.com/npm/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://mirrors.tencent.com/npm/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://mirrors.tencent.com/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://mirrors.tencent.com/npm/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://mirrors.tencent.com/npm/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://mirrors.tencent.com/npm/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://mirrors.tencent.com/npm/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://mirrors.tencent.com/npm/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://mirrors.tencent.com/npm/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://mirrors.tencent.com/npm/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://mirrors.tencent.com/npm/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://mirrors.tencent.com/npm/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://mirrors.tencent.com/npm/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://mirrors.tencent.com/npm/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://mirrors.tencent.com/npm/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://mirrors.tencent.com/npm/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://mirrors.tencent.com/npm/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://mirrors.tencent.com/npm/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+944
View File
@@ -8,9 +8,953 @@
"name": "tankwar-server",
"version": "1.0.0",
"dependencies": {
"express": "^5.2.1",
"multer": "^2.1.1",
"ws": "^8.16.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://mirrors.tencent.com/npm/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://mirrors.tencent.com/npm/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://mirrors.tencent.com/npm/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://mirrors.tencent.com/npm/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://mirrors.tencent.com/npm/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://mirrors.tencent.com/npm/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://mirrors.tencent.com/npm/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://mirrors.tencent.com/npm/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://mirrors.tencent.com/npm/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://mirrors.tencent.com/npm/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://mirrors.tencent.com/npm/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://mirrors.tencent.com/npm/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://mirrors.tencent.com/npm/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://mirrors.tencent.com/npm/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://mirrors.tencent.com/npm/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://mirrors.tencent.com/npm/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://mirrors.tencent.com/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://mirrors.tencent.com/npm/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://mirrors.tencent.com/npm/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://mirrors.tencent.com/npm/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://mirrors.tencent.com/npm/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://mirrors.tencent.com/npm/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://mirrors.tencent.com/npm/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://mirrors.tencent.com/npm/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://mirrors.tencent.com/npm/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://mirrors.tencent.com/npm/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://mirrors.tencent.com/npm/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://mirrors.tencent.com/npm/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://mirrors.tencent.com/npm/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://mirrors.tencent.com/npm/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://mirrors.tencent.com/npm/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://mirrors.tencent.com/npm/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+4 -1
View File
@@ -5,9 +5,12 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node index.js"
"dev": "node index.js",
"test": "node --test test/"
},
"dependencies": {
"express": "^5.2.1",
"multer": "^2.1.1",
"ws": "^8.16.0"
}
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Audit Logger
* Logs content security audit events with content desensitization.
* Logs are retained for at least 180 days.
*
* Features:
* - Content desensitization: only first 3 and last 3 chars kept, middle replaced with ***
* - Access Token never logged in plaintext
* - Structured JSON log format
* - Automatic log rotation
*/
const fs = require('fs');
const path = require('path');
// ============================================================
// Configuration
// ============================================================
const LOG_DIR = path.join(__dirname, '..', 'logs', 'audit');
const LOG_RETENTION_DAYS = 180;
const LOG_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; // Rotate daily
class AuditLogger {
constructor() {
this._ensureLogDir();
this._currentLogFile = this._getLogFilePath(new Date());
this._rotationTimer = null;
this._startRotation();
}
/**
* Log an audit event.
* @param {object} entry
* @param {string} entry.userId - User identifier (openid)
* @param {string} entry.contentType - 'text' or 'image'
* @param {string} entry.contentSummary - Desensitized content summary
* @param {number} entry.scene - Scene value
* @param {string} entry.result - 'pass', 'reject', 'error', 'rejected'
* @param {string} entry.reason - Reason for the result
* @param {number} [entry.duration] - Duration of the check in ms
*/
logAudit(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
userId: entry.userId || 'unknown',
contentType: entry.contentType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
scene: entry.scene || 0,
result: entry.result || 'unknown',
reason: entry.reason || '',
duration: entry.duration || 0,
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write log:', err.message);
}
}
/**
* Log a violation event.
* @param {object} entry
* @param {string} entry.userId - User identifier
* @param {string} entry.violationType - Type of violation
* @param {string} entry.contentSummary - Desensitized content
* @param {string} entry.action - Action taken (e.g., 'mute_24h')
*/
logViolation(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'violation',
userId: entry.userId || 'unknown',
violationType: entry.violationType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
action: entry.action || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write violation log:', err.message);
}
}
/**
* Log a report event.
* @param {object} entry
* @param {string} entry.reporterId - Reporter's user ID (kept confidential)
* @param {string} entry.targetUserId - Reported user's ID
* @param {string} entry.contentSummary - Desensitized reported content
* @param {string} entry.reason - Report reason
*/
logReport(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'report',
reporterId: entry.reporterId ? '***' : 'unknown', // Keep reporter confidential
targetUserId: entry.targetUserId || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
reason: entry.reason || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write report log:', err.message);
}
}
/**
* Ensure the log directory exists.
*/
_ensureLogDir() {
try {
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
} catch (err) {
console.error('[AuditLogger] Failed to create log directory:', err.message);
}
}
/**
* Get the log file path for a given date.
* @param {Date} date
* @returns {string}
*/
_getLogFilePath(date) {
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD
return path.join(LOG_DIR, `audit-${dateStr}.log`);
}
/**
* Start daily log rotation.
*/
_startRotation() {
this._rotationTimer = setInterval(() => {
const newLogFile = this._getLogFilePath(new Date());
if (newLogFile !== this._currentLogFile) {
this._currentLogFile = newLogFile;
console.log('[AuditLogger] Rotated to new log file:', newLogFile);
}
// Clean up old logs
this._cleanOldLogs();
}, LOG_ROTATION_INTERVAL_MS);
}
/**
* Remove log files older than the retention period.
*/
_cleanOldLogs() {
try {
const files = fs.readdirSync(LOG_DIR);
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const file of files) {
const filePath = path.join(LOG_DIR, file);
const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log('[AuditLogger] Deleted old log file:', file);
}
}
} catch (err) {
console.error('[AuditLogger] Failed to clean old logs:', err.message);
}
}
/**
* Sanitize content: keep only first 3 and last 3 chars, replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
/**
* Clean up resources.
*/
destroy() {
if (this._rotationTimer) {
clearInterval(this._rotationTimer);
this._rotationTimer = null;
}
console.log('[AuditLogger] Destroyed');
}
}
module.exports = AuditLogger;
+279
View File
@@ -0,0 +1,279 @@
/**
* Content Security API Routes
* Express routes for content security checking, sensitive words, and reporting.
*/
const express = require('express');
const multer = require('multer');
const ContentSecurityService = require('./contentSecurityService');
const ViolationService = require('./violationService');
const ReportService = require('./reportService');
const sensitiveWords = require('./sensitiveWords');
// Configure multer for image uploads (memory storage, max 1MB)
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 1024 * 1024 }, // 1MB max
});
/**
* Create and return the content security router.
* @param {object} options
* @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
* @returns {express.Router}
*/
function createContentSecurityRouter(options = {}) {
const router = express.Router();
const contentSecurity = new ContentSecurityService({
tokenManager: options.tokenManager,
logger: options.logger,
});
const violationService = options.violationService || new ViolationService({
logger: options.logger,
});
const reportService = options.reportService || new ReportService({
logger: options.logger,
violationService,
});
// ============================================================
// POST /api/content/check-text - Text content security check
// ============================================================
router.post('/check-text', express.json(), async (req, res) => {
const { openid, content, scene } = req.body;
// Validate required fields
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40001,
errmsg: 'openid is required',
});
}
if (!content || typeof content !== 'string') {
return res.status(400).json({
errcode: 40002,
errmsg: 'content is required',
});
}
if (typeof scene !== 'number' || ![1, 2, 3, 4].includes(scene)) {
return res.status(400).json({
errcode: 40003,
errmsg: 'scene must be 1(nickname), 2(chat), 3(signature), or 4(description)',
});
}
try {
// First, do server-side local sensitive word check
const localCheck = sensitiveWords.checkText(content);
if (localCheck.hasViolation) {
// Auto-record violation
violationService.recordViolation({
userId: openid,
violationType: 'local_sensitive_word',
contentSummary: content.substring(0, 20),
scene,
});
return res.json({
pass: false,
errcode: 0,
errmsg: '内容违规,请修改',
suggest: 'risky',
label: 20000,
localCheck: true,
categories: localCheck.categories,
});
}
// Then, call WeChat msgSecCheck API
const result = await contentSecurity.checkTextContent(openid, content, scene);
// Auto-record violation if msgSecCheck rejects
if (!result.pass && result.label > 0) {
violationService.recordViolation({
userId: openid,
violationType: `wechat_label_${result.label}`,
contentSummary: content.substring(0, 20),
scene,
});
}
return res.json(result);
} catch (err) {
console.error('[API] check-text error:', err.message);
return res.status(500).json({
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
});
}
});
// ============================================================
// POST /api/content/check-image - Image content security check
// ============================================================
router.post('/check-image', upload.single('image'), async (req, res) => {
if (!req.file) {
return res.status(400).json({
errcode: 40004,
errmsg: 'image file is required',
});
}
// Check file size (multer already enforces this, but double-check)
if (req.file.size > 1024 * 1024) {
return res.status(400).json({
errcode: 40005,
errmsg: '图片大小不能超过1MB',
});
}
try {
const result = await contentSecurity.checkImageContent(req.file.buffer);
// Auto-record violation if image is rejected
if (!result.pass && req.body && req.body.openid) {
violationService.recordViolation({
userId: req.body.openid,
violationType: 'image_violation',
contentSummary: `image_${req.file.size}bytes`,
scene: 5,
});
}
return res.json(result);
} catch (err) {
console.error('[API] check-image error:', err.message);
return res.status(500).json({
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
});
}
});
// ============================================================
// GET /api/content/sensitive-words - Get sensitive word list for client caching
// ============================================================
router.get('/sensitive-words', (req, res) => {
const version = req.query.version;
// If client sends current version and it matches, return 304
if (version && version === sensitiveWords.getVersion()) {
return res.status(304).json({
version: sensitiveWords.getVersion(),
updated: false,
});
}
return res.json({
version: sensitiveWords.getVersion(),
updated: true,
words: sensitiveWords.getAllWords(),
categories: sensitiveWords.getWordsByCategory(),
});
});
// ============================================================
// GET /api/user/mute-status - Check if a user is muted
// ============================================================
router.get('/user/mute-status', (req, res) => {
const openid = req.query.openid;
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const status = violationService.getMuteStatus(openid);
return res.json(status);
});
// ============================================================
// GET /api/user/violation-summary - Get violation summary for a user
// ============================================================
router.get('/user/violation-summary', (req, res) => {
const openid = req.query.openid;
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const summary = violationService.getViolationSummary(openid);
return res.json(summary);
});
// ============================================================
// POST /api/content/report - Submit a content report
// ============================================================
router.post('/report', express.json(), (req, res) => {
const { contentId, targetUserId, contentType, contentSummary, reporterId, reason } = req.body;
// Validate required fields
if (!contentId || !targetUserId || !contentType || !reporterId || !reason) {
return res.status(400).json({
errcode: 40007,
errmsg: 'Missing required fields: contentId, targetUserId, contentType, reporterId, reason',
});
}
// Validate reason
const validReasons = Object.values(ReportService.REPORT_REASONS);
if (!validReasons.includes(reason)) {
return res.status(400).json({
errcode: 40008,
errmsg: `Invalid reason. Must be one of: ${validReasons.join(', ')}`,
});
}
const result = reportService.submitReport({
contentId,
targetUserId,
contentType,
contentSummary: contentSummary || '',
reporterId,
reason,
});
if (!result.success) {
if (result.reportCount === 0) {
return res.status(400).json({
errcode: 40009,
errmsg: '举报提交失败',
});
}
// Already reported
return res.json({
success: false,
message: '您已举报过该内容',
reportCount: result.reportCount,
});
}
return res.json({
success: true,
message: '举报已提交',
reportCount: result.reportCount,
autoTakenDown: result.autoTakenDown,
});
});
// ============================================================
// POST /api/content/check-text - Auto-record violation on reject
// (Extended: records violation when content is rejected)
// ============================================================
// This is already handled in the check-text route above,
// but we also add violation recording here for server-side content.
// The check-text endpoint above will auto-record violations
// when msgSecCheck returns a rejection.
return router;
}
module.exports = { createContentSecurityRouter };
+475
View File
@@ -0,0 +1,475 @@
/**
* Content Security Service
* Provides unified methods for text and image content security checking
* via WeChat's msgSecCheck and imgSecCheck APIs.
*
* Features:
* - checkTextContent(openid, content, scene): Text content audit
* - checkImageContent(imageBuffer): Image content audit
* - 3-second timeout for each API call
* - Rate limiting queue (5000 requests/minute for msgSecCheck)
*/
const https = require('https');
const AuditLogger = require('./auditLogger');
// ============================================================
// Configuration
// ============================================================
const MSG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/msg_sec_check';
const IMG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/img_sec_check';
const API_TIMEOUT_MS = 3000; // 3 seconds timeout
const RATE_LIMIT_PER_MINUTE = 5000;
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
// Scene mapping for msgSecCheck
const SCENE_MAP = {
NICKNAME: 1,
CHAT: 2,
SIGNATURE: 3,
DESCRIPTION: 4,
};
class ContentSecurityService {
/**
* @param {object} options
* @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance
* @param {object} [options.logger] - Optional custom logger
*/
constructor(options = {}) {
this.tokenManager = options.tokenManager;
this.logger = options.logger || new AuditLogger();
// Rate limiting
this._requestTimestamps = [];
this._requestQueue = [];
this._isProcessingQueue = false;
}
/**
* Check text content for security violations using msgSecCheck.
* @param {string} openid - User's openid
* @param {string} content - Text content to check
* @param {number} scene - Scene value (1=nickname, 2=chat, 3=signature, 4=description)
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string, suggest: string, label: number}>}
*/
async checkTextContent(openid, content, scene) {
const startTime = Date.now();
// Validate scene value
if (!Object.values(SCENE_MAP).includes(scene)) {
const result = {
pass: false,
errcode: -1,
errmsg: 'Invalid scene value',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'invalid_scene',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${MSG_SEC_CHECK_URL}?access_token=${token}`;
const postData = JSON.stringify({
openid,
scene,
version: 2,
content,
});
const apiResult = await this._callWechatAPI(url, postData, 'msgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0 && apiResult.result && apiResult.result.suggest === 'pass';
const result = {
pass,
errcode: apiResult.errcode || 0,
errmsg: apiResult.errmsg || '',
suggest: apiResult.result ? apiResult.result.suggest : 'risky',
label: apiResult.result ? apiResult.result.label : 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: pass ? 'pass' : 'reject',
reason: pass ? 'content_safe' : `label_${result.label}`,
duration,
});
return result;
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] msgSecCheck error:', err.message);
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'error',
reason: err.message,
duration,
});
// On error, reject the content (fail-closed: safety over availability)
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
}
}
/**
* Check image content for security violations using imgSecCheck.
* @param {Buffer} imageBuffer - Image data buffer
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>}
*/
async checkImageContent(imageBuffer) {
const startTime = Date.now();
// Check image size (max 1MB)
if (imageBuffer.length > 1024 * 1024) {
const result = {
pass: false,
errcode: -1,
errmsg: '图片大小不能超过1MB',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'image_too_large',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${IMG_SEC_CHECK_URL}?access_token=${token}`;
// Build multipart form data for imgSecCheck
const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW';
const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="image.png"\r\nContent-Type: image/png\r\n\r\n`;
const suffix = `\r\n--${boundary}--\r\n`;
const buffer = Buffer.concat([
Buffer.from(prefix),
imageBuffer,
Buffer.from(suffix),
]);
const apiResult = await this._callWechatAPIWithBuffer(url, buffer, boundary, 'imgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0;
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: pass ? 'pass' : 'reject',
reason: pass ? 'image_safe' : `errcode_${apiResult.errcode}`,
duration,
});
return {
pass,
errcode: apiResult.errcode || 0,
errmsg: pass ? 'ok' : '图片内容违规,请更换',
};
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] imgSecCheck error:', err.message);
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'error',
reason: err.message,
duration,
});
// Fail-closed
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
}
}
/**
* Call WeChat API with JSON POST data.
* @param {string} url
* @param {string} postData
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPI(url, postData, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(postData);
req.end();
});
}
/**
* Call WeChat API with multipart form data (for image upload).
* @param {string} url
* @param {Buffer} buffer
* @param {string} boundary
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPIWithBuffer(url, buffer, boundary, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': buffer.length,
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(buffer);
req.end();
});
}
/**
* Enforce rate limiting: queue requests if exceeding 5000/min.
* @returns {Promise<void>}
*/
async _enforceRateLimit() {
const now = Date.now();
// Clean old timestamps outside the current window
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE) {
this._requestTimestamps.push(now);
return;
}
// Rate limited - wait until a slot opens
return new Promise((resolve) => {
this._requestQueue.push(resolve);
this._processQueue();
});
}
/**
* Process queued requests as rate limit slots become available.
*/
_processQueue() {
if (this._isProcessingQueue || this._requestQueue.length === 0) return;
this._isProcessingQueue = true;
const processNext = () => {
const now = Date.now();
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE && this._requestQueue.length > 0) {
this._requestTimestamps.push(now);
const nextResolve = this._requestQueue.shift();
nextResolve();
processNext();
} else if (this._requestQueue.length > 0) {
// Wait for the oldest timestamp to expire
const oldestTs = this._requestTimestamps[0];
const waitTime = RATE_LIMIT_WINDOW_MS - (now - oldestTs) + 100;
setTimeout(processNext, waitTime);
} else {
this._isProcessingQueue = false;
}
};
processNext();
}
/**
* Sanitize content for logging: keep only first 3 and last 3 chars,
* replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
}
// Export scene mapping for external use
ContentSecurityService.SCENE_MAP = SCENE_MAP;
module.exports = ContentSecurityService;
+301
View File
@@ -0,0 +1,301 @@
/**
* Report Service
* Handles user reports of inappropriate content and automatic content takedown.
*
* Features:
* - Report submission with confidential reporter identity
* - Auto-takedown when 3+ different users report the same content
* - Content reset on takedown (nickname "玩家+随机数", signature/description cleared)
* - Admin review confirmation
*/
const AuditLogger = require('./auditLogger');
// ============================================================
// Configuration
// ============================================================
const AUTO_TAKEOWN_THRESHOLD = 3; // Number of reports to trigger auto-takedown
// Valid report reasons
const REPORT_REASONS = {
POLITICS: 'politics',
PORNOGRAPHY: 'pornography',
GAMBLING: 'gambling',
OTHER: 'other',
};
class ReportService {
/**
* @param {object} options
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
* @param {import('./violationService')} [options.violationService] - Violation service instance
*/
constructor(options = {}) {
this.logger = options.logger || new AuditLogger();
this.violationService = options.violationService || null;
/**
* In-memory report records.
* Key: contentId, Value: { targetUserId, contentType, contentSummary, reports: [{reporterId, reason, timestamp}], status, createdAt }
* @type {Map<string, object>}
*/
this._reports = new Map();
/**
* User content storage (for content reset on takedown).
* Key: userId, Value: { nickname, signature, description, avatarUrl }
* @type {Map<string, object>}
*/
this._userContent = new Map();
}
/**
* Submit a report for inappropriate content.
* @param {object} entry
* @param {string} entry.contentId - Unique identifier for the reported content
* @param {string} entry.targetUserId - User ID of the content author
* @param {string} entry.contentType - Type of content ('chat', 'nickname', 'signature', 'description', 'avatar')
* @param {string} entry.contentSummary - Desensitized content summary
* @param {string} entry.reporterId - User ID of the reporter
* @param {string} entry.reason - Report reason (politics/pornography/gambling/other)
* @returns {{ success: boolean, reportCount: number, autoTakenDown: boolean }}
*/
submitReport(entry) {
const {
contentId,
targetUserId,
contentType,
contentSummary,
reporterId,
reason,
} = entry;
// Validate reason
if (!Object.values(REPORT_REASONS).includes(reason)) {
return { success: false, reportCount: 0, autoTakenDown: false };
}
// Get or create report record
let record = this._reports.get(contentId);
if (!record) {
record = {
contentId,
targetUserId,
contentType,
contentSummary,
reports: [],
status: 'active', // active | taken_down | reviewed
createdAt: Date.now(),
};
this._reports.set(contentId, record);
}
// Check if already reported by same user
const alreadyReported = record.reports.some(
(r) => r.reporterId === reporterId
);
if (alreadyReported) {
return {
success: false,
reportCount: record.reports.length,
autoTakenDown: false,
};
}
// Add the report
record.reports.push({
reporterId,
reason,
timestamp: Date.now(),
});
// Log the report (reporter identity kept confidential)
this.logger.logReport({
reporterId,
targetUserId,
contentSummary,
reason,
});
console.log(
`[ReportService] Report submitted for content ${contentId} by user *** (${record.reports.length}/${AUTO_TAKEOWN_THRESHOLD})`
);
// Check for auto-takedown
let autoTakenDown = false;
if (
record.reports.length >= AUTO_TAKEOWN_THRESHOLD &&
record.status === 'active'
) {
autoTakenDown = this._autoTakedown(contentId, record);
}
return {
success: true,
reportCount: record.reports.length,
autoTakenDown,
};
}
/**
* Auto-takedown content when threshold is reached.
* @param {string} contentId
* @param {object} record
* @returns {boolean}
*/
_autoTakedown(contentId, record) {
record.status = 'taken_down';
record.takenDownAt = Date.now();
console.log(
`[ReportService] Auto-takedown triggered for content ${contentId} (${record.reports.length} reports)`
);
// Reset the user's content
this._resetUserContent(record.targetUserId, record.contentType);
// Record violation for the content author
if (this.violationService) {
this.violationService.recordViolation({
userId: record.targetUserId,
violationType: 'report_takedown',
contentSummary: record.contentSummary,
scene: this._contentTypeToScene(record.contentType),
});
}
return true;
}
/**
* Confirm a report as violation (admin action).
* @param {string} contentId
* @returns {{ success: boolean }}
*/
confirmViolation(contentId) {
const record = this._reports.get(contentId);
if (!record) {
return { success: false };
}
record.status = 'reviewed';
record.reviewedAt = Date.now();
// Reset the user's content
this._resetUserContent(record.targetUserId, record.contentType);
// Record violation for the content author
if (this.violationService) {
this.violationService.recordViolation({
userId: record.targetUserId,
violationType: 'admin_confirmed',
contentSummary: record.contentSummary,
scene: this._contentTypeToScene(record.contentType),
});
}
console.log(
`[ReportService] Admin confirmed violation for content ${contentId}`
);
return { success: true };
}
/**
* Reset user content after takedown or deletion.
* Nickname "玩家" + random 4-digit number
* Signature/description cleared
* @param {string} userId
* @param {string} contentType
*/
_resetUserContent(userId, contentType) {
let userContent = this._userContent.get(userId);
if (!userContent) {
userContent = { nickname: '', signature: '', description: '', avatarUrl: '' };
this._userContent.set(userId, userContent);
}
switch (contentType) {
case 'nickname':
userContent.nickname = `玩家${Math.floor(1000 + Math.random() * 9000)}`;
break;
case 'signature':
userContent.signature = '';
break;
case 'description':
userContent.description = '';
break;
case 'avatar':
userContent.avatarUrl = '';
break;
case 'chat':
// Chat messages are just hidden, no user content reset needed
break;
}
console.log(
`[ReportService] Content reset for user ${userId}, type: ${contentType}`
);
}
/**
* Convert content type to scene number.
* @param {string} contentType
* @returns {number}
*/
_contentTypeToScene(contentType) {
const map = {
nickname: 1,
chat: 2,
signature: 3,
description: 4,
avatar: 5,
};
return map[contentType] || 0;
}
/**
* Get report status for a content item.
* @param {string} contentId
* @returns {object|null}
*/
getReportStatus(contentId) {
const record = this._reports.get(contentId);
if (!record) return null;
return {
contentId: record.contentId,
contentType: record.contentType,
reportCount: record.reports.length,
status: record.status,
// Do NOT expose reporter identities
};
}
/**
* Get all pending reports for admin review.
* @returns {Array}
*/
getPendingReports() {
const pending = [];
for (const record of this._reports.values()) {
if (record.status === 'taken_down') {
pending.push({
contentId: record.contentId,
targetUserId: record.targetUserId,
contentType: record.contentType,
contentSummary: record.contentSummary,
reportCount: record.reports.length,
reasons: [...new Set(record.reports.map((r) => r.reason))],
takenDownAt: record.takenDownAt,
});
}
}
return pending;
}
}
// Export report reasons for external use
ReportService.REPORT_REASONS = REPORT_REASONS;
module.exports = ReportService;
+133
View File
@@ -0,0 +1,133 @@
/**
* Sensitive Words Dictionary
* Provides sensitive word lists for content security filtering.
* This is the server-side master word list that gets served to clients.
*
* Categories:
* - politics: Politically harmful content
* - pornography: Pornographic and obscene content
* - gambling: Gambling and illegal betting content
* - violence: Violent and threatening content
* - abuse: Abusive and insulting language
* - fraud: Fraud and scam content
* - other: Other regulated content
*/
const SENSITIVE_WORDS = {
politics: [
// Politically sensitive terms (representative samples)
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
'反动', '暴乱', '煽动颠覆', '分裂势力', '恐怖袭击',
'邪教组织', '法轮', '法轮功', '台独', '藏独', '疆独',
],
pornography: [
// Pornographic and obscene terms (representative samples)
'色情', '淫秽', '裸体', '性交', '卖淫',
'嫖娼', '成人电影', '情色', '黄色视频', '一夜情',
'援交', '约炮', '色诱', '露点', '性服务',
],
gambling: [
// Gambling related terms (representative samples)
'赌博', '赌场', '下注', '赌资', '博彩',
'六合彩', '时时彩', '赌球', '网络赌', '百家乐',
'老虎机', '扑克赌', '赌狗', '开盘下注', '庄家赔率',
],
violence: [
// Violence related terms (representative samples)
'杀人', '砍人', '捅死', '爆炸装置', '自制炸弹',
'灭门', '血腥屠杀', '残忍杀害', '暴力袭击', '砍杀',
],
abuse: [
// Abusive language (representative samples)
'傻逼', '操你', '妈的', '去死', '废物',
'滚蛋', '贱人', '狗日的', '草泥马', '脑残',
'白痴', '弱智', '猪头', '王八蛋', '混蛋',
],
fraud: [
// Fraud and scam terms (representative samples)
'代开发票', '虚假投资', '传销', '诈骗', '骗钱',
'刷单', '套现', '洗钱', '假币', '传销组织',
],
other: [
// Other regulated terms
'代孕', '买卖器官', '毒品', '吸毒', '走私',
'枪支', '管制刀具', '假药', '违禁品',
],
};
/**
* Get all sensitive words as a flat array.
* @returns {string[]}
*/
function getAllWords() {
return Object.values(SENSITIVE_WORDS).flat();
}
/**
* Get words grouped by category.
* @returns {object}
*/
function getWordsByCategory() {
return { ...SENSITIVE_WORDS };
}
/**
* Get the total count of words.
* @returns {number}
*/
function getWordCount() {
return getAllWords().length;
}
/**
* Check if a text contains any sensitive words.
* @param {string} text - Text to check
* @returns {{ hasViolation: boolean, matchedWords: string[], categories: string[] }}
*/
function checkText(text) {
if (!text || typeof text !== 'string') {
return { hasViolation: false, matchedWords: [], categories: [] };
}
const matchedWords = [];
const categories = new Set();
const lowerText = text.toLowerCase();
for (const [category, words] of Object.entries(SENSITIVE_WORDS)) {
for (const word of words) {
if (lowerText.includes(word.toLowerCase())) {
matchedWords.push(word);
categories.add(category);
}
}
}
return {
hasViolation: matchedWords.length > 0,
matchedWords: [...new Set(matchedWords)],
categories: [...categories],
};
}
/**
* Get the version/timestamp of the word list for cache validation.
* @returns {string}
*/
function getVersion() {
return '2026-05-11-v1';
}
module.exports = {
SENSITIVE_WORDS,
getAllWords,
getWordsByCategory,
getWordCount,
checkText,
getVersion,
};
+265
View File
@@ -0,0 +1,265 @@
/**
* Violation Service
* Manages user violation records and mute penalties.
*
* Penalty tiers:
* - 3 violations 24-hour mute
* - 5 violations 7-day mute
* - 10 violations permanent mute
*
* When a mute period expires, the count for the current penalty tier is reset.
*/
const AuditLogger = require('./auditLogger');
// ============================================================
// Penalty Tiers
// ============================================================
const PENALTY_TIERS = [
{ threshold: 3, durationMs: 24 * 60 * 60 * 1000, label: '24小时禁言' },
{ threshold: 5, durationMs: 7 * 24 * 60 * 60 * 1000, label: '7天禁言' },
{ threshold: 10, durationMs: Infinity, label: '永久禁言' },
];
// Scene labels for logging
const SCENE_LABELS = {
1: 'nickname',
2: 'chat',
3: 'signature',
4: 'description',
5: 'image',
};
class ViolationService {
/**
* @param {object} options
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
*/
constructor(options = {}) {
this.logger = options.logger || new AuditLogger();
/**
* In-memory violation records.
* Key: userId (openid), Value: { count, violations[], currentTierStart }
* In production, this should be backed by a database.
* @type {Map<string, { count: number, violations: Array, currentTierStart: number }>}
*/
this._records = new Map();
/**
* In-memory mute records.
* Key: userId (openid), Value: { expiresAt: number|null, tier: number }
* @type {Map<string, { expiresAt: number|null, tier: number }>}
*/
this._mutes = new Map();
// Start periodic cleanup of expired mutes
this._cleanupInterval = setInterval(() => this._cleanupExpiredMutes(), 60000);
}
/**
* Record a violation for a user.
* @param {object} entry
* @param {string} entry.userId - User's openid
* @param {string} entry.violationType - Type of violation (e.g., 'text_violation', 'image_violation')
* @param {string} entry.contentSummary - Desensitized content summary
* @param {number} entry.scene - Scene value
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
*/
recordViolation(entry) {
const { userId, violationType, contentSummary, scene } = entry;
// Get or create record
let record = this._records.get(userId);
if (!record) {
record = { count: 0, violations: [], currentTierStart: 0 };
this._records.set(userId, record);
}
// Increment violation count
record.count++;
record.violations.push({
type: violationType,
contentSummary,
scene,
timestamp: Date.now(),
});
// Log the violation
this.logger.logViolation({
userId,
violationType,
contentSummary,
action: `violation_count_${record.count}`,
});
// Check if a penalty should be applied
const penalty = this._checkPenalty(userId, record.count);
return penalty;
}
/**
* Check if a user is currently muted.
* @param {string} userId - User's openid
* @returns {{ isMuted: boolean, remainingMs: number, remainingText: string, tier: number }}
*/
getMuteStatus(userId) {
const mute = this._mutes.get(userId);
if (!mute) {
return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 };
}
// Permanent mute
if (mute.expiresAt === null) {
return { isMuted: true, remainingMs: Infinity, remainingText: '永久', tier: mute.tier };
}
const remaining = mute.expiresAt - Date.now();
if (remaining <= 0) {
// Mute expired, clean up
this._removeMute(userId);
return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 };
}
return {
isMuted: true,
remainingMs: remaining,
remainingText: this._formatDuration(remaining),
tier: mute.tier,
};
}
/**
* Remove a mute (used when mute expires or is manually lifted).
* Resets the violation count for the current penalty tier.
* @param {string} userId
*/
_removeMute(userId) {
const record = this._records.get(userId);
if (record) {
// Find which tier they were at and reset count to just below that tier
const mute = this._mutes.get(userId);
if (mute && mute.tier > 0) {
// Reset to just below the current tier threshold
// so they need a full set of new violations to reach the next tier
const tierIndex = PENALTY_TIERS.findIndex(t => t.threshold === mute.tier);
if (tierIndex > 0) {
record.count = PENALTY_TIERS[tierIndex - 1].threshold;
} else {
record.count = 0;
}
record.currentTierStart = record.count;
}
}
this._mutes.delete(userId);
console.log(`[ViolationService] Mute removed for user ${userId}`);
}
/**
* Check if a penalty should be applied based on violation count.
* @param {string} userId
* @param {number} count
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
*/
_checkPenalty(userId, count) {
// Check penalty tiers in reverse order (highest first)
for (let i = PENALTY_TIERS.length - 1; i >= 0; i--) {
const tier = PENALTY_TIERS[i];
if (count >= tier.threshold) {
// Only apply if not already muted at this or higher tier
const currentMute = this._mutes.get(userId);
if (currentMute && currentMute.tier >= tier.threshold) {
return { penalty: null, isMuted: true, muteDuration: null };
}
// Apply mute
const expiresAt = tier.durationMs === Infinity ? null : Date.now() + tier.durationMs;
this._mutes.set(userId, {
expiresAt,
tier: tier.threshold,
});
console.log(`[ViolationService] User ${userId} muted: ${tier.label} (violations: ${count})`);
this.logger.logViolation({
userId,
violationType: 'penalty_applied',
contentSummary: '',
action: `mute_${tier.label}`,
});
return {
penalty: tier.label,
isMuted: true,
muteDuration: tier.durationMs === Infinity ? '永久' : this._formatDuration(tier.durationMs),
};
}
}
return { penalty: null, isMuted: false, muteDuration: null };
}
/**
* Clean up expired mutes periodically.
*/
_cleanupExpiredMutes() {
const now = Date.now();
for (const [userId, mute] of this._mutes) {
if (mute.expiresAt !== null && mute.expiresAt <= now) {
this._removeMute(userId);
}
}
}
/**
* Format a duration in milliseconds to a human-readable string.
* @param {number} ms
* @returns {string}
*/
_formatDuration(ms) {
if (ms === Infinity) return '永久';
const totalMinutes = Math.floor(ms / (60 * 1000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
if (days > 0) {
return `${days}${remainingHours}小时`;
}
return `${hours}小时${minutes}分钟`;
}
/**
* Get violation summary for a user.
* @param {string} userId
* @returns {{ count: number, isMuted: boolean, recentViolations: Array }}
*/
getViolationSummary(userId) {
const record = this._records.get(userId);
const muteStatus = this.getMuteStatus(userId);
return {
count: record ? record.count : 0,
isMuted: muteStatus.isMuted,
muteRemainingText: muteStatus.remainingText,
recentViolations: record ? record.violations.slice(-5) : [],
};
}
/**
* Clean up resources.
*/
destroy() {
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
}
console.log('[ViolationService] Destroyed');
}
}
module.exports = ViolationService;
+223
View File
@@ -0,0 +1,223 @@
/**
* WeChat Access Token Manager
* Manages the lifecycle of WeChat access tokens for content security APIs.
* - Auto-refreshes tokens before expiry (2-hour validity, refresh 5 minutes early)
* - Retry with exponential backoff on failure (2s, 4s, 8s)
* - Marks service as unavailable after all retries exhausted
*/
const https = require('https');
// ============================================================
// Configuration
// ============================================================
const TOKEN_VALIDITY_MS = 2 * 60 * 60 * 1000; // 2 hours
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // Refresh 5 minutes before expiry
const RETRY_DELAYS = [2000, 4000, 8000]; // Exponential backoff delays in ms
const WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token';
class WechatTokenManager {
/**
* @param {object} options
* @param {string} options.appId - WeChat Mini Program App ID
* @param {string} options.appSecret - WeChat Mini Program App Secret
*/
constructor(options = {}) {
this.appId = options.appId || process.env.WX_APPID || '';
this.appSecret = options.appSecret || process.env.WX_APPSECRET || '';
this._accessToken = null;
this._expiresAt = 0;
this._refreshTimer = null;
this._isAvailable = false;
this._isRefreshing = false;
this._retryCount = 0;
}
/**
* Initialize the token manager and fetch the first token.
* @returns {Promise<boolean>} true if token obtained successfully
*/
async init() {
console.log('[TokenManager] Initializing...');
const success = await this._refreshToken();
if (success) {
this._scheduleRefresh();
}
return success;
}
/**
* Get the current valid access token.
* If the token is expired or about to expire, refresh it first.
* @returns {Promise<string|null>} The access token, or null if unavailable
*/
async getAccessToken() {
// If token is still valid (with margin), return it directly
if (this._accessToken && Date.now() < this._expiresAt - TOKEN_REFRESH_MARGIN_MS) {
return this._accessToken;
}
// Token expired or about to expire, try to refresh
if (!this._isRefreshing) {
const success = await this._refreshToken();
if (success) {
this._scheduleRefresh();
}
}
return this._isAvailable ? this._accessToken : null;
}
/**
* Check if the token manager is currently available.
* @returns {boolean}
*/
isAvailable() {
return this._isAvailable;
}
/**
* Refresh the access token by calling WeChat API.
* Implements retry with exponential backoff.
* @returns {Promise<boolean>}
*/
async _refreshToken() {
if (this._isRefreshing) {
// Wait for the ongoing refresh to complete
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this._isRefreshing) {
clearInterval(checkInterval);
resolve(this._isAvailable);
}
}, 100);
});
}
this._isRefreshing = true;
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
try {
const token = await this._fetchTokenFromWechat();
if (token) {
this._accessToken = token;
this._expiresAt = Date.now() + TOKEN_VALIDITY_MS;
this._isAvailable = true;
this._retryCount = 0;
this._isRefreshing = false;
console.log('[TokenManager] Token refreshed successfully, expires at:',
new Date(this._expiresAt).toISOString());
return true;
}
} catch (err) {
console.error(`[TokenManager] Fetch token failed (attempt ${attempt + 1}):`, err.message);
}
// Wait before retry (if there are more retries)
if (attempt < RETRY_DELAYS.length) {
const delay = RETRY_DELAYS[attempt];
console.log(`[TokenManager] Retrying in ${delay}ms...`);
await this._sleep(delay);
}
}
// All retries exhausted
this._isAvailable = false;
this._isRefreshing = false;
this._accessToken = null;
console.error('[TokenManager] All retry attempts exhausted. Service marked as unavailable.');
// Schedule a recovery attempt after 10 minutes
setTimeout(() => {
console.log('[TokenManager] Attempting recovery refresh...');
this._refreshToken().then((success) => {
if (success) {
this._scheduleRefresh();
}
});
}, 10 * 60 * 1000);
return false;
}
/**
* Fetch access token from WeChat API.
* @returns {Promise<string|null>}
*/
_fetchTokenFromWechat() {
return new Promise((resolve, reject) => {
if (!this.appId || !this.appSecret) {
reject(new Error('AppId or AppSecret not configured'));
return;
}
const url = `${WECHAT_TOKEN_URL}?grant_type=client_credential&appid=${this.appId}&secret=${this.appSecret}`;
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
if (result.access_token) {
resolve(result.access_token);
} else {
reject(new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`));
}
} catch (e) {
reject(new Error(`Invalid JSON response: ${data.substring(0, 100)}`));
}
});
}).on('error', (err) => {
reject(err);
});
});
}
/**
* Schedule the next token refresh before expiry.
*/
_scheduleRefresh() {
if (this._refreshTimer) {
clearTimeout(this._refreshTimer);
}
const refreshAt = this._expiresAt - TOKEN_REFRESH_MARGIN_MS;
const delay = Math.max(refreshAt - Date.now(), 60000); // At least 1 minute
console.log(`[TokenManager] Next refresh scheduled in ${Math.round(delay / 1000)}s`);
this._refreshTimer = setTimeout(async () => {
console.log('[TokenManager] Scheduled refresh triggered');
const success = await this._refreshToken();
if (success) {
this._scheduleRefresh();
}
}, delay);
}
/**
* Utility: sleep for a given number of milliseconds.
* @param {number} ms
* @returns {Promise<void>}
*/
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Clean up resources.
*/
destroy() {
if (this._refreshTimer) {
clearTimeout(this._refreshTimer);
this._refreshTimer = null;
}
this._accessToken = null;
this._isAvailable = false;
console.log('[TokenManager] Destroyed');
}
}
module.exports = WechatTokenManager;
+197
View File
@@ -0,0 +1,197 @@
/**
* contentSecurityService.test.js
* Unit tests for server-side content security service.
*/
const { describe, it, before, after, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const ContentSecurityService = require('../services/contentSecurityService');
describe('ContentSecurityService', () => {
let service;
before(() => {
service = new ContentSecurityService();
});
after(() => {
// Clean up rate limiter state
service._requestTimestamps = [];
service._requestQueue.forEach(resolve => resolve());
service._requestQueue = [];
service._isProcessingQueue = false;
});
it('should initialize with rate limiter', () => {
assert.ok(service._requestTimestamps);
assert.ok(Array.isArray(service._requestTimestamps));
assert.ok(service._requestQueue);
assert.ok(Array.isArray(service._requestQueue));
});
it('should have checkTextContent method', () => {
assert.strictEqual(typeof service.checkTextContent, 'function');
});
it('should have checkImageContent method', () => {
assert.strictEqual(typeof service.checkImageContent, 'function');
});
it('should reject checkTextContent when token manager is unavailable', async () => {
// Override token manager to be unavailable
if (service.tokenManager) {
service.tokenManager._accessToken = '';
service.tokenManager._isAvailable = false;
}
try {
const result = await service.checkTextContent('test_openid', 'hello', 1);
// When token is unavailable, should return error or risky
assert.ok(result);
assert.strictEqual(result.pass, false);
} catch (e) {
// Also acceptable - throws when service unavailable
assert.ok(e);
}
});
it('should respect rate limiting', async () => {
// Test rate limiter concept
const now = Date.now();
service._requestTimestamps = [];
// Should allow first request
await service._enforceRateLimit();
assert.ok(service._requestTimestamps.length > 0);
// Fill up rate limit
for (let i = 0; i < 5000; i++) {
service._requestTimestamps.push(now);
}
// Should queue when limit reached (returns a pending Promise)
const limited = service._enforceRateLimit();
assert.ok(limited instanceof Promise);
// Clean up: clear the queue to avoid hanging
service._requestQueue.forEach(resolve => resolve());
service._requestQueue = [];
service._requestTimestamps = [];
service._isProcessingQueue = false;
});
});
describe('ViolationService', () => {
let violationService;
before(() => {
const ViolationService = require('../services/violationService');
const AuditLogger = require('../services/auditLogger');
violationService = new ViolationService({ logger: new AuditLogger() });
});
after(() => {
// Clean up any timers using destroy method
if (violationService.destroy) {
violationService.destroy();
}
});
it('should initialize without errors', () => {
assert.ok(violationService);
});
it('should have recordViolation method', () => {
assert.strictEqual(typeof violationService.recordViolation, 'function');
});
it('should have getMuteStatus method', () => {
assert.strictEqual(typeof violationService.getMuteStatus, 'function');
});
it('should record a violation and increment count', () => {
const testUserId = 'test_user_' + Date.now();
violationService.recordViolation({
userId: testUserId,
violationType: 'text_violation',
contentSummary: '测试违规内容',
scene: 2,
});
const status = violationService.getMuteStatus(testUserId);
assert.ok(status);
assert.strictEqual(typeof status.isMuted, 'boolean');
// Check violation count via internal records
const record = violationService._records.get(testUserId);
assert.ok(record);
assert.strictEqual(typeof record.count, 'number');
assert.strictEqual(record.count, 1);
});
it('should apply mute penalty at 3 violations', () => {
const testUserId = 'mute_test_' + Date.now();
// Record 3 violations
for (let i = 0; i < 3; i++) {
violationService.recordViolation({
userId: testUserId,
violationType: 'pornography',
contentSummary: `violation ${i}`,
scene: 2,
});
}
const status = violationService.getMuteStatus(testUserId);
assert.strictEqual(status.isMuted, true);
assert.ok(status.remainingMs > 0);
});
it('should escalate mute at 5 violations', () => {
const testUserId = 'escalate_test_' + Date.now();
// Record 5 violations
for (let i = 0; i < 5; i++) {
violationService.recordViolation({
userId: testUserId,
violationType: 'gambling',
contentSummary: `violation ${i}`,
scene: 2,
});
}
const status = violationService.getMuteStatus(testUserId);
assert.strictEqual(status.isMuted, true);
});
});
describe('ReportService', () => {
let reportService;
before(() => {
const ReportService = require('../services/reportService');
const AuditLogger = require('../services/auditLogger');
reportService = new ReportService({ logger: new AuditLogger() });
});
it('should initialize without errors', () => {
assert.ok(reportService);
});
it('should have submitReport method', () => {
assert.strictEqual(typeof reportService.submitReport, 'function');
});
it('should accept a report submission', () => {
const result = reportService.submitReport({
contentId: 'test_content_' + Date.now(),
targetUserId: 'target_user_123',
contentType: 'chat',
contentSummary: 'test content',
reporterId: 'reporter_user_456',
reason: 'politics',
});
assert.ok(result);
assert.strictEqual(typeof result.success, 'boolean');
});
});
+259
View File
@@ -0,0 +1,259 @@
/**
* contentSecurityManager.test.js
* Unit tests for client-side ContentSecurityManager.
*
* Since ContentSecurityManager depends on wx APIs (wx.request, wx.getStorageSync, etc.),
* we mock these APIs before requiring the module.
*/
const { describe, it, before, after, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
// ============================================================
// Mock wx APIs
// ============================================================
const mockStorage = {};
const mockRequests = {};
global.wx = {
getStorageSync: (key) => mockStorage[key] || '',
setStorageSync: (key, value) => { mockStorage[key] = value; },
request: (options) => {
const key = `${options.method}:${options.url}`;
if (mockRequests[key]) {
const handler = mockRequests[key];
setTimeout(() => {
if (handler.success) {
options.success(handler.success);
} else if (handler.fail) {
options.fail(handler.fail);
}
}, 0);
} else {
// Default: 404
setTimeout(() => {
options.fail({ errMsg: 'request:fail' });
}, 0);
}
},
uploadFile: (options) => {
setTimeout(() => {
options.fail({ errMsg: 'uploadFile:fail' });
}, 0);
},
showToast: () => {},
showKeyboard: () => {},
hideKeyboard: () => {},
onKeyboardInput: () => {},
offKeyboardInput: () => {},
onKeyboardConfirm: () => {},
offKeyboardConfirm: () => {},
chooseImage: () => {},
getFileInfo: () => ({ size: 0 }),
};
// Now require the module after mocking wx
const ContentSecurityManager = require('../../js/managers/ContentSecurityManager');
// ============================================================
// Tests
// ============================================================
describe('ContentSecurityManager - Local Check', () => {
let manager;
before(async () => {
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should initialize with fallback word list', () => {
assert.ok(manager.isInitialized());
assert.ok(manager.getWordCount() > 0);
});
it('should detect sensitive words in content', () => {
const result = manager.checkLocalText('这是一个赌博网站');
assert.strictEqual(result.hasViolation, true);
assert.ok(result.matchedWords.length > 0);
assert.ok(result.matchedWords.includes('赌博'));
});
it('should detect political content', () => {
const result = manager.checkLocalText('法轮功是邪教');
assert.strictEqual(result.hasViolation, true);
assert.ok(result.matchedWords.includes('法轮功'));
});
it('should detect pornographic content', () => {
const result = manager.checkLocalText('色情视频');
assert.strictEqual(result.hasViolation, true);
assert.ok(result.matchedWords.includes('色情'));
});
it('should detect abusive language', () => {
const result = manager.checkLocalText('你是个傻逼');
assert.strictEqual(result.hasViolation, true);
assert.ok(result.matchedWords.includes('傻逼'));
});
it('should pass clean content', () => {
const result = manager.checkLocalText('今天天气真好,适合出去玩');
assert.strictEqual(result.hasViolation, false);
assert.strictEqual(result.matchedWords.length, 0);
});
it('should handle empty content', () => {
const result = manager.checkLocalText('');
assert.strictEqual(result.hasViolation, false);
});
it('should handle null content', () => {
const result = manager.checkLocalText(null);
assert.strictEqual(result.hasViolation, false);
});
it('should complete within 50ms', () => {
const start = Date.now();
for (let i = 0; i < 100; i++) {
manager.checkLocalText('这是一个普通的测试内容,不包含任何敏感词汇');
}
const duration = Date.now() - start;
const avgMs = duration / 100;
assert.ok(avgMs < 50, `Average check time ${avgMs}ms exceeds 50ms target`);
});
it('should be case-insensitive', () => {
const result = manager.checkLocalText('赌博');
assert.strictEqual(result.hasViolation, true);
});
});
describe('ContentSecurityManager - Word List Cache', () => {
let manager;
before(async () => {
// Clear cache
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should cache word list locally', () => {
// After init, cache should be populated
assert.ok(mockStorage['content_security_word_cache'] || manager.getWordCount() > 0);
});
it('should load from cache on subsequent init', async () => {
// Pre-populate cache
mockStorage['content_security_word_timestamp'] = Date.now();
mockStorage['content_security_word_version'] = 'test_v1';
mockStorage['content_security_word_cache'] = ['测试词1', '测试词2'];
const manager2 = new ContentSecurityManager();
await manager2.init({ serverUrl: 'http://localhost:3000' });
assert.strictEqual(manager2.getWordCount(), 2);
});
it('should ignore expired cache', async () => {
// Set expired cache
mockStorage['content_security_word_timestamp'] = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
mockStorage['content_security_word_version'] = 'expired_v1';
mockStorage['content_security_word_cache'] = ['过期词1'];
const manager3 = new ContentSecurityManager();
await manager3.init({ serverUrl: 'http://localhost:3000' });
// Should use fallback, not expired cache
assert.ok(manager3.getWordCount() > 1);
});
});
describe('ContentSecurityManager - Full Text Check', () => {
let manager;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should reject content with local violations', async () => {
const result = await manager.fullTextCheck('test_openid', '赌博网站', ContentSecurityManager.SCENE.CHAT);
assert.strictEqual(result.pass, false);
assert.strictEqual(result.localViolation, true);
assert.ok(result.errorMessage.length > 0);
});
it('should provide appropriate error message for nickname scene', async () => {
const result = await manager.fullTextCheck('test_openid', '赌博', ContentSecurityManager.SCENE.NICKNAME);
assert.ok(result.errorMessage.includes('昵称'));
});
it('should provide appropriate error message for chat scene', async () => {
const result = await manager.fullTextCheck('test_openid', '色情内容', ContentSecurityManager.SCENE.CHAT);
assert.ok(result.errorMessage.includes('违规'));
});
it('should provide appropriate error message for signature scene', async () => {
const result = await manager.fullTextCheck('test_openid', '色情', ContentSecurityManager.SCENE.SIGNATURE);
assert.ok(result.errorMessage.includes('违规'));
});
});
describe('ContentSecurityManager - SCENE Constants', () => {
it('should export SCENE constants', () => {
assert.strictEqual(ContentSecurityManager.SCENE.NICKNAME, 1);
assert.strictEqual(ContentSecurityManager.SCENE.CHAT, 2);
assert.strictEqual(ContentSecurityManager.SCENE.SIGNATURE, 3);
assert.strictEqual(ContentSecurityManager.SCENE.DESCRIPTION, 4);
});
it('should export REPORT_REASONS constants', () => {
assert.strictEqual(ContentSecurityManager.REPORT_REASONS.POLITICS, 'politics');
assert.strictEqual(ContentSecurityManager.REPORT_REASONS.PORNOGRAPHY, 'pornography');
assert.strictEqual(ContentSecurityManager.REPORT_REASONS.GAMBLING, 'gambling');
assert.strictEqual(ContentSecurityManager.REPORT_REASONS.OTHER, 'other');
});
});
describe('ContentSecurityManager - Mute Status', () => {
let manager;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should return not muted when server is unreachable', async () => {
const status = await manager.getMuteStatus('nonexistent_user');
assert.strictEqual(status.isMuted, false);
assert.strictEqual(status.remainingMs, 0);
});
});
describe('ContentSecurityManager - Submit Report', () => {
let manager;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should handle report submission failure gracefully', async () => {
const result = await manager.submitReport({
contentId: 'test_123',
targetUserId: 'target_456',
contentType: 'chat',
contentSummary: 'test',
reporterId: 'reporter_789',
reason: 'politics',
});
// Should not throw, returns failure object
assert.strictEqual(result.success, false);
});
});
+289
View File
@@ -0,0 +1,289 @@
/**
* e2e-content-security.test.js
* End-to-end integration test for content security system.
*
* Tests the full flow:
* 1. Nickname modification with content security check
* 2. Chat message with real-time check
* 3. Signature/description modification with check
* 4. Avatar upload with image check
* 5. Report submission
* 6. Auto-takedown after 3 reports
* 7. Mute penalty after violations
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
// ============================================================
// Mock wx APIs for client-side modules
// ============================================================
const mockStorage = {};
global.wx = {
getStorageSync: (key) => mockStorage[key] || '',
setStorageSync: (key, value) => { mockStorage[key] = value; },
request: (options) => {
// Simulate server responses
const url = options.url;
if (url.includes('/api/content/check-text')) {
// Simulate msgSecCheck: reject known bad content
const content = options.data?.content || '';
const badWords = ['赌博', '色情', '法轮功', '杀人', '傻逼'];
const hasBad = badWords.some(w => content.includes(w));
setTimeout(() => {
options.success({
statusCode: 200,
data: hasBad
? { pass: false, errcode: 87014, errmsg: 'risky content', suggest: 'risky', label: 20001 }
: { pass: true, errcode: 0, errmsg: 'ok', suggest: 'pass', label: 100 },
});
}, 10);
} else if (url.includes('/api/content/user/mute-status')) {
setTimeout(() => {
options.success({
statusCode: 200,
data: { isMuted: false, remainingMs: 0, remainingText: '', violationCount: 0 },
});
}, 10);
} else if (url.includes('/api/content/report')) {
setTimeout(() => {
options.success({
statusCode: 200,
data: { success: true, message: '举报已提交' },
});
}, 10);
} else if (url.includes('/api/content/sensitive-words')) {
setTimeout(() => {
options.success({
statusCode: 200,
data: { updated: false, version: '1' },
});
}, 10);
} else {
setTimeout(() => {
options.fail({ errMsg: 'request:fail unknown url' });
}, 10);
}
},
uploadFile: (options) => {
setTimeout(() => {
options.success({
statusCode: 200,
data: JSON.stringify({ pass: true, errcode: 0, errmsg: 'ok' }),
});
}, 10);
},
showToast: () => {},
showKeyboard: () => {},
hideKeyboard: () => {},
onKeyboardInput: () => {},
offKeyboardInput: () => {},
onKeyboardConfirm: () => {},
offKeyboardConfirm: () => {},
chooseImage: () => {},
getFileInfo: () => ({ size: 500 * 1024 }), // 500KB
};
const ContentSecurityManager = require('../../js/managers/ContentSecurityManager');
// ============================================================
// E2E Tests
// ============================================================
describe('E2E: Nickname Modification Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should pass clean nickname through local + server check', async () => {
const result = await csm.fullTextCheck('user_001', '小明同学', ContentSecurityManager.SCENE.NICKNAME);
assert.strictEqual(result.pass, true);
assert.strictEqual(result.localViolation, false);
});
it('should reject nickname with local violation', async () => {
const result = await csm.fullTextCheck('user_001', '赌博大王', ContentSecurityManager.SCENE.NICKNAME);
assert.strictEqual(result.pass, false);
assert.strictEqual(result.localViolation, true);
assert.ok(result.errorMessage.includes('昵称'));
});
it('should reject nickname with server violation (not in local list)', async () => {
// "杀人" is in the fallback list, so this will be caught locally
// For a true server-only test, we'd need a word not in the local list
// but the server would flag. Here we verify the flow works.
const result = await csm.fullTextCheck('user_001', '杀人狂魔', ContentSecurityManager.SCENE.NICKNAME);
assert.strictEqual(result.pass, false);
});
it('should reject empty nickname at UI level', () => {
const localCheck = csm.checkLocalText('');
assert.strictEqual(localCheck.hasViolation, false);
// Empty content is handled at UI level, not by content security
});
});
describe('E2E: Chat Message Real-time Check Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should pass clean chat message', async () => {
const result = await csm.fullTextCheck('user_001', '大家好,来一起玩坦克大战吧!', ContentSecurityManager.SCENE.CHAT);
assert.strictEqual(result.pass, true);
});
it('should reject chat with violation', async () => {
const result = await csm.fullTextCheck('user_001', '快来看色情视频', ContentSecurityManager.SCENE.CHAT);
assert.strictEqual(result.pass, false);
assert.ok(result.errorMessage.includes('违规'));
});
it('should show mute status for muted user', async () => {
const status = await csm.getMuteStatus('user_001');
// Our mock returns not muted
assert.strictEqual(status.isMuted, false);
});
});
describe('E2E: Signature and Description Check Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should pass clean signature', async () => {
const result = await csm.fullTextCheck('user_001', '热爱生活,天天向上', ContentSecurityManager.SCENE.SIGNATURE);
assert.strictEqual(result.pass, true);
});
it('should reject signature with violation', async () => {
const result = await csm.fullTextCheck('user_001', '加入法轮功', ContentSecurityManager.SCENE.SIGNATURE);
assert.strictEqual(result.pass, false);
assert.strictEqual(result.localViolation, true);
});
it('should pass clean description', async () => {
const result = await csm.fullTextCheck('user_001', '这是一个游戏爱好者的个人空间', ContentSecurityManager.SCENE.DESCRIPTION);
assert.strictEqual(result.pass, true);
});
it('should reject description with violation', async () => {
const result = await csm.fullTextCheck('user_001', '代开发票请联系', ContentSecurityManager.SCENE.DESCRIPTION);
assert.strictEqual(result.pass, false);
assert.strictEqual(result.localViolation, true);
});
});
describe('E2E: Avatar Upload Check Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should pass clean avatar upload', async () => {
const result = await csm.checkImageContent('/tmp/test_avatar.png', 'user_001');
assert.strictEqual(result.pass, true);
});
});
describe('E2E: Report Submission Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should submit report successfully', async () => {
const result = await csm.submitReport({
contentId: 'chat_msg_123',
targetUserId: 'bad_user_456',
contentType: 'chat',
contentSummary: '违规内容摘要',
reporterId: 'user_001',
reason: 'politics',
});
assert.strictEqual(result.success, true);
});
it('should submit report with different reasons', async () => {
const reasons = ['politics', 'pornography', 'gambling', 'other'];
for (const reason of reasons) {
const result = await csm.submitReport({
contentId: `msg_${reason}_${Date.now()}`,
targetUserId: 'bad_user',
contentType: 'chat',
contentSummary: 'test',
reporterId: 'user_001',
reason,
});
assert.strictEqual(result.success, true);
}
});
});
describe('E2E: Audit Log Verification', () => {
it('should verify audit logger exists and has correct methods', () => {
const AuditLogger = require('../../server/services/auditLogger');
const logger = new AuditLogger();
assert.strictEqual(typeof logger.logAudit, 'function');
assert.strictEqual(typeof logger.logViolation, 'function');
assert.strictEqual(typeof logger.logReport, 'function');
});
it('should log content check result with desensitized content', () => {
const AuditLogger = require('../../server/services/auditLogger');
const logger = new AuditLogger();
// Test desensitization
const original = '这是一个很长的内容用于测试脱敏';
const desensitized = logger._sanitizeContent(original);
// _sanitizeContent keeps first 3 and last 3 chars, replaces middle with ***
assert.ok(desensitized.length < original.length || desensitized.includes('***'));
});
});
describe('E2E: Sensitive Word List Verification', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should have fallback word list covering major categories', () => {
const wordCount = csm.getWordCount();
assert.ok(wordCount >= 30, `Fallback word list should have at least 30 words, got ${wordCount}`);
// Verify key categories are covered
const politicsCheck = csm.checkLocalText('颠覆国家');
assert.strictEqual(politicsCheck.hasViolation, true);
const pornCheck = csm.checkLocalText('色情');
assert.strictEqual(pornCheck.hasViolation, true);
const gamblingCheck = csm.checkLocalText('赌博');
assert.strictEqual(gamblingCheck.hasViolation, true);
const abuseCheck = csm.checkLocalText('傻逼');
assert.strictEqual(abuseCheck.hasViolation, true);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* wechatTokenManager.test.js
* Unit tests for WeChat Access Token management.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
// Mock wx environment for WeChat mini game server
// The wechatTokenManager uses wx APIs on client, but on server it uses
// HTTPS requests to WeChat API. We test the server-side module.
const WechatTokenManager = require('../services/wechatTokenManager');
describe('WechatTokenManager', () => {
let manager;
before(() => {
manager = new WechatTokenManager();
});
after(() => {
// Clean up any timers
if (manager._refreshTimer) {
clearInterval(manager._refreshTimer);
}
});
it('should initialize with no token', () => {
assert.strictEqual(manager._accessToken, '');
assert.strictEqual(manager._expiresAt, 0);
assert.strictEqual(manager._isAvailable, false);
});
it('should report unavailable when no token is set', () => {
const available = manager.isAvailable();
assert.strictEqual(available, false);
});
it('should report available when valid token is set', () => {
manager._accessToken = 'test_token_123';
manager._expiresAt = Date.now() + 7000 * 1000; // 7000 seconds from now
manager._isAvailable = true;
assert.strictEqual(manager.isAvailable(), true);
});
it('should return token when available', () => {
manager._accessToken = 'test_token_456';
manager._expiresAt = Date.now() + 7000 * 1000;
manager._isAvailable = true;
const token = manager.getAccessToken();
assert.strictEqual(token, 'test_token_456');
});
it('should return empty string when token is expired', () => {
manager._accessToken = 'expired_token';
manager._expiresAt = Date.now() - 1000; // expired
manager._isAvailable = false;
const token = manager.getAccessToken();
assert.strictEqual(token, '');
});
it('should handle token refresh failure gracefully', async () => {
// Create a manager with invalid appid/secret to test failure
const failManager = new WechatTokenManager();
failManager.appId = 'invalid_appid';
failManager.appSecret = 'invalid_secret';
// Override retry delays to speed up test
failManager._isRefreshing = false;
// The _refreshToken will attempt with retries, but should not throw.
// Use a short timeout to avoid long waits.
const result = await Promise.race([
failManager._refreshToken(),
new Promise((resolve) => setTimeout(() => resolve(false), 5000)),
]);
assert.strictEqual(failManager.isAvailable(), false);
// Clean up any pending timers
failManager.destroy();
});
it('should not leak token in toString', () => {
manager._accessToken = 'sensitive_token_xyz';
const str = manager.toString();
assert.strictEqual(str.includes('sensitive_token_xyz'), false);
});
});