Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled

Full-stack web application for Telegram management
- Frontend: Vue 3 + Vben Admin
- Backend: NestJS
- Features: User management, group broadcast, statistics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
你的用户名
2025-11-04 15:37:50 +08:00
commit 237c7802e5
3674 changed files with 525172 additions and 0 deletions

View File

@@ -0,0 +1 @@
import './index.js';

View File

@@ -0,0 +1,49 @@
import mongoose from 'mongoose';
import { logger } from '../utils/logger.js';
export const connectDatabase = async () => {
const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/safety-guard';
const options = {
useNewUrlParser: true,
useUnifiedTopology: true,
autoIndex: true,
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
family: 4
};
try {
await mongoose.connect(mongoUri, options);
logger.info('MongoDB connected successfully');
// Handle connection events
mongoose.connection.on('error', (err) => {
logger.error('MongoDB connection error:', err);
});
mongoose.connection.on('disconnected', () => {
logger.warn('MongoDB disconnected');
});
mongoose.connection.on('reconnected', () => {
logger.info('MongoDB reconnected');
});
return mongoose.connection;
} catch (error) {
logger.error('Failed to connect to MongoDB:', error);
throw error;
}
};
export const disconnectDatabase = async () => {
try {
await mongoose.disconnect();
logger.info('MongoDB disconnected successfully');
} catch (error) {
logger.error('Error disconnecting from MongoDB:', error);
throw error;
}
};

View File

@@ -0,0 +1,156 @@
import Redis from 'ioredis';
import { logger } from '../utils/logger.js';
export class RedisClient {
constructor() {
this.client = null;
}
static getInstance() {
if (!RedisClient.instance) {
RedisClient.instance = new RedisClient();
}
return RedisClient.instance;
}
async connect() {
const config = {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB) || 2, // Different DB for safety guard
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
enableOfflineQueue: false
};
try {
this.client = new Redis(config);
this.client.on('connect', () => {
logger.info('Redis connection established');
});
this.client.on('error', (err) => {
logger.error('Redis error:', err);
});
this.client.on('close', () => {
logger.warn('Redis connection closed');
});
// Wait for connection
await this.client.ping();
return this.client;
} catch (error) {
logger.error('Failed to connect to Redis:', error);
throw error;
}
}
async checkHealth() {
try {
const result = await this.client.ping();
return result === 'PONG';
} catch (error) {
logger.error('Redis health check failed:', error);
return false;
}
}
async disconnect() {
if (this.client) {
await this.client.quit();
logger.info('Redis connection closed');
}
}
// Cache methods with JSON serialization
async setWithExpiry(key, value, ttl) {
return await this.client.setex(key, ttl, JSON.stringify(value));
}
async get(key) {
const value = await this.client.get(key);
return value ? JSON.parse(value) : null;
}
async del(key) {
return await this.client.del(key);
}
async exists(key) {
return await this.client.exists(key);
}
// Hash operations
async hset(key, field, value) {
return await this.client.hset(key, field, JSON.stringify(value));
}
async hget(key, field) {
const value = await this.client.hget(key, field);
return value ? JSON.parse(value) : null;
}
async hdel(key, field) {
return await this.client.hdel(key, field);
}
async hgetall(key) {
const data = await this.client.hgetall(key);
const result = {};
for (const [field, value] of Object.entries(data)) {
result[field] = JSON.parse(value);
}
return result;
}
// List operations
async lpush(key, value) {
return await this.client.lpush(key, JSON.stringify(value));
}
async rpush(key, value) {
return await this.client.rpush(key, JSON.stringify(value));
}
async lrange(key, start, stop) {
const items = await this.client.lrange(key, start, stop);
return items.map(item => JSON.parse(item));
}
async ltrim(key, start, stop) {
return await this.client.ltrim(key, start, stop);
}
// Set operations
async sadd(key, member) {
return await this.client.sadd(key, JSON.stringify(member));
}
async srem(key, member) {
return await this.client.srem(key, JSON.stringify(member));
}
async smembers(key) {
const members = await this.client.smembers(key);
return members.map(member => JSON.parse(member));
}
async sismember(key, member) {
return await this.client.sismember(key, JSON.stringify(member));
}
// Expiry operations
async expire(key, seconds) {
return await this.client.expire(key, seconds);
}
async ttl(key) {
return await this.client.ttl(key);
}
}

View File

@@ -0,0 +1,105 @@
import 'dotenv/config';
import Hapi from '@hapi/hapi';
import { logger } from './utils/logger.js';
import { connectDatabase } from './config/database.js';
import { RedisClient } from './config/redis.js';
import routes from './routes/index.js';
import { ModerationService } from './services/ModerationService.js';
import { ComplianceService } from './services/ComplianceService.js';
import { RateLimiterService } from './services/RateLimiterService.js';
import { ContentPolicyService } from './services/ContentPolicyService.js';
const init = async () => {
// Initialize database connections
await connectDatabase();
const redisClient = RedisClient.getInstance();
await redisClient.connect();
// Initialize services
ModerationService.getInstance();
ComplianceService.getInstance();
RateLimiterService.getInstance();
ContentPolicyService.getInstance();
// Create Hapi server
const server = Hapi.server({
port: process.env.PORT || 3004,
host: process.env.HOST || 'localhost',
routes: {
cors: {
origin: ['*'],
headers: ['Accept', 'Content-Type', 'Authorization'],
credentials: true
},
payload: {
maxBytes: 10485760 // 10MB
}
}
});
// Register routes
server.route(routes);
// Health check endpoint
server.route({
method: 'GET',
path: '/health',
handler: async (request, h) => {
const dbHealth = await checkDatabaseHealth();
const redisHealth = await redisClient.checkHealth();
const isHealthy = dbHealth && redisHealth;
return h.response({
status: isHealthy ? 'healthy' : 'unhealthy',
service: 'safety-guard',
timestamp: new Date().toISOString(),
checks: {
database: dbHealth,
redis: redisHealth
}
}).code(isHealthy ? 200 : 503);
}
});
// Start server
await server.start();
logger.info(`Safety Guard service running on ${server.info.uri}`);
// Graceful shutdown
process.on('SIGTERM', async () => {
logger.info('SIGTERM signal received');
await server.stop({ timeout: 10000 });
await redisClient.disconnect();
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('SIGINT signal received');
await server.stop({ timeout: 10000 });
await redisClient.disconnect();
process.exit(0);
});
};
const checkDatabaseHealth = async () => {
try {
const { connection } = await import('mongoose');
return connection.readyState === 1;
} catch (error) {
logger.error('Database health check failed:', error);
return false;
}
};
// Handle unhandled rejections
process.on('unhandledRejection', (err) => {
logger.error('Unhandled rejection:', err);
process.exit(1);
});
// Start the service
init().catch((err) => {
logger.error('Failed to start Safety Guard service:', err);
process.exit(1);
});

View File

@@ -0,0 +1,62 @@
import mongoose from 'mongoose';
const complianceCheckSchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
action: {
type: String,
required: true
},
targetRegion: {
type: String,
required: true
},
violations: [{
type: String,
severity: {
type: String,
enum: ['low', 'medium', 'high'],
required: true
},
message: String,
regulation: String
}],
warnings: [{
type: String,
severity: {
type: String,
enum: ['low', 'medium', 'high'],
required: true
},
message: String,
category: String
}],
processingTime: {
type: Number,
required: true
},
timestamp: {
type: Date,
default: Date.now,
index: true
}
}, {
timestamps: true
});
// Indexes
complianceCheckSchema.index({ timestamp: -1 });
complianceCheckSchema.index({ targetRegion: 1, action: 1 });
complianceCheckSchema.index({ 'violations.type': 1 });
// Multi-tenant indexes
complianceCheckSchema.index({ tenantId: 1, timestamp: -1 });
complianceCheckSchema.index({ tenantId: 1, targetRegion: 1, action: 1 });
complianceCheckSchema.index({ tenantId: 1, 'violations.type': 1 });
export const ComplianceCheck = mongoose.model('ComplianceCheck', complianceCheckSchema);

View File

