108 lines
4.3 KiB
Dart
108 lines
4.3 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
/// On-device content moderation that runs BEFORE encryption.
|
|
/// No network calls — pure local pattern matching + keyword detection.
|
|
/// This is Layer 1: fast, private, zero-latency content gate.
|
|
class ContentGuardService {
|
|
static final ContentGuardService _instance = ContentGuardService._();
|
|
static ContentGuardService get instance => _instance;
|
|
ContentGuardService._();
|
|
|
|
/// Check content before sending. Returns null if clean, or a reason string if blocked.
|
|
String? check(String text, {String? imageUrl}) {
|
|
if (text.isEmpty) return null;
|
|
|
|
final lower = text.toLowerCase().trim();
|
|
|
|
// 1. Slur / hate speech blocklist
|
|
final slurMatch = _checkBlocklist(lower, _slurPatterns);
|
|
if (slurMatch != null) return 'Content contains prohibited language: $slurMatch';
|
|
|
|
// 2. Violent threat patterns
|
|
final threatMatch = _checkPatterns(lower, _threatPatterns);
|
|
if (threatMatch != null) return 'Content contains threatening language';
|
|
|
|
// 3. CSAM / exploitation indicators
|
|
final csamMatch = _checkBlocklist(lower, _exploitationPatterns);
|
|
if (csamMatch != null) return 'Content violates safety policies';
|
|
|
|
// 4. Spam patterns (excessive caps, repeated chars, link spam)
|
|
final spamReason = _checkSpam(lower, text);
|
|
if (spamReason != null) return spamReason;
|
|
|
|
return null; // Clean
|
|
}
|
|
|
|
/// Quick check — returns true if content is allowed
|
|
bool isAllowed(String text, {String? imageUrl}) => check(text, imageUrl: imageUrl) == null;
|
|
|
|
// ─── Pattern Matching Engine ────────────────────────────────────────
|
|
|
|
String? _checkBlocklist(String text, List<String> patterns) {
|
|
for (final pattern in patterns) {
|
|
// Word boundary matching: ensure we match whole words, not substrings
|
|
final regex = RegExp(r'\b' + RegExp.escape(pattern) + r'\b', caseSensitive: false);
|
|
if (regex.hasMatch(text)) return pattern;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? _checkPatterns(String text, List<RegExp> patterns) {
|
|
for (final pattern in patterns) {
|
|
if (pattern.hasMatch(text)) return pattern.pattern;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? _checkSpam(String lower, String original) {
|
|
// Excessive ALL CAPS (>70% uppercase in messages longer than 20 chars)
|
|
if (original.length > 20) {
|
|
final upperCount = original.runes.where((r) => String.fromCharCode(r) == String.fromCharCode(r).toUpperCase() && String.fromCharCode(r) != String.fromCharCode(r).toLowerCase()).length;
|
|
if (upperCount / original.length > 0.7) return 'Excessive capitalization';
|
|
}
|
|
|
|
// Repeated character spam (e.g., "aaaaaaaaaa")
|
|
final repeatedChar = RegExp(r'(.)\1{9,}');
|
|
if (repeatedChar.hasMatch(lower)) return 'Spam detected';
|
|
|
|
return null;
|
|
}
|
|
|
|
// ─── Blocklists ─────────────────────────────────────────────────────
|
|
// These are compiled at app start and checked per-message.
|
|
// Admin can push updates via config endpoint in the future.
|
|
|
|
/// Racial slurs and hate speech terms
|
|
static final List<String> _slurPatterns = [
|
|
// Racial slurs
|
|
'nigger', 'nigga', 'kike', 'spic', 'wetback', 'chink', 'gook',
|
|
'raghead', 'towelhead', 'beaner', 'coon', 'darkie',
|
|
// Anti-LGBTQ slurs
|
|
'faggot', 'fag', 'dyke', 'tranny',
|
|
// Disability slurs
|
|
'retard', 'retarded',
|
|
];
|
|
|
|
/// Violent threat patterns (regex)
|
|
static final List<RegExp> _threatPatterns = [
|
|
RegExp(r'\b(i will|im going to|gonna)\s+(kill|murder|shoot|stab|hurt)\s+(you|them|him|her|everyone)\b', caseSensitive: false),
|
|
RegExp(r'\b(bomb|shoot up|blow up)\s+(the|a|this)\s+(school|church|mosque|synagogue|building)\b', caseSensitive: false),
|
|
RegExp(r'\b(death|kill)\s+threat\b', caseSensitive: false),
|
|
];
|
|
|
|
/// CSAM and exploitation indicators
|
|
static final List<String> _exploitationPatterns = [
|
|
'child porn', 'cp links', 'underage', 'jailbait',
|
|
];
|
|
}
|
|
|
|
/// Thrown when local content moderation blocks a message before encryption.
|
|
class ContentBlockedException implements Exception {
|
|
final String reason;
|
|
const ContentBlockedException(this.reason);
|
|
|
|
@override
|
|
String toString() => 'ContentBlockedException: $reason';
|
|
}
|