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:
@@ -0,0 +1,33 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
LABEL maintainer="tankwar-team"
|
||||
LABEL description="Content Security Microservice for UGC moderation"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund && \
|
||||
npm cache clean --force
|
||||
|
||||
# Copy service code
|
||||
COPY index.js ./
|
||||
COPY services/ ./services/
|
||||
|
||||
# Expose HTTP port
|
||||
EXPOSE 3000
|
||||
|
||||
# Environment defaults
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3000
|
||||
|
||||
# Graceful shutdown & init for PID 1
|
||||
RUN apk add --no-cache tini
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Content Security Microservice - Standalone Entry Point
|
||||
*
|
||||
* Independent content security service for UGC moderation.
|
||||
* Shared by multiple mini-games via game_id tenant isolation.
|
||||
*
|
||||
* API Endpoints:
|
||||
* POST /api/content/check-text - Text content security check
|
||||
* POST /api/content/check-image - Image content security check
|
||||
* GET /api/content/sensitive-words - Get sensitive word list
|
||||
* GET /api/user/mute-status - Check if a user is muted
|
||||
* GET /api/user/violation-summary - Get violation summary
|
||||
* POST /api/content/report - Submit a content report
|
||||
* GET /api/health - Health check
|
||||
* GET /api/metrics - Service metrics
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const WechatTokenManager = require('./services/wechatTokenManager');
|
||||
const AuditLogger = require('./services/auditLogger');
|
||||
const { createContentSecurityRouter } = require('./services/contentSecurityRoutes');
|
||||
const ViolationService = require('./services/violationService');
|
||||
const ReportService = require('./services/reportService');
|
||||
|
||||
// ============================================================
|
||||
// Configuration
|
||||
// ============================================================
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const DEFAULT_GAME_ID = process.env.DEFAULT_GAME_ID || 'tankwar';
|
||||
|
||||
// ============================================================
|
||||
// Express App
|
||||
// ============================================================
|
||||
const app = express();
|
||||
|
||||
// Parse JSON bodies
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const game_id = req.headers['x-game-id'] || req.body?.game_id || DEFAULT_GAME_ID;
|
||||
console.log(`[HTTP] ${req.method} ${req.url} ${res.statusCode} ${duration}ms game_id=${game_id}`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Health Check
|
||||
// ============================================================
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'content-security',
|
||||
version: '1.0.0',
|
||||
timestamp: Date.now(),
|
||||
tokenManagerAvailable: tokenManager ? tokenManager.isAvailable() : false,
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Metrics Endpoint
|
||||
// ============================================================
|
||||
app.get('/api/metrics', (req, res) => {
|
||||
res.json({
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
tokenManagerAvailable: tokenManager ? tokenManager.isAvailable() : false,
|
||||
activeViolations: violationService ? violationService._records.size : 0,
|
||||
activeMutes: violationService ? violationService._mutes.size : 0,
|
||||
activeReports: reportService ? reportService._reports.size : 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Initialize Services
|
||||
// ============================================================
|
||||
const auditLogger = new AuditLogger();
|
||||
const tokenManager = new WechatTokenManager();
|
||||
const violationService = new ViolationService({ logger: auditLogger });
|
||||
const reportService = new ReportService({ logger: auditLogger, violationService });
|
||||
|
||||
// Mount content security routes
|
||||
const contentSecurityRouter = createContentSecurityRouter({
|
||||
tokenManager,
|
||||
logger: auditLogger,
|
||||
violationService,
|
||||
reportService,
|
||||
});
|
||||
app.use('/api/content', contentSecurityRouter);
|
||||
|
||||
// ============================================================
|
||||
// Start Server
|
||||
// ============================================================
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.listen(PORT, HOST, async () => {
|
||||
console.log(`[Content Security Service] Running on ${HOST}:${PORT}`);
|
||||
console.log(`[Content Security Service] Default game_id: ${DEFAULT_GAME_ID}`);
|
||||
console.log(`[Content Security Service] API URL: http://${HOST}:${PORT}`);
|
||||
|
||||
// Initialize token manager
|
||||
const success = await tokenManager.init();
|
||||
if (success) {
|
||||
console.log('[Content Security Service] WeChat token manager initialized successfully');
|
||||
} else {
|
||||
console.warn('[Content Security Service] WeChat token manager unavailable (token fetch failed)');
|
||||
console.warn('[Content Security Service] Content checks will be rejected until token is obtained');
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[Content Security Service] SIGTERM received, shutting down gracefully...');
|
||||
tokenManager.destroy();
|
||||
auditLogger.destroy();
|
||||
violationService.destroy();
|
||||
server.close(() => {
|
||||
console.log('[Content Security Service] Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('[Content Security Service] SIGINT received, shutting down gracefully...');
|
||||
tokenManager.destroy();
|
||||
auditLogger.destroy();
|
||||
violationService.destroy();
|
||||
server.close(() => {
|
||||
console.log('[Content Security Service] Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = { app, server };
|
||||
+971
@@ -0,0 +1,971 @@
|
||||
{
|
||||
"name": "content-security-service",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "content-security-service",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"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"
|
||||
],
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"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==",
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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=="
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "ISC",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "content-security-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Content security microservice for UGC moderation (WeChat msgSecCheck/imgSecCheck)",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node index.js",
|
||||
"test": "node --test test/"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1"
|
||||
}
|
||||
}
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# Content Security Service K8s Deploy Script
|
||||
# Syncs source -> Master node -> builds Docker image ->
|
||||
# distributes image to Worker nodes via ctr -> applies K8s resources
|
||||
# -> rolls out the content-security-service deployment.
|
||||
# ============================================================
|
||||
set -e
|
||||
|
||||
LOG="/tmp/content-security-k8s-deploy.log"
|
||||
> "$LOG"
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
SERVICE_DIR="/Users/hanchengxi/workspace/tankwar_proj/content-security-service"
|
||||
DEPLOY_DIR="/Users/hanchengxi/workspace/tankwar_proj/deploy/content-security"
|
||||
MASTER="root@host_172.16.16.16"
|
||||
WORKERS_IP=("172.16.16.17" "172.16.16.8")
|
||||
REMOTE_BUILD_DIR="/tmp/content-security-build"
|
||||
IMAGE_NAME="content-security-service:latest"
|
||||
|
||||
ts() { echo "[$(date '+%H:%M:%S')]"; }
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 0: Sync service source to master node
|
||||
# ------------------------------------------------------------
|
||||
echo "$(ts) ===== Syncing content-security-service source to master node ====="
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" "mkdir -p $REMOTE_BUILD_DIR"
|
||||
rsync -az --delete --exclude='.git' --exclude='node_modules' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
"$SERVICE_DIR/" "${MASTER}:${REMOTE_BUILD_DIR}/"
|
||||
echo "$(ts) ✓ Source synced"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 1: Ensure docker is available on master
|
||||
# ------------------------------------------------------------
|
||||
echo "$(ts) ===== Checking docker on master ====="
|
||||
if ! ssh -o StrictHostKeyChecking=no "$MASTER" "which docker >/dev/null 2>&1"; then
|
||||
echo "$(ts) Docker not found on master. Installing..."
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" "curl -fsSL https://get.docker.com | sh"
|
||||
fi
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" "docker version --format '{{.Server.Version}}' 2>/dev/null || systemctl start docker"
|
||||
echo "$(ts) ✓ Docker ready on master"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 2: Build image on master
|
||||
# ------------------------------------------------------------
|
||||
echo "$(ts) ===== Building $IMAGE_NAME on master ====="
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" \
|
||||
"cd $REMOTE_BUILD_DIR && docker build -t $IMAGE_NAME -f Dockerfile ."
|
||||
echo "$(ts) ✓ Image built"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 3: Distribute image to workers (containerd / ctr)
|
||||
# ------------------------------------------------------------
|
||||
echo "$(ts) ===== Distributing $IMAGE_NAME to workers ====="
|
||||
# Master itself may also be a worker; import locally first
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" \
|
||||
"docker save $IMAGE_NAME | ctr -n k8s.io images import -"
|
||||
for w in "${WORKERS_IP[@]}"; do
|
||||
echo "$(ts) -> $w"
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" \
|
||||
"docker save $IMAGE_NAME | ssh -o StrictHostKeyChecking=no root@$w 'ctr -n k8s.io images import -'"
|
||||
done
|
||||
echo "$(ts) ✓ Image distributed"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 4: Apply K8s manifests
|
||||
# ------------------------------------------------------------
|
||||
echo "$(ts) ===== Applying K8s manifests ====="
|
||||
# Apply in order: namespace first, then configmap/secret, then deployment/service
|
||||
for manifest in namespace.yaml configmap.yaml secret.yaml deployment.yaml service.yaml networkpolicy.yaml; do
|
||||
if [ -f "$DEPLOY_DIR/$manifest" ]; then
|
||||
echo "$(ts) Applying $manifest"
|
||||
cat "$DEPLOY_DIR/$manifest" | \
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl apply -f -"
|
||||
fi
|
||||
done
|
||||
echo "$(ts) ✓ Manifests applied"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 5: Restart deployment to pick up the new image
|
||||
# ------------------------------------------------------------
|
||||
echo "$(ts) ===== Restarting content-security-service deployment ====="
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" \
|
||||
"kubectl -n content-security rollout restart deployment/content-security-service" || true
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" \
|
||||
"kubectl -n content-security rollout status deployment/content-security-service --timeout=120s" || true
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 6: Show final status
|
||||
# ------------------------------------------------------------
|
||||
echo "$(ts) ===== Final Status ====="
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n content-security get pods -o wide"
|
||||
echo ""
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n content-security get svc"
|
||||
|
||||
echo ""
|
||||
echo "$(ts) ===== ALL DONE ====="
|
||||
echo "$(ts) Internal endpoint: content-security-service.content-security.svc.cluster.local:3000"
|
||||
echo "$(ts) Game services should call: http://content-security-service.content-security.svc.cluster.local:3000/api/content/..."
|
||||
|
||||
# Cleanup
|
||||
ssh -o StrictHostKeyChecking=no "$MASTER" "rm -rf $REMOTE_BUILD_DIR" 2>/dev/null || true
|
||||
@@ -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;
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Middleware: Extract game_id from request
|
||||
// Priority: X-Game-Id header > game_id body/query param > DEFAULT_GAME_ID
|
||||
// ============================================================
|
||||
const DEFAULT_GAME_ID = process.env.DEFAULT_GAME_ID || 'tankwar';
|
||||
|
||||
function extractGameId(req) {
|
||||
return req.headers['x-game-id'] || req.body?.game_id || req.query?.game_id || DEFAULT_GAME_ID;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/content/check-text - Text content security check
|
||||
// ============================================================
|
||||
router.post('/check-text', express.json(), async (req, res) => {
|
||||
const { openid, content, scene } = req.body;
|
||||
const gameId = extractGameId(req);
|
||||
|
||||
// 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,
|
||||
gameId,
|
||||
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,
|
||||
game_id: gameId,
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
gameId,
|
||||
violationType: `wechat_label_${result.label}`,
|
||||
contentSummary: content.substring(0, 20),
|
||||
scene,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ ...result, game_id: gameId });
|
||||
} 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) => {
|
||||
const gameId = extractGameId(req);
|
||||
|
||||
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,
|
||||
gameId,
|
||||
violationType: 'image_violation',
|
||||
contentSummary: `image_${req.file.size}bytes`,
|
||||
scene: 5,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ ...result, game_id: gameId });
|
||||
} 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;
|
||||
const gameId = extractGameId(req);
|
||||
|
||||
if (!openid || typeof openid !== 'string') {
|
||||
return res.status(400).json({
|
||||
errcode: 40006,
|
||||
errmsg: 'openid is required',
|
||||
});
|
||||
}
|
||||
|
||||
const status = violationService.getMuteStatus(openid, gameId);
|
||||
return res.json({ ...status, game_id: gameId });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/user/violation-summary - Get violation summary for a user
|
||||
// ============================================================
|
||||
router.get('/user/violation-summary', (req, res) => {
|
||||
const openid = req.query.openid;
|
||||
const gameId = extractGameId(req);
|
||||
|
||||
if (!openid || typeof openid !== 'string') {
|
||||
return res.status(400).json({
|
||||
errcode: 40006,
|
||||
errmsg: 'openid is required',
|
||||
});
|
||||
}
|
||||
|
||||
const summary = violationService.getViolationSummary(openid, gameId);
|
||||
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;
|
||||
const gameId = extractGameId(req);
|
||||
|
||||
// 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,
|
||||
gameId,
|
||||
});
|
||||
|
||||
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,
|
||||
game_id: gameId,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '举报已提交',
|
||||
reportCount: result.reportCount,
|
||||
autoTakenDown: result.autoTakenDown,
|
||||
game_id: gameId,
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 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 };
|
||||
@@ -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;
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 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)
|
||||
* @param {string} [entry.gameId] - Game identifier for tenant isolation (default: 'default')
|
||||
* @returns {{ success: boolean, reportCount: number, autoTakenDown: boolean }}
|
||||
*/
|
||||
submitReport(entry) {
|
||||
const {
|
||||
contentId,
|
||||
targetUserId,
|
||||
contentType,
|
||||
contentSummary,
|
||||
reporterId,
|
||||
reason,
|
||||
} = entry;
|
||||
const gameId = entry.gameId || 'default';
|
||||
// Namespace contentId with gameId to avoid cross-game collisions
|
||||
const namespacedContentId = `${gameId}:${contentId}`;
|
||||
|
||||
// Validate reason
|
||||
if (!Object.values(REPORT_REASONS).includes(reason)) {
|
||||
return { success: false, reportCount: 0, autoTakenDown: false };
|
||||
}
|
||||
|
||||
// Get or create report record (using namespaced key)
|
||||
let record = this._reports.get(namespacedContentId);
|
||||
if (!record) {
|
||||
record = {
|
||||
contentId,
|
||||
gameId,
|
||||
namespacedContentId,
|
||||
targetUserId,
|
||||
contentType,
|
||||
contentSummary,
|
||||
reports: [],
|
||||
status: 'active', // active | taken_down | reviewed
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this._reports.set(namespacedContentId, 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,
|
||||
gameId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[ReportService] Report submitted for content ${contentId} (game: ${gameId}) 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(namespacedContentId, 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,
|
||||
gameId: record.gameId,
|
||||
violationType: 'report_takedown',
|
||||
contentSummary: record.contentSummary,
|
||||
scene: this._contentTypeToScene(record.contentType),
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a report as violation (admin action).
|
||||
* @param {string} contentId - Original contentId (will be namespaced internally)
|
||||
* @param {string} [gameId] - Game identifier (default: 'default')
|
||||
* @returns {{ success: boolean }}
|
||||
*/
|
||||
confirmViolation(contentId, gameId) {
|
||||
const gid = gameId || 'default';
|
||||
const namespacedContentId = `${gid}:${contentId}`;
|
||||
const record = this._reports.get(namespacedContentId);
|
||||
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,
|
||||
gameId: record.gameId,
|
||||
violationType: 'admin_confirmed',
|
||||
contentSummary: record.contentSummary,
|
||||
scene: this._contentTypeToScene(record.contentType),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ReportService] Admin confirmed violation for content ${contentId} (game: ${gid})`
|
||||
);
|
||||
|
||||
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 - Original contentId
|
||||
* @param {string} [gameId] - Game identifier (default: 'default')
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getReportStatus(contentId, gameId) {
|
||||
const gid = gameId || 'default';
|
||||
const namespacedContentId = `${gid}:${contentId}`;
|
||||
const record = this._reports.get(namespacedContentId);
|
||||
if (!record) return null;
|
||||
|
||||
return {
|
||||
contentId: record.contentId,
|
||||
gameId: record.gameId,
|
||||
contentType: record.contentType,
|
||||
reportCount: record.reports.length,
|
||||
status: record.status,
|
||||
// Do NOT expose reporter identities
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending reports for admin review.
|
||||
* @param {string} [gameId] - Filter by game identifier (optional, returns all if not specified)
|
||||
* @returns {Array}
|
||||
*/
|
||||
getPendingReports(gameId) {
|
||||
const pending = [];
|
||||
for (const record of this._reports.values()) {
|
||||
// Filter by gameId if specified
|
||||
if (gameId && record.gameId !== gameId) continue;
|
||||
|
||||
if (record.status === 'taken_down') {
|
||||
pending.push({
|
||||
contentId: record.contentId,
|
||||
gameId: record.gameId,
|
||||
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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 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: "game_id:userId" (composite key for tenant isolation)
|
||||
* Value: { count, violations[], currentTierStart, gameId, userId }
|
||||
* In production, this should be backed by a database.
|
||||
* @type {Map<string, { count: number, violations: Array, currentTierStart: number, gameId: string, userId: string }>}
|
||||
*/
|
||||
this._records = new Map();
|
||||
|
||||
/**
|
||||
* In-memory mute records.
|
||||
* Key: "game_id:userId" (composite key for tenant isolation)
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build composite key for tenant isolation.
|
||||
* @param {string} gameId
|
||||
* @param {string} userId
|
||||
* @returns {string}
|
||||
*/
|
||||
_compositeKey(gameId, userId) {
|
||||
return `${gameId}:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a violation for a user.
|
||||
* @param {object} entry
|
||||
* @param {string} entry.userId - User's openid
|
||||
* @param {string} [entry.gameId] - Game identifier for tenant isolation (default: 'default')
|
||||
* @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;
|
||||
const gameId = entry.gameId || 'default';
|
||||
const key = this._compositeKey(gameId, userId);
|
||||
|
||||
// Get or create record
|
||||
let record = this._records.get(key);
|
||||
if (!record) {
|
||||
record = { count: 0, violations: [], currentTierStart: 0, gameId, userId };
|
||||
this._records.set(key, record);
|
||||
}
|
||||
|
||||
// Increment violation count
|
||||
record.count++;
|
||||
record.violations.push({
|
||||
type: violationType,
|
||||
contentSummary,
|
||||
scene,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Log the violation
|
||||
this.logger.logViolation({
|
||||
userId,
|
||||
gameId,
|
||||
violationType,
|
||||
contentSummary,
|
||||
action: `violation_count_${record.count}`,
|
||||
});
|
||||
|
||||
// Check if a penalty should be applied
|
||||
const penalty = this._checkPenalty(key, record.count);
|
||||
|
||||
return penalty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is currently muted.
|
||||
* @param {string} userId - User's openid
|
||||
* @param {string} [gameId] - Game identifier for tenant isolation (default: 'default')
|
||||
* @returns {{ isMuted: boolean, remainingMs: number, remainingText: string, tier: number }}
|
||||
*/
|
||||
getMuteStatus(userId, gameId) {
|
||||
const gid = gameId || 'default';
|
||||
const key = this._compositeKey(gid, userId);
|
||||
const mute = this._mutes.get(key);
|
||||
|
||||
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(key);
|
||||
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} key - Composite key "gameId:userId"
|
||||
*/
|
||||
_removeMute(key) {
|
||||
const record = this._records.get(key);
|
||||
if (record) {
|
||||
// Find which tier they were at and reset count to just below that tier
|
||||
const mute = this._mutes.get(key);
|
||||
if (mute && mute.tier > 0) {
|
||||
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(key);
|
||||
console.log(`[ViolationService] Mute removed for key ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a penalty should be applied based on violation count.
|
||||
* @param {string} key - Composite key "gameId:userId"
|
||||
* @param {number} count
|
||||
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
|
||||
*/
|
||||
_checkPenalty(key, 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(key);
|
||||
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(key, {
|
||||
expiresAt,
|
||||
tier: tier.threshold,
|
||||
});
|
||||
|
||||
console.log(`[ViolationService] User ${key} muted: ${tier.label} (violations: ${count})`);
|
||||
|
||||
// Extract userId and gameId from key for logging
|
||||
const colonIdx = key.indexOf(':');
|
||||
const gameId = key.substring(0, colonIdx);
|
||||
const userId = key.substring(colonIdx + 1);
|
||||
|
||||
this.logger.logViolation({
|
||||
userId,
|
||||
gameId,
|
||||
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
|
||||
* @param {string} [gameId] - Game identifier for tenant isolation (default: 'default')
|
||||
* @returns {{ count: number, isMuted: boolean, recentViolations: Array, gameId: string }}
|
||||
*/
|
||||
getViolationSummary(userId, gameId) {
|
||||
const gid = gameId || 'default';
|
||||
const key = this._compositeKey(gid, userId);
|
||||
const record = this._records.get(key);
|
||||
const muteStatus = this.getMuteStatus(userId, gid);
|
||||
|
||||
return {
|
||||
count: record ? record.count : 0,
|
||||
isMuted: muteStatus.isMuted,
|
||||
muteRemainingText: muteStatus.remainingText,
|
||||
recentViolations: record ? record.violations.slice(-5) : [],
|
||||
gameId: gid,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
*/
|
||||
destroy() {
|
||||
if (this._cleanupInterval) {
|
||||
clearInterval(this._cleanupInterval);
|
||||
}
|
||||
console.log('[ViolationService] Destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ViolationService;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user