@@ -0,0 +1,73 @@
import mongoose from 'mongoose';
const complianceRuleSchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
ruleId: {
type: String,
required: true,
unique: true
},
name: {
type: String,
required: true
},
description: String,
regulation: {
type: String,
enum: ['GDPR', 'CCPA', 'LGPD', 'POPIA', 'COPPA', 'CAN-SPAM', 'PECR', 'PIPEDA', 'CUSTOM'],
required: true
},
region: {
type: String,
required: true
},
requirements: {
consentRequired: Boolean,
doubleOptIn: Boolean,
rightToDelete: Boolean,
dataPortability: Boolean,
dataRetentionDays: Number,
minAge: Number,
explicitOptOut: Boolean,
crossBorderRestrictions: Boolean
},
validations: [{
field: String,
operator: {
type: String,
enum: ['equals', 'contains', 'regex', 'min', 'max', 'exists']
},
value: mongoose.Schema.Types.Mixed,
severity: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium'
}
}],
active: {
type: Boolean,
default: true
},
effectiveDate: Date,
expiryDate: Date
}, {
timestamps: true
});
// Indexes
complianceRuleSchema.index({ ruleId: 1 });
complianceRuleSchema.index({ region: 1, active: 1 });
complianceRuleSchema.index({ regulation: 1 });
// Multi-tenant indexes
complianceRuleSchema.index({ tenantId: 1, ruleId: 1 });
complianceRuleSchema.index({ tenantId: 1, region: 1, active: 1 });
complianceRuleSchema.index({ tenantId: 1, regulation: 1 });
export const ComplianceRule = mongoose.model('ComplianceRule', complianceRuleSchema);

View File

@@ -0,0 +1,82 @@
import mongoose from 'mongoose';
const contentPolicySchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
policyId: {
type: String,
required: true,
unique: true
},
name: {
type: String,
required: true
},
description: String,
rules: {
maxMessageLength: {
type: Number,
default: 4096
},
minMessageLength: {
type: Number,
default: 1
},
allowedContentTypes: [{
type: String,
enum: ['text', 'image', 'video', 'audio', 'document', 'location', 'contact']
}],
prohibitedPatterns: [String],
maxMediaSize: Number,
maxRiskScore: {
type: Number,
min: 0,
max: 1,
default: 0.7
},
requireModeration: {
type: Boolean,
default: true
},
requireCompliance: {
type: Boolean,
default: true
},
requireOptOut: Boolean,
maxLinksPerMessage: Number,
requireCTA: Boolean,
additionalChecks: [{
type: String,
enum: ['duplicate_detection', 'spam_detection', 'link_validation', 'language_detection']
}]
},
active: {
type: Boolean,
default: true
},
priority: {
type: Number,
default: 0
},
applicableRegions: [String],
applicableActions: [String]
}, {
timestamps: true
});
// Indexes
contentPolicySchema.index({ policyId: 1 });
contentPolicySchema.index({ active: 1, priority: -1 });
contentPolicySchema.index({ applicableRegions: 1 });
// Multi-tenant indexes
contentPolicySchema.index({ tenantId: 1, policyId: 1 });
contentPolicySchema.index({ tenantId: 1, active: 1, priority: -1 });
contentPolicySchema.index({ tenantId: 1, applicableRegions: 1 });
export const ContentPolicy = mongoose.model('ContentPolicy', contentPolicySchema);

View File

@@ -0,0 +1,60 @@
import mongoose from 'mongoose';
const contentViolationSchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
content: {
type: String,
required: true,
maxlength: 1000 // Store only preview
},
violations: [{
type: {
type: String,
required: true
},
severity: {
type: String,
enum: ['low', 'medium', 'high', 'critical'],
required: true
},
details: mongoose.Schema.Types.Mixed
}],
riskScore: {
type: Number,
required: true,
min: 0,
max: 1
},
sentiment: {
score: Number,
comparative: Number,
positive: Number,
negative: Number,
neutral: Number
},
timestamp: {
type: Date,
default: Date.now,
index: true
}
}, {
timestamps: true
});
// Indexes
contentViolationSchema.index({ timestamp: -1 });
contentViolationSchema.index({ 'violations.type': 1 });
contentViolationSchema.index({ riskScore: -1 });
// Multi-tenant indexes
contentViolationSchema.index({ tenantId: 1, timestamp: -1 });
contentViolationSchema.index({ tenantId: 1, 'violations.type': 1 });
contentViolationSchema.index({ tenantId: 1, riskScore: -1 });
export const ContentViolation = mongoose.model('ContentViolation', contentViolationSchema);

View File

@@ -0,0 +1,64 @@
import mongoose from 'mongoose';
const policyViolationSchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
policyId: {
type: String,
required: true
},
contentPreview: {
type: String,
maxlength: 200
},
violations: [{
type: String,
severity: {
type: String,
enum: ['low', 'medium', 'high', 'critical'],
required: true
},
message: String,
details: mongoose.Schema.Types.Mixed
}],
warnings: [{
type: String,
severity: {
type: String,
enum: ['low', 'medium', 'high'],
required: true
},
message: String
}],
moderationResult: mongoose.Schema.Types.Mixed,
complianceResult: mongoose.Schema.Types.Mixed,
processingTime: Number,
timestamp: {
type: Date,
default: Date.now,
index: true
}
}, {
timestamps: true
});
// Indexes
policyViolationSchema.index({ timestamp: -1 });
policyViolationSchema.index({ policyId: 1, timestamp: -1 });
policyViolationSchema.index({ 'violations.type': 1 });
// TTL index to auto-delete old violations after 90 days
policyViolationSchema.index({ timestamp: 1 }, { expireAfterSeconds: 7776000 });
// Multi-tenant indexes
policyViolationSchema.index({ tenantId: 1, timestamp: -1 });
policyViolationSchema.index({ tenantId: 1, policyId: 1, timestamp: -1 });
policyViolationSchema.index({ tenantId: 1, 'violations.type': 1 });
policyViolationSchema.index({ tenantId: 1, timestamp: 1 }, { expireAfterSeconds: 7776000 });
export const PolicyViolation = mongoose.model('PolicyViolation', policyViolationSchema);

View File

@@ -0,0 +1,48 @@
import mongoose from 'mongoose';
const rateLimitViolationSchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
limitType: {
type: String,
required: true
},
identifier: {
type: String,
required: true
},
tier: String,
cost: {
type: Number,
default: 1
},
msBeforeNext: Number,
timestamp: {
type: Date,
default: Date.now,
index: true
}
}, {
timestamps: true
});
// Indexes
rateLimitViolationSchema.index({ timestamp: -1 });
rateLimitViolationSchema.index({ limitType: 1, identifier: 1 });
rateLimitViolationSchema.index({ identifier: 1, timestamp: -1 });
// TTL index to auto-delete old violations after 30 days
rateLimitViolationSchema.index({ timestamp: 1 }, { expireAfterSeconds: 2592000 });
// Multi-tenant indexes
rateLimitViolationSchema.index({ tenantId: 1, timestamp: -1 });
rateLimitViolationSchema.index({ tenantId: 1, limitType: 1, identifier: 1 });
rateLimitViolationSchema.index({ tenantId: 1, identifier: 1, timestamp: -1 });
rateLimitViolationSchema.index({ tenantId: 1, timestamp: 1 }, { expireAfterSeconds: 2592000 });
export const RateLimitViolation = mongoose.model('RateLimitViolation', rateLimitViolationSchema);

View File

