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
199 lines
5.9 KiB
JavaScript
199 lines
5.9 KiB
JavaScript
/**
|
|
* 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; |