mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Add profanity in display and usernames, fix Docker setup
This commit is contained in:
@@ -16,6 +16,7 @@ from validation import is_valid_password, is_valid_username, is_valid_display_na
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from security.audit import log_security
|
from security.audit import log_security
|
||||||
|
from security.profanity import contains_profanity
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
||||||
@@ -185,12 +186,22 @@ def register(request: RegisterRequest, http: Request, db: Session = Depends(get_
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
||||||
)
|
)
|
||||||
|
if contains_profanity(username):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Имя пользователя содержит запрещённые слова"
|
||||||
|
)
|
||||||
|
|
||||||
if not is_valid_display_name(display_name):
|
if not is_valid_display_name(display_name):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||||
)
|
)
|
||||||
|
if contains_profanity(display_name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Отображаемое имя содержит запрещённые слова"
|
||||||
|
)
|
||||||
|
|
||||||
if not is_valid_password(password):
|
if not is_valid_password(password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
+65
-22
@@ -7,6 +7,7 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
import unicodedata
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -44,22 +45,51 @@ _SPAM_SIMILARITY_THRESHOLD = 0.88
|
|||||||
_SPAM_MESSAGE_LIMIT = 5
|
_SPAM_MESSAGE_LIMIT = 5
|
||||||
_BURST_WINDOW_SECONDS = 30
|
_BURST_WINDOW_SECONDS = 30
|
||||||
_BURST_COUNT_THRESHOLD = 20
|
_BURST_COUNT_THRESHOLD = 20
|
||||||
|
_SHORT_MESSAGE_LENGTH = 8
|
||||||
|
_SHORT_MESSAGE_REPEAT_LIMIT = 4
|
||||||
|
|
||||||
_recent_message_cache: dict[int, deque[tuple[str, float]]] = defaultdict(deque)
|
_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque)
|
||||||
_message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
|
_message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
|
||||||
_burst_last_logged: dict[int, float] = {}
|
_burst_last_logged: dict[int, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_for_spam(text: str) -> str:
|
||||||
|
normalized = unicodedata.normalize("NFKC", text or "").casefold()
|
||||||
|
# Remove whitespace and punctuation while keeping alphanumerics
|
||||||
|
cleaned = re.sub(r"[^0-9a-zа-яё]+", "", normalized, flags=re.IGNORECASE)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def _monitor_public_message_activity(user: User, content: str, db: Session) -> None:
|
def _monitor_public_message_activity(user: User, content: str, db: Session) -> None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
|
def suspend(reason: str, event: str, **extra: Any) -> None:
|
||||||
|
if user.suspended or user.id == 1:
|
||||||
|
return
|
||||||
|
user.suspended = True
|
||||||
|
user.suspension_reason = reason
|
||||||
|
db.commit()
|
||||||
|
log_security(
|
||||||
|
event,
|
||||||
|
severity="warning",
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
reason=reason,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Rate tracking for burst detection
|
# Rate tracking for burst detection
|
||||||
rate_bucket = _message_rate_cache[user.id]
|
rate_bucket = _message_rate_cache[user.id]
|
||||||
rate_bucket.append(now)
|
rate_bucket.append(now)
|
||||||
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
|
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
|
||||||
rate_bucket.popleft()
|
rate_bucket.popleft()
|
||||||
|
|
||||||
if len(rate_bucket) >= _BURST_COUNT_THRESHOLD:
|
burst_count = len(rate_bucket)
|
||||||
|
if burst_count >= _BURST_COUNT_THRESHOLD:
|
||||||
last_logged = _burst_last_logged.get(user.id)
|
last_logged = _burst_last_logged.get(user.id)
|
||||||
if not last_logged or now - last_logged > _BURST_WINDOW_SECONDS:
|
if not last_logged or now - last_logged > _BURST_WINDOW_SECONDS:
|
||||||
log_security(
|
log_security(
|
||||||
@@ -67,39 +97,52 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
|
|||||||
severity="warning",
|
severity="warning",
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
count=len(rate_bucket),
|
count=burst_count,
|
||||||
window_seconds=_BURST_WINDOW_SECONDS,
|
window_seconds=_BURST_WINDOW_SECONDS,
|
||||||
)
|
)
|
||||||
_burst_last_logged[user.id] = now
|
_burst_last_logged[user.id] = now
|
||||||
|
suspend(
|
||||||
|
"Automatic suspension: excessive message rate",
|
||||||
|
"auto_suspension_public_burst",
|
||||||
|
count=burst_count,
|
||||||
|
window_seconds=_BURST_WINDOW_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
# Similarity-based spam detection
|
# Similarity-based spam detection
|
||||||
|
normalized = _normalize_for_spam(content)
|
||||||
history = _recent_message_cache[user.id]
|
history = _recent_message_cache[user.id]
|
||||||
history.append((content, now))
|
while history and now - history[0][2] > _SPAM_WINDOW_SECONDS:
|
||||||
while history and now - history[0][1] > _SPAM_WINDOW_SECONDS:
|
|
||||||
history.popleft()
|
history.popleft()
|
||||||
|
|
||||||
similar_messages = sum(
|
prior_same = sum(1 for prev_norm, _, _ in history if prev_norm == normalized)
|
||||||
1 for previous_content, _ in history
|
prior_similar = sum(
|
||||||
if SequenceMatcher(None, content, previous_content).ratio() >= _SPAM_SIMILARITY_THRESHOLD
|
1
|
||||||
|
for prev_norm, _, _ in history
|
||||||
|
if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD
|
||||||
)
|
)
|
||||||
|
|
||||||
if similar_messages >= _SPAM_MESSAGE_LIMIT and not user.suspended and user.id != 1:
|
history.append((normalized, content, now))
|
||||||
reason = "Automatic suspension: repeated similar public messages"
|
|
||||||
user.suspended = True
|
total_matches = prior_same + prior_similar + 1
|
||||||
user.suspension_reason = reason
|
|
||||||
db.commit()
|
if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT:
|
||||||
log_security(
|
suspend(
|
||||||
|
"Automatic suspension: repeated short messages",
|
||||||
"auto_suspension_public_spam",
|
"auto_suspension_public_spam",
|
||||||
severity="warning",
|
occurrences=prior_same + 1,
|
||||||
user_id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
similar_messages=similar_messages,
|
|
||||||
window_seconds=_SPAM_WINDOW_SECONDS,
|
window_seconds=_SPAM_WINDOW_SECONDS,
|
||||||
|
match_type="short",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if total_matches >= _SPAM_MESSAGE_LIMIT:
|
||||||
|
suspend(
|
||||||
|
"Automatic suspension: repeated similar public messages",
|
||||||
|
"auto_suspension_public_spam",
|
||||||
|
similar_messages=total_matches,
|
||||||
|
window_seconds=_SPAM_WINDOW_SECONDS,
|
||||||
|
match_type="similar",
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def convert_message(msg: Message) -> dict:
|
def convert_message(msg: Message) -> dict:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from validation import is_valid_username, is_valid_display_name
|
|||||||
from similarity import is_user_similar_to_verified
|
from similarity import is_user_similar_to_verified
|
||||||
from .messaging import messagingManager
|
from .messaging import messagingManager
|
||||||
from security.audit import log_security
|
from security.audit import log_security
|
||||||
|
from security.profanity import contains_profanity
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -180,6 +181,11 @@ async def update_user_profile(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
||||||
)
|
)
|
||||||
|
if contains_profanity(username):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Имя пользователя содержит запрещённые слова"
|
||||||
|
)
|
||||||
|
|
||||||
# Check if username is already taken by another user
|
# Check if username is already taken by another user
|
||||||
existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first()
|
existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first()
|
||||||
@@ -197,6 +203,11 @@ async def update_user_profile(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||||
)
|
)
|
||||||
|
if contains_profanity(display_name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Отображаемое имя содержит запрещённые слова"
|
||||||
|
)
|
||||||
|
|
||||||
current_user.display_name = display_name
|
current_user.display_name = display_name
|
||||||
updated = True
|
updated = True
|
||||||
|
|||||||
@@ -145,10 +145,28 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
|
|||||||
if action == "auto_suspension_public_spam":
|
if action == "auto_suspension_public_spam":
|
||||||
lines = [
|
lines = [
|
||||||
f"Automatic suspension triggered for {_format_user(fields)}",
|
f"Automatic suspension triggered for {_format_user(fields)}",
|
||||||
f"Similar messages detected: {fields.get('similar_messages')}",
|
|
||||||
]
|
]
|
||||||
|
match_type = fields.get("match_type")
|
||||||
|
if match_type:
|
||||||
|
lines.append(f"Match type: {match_type}")
|
||||||
|
similar = fields.get("similar_messages")
|
||||||
|
occurrences = fields.get("occurrences")
|
||||||
|
if similar:
|
||||||
|
lines.append(f"Similar messages detected: {similar}")
|
||||||
|
if occurrences and not similar:
|
||||||
|
lines.append(f"Occurrences: {occurrences}")
|
||||||
if fields.get("window_seconds"):
|
if fields.get("window_seconds"):
|
||||||
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
||||||
|
if fields.get("reason"):
|
||||||
|
lines.append(f"Reason: {fields['reason']}")
|
||||||
|
return lines
|
||||||
|
if action == "auto_suspension_public_burst":
|
||||||
|
lines = [
|
||||||
|
f"Automatic suspension triggered for {_format_user(fields)}",
|
||||||
|
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
|
||||||
|
]
|
||||||
|
if fields.get("reason"):
|
||||||
|
lines.append(f"Reason: {fields['reason']}")
|
||||||
return lines
|
return lines
|
||||||
if action == "public_message_burst":
|
if action == "public_message_burst":
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ _CUSTOM_RU_TERMS: Set[str] = {
|
|||||||
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
||||||
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
|
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
|
||||||
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
|
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
|
||||||
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "урод",
|
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
||||||
|
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
|
||||||
|
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
||||||
}
|
}
|
||||||
|
|
||||||
_ADULT_TERMS: Set[str] = {
|
_ADULT_TERMS: Set[str] = {
|
||||||
@@ -33,8 +35,167 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
|||||||
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||||
re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE),
|
re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE),
|
||||||
re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE),
|
re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_LEET_MAP = {
|
||||||
|
"0": "о",
|
||||||
|
"o": "о",
|
||||||
|
"о": "о",
|
||||||
|
"a": "а",
|
||||||
|
"@": "а",
|
||||||
|
"4": "а",
|
||||||
|
"а": "а",
|
||||||
|
"e": "е",
|
||||||
|
"ё": "е",
|
||||||
|
"3": "е",
|
||||||
|
"c": "с",
|
||||||
|
"s": "с",
|
||||||
|
"с": "с",
|
||||||
|
"x": "х",
|
||||||
|
"х": "х",
|
||||||
|
"t": "т",
|
||||||
|
"т": "т",
|
||||||
|
"p": "п",
|
||||||
|
"п": "п",
|
||||||
|
"n": "н",
|
||||||
|
"н": "н",
|
||||||
|
"m": "м",
|
||||||
|
"м": "м",
|
||||||
|
"y": "у",
|
||||||
|
"u": "у",
|
||||||
|
"у": "у",
|
||||||
|
"g": "г",
|
||||||
|
"г": "г",
|
||||||
|
"v": "в",
|
||||||
|
"в": "в",
|
||||||
|
"f": "ф",
|
||||||
|
"ф": "ф",
|
||||||
|
"i": "и",
|
||||||
|
"1": "и",
|
||||||
|
"и": "и",
|
||||||
|
}
|
||||||
|
|
||||||
|
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
||||||
|
("generic", ("айфон", "топ")),
|
||||||
|
("generic", ("самсунг", "говно")),
|
||||||
|
)
|
||||||
|
|
||||||
|
_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
|
||||||
|
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_char(ch: str) -> str:
|
||||||
|
lower = ch.lower()
|
||||||
|
return _LEET_MAP.get(lower, lower)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_token(token: str) -> str:
|
||||||
|
return "".join(_normalize_char(ch) for ch in token)
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
|
||||||
|
tokens: List[Tuple[int, int, str]] = []
|
||||||
|
start: int | None = None
|
||||||
|
buffer: List[str] = []
|
||||||
|
|
||||||
|
for idx, ch in enumerate(text):
|
||||||
|
if ch.isalnum() or ch in {"@", "#", "_"}:
|
||||||
|
if start is None:
|
||||||
|
start = idx
|
||||||
|
buffer.append(ch)
|
||||||
|
else:
|
||||||
|
if buffer and start is not None:
|
||||||
|
token_raw = "".join(buffer)
|
||||||
|
tokens.append((start, idx, _normalize_token(token_raw)))
|
||||||
|
buffer.clear()
|
||||||
|
start = None
|
||||||
|
if buffer and start is not None:
|
||||||
|
token_raw = "".join(buffer)
|
||||||
|
tokens.append((start, len(text), _normalize_token(token_raw)))
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool:
|
||||||
|
if a == b:
|
||||||
|
return True
|
||||||
|
if max_distance <= 0:
|
||||||
|
return False
|
||||||
|
if abs(len(a) - len(b)) > max_distance:
|
||||||
|
return False
|
||||||
|
|
||||||
|
previous = list(range(len(b) + 1))
|
||||||
|
for i, ca in enumerate(a, 1):
|
||||||
|
current = [i]
|
||||||
|
best = current[0]
|
||||||
|
for j, cb in enumerate(b, 1):
|
||||||
|
insert_cost = current[j - 1] + 1
|
||||||
|
delete_cost = previous[j] + 1
|
||||||
|
replace_cost = previous[j - 1] + (0 if ca == cb else 1)
|
||||||
|
cost = min(insert_cost, delete_cost, replace_cost)
|
||||||
|
current.append(cost)
|
||||||
|
if cost < best:
|
||||||
|
best = cost
|
||||||
|
if best > max_distance:
|
||||||
|
return False
|
||||||
|
previous = current
|
||||||
|
return previous[-1] <= max_distance
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sensitive_phrases() -> List[Tuple[str, ...]]:
|
||||||
|
if not _SENSITIVE_PHRASE_PATH.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8"))
|
||||||
|
phrases: List[Tuple[str, ...]] = []
|
||||||
|
if isinstance(payload, list):
|
||||||
|
for entry in payload:
|
||||||
|
if isinstance(entry, list) and entry:
|
||||||
|
normalized = tuple(str(part).strip() for part in entry if str(part).strip())
|
||||||
|
if normalized:
|
||||||
|
phrases.append(normalized)
|
||||||
|
return phrases
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]:
|
||||||
|
if group not in _PHRASE_CACHE:
|
||||||
|
base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group]
|
||||||
|
if group == "sensitive":
|
||||||
|
base.extend(_load_sensitive_phrases())
|
||||||
|
_PHRASE_CACHE[group] = tuple(
|
||||||
|
tuple(_normalize_token(part) for part in phrase)
|
||||||
|
for phrase in base
|
||||||
|
)
|
||||||
|
return _PHRASE_CACHE[group]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]:
|
||||||
|
tokens = _tokenize_with_spans(text)
|
||||||
|
if not tokens:
|
||||||
|
return []
|
||||||
|
|
||||||
|
spans: List[Tuple[int, int]] = []
|
||||||
|
normalized_phrases = _get_phrases(group)
|
||||||
|
|
||||||
|
for index in range(len(tokens)):
|
||||||
|
for phrase in normalized_phrases:
|
||||||
|
if index + len(phrase) > len(tokens):
|
||||||
|
continue
|
||||||
|
matches = True
|
||||||
|
for offset, target in enumerate(phrase):
|
||||||
|
token = tokens[index + offset][2]
|
||||||
|
if not _edit_distance_limited(token, target):
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
if matches:
|
||||||
|
span_start = tokens[index][0]
|
||||||
|
span_end = tokens[index + len(phrase) - 1][1]
|
||||||
|
spans.append((span_start, span_end))
|
||||||
|
return spans
|
||||||
|
|
||||||
_dictionary_lock = RLock()
|
_dictionary_lock = RLock()
|
||||||
_blocklist_signature: Tuple[str, ...] | None = None
|
_blocklist_signature: Tuple[str, ...] | None = None
|
||||||
_profanity = Profanity()
|
_profanity = Profanity()
|
||||||
@@ -96,7 +257,11 @@ def _apply_phrase_filters(text: str) -> str:
|
|||||||
match = pattern.search(result)
|
match = pattern.search(result)
|
||||||
if not match:
|
if not match:
|
||||||
break
|
break
|
||||||
result = result[:match.start()] + ("\\*" * (match.end() - match.start())) + result[match.end():]
|
result = result[:match.start()] + ("*" * (match.end() - match.start())) + result[match.end():]
|
||||||
|
|
||||||
|
for start, end in sorted(_find_fuzzy_phrase_spans(text, "generic"), reverse=True):
|
||||||
|
result = result[:start] + ("*" * (end - start)) + result[end:]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -109,6 +274,27 @@ def censor_text(text: str) -> str:
|
|||||||
return _profanity.censor(preprocessed, censor_char="\\*")
|
return _profanity.censor(preprocessed, censor_char="\\*")
|
||||||
|
|
||||||
|
|
||||||
|
def contains_profanity(text: str) -> bool:
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_rebuild_dictionary()
|
||||||
|
for pattern in _PHRASE_PATTERNS:
|
||||||
|
if pattern.search(text):
|
||||||
|
return True
|
||||||
|
if _find_fuzzy_phrase_spans(text, "generic"):
|
||||||
|
return True
|
||||||
|
return _profanity.contains_profanity(text)
|
||||||
|
|
||||||
|
|
||||||
|
def contains_sensitive_phrase(text: str) -> bool:
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
if _find_fuzzy_phrase_spans(text, "sensitive"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_blocklist() -> List[str]:
|
def get_blocklist() -> List[str]:
|
||||||
with _dictionary_lock:
|
with _dictionary_lock:
|
||||||
return sorted(_load_blocklist())
|
return sorted(_load_blocklist())
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ coverage
|
|||||||
test_results/
|
test_results/
|
||||||
out
|
out
|
||||||
|
|
||||||
data
|
data
|
||||||
|
logs
|
||||||
Reference in New Issue
Block a user