@@ -0,0 +1,557 @@
import Joi from '@hapi/joi';
import { ModerationService } from '../services/ModerationService.js';
import { ComplianceService } from '../services/ComplianceService.js';
import { RateLimiterService } from '../services/RateLimiterService.js';
import { ContentPolicyService } from '../services/ContentPolicyService.js';
import { logger } from '../utils/logger.js';
const moderationService = ModerationService.getInstance();
const complianceService = ComplianceService.getInstance();
const rateLimiterService = RateLimiterService.getInstance();
const contentPolicyService = ContentPolicyService.getInstance();
export default [
// Content Moderation Routes
{
method: 'POST',
path: '/api/v1/moderate',
options: {
validate: {
payload: Joi.object({
content: Joi.string().required(),
contentType: Joi.string().valid('text', 'image', 'video').default('text'),
context: Joi.object({
accountId: Joi.string(),
chatId: Joi.string(),
userId: Joi.string(),
language: Joi.string()
}).optional()
})
},
handler: async (request, h) => {
try {
const { content } = request.payload;
const result = await moderationService.moderateContent(content);
return h.response({
success: true,
data: result
});
} catch (error) {
logger.error('Moderation error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
{
method: 'POST',
path: '/api/v1/moderate/bulk',
options: {
validate: {
payload: Joi.object({
contents: Joi.array().items(Joi.string()).required(),
contentType: Joi.string().valid('text', 'image', 'video').default('text')
})
},
handler: async (request, h) => {
try {
const { contents } = request.payload;
const result = await moderationService.moderateBulkContent(contents);
return h.response({
success: true,
data: result
});
} catch (error) {
logger.error('Bulk moderation error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
// Compliance Routes
{
method: 'POST',
path: '/api/v1/compliance/check',
options: {
validate: {
payload: Joi.object({
action: Joi.string().required(),
content: Joi.string().required(),
targetRegion: Joi.string().required(),
targetAudience: Joi.object({
minAge: Joi.number().min(0),
segments: Joi.array().items(Joi.string())
}).optional(),
metadata: Joi.object().optional()
})
},
handler: async (request, h) => {
try {
const result = await complianceService.checkCompliance(request.payload);
return h.response({
success: true,
data: result
});
} catch (error) {
logger.error('Compliance check error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
{
method: 'POST',
path: '/api/v1/compliance/consent/validate',
options: {
validate: {
payload: Joi.object({
userId: Joi.string().required(),
purpose: Joi.string().required(),
region: Joi.string().required()
})
},
handler: async (request, h) => {
try {
const { userId, purpose, region } = request.payload;
const result = await complianceService.validateConsent(userId, purpose, region);
return h.response({
success: true,
data: result
});
} catch (error) {
logger.error('Consent validation error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
{
method: 'GET',
path: '/api/v1/compliance/report',
options: {
validate: {
query: Joi.object({
period: Joi.string().valid('24h', '7d', '30d').default('7d')
})
},
handler: async (request, h) => {
try {
const { period } = request.query;
const report = await complianceService.getComplianceReport(period);
return h.response({
success: true,
data: report
});
} catch (error) {
logger.error('Compliance report error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
// Rate Limiting Routes
{
method: 'POST',
path: '/api/v1/ratelimit/check',
options: {
validate: {
payload: Joi.object({
limitType: Joi.string().required(),
identifier: Joi.string().required(),
tier: Joi.string().valid('free', 'basic', 'pro', 'enterprise').optional(),
cost: Joi.number().min(1).default(1)
})
},
handler: async (request, h) => {
try {
const { limitType, identifier, tier, cost } = request.payload;
const result = await rateLimiterService.checkRateLimit(
limitType,
identifier,
{ tier, cost }
);
return h.response({
success: true,
data: result
}).code(result.allowed ? 200 : 429);
} catch (error) {
logger.error('Rate limit check error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
{
method: 'POST',
path: '/api/v1/ratelimit/check-multiple',
options: {
validate: {
payload: Joi.object({
limits: Joi.array().items(Joi.string()).required(),
identifier: Joi.string().required(),
tier: Joi.string().valid('free', 'basic', 'pro', 'enterprise').optional()
})
},
handler: async (request, h) => {
try {
const { limits, identifier, tier } = request.payload;
const result = await rateLimiterService.checkMultipleRateLimits(
limits,
identifier,
{ tier }
);
return h.response({
success: true,
data: result
}).code(result.allowed ? 200 : 429);
} catch (error) {
logger.error('Multiple rate limit check error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
{
method: 'GET',
path: '/api/v1/ratelimit/status',
options: {
validate: {
query: Joi.object({
limitTypes: Joi.array().items(Joi.string()).single().required(),
identifier: Joi.string().required(),
tier: Joi.string().valid('free', 'basic', 'pro', 'enterprise').optional()
})
},
handler: async (request, h) => {
try {
const { limitTypes, identifier, tier } = request.query;
const status = await rateLimiterService.getRateLimitStatus(
limitTypes,
identifier,
tier
);
return h.response({
success: true,
data: status
});
} catch (error) {
logger.error('Rate limit status error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
{
method: 'POST',
path: '/api/v1/ratelimit/reset',
options: {
validate: {
payload: Joi.object({
limitType: Joi.string().required(),
identifier: Joi.string().required()
})
},
handler: async (request, h) => {
try {
const { limitType, identifier } = request.payload;
const result = await rateLimiterService.resetRateLimit(limitType, identifier);
return h.response({
success: true,
data: result
});
} catch (error) {
logger.error('Rate limit reset error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
{
method: 'GET',
path: '/api/v1/ratelimit/violations',
options: {
validate: {
query: Joi.object({
period: Joi.string().valid('1h', '24h', '7d').default('24h')
})
},
handler: async (request, h) => {
try {
const { period } = request.query;
const report = await rateLimiterService.getViolationReport(period);
return h.response({
success: true,
data: report
});
} catch (error) {
logger.error('Rate limit violation report error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
// Content Policy Routes
{
method: 'POST',
path: '/api/v1/policy/validate',
options: {
validate: {
payload: Joi.object({
content: Joi.string().required(),
policyId: Joi.string().default('default'),
context: Joi.object({
contentType: Joi.string().valid('text', 'image', 'video', 'document'),
mediaSize: Joi.number().min(0),
targetRegion: Joi.string(),
targetAudience: Joi.object(),
action: Joi.string(),
accountId: Joi.string(),
metadata: Joi.object()
}).optional()
})
},
handler: async (request, h) => {
try {
const { content, policyId, context } = request.payload;
const result = await contentPolicyService.validateContent(
content,
policyId,
context || {}
);
return h.response({
success: true,
data: result
});
} catch (error) {
logger.error('Policy validation error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
{
method: 'PUT',
path: '/api/v1/policy/{policyId}',
options: {
validate: {
params: Joi.object({
policyId: Joi.string().required()
}),
payload: Joi.object({
name: Joi.string(),
description: Joi.string(),
rules: Joi.object(),
active: Joi.boolean()
})
},
handler: async (request, h) => {
try {
const { policyId } = request.params;
const updates = request.payload;
const policy = await contentPolicyService.updatePolicy(policyId, updates);
return h.response({
success: true,
data: policy
});
} catch (error) {
logger.error('Policy update error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
{
method: 'GET',
path: '/api/v1/policy/violations',
options: {
validate: {
query: Joi.object({
period: Joi.string().valid('24h', '7d', '30d').default('7d')
})
},
handler: async (request, h) => {
try {
const { period } = request.query;
const report = await contentPolicyService.getPolicyViolationReport(period);
return h.response({
success: true,
data: report
});
} catch (error) {
logger.error('Policy violation report error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
},
// Combined Safety Check
{
method: 'POST',
path: '/api/v1/safety/check',
options: {
validate: {
payload: Joi.object({
content: Joi.string().required(),
action: Joi.string().required(),
context: Joi.object({
accountId: Joi.string().required(),
targetRegion: Joi.string().required(),
targetAudience: Joi.object(),
contentType: Joi.string(),
mediaSize: Joi.number(),
tier: Joi.string(),
policyId: Joi.string()
}).required()
})
},
handler: async (request, h) => {
try {
const { content, action, context } = request.payload;
const startTime = Date.now();
// Run all safety checks in parallel
const [moderation, compliance, rateLimit, policy] = await Promise.all([
moderationService.moderateContent(content),
complianceService.checkCompliance({
action,
content,
targetRegion: context.targetRegion,
targetAudience: context.targetAudience || {},
metadata: context
}),
rateLimiterService.checkMultipleRateLimits(
['message:minute', 'message:hour', 'message:day'],
context.accountId,
{ tier: context.tier }
),
contentPolicyService.validateContent(
content,
context.policyId || 'default',
context
)
]);
const approved = moderation.approved &&
compliance.compliant &&
rateLimit.allowed &&
policy.approved;
const result = {
approved,
checks: {
moderation: {
approved: moderation.approved,
riskScore: moderation.riskScore,
violations: moderation.violations
},
compliance: {
compliant: compliance.compliant,
violations: compliance.violations,
warnings: compliance.warnings
},
rateLimit: {
allowed: rateLimit.allowed,
failedLimit: rateLimit.failedLimit,
results: rateLimit.results
},
policy: {
approved: policy.approved,
violations: policy.violations,
warnings: policy.warnings
}
},
processingTime: Date.now() - startTime
};
return h.response({
success: true,
data: result
}).code(approved ? 200 : 403);
} catch (error) {
logger.error('Safety check error:', error);
return h.response({
success: false,
error: error.message
}).code(500);
}
}
}
}
];

View File

@@ -0,0 +1,427 @@
import { logger } from '../utils/logger.js';
import { ComplianceRule } from '../models/ComplianceRule.js';
import { ComplianceCheck } from '../models/ComplianceCheck.js';
import { RedisClient } from '../config/redis.js';
export class ComplianceService {
constructor() {
this.redis = null;
this.rules = new Map();
this.regions = new Map();
}
static getInstance() {
if (!ComplianceService.instance) {
ComplianceService.instance = new ComplianceService();
ComplianceService.instance.initialize();
}
return ComplianceService.instance;
}
async initialize() {
this.redis = RedisClient.getInstance();
// Load compliance rules
await this.loadComplianceRules();
// Initialize region-specific rules
this.initializeRegionRules();
logger.info('Compliance service initialized');
}
async loadComplianceRules() {
try {
const rules = await ComplianceRule.find({ active: true });
for (const rule of rules) {
this.rules.set(rule.ruleId, rule);
}
logger.info(`Loaded ${rules.length} compliance rules`);
} catch (error) {
logger.error('Failed to load compliance rules:', error);
}
}
initializeRegionRules() {
// GDPR (European Union)
this.regions.set('EU', {
dataRetention: 90, // days
consentRequired: true,
rightToDelete: true,
dataPortability: true,
regulations: ['GDPR'],
restrictions: {
minAge: 16,
cookieConsent: true,
explicitOptIn: true
}
});
// CCPA (California)
this.regions.set('CA', {
dataRetention: 365,
consentRequired: true,
rightToDelete: true,
dataPortability: true,
regulations: ['CCPA'],
restrictions: {
minAge: 13,
doNotSell: true,
privacyNotice: true
}
});
// LGPD (Brazil)
this.regions.set('BR', {
dataRetention: 180,
consentRequired: true,
rightToDelete: true,
dataPortability: true,
regulations: ['LGPD'],
restrictions: {
minAge: 18,
dataProtectionOfficer: true
}
});
// POPIA (South Africa)
this.regions.set('ZA', {
dataRetention: 365,
consentRequired: true,
rightToDelete: true,
dataPortability: false,
regulations: ['POPIA'],
restrictions: {
minAge: 18,
crossBorderTransfer: 'restricted'
}
});
// Default (Rest of World)
this.regions.set('DEFAULT', {
dataRetention: 365,
consentRequired: false,
rightToDelete: false,
dataPortability: false,
regulations: [],
restrictions: {
minAge: 13
}
});
}
async checkCompliance(params) {
const {
action,
content,
targetRegion,
targetAudience,
metadata = {}
} = params;
const startTime = Date.now();
const violations = [];
const warnings = [];
try {
// Get region-specific rules
const regionRules = this.regions.get(targetRegion) || this.regions.get('DEFAULT');
// Check age restrictions
if (targetAudience.minAge && targetAudience.minAge < regionRules.restrictions.minAge) {
violations.push({
type: 'age_restriction',
severity: 'high',
message: `Minimum age ${regionRules.restrictions.minAge} required for ${targetRegion}`,
regulation: regionRules.regulations[0]
});
}
// Check consent requirements
if (regionRules.consentRequired && !metadata.hasConsent) {
violations.push({
type: 'consent_missing',
severity: 'high',
message: 'User consent required for this region',
regulation: regionRules.regulations[0]
});
}
// Check data retention
if (metadata.retentionDays && metadata.retentionDays > regionRules.dataRetention) {
warnings.push({
type: 'data_retention',
severity: 'medium',
message: `Data retention exceeds ${regionRules.dataRetention} days limit`,
regulation: regionRules.regulations[0]
});
}
// Check content-specific rules
const contentChecks = await this.checkContentCompliance(content, targetRegion);
violations.push(...contentChecks.violations);
warnings.push(...contentChecks.warnings);
// Check action-specific rules
const actionChecks = await this.checkActionCompliance(action, targetRegion, metadata);
violations.push(...actionChecks.violations);
warnings.push(...actionChecks.warnings);
// Log compliance check
await this.logComplianceCheck({
action,
targetRegion,
violations,
warnings,
processingTime: Date.now() - startTime
});
return {
compliant: violations.length === 0,
violations,
warnings,
regionRules,
processingTime: Date.now() - startTime
};
} catch (error) {
logger.error('Compliance check error:', error);
return {
compliant: true, // Fail open
error: error.message
};
}
}
async checkContentCompliance(content, region) {
const violations = [];
const warnings = [];
// Check for regulated content types
const regulatedTerms = {
'EU': [
{ term: 'personal data', type: 'gdpr_sensitive' },
{ term: 'health', type: 'special_category' },
{ term: 'political', type: 'special_category' }
],
'CA': [
{ term: 'sale of data', type: 'ccpa_restricted' },
{ term: 'financial', type: 'sensitive' }
]
};
const regionTerms = regulatedTerms[region] || [];
const contentLower = content.toLowerCase();
for (const { term, type } of regionTerms) {
if (contentLower.includes(term)) {
warnings.push({
type: 'regulated_content',
severity: 'medium',
message: `Content contains regulated term: ${term}`,
category: type
});
}
}
// Check for marketing-specific compliance
if (region === 'EU' || region === 'CA') {
if (!content.includes('unsubscribe') && !content.includes('opt-out')) {
violations.push({
type: 'missing_opt_out',
severity: 'high',
message: 'Marketing messages must include opt-out option'
});
}
}
return { violations, warnings };
}
async checkActionCompliance(action, region, metadata) {
const violations = [];
const warnings = [];
// Check action-specific rules
switch (action) {
case 'bulk_message':
if (region === 'EU' && !metadata.doubleOptIn) {
violations.push({
type: 'double_opt_in',
severity: 'high',
message: 'Double opt-in required for bulk messaging in EU'
});
}
break;
case 'data_export':
const regionRules = this.regions.get(region);
if (!regionRules.dataPortability) {
warnings.push({
type: 'data_portability',
severity: 'low',
message: 'Data portability not required in this region'
});
}
break;
case 'user_deletion':
const rules = this.regions.get(region);
if (rules.rightToDelete && !metadata.deletionScheduled) {
violations.push({
type: 'right_to_delete',
severity: 'high',
message: 'User deletion must be scheduled within required timeframe'
});
}
break;
}
// Check rate limits by region
const rateLimits = {
'EU': { messagesPerDay: 100, messagesPerHour: 20 },
'CA': { messagesPerDay: 200, messagesPerHour: 30 },
'DEFAULT': { messagesPerDay: 500, messagesPerHour: 50 }
};
const limits = rateLimits[region] || rateLimits['DEFAULT'];
if (metadata.messageCount) {
if (metadata.messageCount.daily > limits.messagesPerDay) {
violations.push({
type: 'rate_limit_daily',
severity: 'medium',
message: `Daily message limit exceeded: ${limits.messagesPerDay}`
});
}
if (metadata.messageCount.hourly > limits.messagesPerHour) {
violations.push({
type: 'rate_limit_hourly',
severity: 'medium',
message: `Hourly message limit exceeded: ${limits.messagesPerHour}`
});
}
}
return { violations, warnings };
}
async validateConsent(userId, purpose, region) {
const cacheKey = `consent:${userId}:${purpose}:${region}`;
// Check cache
const cached = await this.redis.get(cacheKey);
if (cached !== null) {
return cached;
}
// Check database
const consent = await this.checkUserConsent(userId, purpose, region);
// Cache for 1 hour
await this.redis.setWithExpiry(cacheKey, consent, 3600);
return consent;
}
async checkUserConsent(userId, purpose, region) {
// This would check actual consent records
// For now, return a mock implementation
return {
hasConsent: false,
consentDate: null,
expiryDate: null,
purposes: [],
region
};
}
async logComplianceCheck(checkData) {
try {
await ComplianceCheck.create({
action: checkData.action,
targetRegion: checkData.targetRegion,
violations: checkData.violations,
warnings: checkData.warnings,
processingTime: checkData.processingTime,
timestamp: new Date()
});
} catch (error) {
logger.error('Failed to log compliance check:', error);
}
}
async getComplianceReport(period = '7d') {
const since = new Date();
switch (period) {
case '24h':
since.setDate(since.getDate() - 1);
break;
case '7d':
since.setDate(since.getDate() - 7);
break;
case '30d':
since.setDate(since.getDate() - 30);
break;
}
const report = await ComplianceCheck.aggregate([
{ $match: { timestamp: { $gte: since } } },
{
$group: {
_id: {
region: '$targetRegion',
action: '$action'
},
totalChecks: { $sum: 1 },
violations: { $sum: { $size: '$violations' } },
warnings: { $sum: { $size: '$warnings' } }
}
},
{
$group: {
_id: '$_id.region',
actions: {
$push: {
action: '$_id.action',
totalChecks: '$totalChecks',
violations: '$violations',
warnings: '$warnings'
}
},
totalChecks: { $sum: '$totalChecks' },
totalViolations: { $sum: '$violations' },
totalWarnings: { $sum: '$warnings' }
}
}
]);
return {
period,
since,
regions: report
};
}
async updateComplianceRule(ruleId, updates) {
try {
const rule = await ComplianceRule.findOneAndUpdate(
{ ruleId },
updates,
{ new: true }
);
if (rule) {
this.rules.set(ruleId, rule);
logger.info(`Updated compliance rule: ${ruleId}`);
}
return rule;
} catch (error) {
logger.error('Failed to update compliance rule:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,558 @@
import { logger } from '../utils/logger.js';
import { ContentPolicy } from '../models/ContentPolicy.js';
import { PolicyViolation } from '../models/PolicyViolation.js';
import { ModerationService } from './ModerationService.js';
import { ComplianceService } from './ComplianceService.js';
export class ContentPolicyService {
constructor() {
this.policies = new Map();
this.moderationService = null;
this.complianceService = null;
}
static getInstance() {
if (!ContentPolicyService.instance) {
ContentPolicyService.instance = new ContentPolicyService();
ContentPolicyService.instance.initialize();
}
return ContentPolicyService.instance;
}
async initialize() {
this.moderationService = ModerationService.getInstance();
this.complianceService = ComplianceService.getInstance();
// Load content policies
await this.loadPolicies();
logger.info('Content policy service initialized');
}
async loadPolicies() {
try {
const policies = await ContentPolicy.find({ active: true });
for (const policy of policies) {
this.policies.set(policy.policyId, policy);
}
// Create default policies if none exist
if (this.policies.size === 0) {
await this.createDefaultPolicies();
}
logger.info(`Loaded ${this.policies.size} content policies`);
} catch (error) {
logger.error('Failed to load content policies:', error);
}
}
async createDefaultPolicies() {
const defaultPolicies = [
{
policyId: 'default',
name: 'Default Content Policy',
description: 'Standard content policy for general use',
rules: {
maxMessageLength: 4096,
minMessageLength: 1,
allowedContentTypes: ['text', 'image', 'video', 'document'],
prohibitedPatterns: [],
maxMediaSize: 50 * 1024 * 1024, // 50MB
maxRiskScore: 0.7,
requireModeration: true,
requireCompliance: true
},
active: true
},
{
policyId: 'strict',
name: 'Strict Content Policy',
description: 'Enhanced content restrictions for sensitive audiences',
rules: {
maxMessageLength: 1000,
minMessageLength: 10,
allowedContentTypes: ['text'],
prohibitedPatterns: [
'http[s]?://[^\\s]+', // URLs
'\\b\\d{3,}\\b', // Phone numbers
'[\\w._%+-]+@[\\w.-]+\\.[A-Za-z]{2,}' // Emails
],
maxMediaSize: 10 * 1024 * 1024, // 10MB
maxRiskScore: 0.3,
requireModeration: true,
requireCompliance: true,
additionalChecks: ['duplicate_detection', 'spam_detection']
},
active: true
},
{
policyId: 'marketing',
name: 'Marketing Content Policy',
description: 'Policy optimized for marketing campaigns',
rules: {
maxMessageLength: 2000,
minMessageLength: 50,
allowedContentTypes: ['text', 'image', 'video'],
prohibitedPatterns: [],
maxMediaSize: 25 * 1024 * 1024, // 25MB
maxRiskScore: 0.5,
requireModeration: true,
requireCompliance: true,
requireOptOut: true,
maxLinksPerMessage: 2,
requireCTA: true
},
active: true
}
];
for (const policyData of defaultPolicies) {
const policy = await ContentPolicy.create(policyData);
this.policies.set(policy.policyId, policy);
}
}
async validateContent(content, policyId = 'default', context = {}) {
const startTime = Date.now();
try {
// Get policy
const policy = this.policies.get(policyId);
if (!policy) {
throw new Error(`Policy not found: ${policyId}`);
}
const violations = [];
const warnings = [];
// Basic content checks
const basicChecks = this.performBasicChecks(content, policy.rules);
violations.push(...basicChecks.violations);
warnings.push(...basicChecks.warnings);
// Pattern matching
const patternChecks = this.checkProhibitedPatterns(content, policy.rules);
violations.push(...patternChecks.violations);
// Content type validation
if (context.contentType) {
const typeCheck = this.validateContentType(context.contentType, policy.rules);
if (!typeCheck.allowed) {
violations.push(typeCheck.violation);
}
}
// Media size validation
if (context.mediaSize) {
const sizeCheck = this.validateMediaSize(context.mediaSize, policy.rules);
if (!sizeCheck.allowed) {
violations.push(sizeCheck.violation);
}
}
// Moderation check
let moderationResult = null;
if (policy.rules.requireModeration) {
moderationResult = await this.moderationService.moderateContent(content);
if (!moderationResult.approved) {
violations.push({
type: 'moderation_failed',
severity: 'high',
message: 'Content failed moderation',
details: moderationResult.violations
});
}
if (moderationResult.riskScore > policy.rules.maxRiskScore) {
violations.push({
type: 'risk_score_exceeded',
severity: 'high',
message: `Risk score ${moderationResult.riskScore} exceeds limit ${policy.rules.maxRiskScore}`
});
}
}
// Compliance check
let complianceResult = null;
if (policy.rules.requireCompliance && context.targetRegion) {
complianceResult = await this.complianceService.checkCompliance({
action: context.action || 'message',
content,
targetRegion: context.targetRegion,
targetAudience: context.targetAudience || {},
metadata: context.metadata || {}
});
if (!complianceResult.compliant) {
violations.push(...complianceResult.violations.map(v => ({
type: 'compliance_violation',
severity: v.severity,
message: v.message,
regulation: v.regulation
})));
}
warnings.push(...complianceResult.warnings);
}
// Additional checks
if (policy.rules.additionalChecks) {
const additionalResults = await this.performAdditionalChecks(
content,
policy.rules.additionalChecks,
context
);
violations.push(...additionalResults.violations);
warnings.push(...additionalResults.warnings);
}
// Log policy check
await this.logPolicyCheck({
policyId,
content: content.substring(0, 200), // Log preview only
violations,
warnings,
moderationResult,
complianceResult,
processingTime: Date.now() - startTime
});
return {
approved: violations.length === 0,
violations,
warnings,
policy: {
id: policy.policyId,
name: policy.name
},
moderation: moderationResult,
compliance: complianceResult,
processingTime: Date.now() - startTime
};
} catch (error) {
logger.error('Content validation error:', error);
return {
approved: false,
error: error.message
};
}
}
performBasicChecks(content, rules) {
const violations = [];
const warnings = [];
// Length checks
if (content.length < rules.minMessageLength) {
violations.push({
type: 'message_too_short',
severity: 'medium',
message: `Message length ${content.length} is below minimum ${rules.minMessageLength}`
});
}
if (content.length > rules.maxMessageLength) {
violations.push({
type: 'message_too_long',
severity: 'medium',
message: `Message length ${content.length} exceeds maximum ${rules.maxMessageLength}`
});
}
// Marketing-specific checks
if (rules.requireOptOut && !content.toLowerCase().includes('opt') && !content.toLowerCase().includes('unsubscribe')) {
violations.push({
type: 'missing_opt_out',
severity: 'high',
message: 'Marketing messages must include opt-out option'
});
}
if (rules.requireCTA) {
const ctaPatterns = ['click', 'join', 'sign up', 'register', 'buy', 'purchase', 'subscribe'];
const hasCTA = ctaPatterns.some(pattern => content.toLowerCase().includes(pattern));
if (!hasCTA) {
warnings.push({
type: 'missing_cta',
severity: 'low',
message: 'Marketing messages should include a clear call-to-action'
});
}
}
// Link checks
if (rules.maxLinksPerMessage !== undefined) {
const linkPattern = /https?:\/\/[^\s]+/g;
const links = content.match(linkPattern) || [];
if (links.length > rules.maxLinksPerMessage) {
violations.push({
type: 'too_many_links',
severity: 'medium',
message: `Message contains ${links.length} links, maximum allowed is ${rules.maxLinksPerMessage}`
});
}
}
return { violations, warnings };
}
checkProhibitedPatterns(content, rules) {
const violations = [];
if (!rules.prohibitedPatterns || rules.prohibitedPatterns.length === 0) {
return { violations };
}
for (const pattern of rules.prohibitedPatterns) {
try {
const regex = new RegExp(pattern, 'gi');
const matches = content.match(regex);
if (matches && matches.length > 0) {
violations.push({
type: 'prohibited_pattern',
severity: 'high',
message: `Content contains prohibited pattern`,
matches: matches.slice(0, 5) // Limit to first 5 matches
});
}
} catch (error) {
logger.error(`Invalid regex pattern: ${pattern}`, error);
}
}
return { violations };
}
validateContentType(contentType, rules) {
if (!rules.allowedContentTypes || rules.allowedContentTypes.includes(contentType)) {
return { allowed: true };
}
return {
allowed: false,
violation: {
type: 'invalid_content_type',
severity: 'high',
message: `Content type '${contentType}' is not allowed`
}
};
}
validateMediaSize(mediaSize, rules) {
if (!rules.maxMediaSize || mediaSize <= rules.maxMediaSize) {
return { allowed: true };
}
return {
allowed: false,
violation: {
type: 'media_size_exceeded',
severity: 'medium',
message: `Media size ${mediaSize} bytes exceeds maximum ${rules.maxMediaSize} bytes`
}
};
}
async performAdditionalChecks(content, checks, context) {
const violations = [];
const warnings = [];
for (const check of checks) {
switch (check) {
case 'duplicate_detection':
const isDuplicate = await this.checkDuplicate(content, context);
if (isDuplicate) {
violations.push({
type: 'duplicate_content',
severity: 'medium',
message: 'Duplicate content detected'
});
}
break;
case 'spam_detection':
const spamScore = await this.calculateSpamScore(content);
if (spamScore > 0.7) {
violations.push({
type: 'spam_detected',
severity: 'high',
message: `Spam score ${spamScore} exceeds threshold`
});
}
break;
case 'link_validation':
const linkResults = await this.validateLinks(content);
violations.push(...linkResults.violations);
warnings.push(...linkResults.warnings);
break;
}
}
return { violations, warnings };
}
async checkDuplicate(content, context) {
// Simple implementation - check for exact matches in recent messages
// In production, use more sophisticated similarity algorithms
const recentKey = `recent:${context.accountId || 'global'}`;
const recentMessages = await this.redis.client.lrange(recentKey, 0, 99);
const contentHash = this.hashContent(content);
const isDuplicate = recentMessages.includes(contentHash);
// Store this message hash
await this.redis.client.lpush(recentKey, contentHash);
await this.redis.client.ltrim(recentKey, 0, 99);
await this.redis.client.expire(recentKey, 3600); // 1 hour
return isDuplicate;
}
async calculateSpamScore(content) {
// Simple spam scoring - in production use ML models
let score = 0;
// Check for spam indicators
const spamIndicators = [
{ pattern: /FREE|WINNER|CONGRATULATIONS|CLAIM/gi, weight: 0.2 },
{ pattern: /\$\d+|\d+%\s*OFF/gi, weight: 0.15 },
{ pattern: /CLICK HERE|ACT NOW|LIMITED TIME/gi, weight: 0.15 },
{ pattern: /!!!+|\?\?\?+/g, weight: 0.1 },
{ pattern: /[A-Z]{10,}/g, weight: 0.1 } // Excessive caps
];
for (const { pattern, weight } of spamIndicators) {
const matches = content.match(pattern);
if (matches) {
score += weight * Math.min(matches.length, 3);
}
}
return Math.min(score, 1);
}
async validateLinks(content) {
const violations = [];
const warnings = [];
const linkPattern = /https?:\/\/[^\s]+/g;
const links = content.match(linkPattern) || [];
for (const link of links) {
// Check against known malicious domains
if (await this.isMaliciousLink(link)) {
violations.push({
type: 'malicious_link',
severity: 'critical',
message: `Malicious link detected: ${link}`
});
}
// Check for URL shorteners
if (this.isURLShortener(link)) {
warnings.push({
type: 'url_shortener',
severity: 'low',
message: `URL shortener detected: ${link}`
});
}
}
return { violations, warnings };
}
async isMaliciousLink(url) {
// In production, integrate with threat intelligence APIs
const maliciousDomains = ['malicious.example.com', 'phishing.test.com'];
return maliciousDomains.some(domain => url.includes(domain));
}
isURLShortener(url) {
const shorteners = ['bit.ly', 'tinyurl.com', 'goo.gl', 't.co', 'short.link'];
return shorteners.some(shortener => url.includes(shortener));
}
hashContent(content) {
const crypto = require('crypto');
return crypto.createHash('sha256').update(content).digest('hex');
}
async logPolicyCheck(checkData) {
try {
await PolicyViolation.create({
policyId: checkData.policyId,
contentPreview: checkData.content,
violations: checkData.violations,
warnings: checkData.warnings,
moderationResult: checkData.moderationResult,
complianceResult: checkData.complianceResult,
processingTime: checkData.processingTime,
timestamp: new Date()
});
} catch (error) {
logger.error('Failed to log policy check:', error);
}
}
async updatePolicy(policyId, updates) {
try {
const policy = await ContentPolicy.findOneAndUpdate(
{ policyId },
updates,
{ new: true }
);
if (policy) {
this.policies.set(policyId, policy);
logger.info(`Updated content policy: ${policyId}`);
}
return policy;
} catch (error) {
logger.error('Failed to update content policy:', error);
throw error;
}
}
async getPolicyViolationReport(period = '7d') {
const since = new Date();
switch (period) {
case '24h':
since.setDate(since.getDate() - 1);
break;
case '7d':
since.setDate(since.getDate() - 7);
break;
case '30d':
since.setDate(since.getDate() - 30);
break;
}
const report = await PolicyViolation.aggregate([
{ $match: { timestamp: { $gte: since } } },
{ $unwind: '$violations' },
{
$group: {
_id: {
policyId: '$policyId',
violationType: '$violations.type'
},
count: { $sum: 1 },
avgProcessingTime: { $avg: '$processingTime' }
}
},
{
$sort: { count: -1 }
}
]);
return {
period,
since,
violations: report
};
}
}

View File

@@ -0,0 +1,390 @@
import OpenAI from 'openai';
import { LanguageServiceClient } from '@google-cloud/language';
import BadWordsFilter from 'bad-words';
import Sentiment from 'sentiment';
import natural from 'natural';
import { logger } from '../utils/logger.js';
import { RedisClient } from '../config/redis.js';
import { ContentViolation } from '../models/ContentViolation.js';
export class ModerationService {
constructor() {
this.openai = null;
this.googleLanguage = null;
this.badWordsFilter = null;
this.sentiment = null;
this.redis = null;
this.tokenizer = null;
}
static getInstance() {
if (!ModerationService.instance) {
ModerationService.instance = new ModerationService();
ModerationService.instance.initialize();
}
return ModerationService.instance;
}
initialize() {
// Initialize OpenAI for advanced content moderation
if (process.env.OPENAI_API_KEY) {
this.openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
}
// Initialize Google Cloud Natural Language API
if (process.env.GOOGLE_CLOUD_PROJECT) {
this.googleLanguage = new LanguageServiceClient();
}
// Initialize bad words filter
this.badWordsFilter = new BadWordsFilter();
// Add custom bad words if needed
const customBadWords = process.env.CUSTOM_BAD_WORDS?.split(',') || [];
if (customBadWords.length > 0) {
this.badWordsFilter.addWords(...customBadWords);
}
// Initialize sentiment analyzer
this.sentiment = new Sentiment();
// Initialize tokenizer
this.tokenizer = new natural.WordTokenizer();
// Get Redis client
this.redis = RedisClient.getInstance();
logger.info('Moderation service initialized');
}
async moderateContent(content) {
const startTime = Date.now();
const cacheKey = `moderation:${this.hashContent(content)}`;
try {
// Check cache first
const cached = await this.redis.get(cacheKey);
if (cached) {
logger.debug('Moderation result from cache');
return cached;
}
// Run multiple moderation checks in parallel
const [profanityCheck, sentimentAnalysis, aiModeration, toxicityCheck] = await Promise.all([
this.checkProfanity(content),
this.analyzeSentiment(content),
this.aiContentModeration(content),
this.checkToxicity(content)
]);
// Combine results
const result = {
approved: true,
violations: [],
riskScore: 0,
sentiment: sentimentAnalysis,
checks: {
profanity: profanityCheck,
toxicity: toxicityCheck,
ai: aiModeration
},
processingTime: Date.now() - startTime
};
// Calculate overall risk score
if (profanityCheck.detected) {
result.riskScore += profanityCheck.severity * 0.3;
result.violations.push({
type: 'profanity',
severity: profanityCheck.severity,
details: profanityCheck.words
});
}
if (toxicityCheck.detected) {
result.riskScore += toxicityCheck.score * 0.4;
result.violations.push({
type: 'toxicity',
severity: toxicityCheck.score,
categories: toxicityCheck.categories
});
}
if (aiModeration && !aiModeration.approved) {
result.riskScore += aiModeration.riskScore * 0.3;
result.violations.push(...aiModeration.violations);
}
// Determine approval based on risk score
result.approved = result.riskScore < 0.7;
// Cache result for 1 hour
await this.redis.setWithExpiry(cacheKey, result, 3600);
// Log violations
if (!result.approved) {
await this.logViolation(content, result);
}
return result;
} catch (error) {
logger.error('Moderation error:', error);
// Return permissive result on error
return {
approved: true,
violations: [],
riskScore: 0,
error: error.message
};
}
}
async checkProfanity(content) {
try {
const isProfane = this.badWordsFilter.isProfane(content);
const cleanedContent = this.badWordsFilter.clean(content);
// Find actual bad words
const words = [];
if (isProfane) {
const tokens = this.tokenizer.tokenize(content.toLowerCase());
for (const token of tokens) {
if (this.badWordsFilter.isProfane(token)) {
words.push(token);
}
}
}
return {
detected: isProfane,
severity: words.length > 3 ? 0.9 : words.length > 1 ? 0.6 : 0.3,
words: words,
cleanedContent
};
} catch (error) {
logger.error('Profanity check error:', error);
return { detected: false, severity: 0 };
}
}
async analyzeSentiment(content) {
try {
const result = this.sentiment.analyze(content);
return {
score: result.score,
comparative: result.comparative,
positive: result.positive.length,
negative: result.negative.length,
neutral: result.tokens.length - result.positive.length - result.negative.length
};
} catch (error) {
logger.error('Sentiment analysis error:', error);
return { score: 0, comparative: 0 };
}
}
async aiContentModeration(content) {
if (!this.openai) {
return null;
}
try {
const response = await this.openai.moderations.create({
input: content
});
const result = response.results[0];
const violations = [];
let riskScore = 0;
// Check each category
for (const [category, flagged] of Object.entries(result.categories)) {
if (flagged) {
const score = result.category_scores[category];
violations.push({
type: 'ai_moderation',
category,
score
});
riskScore = Math.max(riskScore, score);
}
}
return {
approved: violations.length === 0,
violations,
riskScore,
flagged: result.flagged
};
} catch (error) {
logger.error('AI moderation error:', error);
return null;
}
}
async checkToxicity(content) {
if (!this.googleLanguage) {
return { detected: false, score: 0 };
}
try {
const document = {
content,
type: 'PLAIN_TEXT'
};
const [result] = await this.googleLanguage.analyzeSentiment({ document });
const sentiment = result.documentSentiment;
// Check for negative sentiment with high magnitude
const isNegative = sentiment.score < -0.3;
const highMagnitude = sentiment.magnitude > 0.7;
const isToxic = isNegative && highMagnitude;
const categories = [];
if (isToxic) {
if (sentiment.score < -0.7) categories.push('severe_toxicity');
else if (sentiment.score < -0.5) categories.push('toxicity');
else categories.push('negativity');
}
return {
detected: isToxic,
score: isToxic ? Math.abs(sentiment.score) : 0,
sentiment: {
score: sentiment.score,
magnitude: sentiment.magnitude
},
categories
};
} catch (error) {
logger.error('Toxicity check error:', error);
return { detected: false, score: 0 };
}
}
async moderateBulkContent(contents) {
const results = await Promise.all(
contents.map(content => this.moderateContent(content))
);
return {
total: contents.length,
approved: results.filter(r => r.approved).length,
rejected: results.filter(r => !r.approved).length,
results
};
}
async checkContentPolicy(content, policyType = 'default') {
const moderation = await this.moderateContent(content);
const policy = await this.getContentPolicy(policyType);
const violations = [];
// Check against policy thresholds
if (moderation.riskScore > policy.maxRiskScore) {
violations.push({
rule: 'risk_score',
threshold: policy.maxRiskScore,
actual: moderation.riskScore
});
}
if (moderation.sentiment.comparative < policy.minSentiment) {
violations.push({
rule: 'sentiment',
threshold: policy.minSentiment,
actual: moderation.sentiment.comparative
});
}
return {
approved: violations.length === 0,
violations,
moderation,
policy: policyType
};
}
async getContentPolicy(type) {
const policies = {
default: {
maxRiskScore: 0.7,
minSentiment: -0.5,
allowedCategories: []
},
strict: {
maxRiskScore: 0.3,
minSentiment: -0.2,
allowedCategories: []
},
permissive: {
maxRiskScore: 0.9,
minSentiment: -0.8,
allowedCategories: ['mild_negativity']
}
};
return policies[type] || policies.default;
}
async logViolation(content, result) {
try {
await ContentViolation.create({
content: content.substring(0, 1000), // Limit stored content
violations: result.violations,
riskScore: result.riskScore,
sentiment: result.sentiment,
timestamp: new Date()
});
} catch (error) {
logger.error('Failed to log violation:', error);
}
}
hashContent(content) {
const crypto = require('crypto');
return crypto.createHash('sha256').update(content).digest('hex');
}
async getViolationStats(period = '24h') {
const since = new Date();
switch (period) {
case '1h':
since.setHours(since.getHours() - 1);
break;
case '24h':
since.setDate(since.getDate() - 1);
break;
case '7d':
since.setDate(since.getDate() - 7);
break;
case '30d':
since.setDate(since.getDate() - 30);
break;
}
const stats = await ContentViolation.aggregate([
{ $match: { timestamp: { $gte: since } } },
{
$group: {
_id: null,
total: { $sum: 1 },
avgRiskScore: { $avg: '$riskScore' },
byType: {
$push: {
type: { $arrayElemAt: ['$violations.type', 0] }
}
}
}
}
]);
return stats[0] || { total: 0, avgRiskScore: 0, byType: [] };
}
}

View File

@@ -0,0 +1,380 @@
import { RateLimiterRedis, RateLimiterMemory } from 'rate-limiter-flexible';
import { logger } from '../utils/logger.js';
import { RedisClient } from '../config/redis.js';
import { RateLimitViolation } from '../models/RateLimitViolation.js';
export class RateLimiterService {
constructor() {
this.limiters = new Map();
this.redis = null;
}
static getInstance() {
if (!RateLimiterService.instance) {
RateLimiterService.instance = new RateLimiterService();
RateLimiterService.instance.initialize();
}
return RateLimiterService.instance;
}
async initialize() {
this.redis = RedisClient.getInstance();
// Initialize rate limiters
await this.setupRateLimiters();
logger.info('Rate limiter service initialized');
}
async setupRateLimiters() {
const redisClient = this.redis.client;
// Message sending rate limits
this.limiters.set('message:minute', new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl:msg:min',
points: 30, // 30 messages
duration: 60, // per minute
blockDuration: 60 // block for 1 minute
}));
this.limiters.set('message:hour', new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl:msg:hour',
points: 500, // 500 messages
duration: 3600, // per hour
blockDuration: 600 // block for 10 minutes
}));
this.limiters.set('message:day', new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl:msg:day',
points: 5000, // 5000 messages
duration: 86400, // per day
blockDuration: 3600 // block for 1 hour
}));
// Group operations rate limits
this.limiters.set('group:join:hour', new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl:grp:join',
points: 10, // 10 group joins
duration: 3600, // per hour
blockDuration: 1800 // block for 30 minutes
}));
this.limiters.set('group:create:day', new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl:grp:create',
points: 5, // 5 group creations
duration: 86400, // per day
blockDuration: 7200 // block for 2 hours
}));
// API request rate limits
this.limiters.set('api:request:second', new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl:api:sec',
points: 10, // 10 requests
duration: 1, // per second
blockDuration: 5 // block for 5 seconds
}));
this.limiters.set('api:request:minute', new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl:api:min',
points: 100, // 100 requests
duration: 60, // per minute
blockDuration: 60 // block for 1 minute
}));
// Content moderation rate limits
this.limiters.set('moderation:minute', new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl:mod:min',
points: 100, // 100 moderation requests
duration: 60, // per minute
blockDuration: 30 // block for 30 seconds
}));
// Campaign rate limits
this.limiters.set('campaign:hour', new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl:camp:hour',
points: 5, // 5 campaigns
duration: 3600, // per hour
blockDuration: 1800 // block for 30 minutes
}));
// Custom rate limiters based on account tier
this.setupTierBasedLimiters();
}
setupTierBasedLimiters() {
const tiers = {
free: {
'message:day': 100,
'group:join:day': 5,
'api:request:day': 1000
},
basic: {
'message:day': 1000,
'group:join:day': 20,
'api:request:day': 10000
},
pro: {
'message:day': 10000,
'group:join:day': 100,
'api:request:day': 100000
},
enterprise: {
'message:day': 100000,
'group:join:day': 1000,
'api:request:day': 1000000
}
};
for (const [tier, limits] of Object.entries(tiers)) {
for (const [limitType, points] of Object.entries(limits)) {
const key = `${tier}:${limitType}`;
this.limiters.set(key, new RateLimiterRedis({
storeClient: this.redis.client,
keyPrefix: `rl:tier:${tier}:${limitType.replace(':', '_')}`,
points,
duration: 86400, // per day
blockDuration: 3600 // block for 1 hour
}));
}
}
}
async checkRateLimit(limitType, identifier, options = {}) {
const { tier = null, cost = 1 } = options;
try {
// Check tier-specific limit first if tier is provided
if (tier) {
const tierLimitKey = `${tier}:${limitType}`;
if (this.limiters.has(tierLimitKey)) {
try {
await this.limiters.get(tierLimitKey).consume(identifier, cost);
} catch (tierError) {
await this.logViolation({
limitType: tierLimitKey,
identifier,
tier,
cost
});
throw tierError;
}
}
}
// Check general limit
const limiter = this.limiters.get(limitType);
if (!limiter) {
logger.warn(`Rate limiter not found: ${limitType}`);
return { allowed: true };
}
const result = await limiter.consume(identifier, cost);
return {
allowed: true,
remainingPoints: result.remainingPoints,
msBeforeNext: result.msBeforeNext,
consumedPoints: result.consumedPoints,
isFirstInDuration: result.isFirstInDuration
};
} catch (error) {
if (error.remainingPoints !== undefined) {
// Rate limit exceeded
await this.logViolation({
limitType,
identifier,
tier,
cost,
msBeforeNext: error.msBeforeNext
});
return {
allowed: false,
remainingPoints: error.remainingPoints,
msBeforeNext: error.msBeforeNext,
retryAfter: new Date(Date.now() + error.msBeforeNext)
};
}
// Actual error
logger.error('Rate limit check error:', error);
// Fail open on error
return { allowed: true, error: error.message };
}
}
async checkMultipleRateLimits(limits, identifier, options = {}) {
const results = [];
for (const limitType of limits) {
const result = await this.checkRateLimit(limitType, identifier, options);
results.push({ limitType, ...result });
if (!result.allowed) {
return {
allowed: false,
failedLimit: limitType,
results
};
}
}
return {
allowed: true,
results
};
}
async getRateLimitStatus(limitTypes, identifier, tier = null) {
const status = {};
for (const limitType of limitTypes) {
try {
// Check tier-specific limit
if (tier) {
const tierLimitKey = `${tier}:${limitType}`;
if (this.limiters.has(tierLimitKey)) {
const tierLimiter = this.limiters.get(tierLimitKey);
const tierStatus = await tierLimiter.get(identifier);
status[tierLimitKey] = {
consumedPoints: tierStatus ? tierStatus.consumedPoints : 0,
remainingPoints: tierStatus ? tierStatus.remainingPoints : tierLimiter.points,
msBeforeNext: tierStatus ? tierStatus.msBeforeNext : 0
};
}
}
// Check general limit
const limiter = this.limiters.get(limitType);
if (limiter) {
const limitStatus = await limiter.get(identifier);
status[limitType] = {
consumedPoints: limitStatus ? limitStatus.consumedPoints : 0,
remainingPoints: limitStatus ? limitStatus.remainingPoints : limiter.points,
msBeforeNext: limitStatus ? limitStatus.msBeforeNext : 0
};
}
} catch (error) {
logger.error(`Failed to get rate limit status for ${limitType}:`, error);
status[limitType] = { error: error.message };
}
}
return status;
}
async resetRateLimit(limitType, identifier) {
try {
const limiter = this.limiters.get(limitType);
if (!limiter) {
throw new Error(`Rate limiter not found: ${limitType}`);
}
await limiter.delete(identifier);
logger.info(`Reset rate limit ${limitType} for ${identifier}`);
return { success: true };
} catch (error) {
logger.error('Failed to reset rate limit:', error);
throw error;
}
}
async setCustomRateLimit(key, config) {
try {
const limiter = new RateLimiterRedis({
storeClient: this.redis.client,
keyPrefix: `rl:custom:${key}`,
points: config.points,
duration: config.duration,
blockDuration: config.blockDuration || config.duration
});
this.limiters.set(`custom:${key}`, limiter);
logger.info(`Created custom rate limit: ${key}`);
return { success: true, key: `custom:${key}` };
} catch (error) {
logger.error('Failed to create custom rate limit:', error);
throw error;
}
}
async logViolation(violation) {
try {
await RateLimitViolation.create({
limitType: violation.limitType,
identifier: violation.identifier,
tier: violation.tier,
cost: violation.cost,
msBeforeNext: violation.msBeforeNext,
timestamp: new Date()
});
} catch (error) {
logger.error('Failed to log rate limit violation:', error);
}
}
async getViolationReport(period = '24h') {
const since = new Date();
switch (period) {
case '1h':
since.setHours(since.getHours() - 1);
break;
case '24h':
since.setDate(since.getDate() - 1);
break;
case '7d':
since.setDate(since.getDate() - 7);
break;
}
const report = await RateLimitViolation.aggregate([
{ $match: { timestamp: { $gte: since } } },
{
$group: {
_id: {
limitType: '$limitType',
identifier: '$identifier'
},
count: { $sum: 1 },
totalCost: { $sum: '$cost' },
lastViolation: { $max: '$timestamp' }
}
},
{
$sort: { count: -1 }
},
{
$limit: 100
}
]);
return {
period,
since,
topViolators: report
};
}
// Utility method to calculate optimal rate limit settings
calculateOptimalLimits(usage, targetSuccessRate = 0.95) {
const buffer = 1 / (1 - targetSuccessRate);
return {
points: Math.ceil(usage.peak * buffer),
duration: usage.duration,
blockDuration: Math.min(usage.duration, 3600)
};
}
}

View File

@@ -0,0 +1,93 @@
import winston from 'winston';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const { combine, timestamp, printf, colorize, errors } = winston.format;
// Custom log format
const logFormat = printf(({ level, message, timestamp, stack, ...metadata }) => {
let msg = `${timestamp} [${level}] ${message}`;
if (stack) {
msg += `\n${stack}`;
}
if (Object.keys(metadata).length > 0) {
msg += ` ${JSON.stringify(metadata)}`;
}
return msg;
});
// Create logger instance
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: combine(
errors({ stack: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
transports: [
// Console transport
new winston.transports.Console({
format: combine(
colorize(),
logFormat
)
}),
// File transport for errors
new winston.transports.File({
filename: path.join(__dirname, '../../logs/error.log'),
level: 'error',
maxsize: 10485760, // 10MB
maxFiles: 5
}),
// File transport for all logs
new winston.transports.File({
filename: path.join(__dirname, '../../logs/combined.log'),
maxsize: 10485760, // 10MB
maxFiles: 10
})
],
exceptionHandlers: [
new winston.transports.File({
filename: path.join(__dirname, '../../logs/exceptions.log')
})
],
rejectionHandlers: [
new winston.transports.File({
filename: path.join(__dirname, '../../logs/rejections.log')
})
]
});
// Add request logging helper
export const logRequest = (request) => {
const { method, path, payload, query, params } = request;
logger.info('API Request', {
method,
path,
query,
params,
payload: payload ? Object.keys(payload) : null
});
};
// Add response logging helper
export const logResponse = (request, response, processingTime) => {
const { method, path } = request;
const statusCode = response.statusCode;
const level = statusCode >= 500 ? 'error' :
statusCode >= 400 ? 'warn' : 'info';
logger[level]('API Response', {
method,
path,
statusCode,
processingTime: `${processingTime}ms`
});
};