Add better profanity filter, better logging, admin CLI

This commit is contained in:
2025-11-08 20:05:21 +03:00
Unverified
parent 07639a144f
commit 89daf2718b
18 changed files with 1699 additions and 95 deletions
+134 -8
View File
@@ -1,4 +1,6 @@
from datetime import datetime
from collections import defaultdict, deque
import time
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
@@ -8,14 +10,33 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
from utils import create_token, get_password_hash, verify_password
from validation import is_valid_password, is_valid_username, is_valid_display_name
import os
from security.audit import log_security
router = APIRouter()
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
_FAILED_ATTEMPT_THRESHOLD = 5
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
def _record_failed_login(identifier: str) -> bool:
now = time.time()
attempts = _failed_login_attempts[identifier]
attempts.append(now)
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
attempts.popleft()
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
def _reset_failed_logins(identifier: str) -> None:
_failed_login_attempts.pop(identifier, None)
def convert_user(user: User) -> dict:
return {
"id": user.id,
@@ -43,18 +64,51 @@ def check_auth(current_user: User = Depends(get_current_user)):
@router.post("/login")
def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = None):
user = db.query(User).filter(User.username == request.username.strip()).first()
def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)):
username = request.username.strip()
x_forwarded_for = http.headers.get("x-forwarded-for") if http else None
client_ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else (http.client.host if http and http.client else None)
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(request.password.strip(), user.password_hash):
log_security(
"login_failed",
severity="warning",
username=username,
ip=client_ip,
reason="invalid_credentials",
)
identifiers = [f"user:{username}"]
if client_ip:
identifiers.append(f"ip:{client_ip}")
suspicious = False
for identifier in identifiers:
if _record_failed_login(identifier):
suspicious = True
if suspicious:
total_failures = {
identifier: len(_failed_login_attempts.get(identifier, []))
for identifier in identifiers
}
log_security(
"auth_bruteforce_detected",
severity="warning",
username=username,
ip=client_ip,
failures=total_failures,
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
)
raise HTTPException(
status_code=401,
detail="Неверное имя пользователя или пароль"
)
# Create device session and embed into JWT
raw_ua = http.headers.get("user-agent") if http else None
device_name = http.headers.get("x-device-name") if http else None
raw_ua = http.headers.get("user-agent")
device_name = http.headers.get("x-device-name")
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
@@ -82,6 +136,23 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
token = create_token(user.id, user.username, session_id)
identifiers = [f"user:{username}"]
if client_ip:
identifiers.append(f"ip:{client_ip}")
for identifier in identifiers:
_reset_failed_logins(identifier)
log_security(
"login_success",
username=user.username,
user_id=user.id,
ip=client_ip,
session_id=session_id,
device=device.device_type,
os=device.os_name,
browser=device.browser_name,
)
return {
"status": "success",
"message": "Login successful",
@@ -91,11 +162,12 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
@router.post("/register")
def register(request: RegisterRequest, db: Session = Depends(get_db), http: Request = None):
def register(request: RegisterRequest, http: Request, db: Session = Depends(get_db)):
username = request.username.strip()
display_name = request.display_name.strip()
password = request.password.strip()
confirm_password = request.confirm_password.strip()
client_ip = http.client.host if http.client else None
# Determine if owner already exists
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
@@ -165,8 +237,8 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
db.refresh(new_user)
# Create initial device session
raw_ua = http.headers.get("user-agent") if http else None
device_name = http.headers.get("x-device-name") if http else None
raw_ua = http.headers.get("user-agent")
device_name = http.headers.get("x-device-name")
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
device = DeviceSession(
@@ -190,6 +262,24 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
token = create_token(new_user.id, new_user.username, session_id)
os_name = ua.os.family or "Unknown OS"
if ua.os.version_string:
os_name = f"{os_name} {ua.os.version_string}"
browser_name = ua.browser.family or "Unknown browser"
if ua.browser.version_string:
browser_name = f"{browser_name} {ua.browser.version_string}"
user_agent_summary = f"{os_name}, {browser_name}"
log_security(
"registration_success",
username=new_user.username,
display_name=new_user.display_name,
user_id=new_user.id,
ip=client_ip,
user_agent=user_agent_summary,
owner=is_owner,
)
return {
"status": "success",
"message": "Регистрация прошла успешно",
@@ -264,10 +354,20 @@ def delete_user_as_owner(
db.delete(user)
db.commit()
log_security(
"admin_delete_user",
severity="warning",
actor=current_user.username,
actor_id=current_user.id,
target_username=user.username,
target_id=user.id,
)
return {"status": "success", "deleted_user_id": user_id}
@router.get("/logout")
def logout(
http: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
@@ -285,6 +385,15 @@ def logout(
current_user.last_seen = datetime.now()
db.commit()
client_ip = http.client.host if http.client else None
log_security(
"logout",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
session_id=payload.get("session_id") if payload else None,
)
return {
"status": "success",
"message": "Logged out successfully"
@@ -294,6 +403,7 @@ def logout(
@router.post("/change-password")
def change_password(
request: ChangePasswordRequest,
http: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
@@ -319,6 +429,15 @@ def change_password(
).update({DeviceSession.revoked: True})
db.commit()
client_ip = http.client.host if http.client else None
log_security(
"password_changed",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
logout_others=bool(request.logoutAllExceptCurrent),
)
return {"status": "success"}
@@ -430,6 +549,13 @@ async def delete_account(
await _delete_user_data(current_user, db)
log_security(
"self_delete_account",
severity="warning",
user_id=current_user.id,
username=current_user.username,
)
return {
"status": "success",
"message": "Account deleted successfully"
+283 -55
View File
@@ -1,4 +1,5 @@
from datetime import datetime
import html
import logging
from pathlib import Path
import os
@@ -6,6 +7,10 @@ import re
import uuid
import asyncio
import time
from collections import defaultdict, deque
from difflib import SequenceMatcher
from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials
@@ -19,6 +24,8 @@ from PIL import Image
import io
import json
from better_profanity import profanity as _bp
from security.audit import log_access, log_dm, log_public_chat, log_security
from security.profanity import censor_text
router = APIRouter()
logger = logging.getLogger("uvicorn.error")
@@ -32,6 +39,68 @@ FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
_SPAM_WINDOW_SECONDS = 45
_SPAM_SIMILARITY_THRESHOLD = 0.88
_SPAM_MESSAGE_LIMIT = 5
_BURST_WINDOW_SECONDS = 30
_BURST_COUNT_THRESHOLD = 20
_recent_message_cache: dict[int, deque[tuple[str, float]]] = defaultdict(deque)
_message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
_burst_last_logged: dict[int, float] = {}
def _monitor_public_message_activity(user: User, content: str, db: Session) -> None:
now = time.time()
# Rate tracking for burst detection
rate_bucket = _message_rate_cache[user.id]
rate_bucket.append(now)
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
rate_bucket.popleft()
if len(rate_bucket) >= _BURST_COUNT_THRESHOLD:
last_logged = _burst_last_logged.get(user.id)
if not last_logged or now - last_logged > _BURST_WINDOW_SECONDS:
log_security(
"public_message_burst",
severity="warning",
user_id=user.id,
username=user.username,
count=len(rate_bucket),
window_seconds=_BURST_WINDOW_SECONDS,
)
_burst_last_logged[user.id] = now
# Similarity-based spam detection
history = _recent_message_cache[user.id]
history.append((content, now))
while history and now - history[0][1] > _SPAM_WINDOW_SECONDS:
history.popleft()
similar_messages = sum(
1 for previous_content, _ in history
if SequenceMatcher(None, content, previous_content).ratio() >= _SPAM_SIMILARITY_THRESHOLD
)
if similar_messages >= _SPAM_MESSAGE_LIMIT and not user.suspended and user.id != 1:
reason = "Automatic suspension: repeated similar public messages"
user.suspended = True
user.suspension_reason = reason
db.commit()
log_security(
"auto_suspension_public_spam",
severity="warning",
user_id=user.id,
username=user.username,
similar_messages=similar_messages,
window_seconds=_SPAM_WINDOW_SECONDS,
)
try:
asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason))
except Exception:
pass
def convert_message(msg: Message) -> dict:
# Group reactions by emoji
@@ -138,46 +207,6 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
]
}
# для тех кто читает этот код я эти маты не писал
# мат писал ии а я сам не матерюсь))
# - denis0001-dev
_RU_EXTRA = [
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
"долбоёб", "долбоеб", "дебил"
]
_bp.load_censor_words()
_bp.add_censor_words(_RU_EXTRA)
# Additional phrase-level filters (case-insensitive)
_PHRASE_PATTERNS: list[re.Pattern] = [
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
]
def _mask_span(text: str, start: int, end: int) -> str:
return text[:start] + ("\\*" * (end - start)) + text[end:]
def _apply_phrase_filters(text: str) -> str:
result = text
for pattern in _PHRASE_PATTERNS:
# Replace all occurrences; iterate until no more matches to avoid overlapping issues
while True:
m = pattern.search(result)
if not m:
break
result = _mask_span(result, m.start(), m.end())
return result
def filter_profanity(text: str) -> str:
preprocessed = _apply_phrase_filters(text)
return _bp.censor(preprocessed, censor_char="\\*")
@router.post("/send_message")
async def send_message(
request: SendMessageRequest | None = None,
@@ -204,23 +233,26 @@ async def send_message(
if not original_message:
raise HTTPException(status_code=404, detail="Original message not found")
if not request.content.strip():
raw_content = request.content.strip()
if not raw_content:
raise HTTPException(
status_code=400,
detail="No content provided"
)
# Apply profanity filter before storing
filtered_content = filter_profanity(request.content.strip())
filtered_content = censor_text(raw_content)
escaped_content = html.escape(filtered_content, quote=False)
if len(filtered_content) > 4096:
if len(escaped_content) > 4096:
raise HTTPException(
status_code=400,
detail="Message too long"
)
new_message = Message(
content=filtered_content,
content=escaped_content,
user_id=current_user.id,
reply_to_id=request.reply_to_id,
timestamp=datetime.now()
@@ -293,7 +325,6 @@ async def send_message(
# Realtime broadcast for HTTP uploads as well
try:
from .messaging import messagingManager # self import safe here
await messagingManager.broadcast({
"type": "newMessage",
"data": convert_message(new_message)
@@ -301,7 +332,22 @@ async def send_message(
except Exception:
pass
return {"status": "success", "message": convert_message(new_message)}
_monitor_public_message_activity(current_user, filtered_content, db)
message_payload = convert_message(new_message)
log_public_chat(
"message_created",
message_id=new_message.id,
user_id=current_user.id,
username=current_user.username,
reply_to=new_message.reply_to_id,
attachments=len(new_message.files or []),
length=len(new_message.content),
suspended=current_user.suspended,
content=new_message.content,
)
return {"status": "success", "message": message_payload}
@router.get("/get_messages")
@@ -404,6 +450,7 @@ async def dm_send(
)
db.add(df)
db.commit()
db.refresh(env)
# Send push notification for DM
try:
@@ -433,6 +480,16 @@ async def dm_send(
except Exception:
pass
log_dm(
"message_sent",
dm_envelope_id=env.id,
sender_id=current_user.id,
sender_username=current_user.username,
recipient_id=env.recipient_id,
attachment_count=len(env.files or []),
reply_to=env.reply_to_id,
)
return {"status": "ok", "id": env.id}
def convert_envelopes(envs: list[DMEnvelope]):
@@ -531,15 +588,35 @@ async def edit_message(
raise HTTPException(status_code=404, detail="Message not found")
if message.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only edit your own messages")
if not request.content.strip():
raw_content = request.content.strip()
if not raw_content:
raise HTTPException(status_code=400, detail="Message content cannot be empty")
message.content = request.content.strip()
original_content = message.content
sanitized_content = censor_text(raw_content)
escaped_content = html.escape(sanitized_content, quote=False)
if len(escaped_content) > 4096:
raise HTTPException(status_code=400, detail="Message too long")
message.content = escaped_content
message.is_edited = True
db.commit()
db.refresh(message)
return {"status": "success", "message": convert_message(message)}
payload = convert_message(message)
log_public_chat(
"message_edited",
message_id=message.id,
user_id=current_user.id,
username=current_user.username,
reply_to=message.reply_to_id,
content=message.content,
previous_content=original_content,
)
return {"status": "success", "message": payload}
@router.delete("/delete_message/{message_id}")
@@ -557,9 +634,19 @@ async def delete_message(
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only delete your own messages")
original_content = message.content
db.delete(message)
db.commit()
log_public_chat(
"message_deleted",
message_id=message_id,
actor_id=current_user.id,
actor_username=current_user.username,
original_author_id=message.user_id,
content=original_content,
)
return {"status": "success", "message_id": message_id}
@@ -600,9 +687,10 @@ async def add_reaction(
# Refresh message to get updated reactions
db.refresh(message)
message_data = convert_message(message)
# Broadcast reaction update
try:
from .messaging import messagingManager
await messagingManager.broadcast({
"type": "reactionUpdate",
"data": {
@@ -611,13 +699,22 @@ async def add_reaction(
"action": action,
"user_id": current_user.id,
"username": current_user.username,
"reactions": convert_message(message)["reactions"]
"reactions": message_data["reactions"]
}
})
except Exception:
pass
return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]}
log_public_chat(
"reaction_update",
message_id=request.message_id,
user_id=current_user.id,
username=current_user.username,
action=action,
emoji=request.emoji,
)
return {"status": "success", "action": action, "reactions": message_data["reactions"]}
@router.post("/dm/add_reaction")
@@ -661,6 +758,8 @@ async def add_dm_reaction(
# Refresh envelope to get updated reactions
db.refresh(envelope)
envelope_data = convert_dm_envelope(envelope)
# Broadcast reaction update to both participants
try:
await messagingManager.broadcast({
@@ -671,13 +770,22 @@ async def add_dm_reaction(
"action": action,
"user_id": current_user.id,
"username": current_user.username,
"reactions": convert_dm_envelope(envelope)["reactions"]
"reactions": envelope_data["reactions"]
}
})
except Exception:
pass
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
log_dm(
"reaction_update",
dm_envelope_id=request.dm_envelope_id,
user_id=current_user.id,
username=current_user.username,
action=action,
emoji=request.emoji,
)
return {"status": "success", "action": action, "reactions": envelope_data["reactions"]}
class MessaggingSocketManager:
@@ -697,13 +805,37 @@ class MessaggingSocketManager:
# Initialize subscriptions for this connection
self.ws_subscriptions[websocket] = set()
ws_path = getattr(getattr(websocket, "url", None), "path", None)
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
ws_path = websocket.scope.get("path")
ws_path = ws_path or "unknown"
headers = {}
if isinstance(getattr(websocket, "scope", None), dict):
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
xff = headers.get("x-forwarded-for")
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
def _log_ws(event: str, user: User | None, **extra: Any) -> None:
log_access(
"ws_event",
path=ws_path,
event=event,
user=user.username if user else None,
user_id=user.id if user else None,
ip=client_ip,
**extra,
)
while True:
data = await websocket.receive_json()
type = data["type"]
def get_current_user_inner() -> User | None:
if data["credentials"]:
dummy_request = SimpleNamespace()
dummy_request.state = SimpleNamespace()
return get_current_user(
dummy_request,
HTTPAuthorizationCredentials(
scheme=data["credentials"]["scheme"],
credentials=data["credentials"]["credentials"]
@@ -714,6 +846,7 @@ class MessaggingSocketManager:
return None
if type == "ping":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if current_user:
@@ -737,6 +870,7 @@ class MessaggingSocketManager:
}
}
})
_log_ws("ping_error", current_user)
except HTTPException:
await websocket.send_json({
"type": "ping",
@@ -748,8 +882,11 @@ class MessaggingSocketManager:
}
}
})
_log_ws("ping_error", current_user)
await websocket.send_json({"type": "ping", "data": {"status": "success"}})
_log_ws("ping", current_user)
elif type == "getMessages":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -757,9 +894,12 @@ class MessaggingSocketManager:
self.user_by_ws[websocket] = current_user.id
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
_log_ws("getMessages", current_user)
except HTTPException as e:
_log_ws("getMessages_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "sendMessage":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -775,9 +915,12 @@ class MessaggingSocketManager:
})
await websocket.send_json({"type": type, "data": response})
_log_ws("sendMessage", current_user, message_id=response["message"]["id"])
except HTTPException as e:
_log_ws("sendMessage_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "dmSend":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -827,9 +970,21 @@ class MessaggingSocketManager:
await self.send_to_user(env.recipient_id, payload);
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
await self.send_to_user(env.sender_id, payload);
_log_ws("dmSend", current_user, dm_envelope_id=env.id, recipient_id=env.recipient_id)
log_dm(
"message_sent_ws",
dm_envelope_id=env.id,
sender_id=current_user.id,
sender_username=current_user.username,
recipient_id=env.recipient_id,
reply_to=env.reply_to_id,
)
except HTTPException as e:
_log_ws("dmSend_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "editMessage":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -845,9 +1000,12 @@ class MessaggingSocketManager:
})
await websocket.send_json({"type": type, "data": response})
_log_ws("editMessage", current_user, message_id=message_id)
except HTTPException as e:
_log_ws("editMessage_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "dmEdit":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -887,9 +1045,19 @@ class MessaggingSocketManager:
await self.send_to_user(env.recipient_id, payload_ws)
await self.send_to_user(env.sender_id, payload_ws)
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
_log_ws("dmEdit", current_user, dm_envelope_id=env.id)
log_dm(
"message_edited",
dm_envelope_id=env.id,
user_id=current_user.id,
username=current_user.username,
)
except HTTPException as e:
_log_ws("dmEdit_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "dmDelete":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -917,9 +1085,20 @@ class MessaggingSocketManager:
await self.send_to_user(env.recipient_id, payload_ws)
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}})
await self.send_to_user(env.sender_id, payload_ws)
_log_ws("dmDelete", current_user, dm_envelope_id=env_id)
log_dm(
"message_deleted",
dm_envelope_id=env_id,
user_id=current_user.id,
username=current_user.username,
recipient_id=env.recipient_id,
)
except HTTPException as e:
_log_ws("dmDelete_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "deleteMessage":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -933,9 +1112,12 @@ class MessaggingSocketManager:
})
await websocket.send_json({"type": type, "data": response})
_log_ws("deleteMessage", current_user, message_id=message_id)
except HTTPException as e:
_log_ws("deleteMessage_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "addReaction":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -963,9 +1145,12 @@ class MessaggingSocketManager:
})
await websocket.send_json({"type": type, "data": response})
_log_ws("addReaction", current_user, message_id=request_data["message_id"], emoji=request_data["emoji"], action=response["action"])
except HTTPException as e:
_log_ws("addReaction_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "addDmReaction":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -993,10 +1178,13 @@ class MessaggingSocketManager:
})
await websocket.send_json({"type": type, "data": response})
_log_ws("addDmReaction", current_user, dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"], action=response["action"])
except HTTPException as e:
_log_ws("addDmReaction_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "call_signaling":
# Forward WebRTC signaling between peers
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -1019,10 +1207,13 @@ class MessaggingSocketManager:
# Optional ack
await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}})
_log_ws("call_signaling", current_user, to_user_id=to_user_id)
except HTTPException as e:
_log_ws("call_signaling_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
elif type == "call_video_toggle":
# Forward video toggle state between peers
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -1049,9 +1240,13 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}})
except HTTPException as e:
_log_ws("call_video_toggle_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
else:
_log_ws("call_video_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False))
elif type == "call_screen_share_toggle":
# Forward screen share toggle state between peers
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -1078,8 +1273,12 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}})
except HTTPException as e:
_log_ws("call_screen_share_toggle_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
else:
_log_ws("call_screen_share_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False))
elif type == "subscribeStatus":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -1096,7 +1295,7 @@ class MessaggingSocketManager:
"data": {
"userId": user_id_to_subscribe,
"online": target_user.online,
"lastSeen": target_user.last_seen.isoformat()
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
}
})
else:
@@ -1105,8 +1304,12 @@ class MessaggingSocketManager:
"data": {"status": "error", "error": "User not found"}
})
except HTTPException as e:
_log_ws("subscribeStatus_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
else:
_log_ws("subscribeStatus", current_user, target_user_id=user_id_to_subscribe)
elif type == "unsubscribeStatus":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -1117,8 +1320,12 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}})
except HTTPException as e:
_log_ws("unsubscribeStatus_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
else:
_log_ws("unsubscribeStatus", current_user, target_user_id=user_id_to_unsubscribe)
elif type == "typing":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -1137,8 +1344,12 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "typing", "data": {"status": "ok"}})
except HTTPException as e:
_log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
else:
_log_ws("typing", current_user)
elif type == "stopTyping":
current_user: User | None = None
try:
current_user = get_current_user_inner()
if not current_user:
@@ -1158,7 +1369,10 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}})
except HTTPException as e:
_log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e)
else:
_log_ws("stopTyping", current_user)
elif type == "dmTyping":
try:
current_user = get_current_user_inner()
@@ -1219,11 +1433,25 @@ class MessaggingSocketManager:
async def connect(self, websocket: WebSocket, db: Session):
await websocket.accept()
client_ip = websocket.client.host if websocket.client else None
log_access(
"ws_connect",
path=str(websocket.url.path),
ip=client_ip,
)
self.connections.append(websocket)
try:
await self.handle_connection(websocket, db)
except WebSocketDisconnect as e:
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
log_access(
"ws_disconnect",
severity="warning" if e.code != 1000 else "info",
path=str(websocket.url.path),
ip=client_ip,
code=e.code,
reason=e.reason,
)
finally:
# Cleanup connection
self.connections.remove(websocket)
+60
View File
@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import List
from constants import OWNER_USERNAME
from dependencies import get_current_user
from models import User
from security.audit import log_security
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
class BlocklistUpdateRequest(BaseModel):
words: List[str] = Field(default_factory=list, min_items=1)
router = APIRouter(prefix="/moderation", tags=["moderation"])
def _ensure_owner(user: User) -> None:
if user.username != OWNER_USERNAME:
raise HTTPException(status_code=403, detail="Only owner can perform this action")
@router.get("/blocklist")
def list_blocklist(current_user: User = Depends(get_current_user)):
_ensure_owner(current_user)
return {"words": get_blocklist()}
@router.post("/blocklist")
def append_blocklist(
request: BlocklistUpdateRequest,
current_user: User = Depends(get_current_user)
):
_ensure_owner(current_user)
added, updated = add_to_blocklist(request.words)
log_security(
"blocklist_add",
actor=current_user.username,
actor_id=current_user.id,
added=added,
)
return {"added": added, "words": updated}
@router.delete("/blocklist")
def delete_from_blocklist(
request: BlocklistUpdateRequest,
current_user: User = Depends(get_current_user)
):
_ensure_owner(current_user)
removed, updated = remove_from_blocklist(request.words)
log_security(
"blocklist_remove",
actor=current_user.username,
actor_id=current_user.id,
removed=removed,
)
return {"removed": removed, "words": updated}
+103 -10
View File
@@ -3,7 +3,6 @@ import re
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
from PIL import Image
import os
import uuid
@@ -15,9 +14,18 @@ from pydantic import BaseModel
from validation import is_valid_username, is_valid_display_name
from similarity import is_user_similar_to_verified
from .messaging import messagingManager
from security.audit import log_security
router = APIRouter()
def _ensure_owner_unsuspended(user: User | None, db: Session):
if user and user.id == 1 and user.suspended:
user.suspended = False
user.suspension_reason = None
db.commit()
db.refresh(user)
# Request models
class UpdateProfileRequest(BaseModel):
username: str | None = None
@@ -104,15 +112,53 @@ async def get_user_profile(
"""
Get current user's profile information
"""
_ensure_owner_unsuspended(current_user, db)
return UserProfileResponse(
id=current_user.id,
username=current_user.username,
display_name=current_user.display_name,
profile_picture=current_user.profile_picture,
bio=current_user.bio,
online=current_user.online,
last_seen=current_user.last_seen,
created_at=current_user.created_at,
verified=current_user.verified,
suspended=current_user.suspended or False,
suspension_reason=current_user.suspension_reason,
deleted=current_user.deleted or False,
)
@router.get("/user/list")
async def list_users(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
if current_user.id != 1:
raise HTTPException(status_code=403, detail="Only admin can list users")
_ensure_owner_unsuspended(current_user, db)
users = db.query(User).order_by(User.username.asc()).all()
return {
"id": current_user.id,
"username": current_user.username,
"display_name": current_user.display_name,
"profile_picture": current_user.profile_picture,
"bio": current_user.bio,
"online": current_user.online,
"last_seen": current_user.last_seen,
"created_at": current_user.created_at
"users": [
UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=user.deleted or False,
).model_dump()
for user in users
]
}
@router.put("/user/profile")
@@ -214,6 +260,8 @@ async def get_user_by_username(
if not user:
raise HTTPException(status_code=404, detail="User not found")
_ensure_owner_unsuspended(user, db)
return UserProfileResponse(
id=user.id,
@@ -223,7 +271,11 @@ async def get_user_by_username(
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=user.deleted or False,
)
@router.get("/user/id/{user_id}")
@@ -238,6 +290,8 @@ async def get_user_by_id(
if not user:
raise HTTPException(status_code=404, detail="User not found")
_ensure_owner_unsuspended(user, db)
# Handle deleted users
if user.deleted:
@@ -293,6 +347,15 @@ async def verify_user(
target_user.verified = not target_user.verified
db.commit()
log_security(
"admin_verify_toggle",
actor=current_user.username,
actor_id=current_user.id,
target_username=target_user.username,
target_id=target_user.id,
verified=target_user.verified,
)
return {
"verified": target_user.verified,
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
@@ -363,6 +426,15 @@ async def suspend_user(
target_user.suspension_reason = request.reason
db.commit()
log_security(
"admin_suspend_user",
actor=current_user.username,
actor_id=current_user.id,
target_username=target_user.username,
target_id=target_user.id,
reason=request.reason,
)
# Send WebSocket suspension message
try:
await messagingManager.send_suspension_to_user(user_id, request.reason)
@@ -399,6 +471,14 @@ async def unsuspend_user(
target_user.suspension_reason = None
db.commit()
log_security(
"admin_unsuspend_user",
actor=current_user.username,
actor_id=current_user.id,
target_username=target_user.username,
target_id=target_user.id,
)
return {
"status": "success",
"message": f"User {target_user.username} has been unsuspended"
@@ -426,9 +506,22 @@ async def delete_user(
if target_user.id == 1:
raise HTTPException(status_code=400, detail="Cannot delete admin account")
snapshot_username = target_user.username
snapshot_display_name = target_user.display_name
from .account import _delete_user_data
await _delete_user_data(target_user, db)
log_security(
"admin_delete_user",
severity="warning",
actor=current_user.username,
actor_id=current_user.id,
target_username=snapshot_username,
target_display_name=snapshot_display_name,
target_id=target_user.id,
)
return {
"status": "success",
"message": f"User {target_user.username} has been deleted"