mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Add better profanity filter, better logging, admin CLI
This commit is contained in:
+134
-8
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user