d263c7bf48
- 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
504 lines
15 KiB
JavaScript
504 lines
15 KiB
JavaScript
/**
|
|
* ContentSecurityManager
|
|
* Client-side content security filtering for WeChat mini game.
|
|
*
|
|
* Features:
|
|
* - Local sensitive word filtering (50ms target)
|
|
* - Remote word list fetching with 24-hour local cache
|
|
* - Built-in fallback word list when remote fetch fails
|
|
* - Incremental word list updates without blocking ongoing checks
|
|
* - Integration with server-side msgSecCheck/imgSecCheck APIs
|
|
*/
|
|
|
|
// ============================================================
|
|
// Configuration
|
|
// ============================================================
|
|
const CACHE_KEY = 'content_security_word_cache';
|
|
const CACHE_VERSION_KEY = 'content_security_word_version';
|
|
const CACHE_TIMESTAMP_KEY = 'content_security_word_timestamp';
|
|
const CACHE_VALIDITY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
const SERVER_BASE_URL = ''; // Will use relative URL or configured server URL
|
|
const DEFAULT_GAME_ID = 'tankwar'; // Default game identifier for multi-tenant isolation
|
|
|
|
// Scene mapping (must match server-side)
|
|
const SCENE = {
|
|
NICKNAME: 1,
|
|
CHAT: 2,
|
|
SIGNATURE: 3,
|
|
DESCRIPTION: 4,
|
|
};
|
|
|
|
// Report reasons
|
|
const REPORT_REASONS = {
|
|
POLITICS: 'politics',
|
|
PORNOGRAPHY: 'pornography',
|
|
GAMBLING: 'gambling',
|
|
OTHER: 'other',
|
|
};
|
|
|
|
// Report reason display labels
|
|
const REPORT_REASON_LABELS = {
|
|
politics: '政治有害',
|
|
pornography: '色情低俗',
|
|
gambling: '赌博诈骗',
|
|
other: '其他',
|
|
};
|
|
|
|
// ============================================================
|
|
// Built-in Fallback Sensitive Word List
|
|
// This is used when the remote word list is unavailable.
|
|
// ============================================================
|
|
const FALLBACK_WORDS = [
|
|
// Politics
|
|
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
|
|
'法轮', '法轮功', '台独', '藏独', '疆独',
|
|
// Pornography
|
|
'色情', '淫秽', '裸体', '卖淫', '嫖娼',
|
|
'约炮', '援交', '一夜情', '黄色视频',
|
|
// Gambling
|
|
'赌博', '赌场', '下注', '博彩', '六合彩',
|
|
'时时彩', '赌球', '百家乐', '老虎机',
|
|
// Violence
|
|
'杀人', '砍人', '捅死', '自制炸弹', '血腥屠杀',
|
|
// Abuse
|
|
'傻逼', '操你', '妈的', '草泥马', '脑残',
|
|
'贱人', '狗日的', '废物', '滚蛋', '王八蛋',
|
|
// Fraud
|
|
'代开发票', '传销', '诈骗', '洗钱',
|
|
// Other
|
|
'代孕', '毒品', '吸毒', '走私', '枪支',
|
|
];
|
|
|
|
class ContentSecurityManager {
|
|
constructor() {
|
|
/** @type {string[]} Active sensitive word list */
|
|
this._words = [];
|
|
|
|
/** @type {string} Current word list version */
|
|
this._version = '';
|
|
|
|
/** @type {boolean} Whether the manager is initialized */
|
|
this._initialized = false;
|
|
|
|
/** @type {boolean} Whether an update is in progress */
|
|
this._updating = false;
|
|
|
|
/** @type {string} Server base URL for API calls */
|
|
this._serverUrl = '';
|
|
|
|
/** @type {string} Game identifier for multi-tenant isolation */
|
|
this._gameId = DEFAULT_GAME_ID;
|
|
}
|
|
|
|
/**
|
|
* Initialize the manager: load cached words, then fetch latest.
|
|
* @param {object} [options]
|
|
* @param {string} [options.serverUrl] - Server base URL
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async init(options = {}) {
|
|
this._serverUrl = options.serverUrl || '';
|
|
this._gameId = options.gameId || DEFAULT_GAME_ID;
|
|
|
|
// Try to load from cache first
|
|
this._loadFromCache();
|
|
|
|
// If no cached words, use fallback
|
|
if (this._words.length === 0) {
|
|
this._words = [...FALLBACK_WORDS];
|
|
console.log('[ContentSecurity] Using fallback word list (' + this._words.length + ' words)');
|
|
}
|
|
|
|
this._initialized = true;
|
|
|
|
// Fetch latest words from server (non-blocking)
|
|
this.fetchWordList();
|
|
|
|
console.log('[ContentSecurity] Initialized with ' + this._words.length + ' words');
|
|
}
|
|
|
|
/**
|
|
* Check text content against local sensitive words.
|
|
* Must complete within 50ms.
|
|
* @param {string} content - Text to check
|
|
* @returns {{ hasViolation: boolean, matchedWords: string[] }}
|
|
*/
|
|
checkLocalText(content) {
|
|
if (!content || typeof content !== 'string') {
|
|
return { hasViolation: false, matchedWords: [] };
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const lowerContent = content.toLowerCase();
|
|
const matchedWords = [];
|
|
|
|
for (let i = 0; i < this._words.length; i++) {
|
|
const word = this._words[i];
|
|
if (word && lowerContent.includes(word.toLowerCase())) {
|
|
matchedWords.push(word);
|
|
// Early exit if we find violations (performance optimization)
|
|
if (matchedWords.length >= 3) break;
|
|
}
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
if (duration > 50) {
|
|
console.warn('[ContentSecurity] Local check took ' + duration + 'ms (target: 50ms)');
|
|
}
|
|
|
|
return {
|
|
hasViolation: matchedWords.length > 0,
|
|
matchedWords,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check text content via server-side API (msgSecCheck).
|
|
* @param {string} openid - User's openid
|
|
* @param {string} content - Text 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) {
|
|
if (!this._serverUrl) {
|
|
return {
|
|
pass: true,
|
|
errcode: 0,
|
|
errmsg: 'ok',
|
|
suggest: 'pass',
|
|
label: 100,
|
|
};
|
|
}
|
|
try {
|
|
const res = await this._request({
|
|
url: `${this._serverUrl}/api/content/check-text`,
|
|
method: 'POST',
|
|
data: { openid, content, scene },
|
|
});
|
|
return res;
|
|
} catch (err) {
|
|
console.error('[ContentSecurity] checkTextContent error:', err && (err.message || err.errMsg) || 'unknown');
|
|
return {
|
|
pass: false,
|
|
errcode: -1,
|
|
errmsg: '审核服务暂时不可用,请稍后再试',
|
|
suggest: 'risky',
|
|
label: 100,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check image content via server-side API (imgSecCheck).
|
|
* @param {string} filePath - Local temporary file path of the image
|
|
* @param {string} openid - User's openid
|
|
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>}
|
|
*/
|
|
async checkImageContent(filePath, openid) {
|
|
if (!this._serverUrl) {
|
|
return { pass: true, errcode: 0, errmsg: 'ok' };
|
|
}
|
|
try {
|
|
const res = await this._uploadImage(filePath, openid);
|
|
return res;
|
|
} catch (err) {
|
|
console.error('[ContentSecurity] checkImageContent error:', err && (err.message || err.errMsg) || 'unknown');
|
|
return {
|
|
pass: false,
|
|
errcode: -1,
|
|
errmsg: '审核服务暂时不可用,请稍后再试',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch sensitive word list from server with cache validation.
|
|
* Non-blocking: does not interfere with ongoing checks.
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async fetchWordList() {
|
|
if (this._updating) return false;
|
|
if (!this._serverUrl) return false;
|
|
this._updating = true;
|
|
|
|
try {
|
|
const res = await this._request({
|
|
url: `${this._serverUrl}/api/content/sensitive-words`,
|
|
method: 'GET',
|
|
data: { version: this._version },
|
|
});
|
|
|
|
if (res.updated && res.words) {
|
|
// Incremental update: replace word list atomically
|
|
this._words = res.words;
|
|
this._version = res.version;
|
|
|
|
// Cache locally
|
|
this._saveToCache();
|
|
|
|
console.log('[ContentSecurity] Word list updated: ' + this._words.length + ' words, version: ' + this._version);
|
|
}
|
|
|
|
this._updating = false;
|
|
return true;
|
|
} catch (err) {
|
|
console.error('[ContentSecurity] fetchWordList error:', err && (err.message || err.errMsg) || 'unknown');
|
|
this._updating = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user is muted.
|
|
* @param {string} openid
|
|
* @returns {Promise<{isMuted: boolean, remainingMs: number, remainingText: string}>}
|
|
*/
|
|
async getMuteStatus(openid) {
|
|
if (!this._serverUrl) {
|
|
return { isMuted: false, remainingMs: 0, remainingText: '' };
|
|
}
|
|
try {
|
|
const res = await this._request({
|
|
url: `${this._serverUrl}/api/content/user/mute-status`,
|
|
method: 'GET',
|
|
data: { openid },
|
|
});
|
|
return res;
|
|
} catch (err) {
|
|
console.error('[ContentSecurity] getMuteStatus error:', err && (err.message || err.errMsg) || 'unknown');
|
|
return { isMuted: false, remainingMs: 0, remainingText: '' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Submit a content report.
|
|
* @param {object} entry
|
|
* @param {string} entry.contentId - Unique content identifier
|
|
* @param {string} entry.targetUserId - Content author's user ID
|
|
* @param {string} entry.contentType - Content type ('chat', 'nickname', 'signature', 'description', 'avatar')
|
|
* @param {string} entry.contentSummary - Content summary
|
|
* @param {string} entry.reporterId - Reporter's user ID
|
|
* @param {string} entry.reason - Report reason
|
|
* @returns {Promise<{success: boolean, message: string}>}
|
|
*/
|
|
async submitReport(entry) {
|
|
if (!this._serverUrl) {
|
|
return { success: false, message: '举报功能暂不可用' };
|
|
}
|
|
try {
|
|
const res = await this._request({
|
|
url: `${this._serverUrl}/api/content/report`,
|
|
method: 'POST',
|
|
data: entry,
|
|
});
|
|
return res;
|
|
} catch (err) {
|
|
console.error('[ContentSecurity] submitReport error:', err && (err.message || err.errMsg) || 'unknown');
|
|
return { success: false, message: '举报提交失败,请稍后再试' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Full text check: local check first, then server-side if local passes.
|
|
* @param {string} openid
|
|
* @param {string} content
|
|
* @param {number} scene
|
|
* @returns {Promise<{pass: boolean, localViolation: boolean, serverResult: object|null, errorMessage: string}>}
|
|
*/
|
|
async fullTextCheck(openid, content, scene) {
|
|
// Step 1: Local sensitive word check (fast)
|
|
const localResult = this.checkLocalText(content);
|
|
if (localResult.hasViolation) {
|
|
return {
|
|
pass: false,
|
|
localViolation: true,
|
|
serverResult: null,
|
|
errorMessage: this._getErrorMessage({ suggest: 'risky' }, scene),
|
|
};
|
|
}
|
|
|
|
// Step 2: Check mute status (for nickname/signature/description scenes)
|
|
if (scene === SCENE.NICKNAME || scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) {
|
|
const muteStatus = await this.getMuteStatus(openid);
|
|
if (muteStatus.isMuted) {
|
|
return {
|
|
pass: false,
|
|
localViolation: false,
|
|
serverResult: null,
|
|
errorMessage: '由于违规行为,您暂无法修改个人信息',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Step 3: Server-side msgSecCheck
|
|
const serverResult = await this.checkTextContent(openid, content, scene);
|
|
if (!serverResult.pass) {
|
|
return {
|
|
pass: false,
|
|
localViolation: false,
|
|
serverResult,
|
|
errorMessage: this._getErrorMessage(serverResult, scene),
|
|
};
|
|
}
|
|
|
|
return {
|
|
pass: true,
|
|
localViolation: false,
|
|
serverResult,
|
|
errorMessage: '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get appropriate error message based on scene and result.
|
|
* @param {object} result
|
|
* @param {number} scene
|
|
* @returns {string}
|
|
*/
|
|
_getErrorMessage(result, scene) {
|
|
if (scene === SCENE.NICKNAME) {
|
|
return '昵称包含违规内容,请重新输入';
|
|
}
|
|
if (scene === SCENE.CHAT) {
|
|
return '消息发送失败:内容违规';
|
|
}
|
|
if (scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) {
|
|
return '内容包含违规信息,请修改';
|
|
}
|
|
return '内容违规,请修改';
|
|
}
|
|
|
|
/**
|
|
* Make an HTTP request using wx.request.
|
|
* @param {object} options
|
|
* @returns {Promise<object>}
|
|
*/
|
|
_request(options) {
|
|
return new Promise((resolve, reject) => {
|
|
const header = Object.assign({}, options.header || { 'content-type': 'application/json' });
|
|
// Add X-Game-Id header for multi-tenant isolation
|
|
if (this._gameId) {
|
|
header['X-Game-Id'] = this._gameId;
|
|
}
|
|
wx.request({
|
|
url: options.url,
|
|
method: options.method || 'GET',
|
|
data: options.data || {},
|
|
header,
|
|
success: (res) => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
resolve(res.data);
|
|
} else {
|
|
reject(new Error(`HTTP ${res.statusCode}`));
|
|
}
|
|
},
|
|
fail: (err) => {
|
|
reject(err);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upload image for security check using wx.uploadFile.
|
|
* @param {string} filePath - Local file path
|
|
* @param {string} openid - User openid
|
|
* @returns {Promise<object>}
|
|
*/
|
|
_uploadImage(filePath, openid) {
|
|
return new Promise((resolve, reject) => {
|
|
const header = {};
|
|
if (this._gameId) {
|
|
header['X-Game-Id'] = this._gameId;
|
|
}
|
|
wx.uploadFile({
|
|
url: `${this._serverUrl}/api/content/check-image`,
|
|
filePath,
|
|
name: 'image',
|
|
formData: { openid },
|
|
header,
|
|
success: (res) => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
try {
|
|
const data = JSON.parse(res.data);
|
|
resolve(data);
|
|
} catch (e) {
|
|
reject(new Error('Invalid response'));
|
|
}
|
|
} else {
|
|
reject(new Error(`HTTP ${res.statusCode}`));
|
|
}
|
|
},
|
|
fail: (err) => {
|
|
reject(err);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load word list from local cache.
|
|
*/
|
|
_loadFromCache() {
|
|
try {
|
|
const timestamp = wx.getStorageSync(CACHE_TIMESTAMP_KEY);
|
|
const version = wx.getStorageSync(CACHE_VERSION_KEY);
|
|
const words = wx.getStorageSync(CACHE_KEY);
|
|
|
|
if (timestamp && version && words && Array.isArray(words)) {
|
|
// Check if cache is still valid
|
|
if (Date.now() - timestamp < CACHE_VALIDITY_MS) {
|
|
this._words = words;
|
|
this._version = version;
|
|
console.log('[ContentSecurity] Loaded ' + words.length + ' words from cache (version: ' + version + ')');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[ContentSecurity] Failed to load cache:', err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save word list to local cache.
|
|
*/
|
|
_saveToCache() {
|
|
try {
|
|
wx.setStorageSync(CACHE_KEY, this._words);
|
|
wx.setStorageSync(CACHE_VERSION_KEY, this._version);
|
|
wx.setStorageSync(CACHE_TIMESTAMP_KEY, Date.now());
|
|
} catch (err) {
|
|
console.error('[ContentSecurity] Failed to save cache:', err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current word count.
|
|
* @returns {number}
|
|
*/
|
|
getWordCount() {
|
|
return this._words.length;
|
|
}
|
|
|
|
/**
|
|
* Get current version.
|
|
* @returns {string}
|
|
*/
|
|
getVersion() {
|
|
return this._version;
|
|
}
|
|
|
|
/**
|
|
* Check if initialized.
|
|
* @returns {boolean}
|
|
*/
|
|
isInitialized() {
|
|
return this._initialized;
|
|
}
|
|
}
|
|
|
|
// Export scene and report reason constants
|
|
ContentSecurityManager.SCENE = SCENE;
|
|
ContentSecurityManager.REPORT_REASONS = REPORT_REASONS;
|
|
ContentSecurityManager.REPORT_REASON_LABELS = REPORT_REASON_LABELS;
|
|
|
|
module.exports = ContentSecurityManager; |