Files
tankwar_proj/content-security-service/services/contentSecurityRoutes.js
jakciehan d263c7bf48 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
2026-05-12 07:05:20 +08:00

302 lines
9.7 KiB
JavaScript

/**
* 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 };