Merge branch 'fix/security'

This commit is contained in:
2025-11-09 16:13:39 +03:00
Unverified
19 changed files with 1970 additions and 96 deletions
+9
View File
@@ -0,0 +1,9 @@
# Security Follow-ups
- Integrate log shipping/alerting (e.g. Loki or ELK) so events from `backend/logs/*.log` raise actionable notifications instead of remaining on disk.
- Add automated review of `security.log` for repeated `auth_bruteforce_detected` and burst messaging entries; trigger temporary IP bans or captcha challenges when thresholds are exceeded.
- Extend profanity filtering tests to cover dynamic blocklist updates and multi-language phrases; add regression suite to ensure adult-content words remain blocked.
- Implement DM spam heuristics similar to public chat (rate limiting, reaction abuse detection) and log attempts that target users who blocked the sender.
- Harden WebSocket session handling by recycling DB sessions per request or adopting async session factories to keep long-lived connections from retaining database handles indefinitely.
- Wire the new moderator blocklist endpoints into an authenticated UI workflow so operators can manage entries without shell access, and audit every change with responsible operator metadata.
+388
View File
@@ -0,0 +1,388 @@
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import os
import shlex
import sys
from getpass import getpass
from typing import Iterable, List, Optional, Tuple
import readline
import httpx
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
class CLIError(Exception):
"""Generic CLI error with a human-readable message."""
def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
return hmac.new(salt, ikm, hashlib.sha256).digest()
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
blocks: list[bytes] = []
previous = b""
counter = 1
while len(b"".join(blocks)) < length:
previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest()
blocks.append(previous)
counter += 1
return b"".join(blocks)[:length]
def derive_auth_secret(username: str, password: str) -> str:
salt = f"fromchat.user:{username}".encode("utf-8")
prk = _hkdf_extract(salt, password.encode("utf-8"))
okm = _hkdf_expand(prk, b"auth-secret", 32)
return base64.b64encode(okm).decode("utf-8")
def _read_single_key() -> str:
try: # Windows
import msvcrt # type: ignore
ch = msvcrt.getch()
return ch.decode("utf-8", errors="ignore").lower()
except ImportError:
import termios
import tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return ch.lower()
class AdminCLI:
def __init__(self, api_url: str) -> None:
self.console = Console()
self.api_url = api_url.rstrip("/")
self.client = httpx.Client(base_url=self.api_url, timeout=30.0)
self.username: Optional[str] = None
self.token: Optional[str] = None
# --------------------------- HTTP helpers --------------------------- #
def _auth_headers(self) -> dict:
headers: dict = {}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
def _request(self, method: str, path: str, *, auth: bool = True, **kwargs) -> httpx.Response:
rel_path = path.lstrip("/")
headers = kwargs.pop("headers", {})
if auth:
headers.update(self._auth_headers())
response = self.client.request(method, rel_path, headers=headers, **kwargs)
if response.status_code >= 400:
detail = ""
try:
payload = response.json()
if isinstance(payload, dict):
detail = payload.get("detail") or payload.get("message") or ""
except Exception:
detail = response.text
message = f"{response.status_code} {response.reason_phrase}"
if detail:
message = f"{message}: {detail}"
raise CLIError(message.strip())
return response
# --------------------------- CLI primitives ------------------------- #
def _require_auth(self) -> None:
if not self.token:
raise CLIError("You must login before running this command.")
def _resolve_user(self, identifier: str) -> dict:
self._require_auth()
if identifier.isdigit():
response = self._request("GET", f"user/id/{identifier}")
else:
response = self._request("GET", f"user/{identifier.replace('@', '')}")
return response.json()
def _confirm(self, prompt: str) -> bool:
self.console.print(f"[bold yellow]{prompt}[/] [green](y)[/] / [red](n)[/]: ", end="")
choice = _read_single_key()
self.console.print("") # move to next line
return choice == "y"
def _render_user(self, user: dict) -> None:
table = Table(show_header=False)
table.add_row("ID", str(user.get("id")))
table.add_row("Username", user.get("username", ""))
table.add_row("Display name", user.get("display_name", ""))
table.add_row("Verified", "" if user.get("verified") else "")
if user.get("suspended"):
table.add_row("Suspended", f"🚫 ({user.get('suspension_reason') or 'no reason'})")
else:
table.add_row("Suspended", "✅ Active")
self.console.print(table)
# --------------------------- Commands ------------------------------- #
def cmd_login(self, args: List[str]) -> None:
if args:
username = args[0]
else:
username = self.console.input("[bold cyan]Username[/]: ").strip()
if not username:
raise CLIError("Username is required.")
password = getpass("Password: ")
derived_password = derive_auth_secret(username, password)
payload = {"username": username, "password": derived_password}
response = self._request("POST", "login", json=payload, auth=False)
body = response.json()
token = body.get("token")
if not token:
raise CLIError("Authentication succeeded but token was not returned.")
self.token = token
self.username = username
self.console.print("[bold green]Login successful.[/]")
def cmd_suspend(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: suspend <user_id|username>")
identifier = args[0]
user = self._resolve_user(identifier)
self.console.print(Panel.fit("[bold red]Suspend user[/]", style="red"))
self._render_user(user)
reason = self.console.input("[bold yellow]Reason (press Enter to leave empty)[/]: ").strip()
if not self._confirm(f"Confirm suspension of {user.get('username')}?"):
self.console.print("[yellow]Suspension cancelled.[/]")
return
payload = {"reason": reason}
self._request("POST", f"user/{user['id']}/suspend", json=payload)
log_reason = reason or "no reason provided"
self.console.print(f"[bold red]User {user['username']} suspended ({log_reason}).[/]")
def cmd_unsuspend(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: unsuspend <user_id|username>")
identifier = args[0]
user = self._resolve_user(identifier)
self.console.print(Panel.fit("[bold green]Unsuspend user[/]", style="green"))
self._render_user(user)
if not self._confirm(f"Unsuspend {user.get('username')}?"):
self.console.print("[yellow]Unsuspension cancelled.[/]")
return
self._request("POST", f"user/{user['id']}/unsuspend")
self.console.print(f"[bold green]User {user['username']} unsuspended.[/]")
def cmd_block_word(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: block-word <word or phrase> [additional words...]")
self._require_auth()
words = args
response = self._request("POST", "moderation/blocklist", json={"words": words})
data = response.json()
added = data.get("added", [])
current = data.get("words", [])
if added:
self.console.print(f"[bold green]Added {len(added)} entr{'y' if len(added)==1 else 'ies'} to blocklist.[/]")
else:
self.console.print("[yellow]No new words added.[/]")
self.console.print(f"Blocklist size: {len(current)}")
def cmd_list_users(self) -> None:
self._require_auth()
payload = self._request("GET", "user/list").json()
users = payload.get("users", [])
table = Table(title="Users", show_lines=False)
table.add_column("ID")
table.add_column("Username")
table.add_column("Display name")
table.add_column("Suspended")
for user in users:
table.add_row(
str(user.get("id")),
user.get("username", ""),
user.get("display_name", ""),
"🚫" if user.get("suspended") else "",
)
self.console.print(table)
def cmd_user(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: user <user_id|username>")
user = self._resolve_user(args[0])
self._render_user(user)
def cmd_delete(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: delete <user_id|username>")
user = self._resolve_user(args[0])
self.console.print(Panel.fit("[bold red]Delete user[/]", style="red"))
self._render_user(user)
if not self._confirm(f"Permanently delete {user.get('username')}?"):
self.console.print("[yellow]Deletion cancelled.[/]")
return
self._request("POST", f"user/{user['id']}/delete")
self.console.print(f"[bold red]User {user['username']} deleted.[/]")
def cmd_unblock_word(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: unblock-word <word or phrase> [additional words...]")
self._require_auth()
response = self._request("DELETE", "moderation/blocklist", json={"words": args})
data = response.json()
removed = data.get("removed", [])
current = data.get("words", [])
if removed:
self.console.print(f"[bold green]Removed {len(removed)} entr{'y' if len(removed)==1 else 'ies'} from blocklist.[/]")
else:
self.console.print("[yellow]No matching words removed.[/]")
self.console.print(f"Blocklist size: {len(current)}")
def cmd_verify(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: verify <user_id|username>")
user = self._resolve_user(args[0])
if user.get("verified"):
self.console.print(f"[yellow]{user['username']} is already verified.[/]")
return
self._request("POST", f"user/{user['id']}/verify")
self.console.print(f"[bold green]{user['username']} marked as verified.[/]")
def cmd_unverify(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: unverify <user_id|username>")
user = self._resolve_user(args[0])
if not user.get("verified"):
self.console.print(f"[yellow]{user['username']} is already unverified.[/]")
return
self._request("POST", f"user/{user['id']}/verify")
self.console.print(f"[bold green]{user['username']} is now unverified.[/]")
def cmd_list_blocklist(self) -> None:
self._require_auth()
response = self._request("GET", "moderation/blocklist")
words = response.json().get("words", [])
if not words:
self.console.print("[cyan]Blocklist is empty.[/]")
return
table = Table(title="Blocked Words", show_lines=True)
table.add_column("Word / Phrase")
for entry in words:
table.add_row(entry)
self.console.print(table)
def cmd_help(self) -> None:
cmds = {
"login [username]": "Authenticate as owner/admin.",
"suspend <user>": "Suspend account (alias: ban).",
"unsuspend <user>": "Unsuspend account (alias: unban).",
"delete <user>": "Permanently delete the user account.",
"verify <user>": "Mark user as verified.",
"unverify <user>": "Remove verification flag.",
"block-word <words>": "Add words/phrases to chat filter.",
"unblock-word <words>": "Remove words/phrases from filter.",
"blocklist": "Show current blocklist.",
"list": "List all users.",
"user <user>": "Show detailed user information.",
"whoami": "Display current session context.",
"help": "Show this help panel.",
"exit": "Quit the CLI.",
}
table = Table(title="Available Commands")
table.add_column("Command", style="cyan")
table.add_column("Description", style="white")
for cmd, desc in cmds.items():
table.add_row(cmd, desc)
self.console.print(table)
def cmd_whoami(self) -> None:
if not self.token:
self.console.print("[yellow]Not authenticated.[/]")
return
self.console.print(f"[green]Logged in as[/] [bold]{self.username}[/] ({self.api_url})")
# --------------------------- Main loop ------------------------------ #
def run(self) -> None:
self.console.print(Panel.fit("[bold magenta]FromChat Admin CLI[/]", style="magenta"))
while True:
prompt_identity = self.username or "guest"
try:
prompt_str = f"\033[36m{prompt_identity}\033[0m \033[1m>\033[0m "
raw = input(prompt_str).strip()
except (KeyboardInterrupt, EOFError):
self.console.print("\n[red]Exiting...[/]")
break
if not raw:
continue
try:
parts = shlex.split(raw)
except ValueError as exc:
self.console.print(f"[red]Parse error:[/] {exc}")
continue
command = parts[0].lstrip("/").lower()
args = parts[1:]
if command in {"exit", "quit"}:
self.console.print("[red]Goodbye.[/]")
break
try:
if command == "login":
self.cmd_login(args)
elif command in {"suspend", "ban"}:
self.cmd_suspend(args)
elif command in {"unsuspend", "unban"}:
self.cmd_unsuspend(args)
elif command == "block-word":
self.cmd_block_word(args)
elif command == "unblock-word":
self.cmd_unblock_word(args)
elif command == "blocklist":
self.cmd_list_blocklist()
elif command == "verify":
self.cmd_verify(args)
elif command == "unverify":
self.cmd_unverify(args)
elif command in {"delete", "remove"}:
self.cmd_delete(args)
elif command == "list":
self.cmd_list_users()
elif command == "user":
self.cmd_user(args)
elif command == "help":
self.cmd_help()
elif command == "whoami":
self.cmd_whoami()
else:
self.console.print("[yellow]Unknown command. Type /help for a list of commands.[/]")
except CLIError as err:
self.console.print(f"[red]Error:[/] {err}")
except httpx.RequestError as err:
self.console.print(f"[red]Network error:[/] {err}")
self.client.close()
def main(argv: Optional[Iterable[str]] = None) -> None:
parser = argparse.ArgumentParser(description="FromChat Emergency Admin CLI")
parser.add_argument(
"--api-url",
default=os.getenv("FC_ADMIN_API_URL", "http://127.0.0.1:8301/api"),
help="Base API URL for the FromChat backend (default: %(default)s).",
)
args = parser.parse_args(list(argv) if argv is not None else None)
cli = AdminCLI(args.api_url)
cli.run()
if __name__ == "__main__":
main()
+53 -11
View File
@@ -1,16 +1,18 @@
from fastapi import FastAPI import time
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import subprocess import subprocess
import sys import sys
import os import os
from constants import DATABASE_URL from routes import account, messaging, profile, push, webrtc, devices, moderation
from routes import account, messaging, profile, push, webrtc, devices
import logging import logging
from models import User from models import User
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from db import POOL_CONFIG, SessionLocal
from logging_config import access_logger # noqa: F401 - ensure loggers configured
from security.audit import log_access
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -33,11 +35,7 @@ async def lifespan(app: FastAPI):
raise raise
try: try:
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
# Find the owner user
owner = db.query(User).filter(User.username == OWNER_USERNAME).first() owner = db.query(User).filter(User.username == OWNER_USERNAME).first()
if owner and not owner.verified: if owner and not owner.verified:
owner.verified = True owner.verified = True
@@ -47,10 +45,18 @@ async def lifespan(app: FastAPI):
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified") logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
else: else:
logger.warning(f"Owner user '{OWNER_USERNAME}' not found") logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
except Exception as e: except Exception as e:
logger.error(f"Failed to ensure owner verification: {e}") logger.error(f"Failed to ensure owner verification: {e}")
logger.info(
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
POOL_CONFIG["pool_size"],
POOL_CONFIG["max_overflow"],
POOL_CONFIG["pool_timeout"],
POOL_CONFIG["pool_recycle"],
POOL_CONFIG["pool_pre_ping"],
)
# Start the messaging cleanup task # Start the messaging cleanup task
try: try:
from routes.messaging import messagingManager from routes.messaging import messagingManager
@@ -66,6 +72,41 @@ async def lifespan(app: FastAPI):
# Инициализация FastAPI # Инициализация FastAPI
app = FastAPI(title="FromChat", lifespan=lifespan) app = FastAPI(title="FromChat", lifespan=lifespan)
@app.middleware("http")
async def access_logging_middleware(request: Request, call_next):
start = time.perf_counter()
try:
response = await call_next(request)
except Exception as exc:
duration = time.perf_counter() - start
user = getattr(getattr(request, "state", None), "current_user", None)
log_access(
"http_error",
method=request.method,
path=request.url.path,
status="error",
user=getattr(user, "username", None),
ip=request.client.host if request.client else None,
duration=f"{duration:.3f}s",
error=str(exc),
)
raise
else:
duration = time.perf_counter() - start
user = getattr(getattr(request, "state", None), "current_user", None)
log_access(
"http_request",
method=request.method,
path=request.url.path,
status=response.status_code,
user=getattr(user, "username", None),
ip=request.headers.get("x-forwarded-for") or (request.client.host if request.client else None),
duration=f"{duration:.3f}s",
)
return response
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -89,4 +130,5 @@ app.include_router(messaging.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(push.router, prefix="/push") app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc") app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(devices.router, prefix="/devices") app.include_router(devices.router, prefix="/devices")
app.include_router(moderation.router)
+31 -1
View File
@@ -6,5 +6,35 @@ from constants import DATABASE_URL
# Ensure data directory exists # Ensure data directory exists
os.makedirs("data", exist_ok=True) os.makedirs("data", exist_ok=True)
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
POOL_CONFIG = {
"pool_size": POOL_SIZE,
"max_overflow": MAX_OVERFLOW,
"pool_recycle": POOL_RECYCLE,
"pool_timeout": POOL_TIMEOUT,
"pool_pre_ping": True,
}
engine_kwargs = {
"pool_size": POOL_SIZE,
"max_overflow": MAX_OVERFLOW,
"pool_recycle": POOL_RECYCLE,
"pool_pre_ping": True,
"pool_timeout": POOL_TIMEOUT,
}
connect_args = {}
if DATABASE_URL.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine = create_engine(
DATABASE_URL,
connect_args=connect_args,
**engine_kwargs,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+12 -2
View File
@@ -1,4 +1,4 @@
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from utils import * from utils import *
@@ -17,8 +17,9 @@ def get_db():
# Зависимость для получения текущего пользователя # Зависимость для получения текущего пользователя
def get_current_user( def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db) db: Session = Depends(get_db),
) -> User: ) -> User:
token = credentials.credentials token = credentials.credentials
payload = verify_token(token) payload = verify_token(token)
@@ -36,6 +37,12 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if user.id == 1 and user.suspended:
user.suspended = False
user.suspension_reason = None
db.commit()
db.refresh(user)
# Validate device session from JWT # Validate device session from JWT
session_id = payload.get("session_id") session_id = payload.get("session_id")
if not session_id: if not session_id:
@@ -77,4 +84,7 @@ def get_current_user(
detail="Account deleted", detail="Account deleted",
) )
request.state.current_user = user
request.state.session_id = session_id
return user return user
+85
View File
@@ -0,0 +1,85 @@
import logging
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from threading import RLock
from typing import Dict
LOGS_DIR = Path(__file__).resolve().parent / "logs"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
class HumanReadableFileHandler(RotatingFileHandler):
def __init__(self, filename: Path, level: int) -> None:
super().__init__(filename, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
self.level = level
self._lock = RLock()
self._last_date: str | None = None
self._previous_entry: str | None = None
def emit(self, record: logging.LogRecord) -> None:
try:
message = record.getMessage().strip()
if not message:
return
timestamp = datetime.fromtimestamp(record.created)
date_str = timestamp.strftime("%d.%m.%Y")
time_str = timestamp.strftime("%H:%M:%S")
lines = [line.rstrip() for line in message.splitlines() if line.strip()]
with self._lock:
if self._last_date != date_str:
if self._last_date is not None:
self.stream.write("\n")
separator = "-" * 11
self.stream.write(f"\n\n{separator}\n{date_str}\n{separator}\n\n")
self._last_date = date_str
entry_lines: list[str] = []
if lines:
entry_lines.append(f"{time_str} {lines[0]}")
for line in lines[1:]:
if line.startswith("|"):
entry_lines.append(f" {line}")
else:
entry_lines.append(f"{line}")
else:
entry_lines.append(time_str)
entry_text = "\n".join(entry_lines)
if entry_text == self._previous_entry:
return
self.stream.write(entry_text + "\n")
self._previous_entry = entry_text
self.flush()
except Exception:
self.handleError(record)
_HANDLED_FILES: Dict[str, Path] = {}
def _configure_logger(name: str, filename: str, level: int = logging.INFO) -> logging.Logger:
logger = logging.getLogger(name)
target_path = LOGS_DIR / filename
if _HANDLED_FILES.get(name) == target_path:
return logger
logger.handlers.clear()
handler = HumanReadableFileHandler(target_path, level)
handler.setLevel(level)
logger.addHandler(handler)
logger.setLevel(level)
logger.propagate = False
_HANDLED_FILES[name] = target_path
return logger
security_logger = _configure_logger("security", "security.log")
public_chat_logger = _configure_logger("public_chat", "public-chat.log")
dm_logger = _configure_logger("dm", "dm.log")
access_logger = _configure_logger("access", "access.log")
+2
View File
@@ -11,3 +11,5 @@ cryptography>=41.0.0
alembic>=1.13.2 alembic>=1.13.2
better-profanity>=0.7.0 better-profanity>=0.7.0
user-agents>=2.2.0 user-agents>=2.2.0
httpx>=0.27.2
rich>=13.9.4
+145 -8
View File
@@ -1,4 +1,6 @@
from datetime import datetime from datetime import datetime
from collections import defaultdict, deque
import time
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
@@ -8,14 +10,34 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db 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 models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
from utils import create_token, get_password_hash, verify_password from utils import create_token, get_password_hash, verify_password
from validation import is_valid_password, is_valid_username, is_valid_display_name from validation import is_valid_password, is_valid_username, is_valid_display_name
import os import os
from security.audit import log_security
from security.profanity import contains_profanity
router = APIRouter() 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: def convert_user(user: User) -> dict:
return { return {
"id": user.id, "id": user.id,
@@ -43,18 +65,51 @@ def check_auth(current_user: User = Depends(get_current_user)):
@router.post("/login") @router.post("/login")
def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = None): def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == request.username.strip()).first() 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): 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( raise HTTPException(
status_code=401, status_code=401,
detail="Неверное имя пользователя или пароль" detail="Неверное имя пользователя или пароль"
) )
# Create device session and embed into JWT # Create device session and embed into JWT
raw_ua = http.headers.get("user-agent") if http else None raw_ua = http.headers.get("user-agent")
device_name = http.headers.get("x-device-name") if http else None device_name = http.headers.get("x-device-name")
ua = parse_ua(raw_ua or "") ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex session_id = uuid.uuid4().hex
@@ -82,6 +137,23 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
token = create_token(user.id, user.username, session_id) 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 { return {
"status": "success", "status": "success",
"message": "Login successful", "message": "Login successful",
@@ -91,11 +163,12 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
@router.post("/register") @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() username = request.username.strip()
display_name = request.display_name.strip() display_name = request.display_name.strip()
password = request.password.strip() password = request.password.strip()
confirm_password = request.confirm_password.strip() confirm_password = request.confirm_password.strip()
client_ip = http.client.host if http.client else None
# Determine if owner already exists # Determine if owner already exists
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
@@ -113,12 +186,22 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
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(
@@ -165,8 +248,8 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
db.refresh(new_user) db.refresh(new_user)
# Create initial device session # Create initial device session
raw_ua = http.headers.get("user-agent") if http else None raw_ua = http.headers.get("user-agent")
device_name = http.headers.get("x-device-name") if http else None device_name = http.headers.get("x-device-name")
ua = parse_ua(raw_ua or "") ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex session_id = uuid.uuid4().hex
device = DeviceSession( device = DeviceSession(
@@ -190,6 +273,24 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
token = create_token(new_user.id, new_user.username, session_id) 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 { return {
"status": "success", "status": "success",
"message": "Регистрация прошла успешно", "message": "Регистрация прошла успешно",
@@ -264,10 +365,20 @@ def delete_user_as_owner(
db.delete(user) db.delete(user)
db.commit() 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} return {"status": "success", "deleted_user_id": user_id}
@router.get("/logout") @router.get("/logout")
def logout( def logout(
http: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
@@ -285,6 +396,15 @@ def logout(
current_user.last_seen = datetime.now() current_user.last_seen = datetime.now()
db.commit() 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 { return {
"status": "success", "status": "success",
"message": "Logged out successfully" "message": "Logged out successfully"
@@ -294,6 +414,7 @@ def logout(
@router.post("/change-password") @router.post("/change-password")
def change_password( def change_password(
request: ChangePasswordRequest, request: ChangePasswordRequest,
http: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
@@ -319,6 +440,15 @@ def change_password(
).update({DeviceSession.revoked: True}) ).update({DeviceSession.revoked: True})
db.commit() 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"} return {"status": "success"}
@@ -430,6 +560,13 @@ async def delete_account(
await _delete_user_data(current_user, db) await _delete_user_data(current_user, db)
log_security(
"self_delete_account",
severity="warning",
user_id=current_user.id,
username=current_user.username,
)
return { return {
"status": "success", "status": "success",
"message": "Account deleted successfully" "message": "Account deleted successfully"
+326 -55
View File
@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
import html
import logging import logging
from pathlib import Path from pathlib import Path
import os import os
@@ -6,6 +7,11 @@ import re
import uuid import uuid
import asyncio import asyncio
import time import time
import unicodedata
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 import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
@@ -19,6 +25,8 @@ from PIL import Image
import io import io
import json import json
from better_profanity import profanity as _bp 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() router = APIRouter()
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -32,6 +40,110 @@ FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
os.makedirs(FILES_NORMAL_DIR, exist_ok=True) os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
os.makedirs(FILES_ENCRYPTED_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
_SHORT_MESSAGE_LENGTH = 8
_SHORT_MESSAGE_REPEAT_LIMIT = 4
_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque)
_message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
_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:
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_bucket = _message_rate_cache[user.id]
rate_bucket.append(now)
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
rate_bucket.popleft()
burst_count = len(rate_bucket)
if burst_count >= _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=burst_count,
window_seconds=_BURST_WINDOW_SECONDS,
)
_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
normalized = _normalize_for_spam(content)
history = _recent_message_cache[user.id]
while history and now - history[0][2] > _SPAM_WINDOW_SECONDS:
history.popleft()
prior_same = sum(1 for prev_norm, _, _ in history if prev_norm == normalized)
prior_similar = sum(
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
)
history.append((normalized, content, now))
total_matches = prior_same + prior_similar + 1
if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT:
suspend(
"Automatic suspension: repeated short messages",
"auto_suspension_public_spam",
occurrences=prior_same + 1,
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",
)
def convert_message(msg: Message) -> dict: def convert_message(msg: Message) -> dict:
# Group reactions by emoji # Group reactions by emoji
@@ -138,46 +250,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") @router.post("/send_message")
async def send_message( async def send_message(
request: SendMessageRequest | None = None, request: SendMessageRequest | None = None,
@@ -204,23 +276,26 @@ async def send_message(
if not original_message: if not original_message:
raise HTTPException(status_code=404, detail="Original message not found") 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( raise HTTPException(
status_code=400, status_code=400,
detail="No content provided" detail="No content provided"
) )
# Apply profanity filter before storing # 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( raise HTTPException(
status_code=400, status_code=400,
detail="Message too long" detail="Message too long"
) )
new_message = Message( new_message = Message(
content=filtered_content, content=escaped_content,
user_id=current_user.id, user_id=current_user.id,
reply_to_id=request.reply_to_id, reply_to_id=request.reply_to_id,
timestamp=datetime.now() timestamp=datetime.now()
@@ -293,7 +368,6 @@ async def send_message(
# Realtime broadcast for HTTP uploads as well # Realtime broadcast for HTTP uploads as well
try: try:
from .messaging import messagingManager # self import safe here
await messagingManager.broadcast({ await messagingManager.broadcast({
"type": "newMessage", "type": "newMessage",
"data": convert_message(new_message) "data": convert_message(new_message)
@@ -301,7 +375,22 @@ async def send_message(
except Exception: except Exception:
pass 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") @router.get("/get_messages")
@@ -404,6 +493,7 @@ async def dm_send(
) )
db.add(df) db.add(df)
db.commit() db.commit()
db.refresh(env)
# Send push notification for DM # Send push notification for DM
try: try:
@@ -433,6 +523,16 @@ async def dm_send(
except Exception: except Exception:
pass 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} return {"status": "ok", "id": env.id}
def convert_envelopes(envs: list[DMEnvelope]): def convert_envelopes(envs: list[DMEnvelope]):
@@ -531,15 +631,35 @@ async def edit_message(
raise HTTPException(status_code=404, detail="Message not found") raise HTTPException(status_code=404, detail="Message not found")
if message.user_id != current_user.id: if message.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only edit your own messages") 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") 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 message.is_edited = True
db.commit() db.commit()
db.refresh(message) 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}") @router.delete("/delete_message/{message_id}")
@@ -557,9 +677,19 @@ async def delete_message(
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id: 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") raise HTTPException(status_code=403, detail="You can only delete your own messages")
original_content = message.content
db.delete(message) db.delete(message)
db.commit() 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} return {"status": "success", "message_id": message_id}
@@ -600,9 +730,10 @@ async def add_reaction(
# Refresh message to get updated reactions # Refresh message to get updated reactions
db.refresh(message) db.refresh(message)
message_data = convert_message(message)
# Broadcast reaction update # Broadcast reaction update
try: try:
from .messaging import messagingManager
await messagingManager.broadcast({ await messagingManager.broadcast({
"type": "reactionUpdate", "type": "reactionUpdate",
"data": { "data": {
@@ -611,13 +742,22 @@ async def add_reaction(
"action": action, "action": action,
"user_id": current_user.id, "user_id": current_user.id,
"username": current_user.username, "username": current_user.username,
"reactions": convert_message(message)["reactions"] "reactions": message_data["reactions"]
} }
}) })
except Exception: except Exception:
pass 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") @router.post("/dm/add_reaction")
@@ -661,6 +801,8 @@ async def add_dm_reaction(
# Refresh envelope to get updated reactions # Refresh envelope to get updated reactions
db.refresh(envelope) db.refresh(envelope)
envelope_data = convert_dm_envelope(envelope)
# Broadcast reaction update to both participants # Broadcast reaction update to both participants
try: try:
await messagingManager.broadcast({ await messagingManager.broadcast({
@@ -671,13 +813,22 @@ async def add_dm_reaction(
"action": action, "action": action,
"user_id": current_user.id, "user_id": current_user.id,
"username": current_user.username, "username": current_user.username,
"reactions": convert_dm_envelope(envelope)["reactions"] "reactions": envelope_data["reactions"]
} }
}) })
except Exception: except Exception:
pass 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: class MessaggingSocketManager:
@@ -697,13 +848,37 @@ class MessaggingSocketManager:
# Initialize subscriptions for this connection # Initialize subscriptions for this connection
self.ws_subscriptions[websocket] = set() 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: while True:
data = await websocket.receive_json() data = await websocket.receive_json()
type = data["type"] type = data["type"]
def get_current_user_inner() -> User | None: def get_current_user_inner() -> User | None:
if data["credentials"]: if data["credentials"]:
dummy_request = SimpleNamespace()
dummy_request.state = SimpleNamespace()
return get_current_user( return get_current_user(
dummy_request,
HTTPAuthorizationCredentials( HTTPAuthorizationCredentials(
scheme=data["credentials"]["scheme"], scheme=data["credentials"]["scheme"],
credentials=data["credentials"]["credentials"] credentials=data["credentials"]["credentials"]
@@ -714,6 +889,7 @@ class MessaggingSocketManager:
return None return None
if type == "ping": if type == "ping":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if current_user: if current_user:
@@ -737,6 +913,7 @@ class MessaggingSocketManager:
} }
} }
}) })
_log_ws("ping_error", current_user)
except HTTPException: except HTTPException:
await websocket.send_json({ await websocket.send_json({
"type": "ping", "type": "ping",
@@ -748,8 +925,11 @@ class MessaggingSocketManager:
} }
} }
}) })
_log_ws("ping_error", current_user)
await websocket.send_json({"type": "ping", "data": {"status": "success"}}) await websocket.send_json({"type": "ping", "data": {"status": "success"}})
_log_ws("ping", current_user)
elif type == "getMessages": elif type == "getMessages":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -757,9 +937,12 @@ class MessaggingSocketManager:
self.user_by_ws[websocket] = current_user.id self.user_by_ws[websocket] = current_user.id
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
_log_ws("getMessages", current_user)
except HTTPException as e: except HTTPException as e:
_log_ws("getMessages_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "sendMessage": elif type == "sendMessage":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -775,9 +958,12 @@ class MessaggingSocketManager:
}) })
await websocket.send_json({"type": type, "data": response}) await websocket.send_json({"type": type, "data": response})
_log_ws("sendMessage", current_user, message_id=response["message"]["id"])
except HTTPException as e: except HTTPException as e:
_log_ws("sendMessage_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "dmSend": elif type == "dmSend":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -827,9 +1013,21 @@ class MessaggingSocketManager:
await self.send_to_user(env.recipient_id, payload); await self.send_to_user(env.recipient_id, payload);
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
await self.send_to_user(env.sender_id, payload); 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: except HTTPException as e:
_log_ws("dmSend_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "editMessage": elif type == "editMessage":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -845,9 +1043,12 @@ class MessaggingSocketManager:
}) })
await websocket.send_json({"type": type, "data": response}) await websocket.send_json({"type": type, "data": response})
_log_ws("editMessage", current_user, message_id=message_id)
except HTTPException as e: except HTTPException as e:
_log_ws("editMessage_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "dmEdit": elif type == "dmEdit":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -887,9 +1088,19 @@ class MessaggingSocketManager:
await self.send_to_user(env.recipient_id, payload_ws) await self.send_to_user(env.recipient_id, payload_ws)
await self.send_to_user(env.sender_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}}) 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: except HTTPException as e:
_log_ws("dmEdit_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "dmDelete": elif type == "dmDelete":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -917,9 +1128,20 @@ class MessaggingSocketManager:
await self.send_to_user(env.recipient_id, payload_ws) await self.send_to_user(env.recipient_id, payload_ws)
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}}) await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}})
await self.send_to_user(env.sender_id, payload_ws) 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: except HTTPException as e:
_log_ws("dmDelete_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "deleteMessage": elif type == "deleteMessage":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -933,9 +1155,12 @@ class MessaggingSocketManager:
}) })
await websocket.send_json({"type": type, "data": response}) await websocket.send_json({"type": type, "data": response})
_log_ws("deleteMessage", current_user, message_id=message_id)
except HTTPException as e: except HTTPException as e:
_log_ws("deleteMessage_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "addReaction": elif type == "addReaction":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -963,9 +1188,12 @@ class MessaggingSocketManager:
}) })
await websocket.send_json({"type": type, "data": response}) 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: except HTTPException as e:
_log_ws("addReaction_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "addDmReaction": elif type == "addDmReaction":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -993,10 +1221,13 @@ class MessaggingSocketManager:
}) })
await websocket.send_json({"type": type, "data": response}) 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: except HTTPException as e:
_log_ws("addDmReaction_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "call_signaling": elif type == "call_signaling":
# Forward WebRTC signaling between peers # Forward WebRTC signaling between peers
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -1019,10 +1250,13 @@ class MessaggingSocketManager:
# Optional ack # Optional ack
await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}}) 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: except HTTPException as e:
_log_ws("call_signaling_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
elif type == "call_video_toggle": elif type == "call_video_toggle":
# Forward video toggle state between peers # Forward video toggle state between peers
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -1049,9 +1283,13 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}}) await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}})
except HTTPException as e: 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) 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": elif type == "call_screen_share_toggle":
# Forward screen share toggle state between peers # Forward screen share toggle state between peers
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -1078,8 +1316,12 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}}) await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}})
except HTTPException as e: 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) 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": elif type == "subscribeStatus":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -1096,7 +1338,7 @@ class MessaggingSocketManager:
"data": { "data": {
"userId": user_id_to_subscribe, "userId": user_id_to_subscribe,
"online": target_user.online, "online": target_user.online,
"lastSeen": target_user.last_seen.isoformat() "lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
} }
}) })
else: else:
@@ -1105,8 +1347,12 @@ class MessaggingSocketManager:
"data": {"status": "error", "error": "User not found"} "data": {"status": "error", "error": "User not found"}
}) })
except HTTPException as e: except HTTPException as e:
_log_ws("subscribeStatus_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
else:
_log_ws("subscribeStatus", current_user, target_user_id=user_id_to_subscribe)
elif type == "unsubscribeStatus": elif type == "unsubscribeStatus":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -1117,8 +1363,12 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}}) await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}})
except HTTPException as e: except HTTPException as e:
_log_ws("unsubscribeStatus_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
else:
_log_ws("unsubscribeStatus", current_user, target_user_id=user_id_to_unsubscribe)
elif type == "typing": elif type == "typing":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -1137,8 +1387,12 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "typing", "data": {"status": "ok"}}) await websocket.send_json({"type": "typing", "data": {"status": "ok"}})
except HTTPException as e: except HTTPException as e:
_log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
else:
_log_ws("typing", current_user)
elif type == "stopTyping": elif type == "stopTyping":
current_user: User | None = None
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
@@ -1158,7 +1412,10 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}}) await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}})
except HTTPException as e: except HTTPException as e:
_log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e)))
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
else:
_log_ws("stopTyping", current_user)
elif type == "dmTyping": elif type == "dmTyping":
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
@@ -1219,11 +1476,25 @@ class MessaggingSocketManager:
async def connect(self, websocket: WebSocket, db: Session): async def connect(self, websocket: WebSocket, db: Session):
await websocket.accept() 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) self.connections.append(websocket)
try: try:
await self.handle_connection(websocket, db) await self.handle_connection(websocket, db)
except WebSocketDisconnect as e: except WebSocketDisconnect as e:
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}") 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: finally:
# Cleanup connection # Cleanup connection
self.connections.remove(websocket) 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}
+114 -10
View File
@@ -3,7 +3,6 @@ import re
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
from PIL import Image from PIL import Image
import os import os
import uuid import uuid
@@ -15,9 +14,19 @@ from pydantic import BaseModel
from validation import is_valid_username, is_valid_display_name 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.profanity import contains_profanity
router = APIRouter() 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 # Request models
class UpdateProfileRequest(BaseModel): class UpdateProfileRequest(BaseModel):
username: str | None = None username: str | None = None
@@ -104,15 +113,53 @@ async def get_user_profile(
""" """
Get current user's profile information 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 { return {
"id": current_user.id, "users": [
"username": current_user.username, UserProfileResponse(
"display_name": current_user.display_name, id=user.id,
"profile_picture": current_user.profile_picture, username=user.username,
"bio": current_user.bio, display_name=user.display_name,
"online": current_user.online, profile_picture=user.profile_picture,
"last_seen": current_user.last_seen, bio=user.bio,
"created_at": current_user.created_at 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") @router.put("/user/profile")
@@ -134,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()
@@ -151,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
@@ -214,6 +271,8 @@ async def get_user_by_username(
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
_ensure_owner_unsuspended(user, db)
return UserProfileResponse( return UserProfileResponse(
id=user.id, id=user.id,
@@ -223,7 +282,11 @@ async def get_user_by_username(
bio=user.bio, bio=user.bio,
online=user.online, online=user.online,
last_seen=user.last_seen, 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}") @router.get("/user/id/{user_id}")
@@ -238,6 +301,8 @@ async def get_user_by_id(
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
_ensure_owner_unsuspended(user, db)
# Handle deleted users # Handle deleted users
if user.deleted: if user.deleted:
@@ -293,6 +358,15 @@ async def verify_user(
target_user.verified = not target_user.verified target_user.verified = not target_user.verified
db.commit() 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 { return {
"verified": target_user.verified, "verified": target_user.verified,
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}" "message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
@@ -363,6 +437,15 @@ async def suspend_user(
target_user.suspension_reason = request.reason target_user.suspension_reason = request.reason
db.commit() 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 # Send WebSocket suspension message
try: try:
await messagingManager.send_suspension_to_user(user_id, request.reason) await messagingManager.send_suspension_to_user(user_id, request.reason)
@@ -399,6 +482,14 @@ async def unsuspend_user(
target_user.suspension_reason = None target_user.suspension_reason = None
db.commit() 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 { return {
"status": "success", "status": "success",
"message": f"User {target_user.username} has been unsuspended" "message": f"User {target_user.username} has been unsuspended"
@@ -426,9 +517,22 @@ async def delete_user(
if target_user.id == 1: if target_user.id == 1:
raise HTTPException(status_code=400, detail="Cannot delete admin account") 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 from .account import _delete_user_data
await _delete_user_data(target_user, db) 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 { return {
"status": "success", "status": "success",
"message": f"User {target_user.username} has been deleted" "message": f"User {target_user.username} has been deleted"
+2
View File
@@ -0,0 +1,2 @@
# Package marker for security utilities
+388
View File
@@ -0,0 +1,388 @@
from __future__ import annotations
import logging
from html import unescape
from typing import Any, Callable, Dict, List
from logging_config import access_logger, dm_logger, public_chat_logger, security_logger
def _clean_username(username: Any) -> str:
if not username:
return "unknown user"
return f"@{username}"
def _format_user(fields: Dict[str, Any], username_key: str = "username", user_id_key: str = "user_id") -> str:
username = fields.get(username_key)
if username is None and "_" in username_key:
base_key = username_key.split("_", 1)[0]
username = fields.get(base_key)
user_id = fields.get(user_id_key)
if user_id is None and "_" in user_id_key:
base_key = user_id_key.split("_", 1)[0]
user_id = fields.get(base_key)
if username and user_id is not None:
return f"{_clean_username(username)} (user id {user_id})"
if username:
return _clean_username(username)
if user_id is not None:
return f"user id {user_id}"
return "unknown user"
def _format_actor(fields: Dict[str, Any], prefix: str) -> str:
return _format_user(fields, f"{prefix}_username", f"{prefix}_id")
def _plural(label: str, count: int) -> str:
return f"{count} {label if count == 1 else label + 's'}"
def _yes_no(flag: Any) -> str:
return "yes" if flag else "no"
def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
if action == "login_success":
lines = [f"Login approved for {_format_user(fields)}"]
session = fields.get("session_id")
if session:
lines.append(f"Session: {session}")
client_bits: List[str] = []
if fields.get("device"):
client_bits.append(fields["device"])
if fields.get("os"):
client_bits.append(fields["os"])
if fields.get("browser"):
client_bits.append(fields["browser"])
if client_bits:
lines.append(f"Client: {', '.join(client_bits)}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "login_failed":
lines = [f"Login denied for {_format_user(fields)}"]
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "auth_bruteforce_detected":
lines = ["Brute-force login pattern detected"]
lines.append(f"Target: {_format_user(fields)}")
failures = fields.get("failures")
if isinstance(failures, dict):
for key, value in failures.items():
lines.append(f"{key}: {value}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
if fields.get("window_seconds"):
lines.append(f"Observation window: {fields['window_seconds']} seconds")
return lines
if action == "registration_success":
ip_raw = fields.get("ip")
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
display_name = fields.get("display_name") or "Unknown"
username = fields.get("username")
user_id = fields.get("user_id")
user_agent = fields.get("user_agent") or "Unknown user agent"
lines = ["Account registered"]
lines.append(f"Display name: {display_name}")
lines.append(f"Username: {_clean_username(username) if username else 'unknown'}")
if ip_display:
lines.append(f"IP: {ip_display}")
if user_agent:
lines.append(f"User agent: {user_agent}")
if user_id is not None:
lines.append(f"User ID: {user_id}")
return lines
if action == "password_changed":
lines = [f"Password changed for {_format_user(fields)}"]
lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "logout":
lines = [f"Logout recorded for {_format_user(fields)}"]
if fields.get("session_id"):
lines.append(f"Session: {fields['session_id']}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "admin_delete_user":
return [
"Account removal",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
]
if action == "admin_suspend_user":
lines = [
"User suspension",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
]
if fields.get("reason"):
lines.append(f"Reason: {fields.get('reason')}")
return lines
if action == "admin_unsuspend_user":
return [
"User unsuspension",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
]
if action == "admin_verify_toggle":
return [
"User verification",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
f"Verified: {_yes_no(fields.get('verified'))}",
]
if action == "self_delete_account":
return [f"User {_format_user(fields)} deleted their account"]
if action == "auto_suspension_public_spam":
lines = [
f"Automatic suspension triggered for {_format_user(fields)}",
]
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"):
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
if action == "public_message_burst":
return [
f"Rapid messaging spike for {_format_user(fields)}",
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
]
if action == "blocklist_add":
added = fields.get("added") or []
lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"]
if added:
lines.append(f"Added entries: {', '.join(added)}")
total = len(fields.get("words") or [])
lines.append(f"Total entries: {total}")
return lines
if action == "blocklist_remove":
removed = fields.get("removed") or []
lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"]
if removed:
lines.append(f"Removed entries: {', '.join(removed)}")
total = len(fields.get("words") or [])
lines.append(f"Total entries: {total}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]:
if action == "message_created":
lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"]
if fields.get("reply_to"):
lines.append(f"In reply to message #{fields['reply_to']}")
attachments = fields.get("attachments")
if attachments:
lines.append(f"Attachments: {_plural('file', attachments)}")
if fields.get("content"):
lines.append("Content:")
for line in unescape(fields["content"]).splitlines():
lines.append(f"| {line}")
return lines
if action == "message_edited":
lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"]
if fields.get("reply_to"):
lines.append(f"Reply to #{fields['reply_to']}")
if fields.get("previous_content"):
lines.append("Previous content:")
for line in unescape(fields["previous_content"] or "").splitlines() or [""]:
lines.append(f"| {line}")
if fields.get("content"):
lines.append("New content:")
for line in unescape(fields["content"] or "").splitlines() or [""]:
lines.append(f"| {line}")
return lines
if action == "message_deleted":
lines = [
f"Message #{fields.get('message_id')} deleted",
f"Actor: {_format_actor(fields, 'actor')}",
]
if fields.get("original_author_id") is not None:
lines.append(f"Original author: user #{fields['original_author_id']}")
if fields.get("content"):
lines.append("↳ Previous content:")
for line in unescape(fields["content"]).splitlines():
lines.append(f"| {line}")
return lines
if action == "reaction_update":
lines = [
f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}",
f"User: {_format_user(fields)}",
]
if fields.get("emoji"):
lines.append(f"Emoji: {fields['emoji']}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]:
if action in {"message_sent", "message_sent_ws"}:
lines = [
f"Direct message #{fields.get('dm_envelope_id')} sent",
f"Sender: {_format_actor(fields, 'sender')}",
]
if fields.get("recipient_id") is not None:
lines.append(f"Recipient: user id {fields['recipient_id']}")
attachments = fields.get("attachment_count")
if attachments:
lines.append(f"Attachments: {_plural('file', attachments)}")
if fields.get("reply_to"):
lines.append(f"In reply to DM #{fields['reply_to']}")
return lines
if action == "message_edited":
return [
f"Direct message #{fields.get('dm_envelope_id')} edited",
f"Author: {_format_user(fields)}",
]
if action == "message_deleted":
lines = [
f"Direct message #{fields.get('dm_envelope_id')} deleted",
f"Actor: {_format_user(fields)}",
]
if fields.get("recipient_id") is not None:
lines.append(f"Recipient: user id {fields['recipient_id']}")
return lines
if action == "reaction_update":
lines = [
f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}",
f"User: {_format_user(fields)}",
]
if fields.get("emoji"):
lines.append(f"Emoji: {fields['emoji']}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _render_access(action: str, fields: Dict[str, Any]) -> List[str]:
ip_raw = fields.get("ip")
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
if action == "http_request":
first_line = f"{fields.get('method')} {fields.get('path')}"
if ip_display:
first_line += f" from {ip_display}"
first_line += f" -> {fields.get('status')}"
lines = [first_line]
if fields.get("user"):
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
return lines
if action == "http_error":
first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}"
if ip_display:
first_line += f" from {ip_display}"
lines = [first_line]
if fields.get("error"):
lines.append(f"Exception: {fields['error']}")
if fields.get("user"):
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
return lines
if action == "ws_connect":
lines = ["WebSocket connected"]
if fields.get("path"):
lines.append(f"Endpoint: {fields['path']}")
if ip_display:
lines.append(f"IP: {ip_display}")
return lines
if action == "ws_disconnect":
lines = ["WebSocket disconnected"]
if fields.get("path"):
lines.append(f"Endpoint: {fields['path']}")
if fields.get("code") is not None:
reason = fields.get("reason") or "no reason"
lines.append(f"Code {fields['code']} ({reason})")
if ip_display:
lines.append(f"IP: {ip_display}")
return lines
if action == "ws_event":
event_name = fields.get("event")
path = fields.get("path")
first_line = "WS"
if path:
first_line += f" {path}"
if ip_display:
first_line += f" from {ip_display}"
if event_name:
first_line += f" -> {event_name}"
lines = [first_line]
if fields.get("user"):
lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}")
for key, value in fields.items():
if key in {"path", "event", "user", "user_id", "ip"} or value is None:
continue
lines.append(f"{key.replace('_', ' ').capitalize()}: {value}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _log_event(
logger: logging.Logger,
renderer: Callable[[str, Dict[str, Any]], List[str]],
action: str,
severity: str,
fields: Dict[str, Any],
) -> None:
lines = renderer(action, fields)
if not lines:
return
level = getattr(logging, severity.upper(), logging.INFO)
logger.log(level, "\n".join(lines))
def log_security(action: str, severity: str = "info", **fields: Any) -> None:
_log_event(security_logger, _render_security, action, severity, fields)
def log_public_chat(action: str, severity: str = "info", **fields: Any) -> None:
_log_event(public_chat_logger, _render_public_chat, action, severity, fields)
def log_dm(action: str, severity: str = "info", **fields: Any) -> None:
sanitized_fields = {key: value for key, value in fields.items() if key != "content"}
_log_event(dm_logger, _render_dm, action, severity, sanitized_fields)
def log_access(action: str, severity: str = "info", **fields: Any) -> None:
_log_event(access_logger, _render_access, action, severity, fields)
+335
View File
@@ -0,0 +1,335 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from threading import RLock
from typing import Iterable, List, Set, Tuple
from better_profanity import Profanity
BLOCKLIST_PATH = Path("data/profanity/blocklist.json")
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
_CUSTOM_RU_TERMS: Set[str] = {
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
"пидоры", "пидорас", "пидорасы", "пидорасов",
}
_ADULT_TERMS: Set[str] = {
"порно", "порнуха", "эротика", "эротический", "секс", "сексуальный",
"инцест", "порнография", "порностудия", "порновидео", "порносайт",
"сексчат", "сексчатик", "секслайв", "сексвидео",
}
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
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),
re.compile(r"\b18\+\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()
_blocklist_signature: Tuple[str, ...] | None = None
_profanity = Profanity()
def _normalize_words(words: Iterable[str]) -> Set[str]:
normalized: Set[str] = set()
for raw in words:
if not raw:
continue
cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower()
if cleaned:
normalized.add(cleaned)
return normalized
def _load_blocklist() -> Set[str]:
if not BLOCKLIST_PATH.exists():
return set()
try:
data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8"))
if isinstance(data, list):
return _normalize_words(data)
except Exception:
pass
return set()
def _write_blocklist(words: Iterable[str]) -> None:
BLOCKLIST_PATH.write_text(
json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8"
)
def _rebuild_dictionary(force: bool = False) -> None:
global _profanity, _blocklist_signature
with _dictionary_lock:
blocklist_list = sorted(_load_blocklist())
signature = tuple(blocklist_list)
if not force and _blocklist_signature == signature and _blocklist_signature is not None:
return
profanity = Profanity()
profanity.load_censor_words()
combined = set(_STATIC_TERMS)
combined.update(blocklist_list)
if combined:
profanity.add_censor_words(list(combined))
_profanity = profanity
_blocklist_signature = signature
def _apply_phrase_filters(text: str) -> str:
result = text
for pattern in _PHRASE_PATTERNS:
while True:
match = pattern.search(result)
if not match:
break
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
def censor_text(text: str) -> str:
if not text:
return text
_rebuild_dictionary()
preprocessed = _apply_phrase_filters(text)
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]:
with _dictionary_lock:
return sorted(_load_blocklist())
def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
normalized = _normalize_words(words)
if not normalized:
return [], get_blocklist()
with _dictionary_lock:
current = _load_blocklist()
added = sorted(normalized - current)
if not added:
return [], sorted(current)
updated = sorted(current | normalized)
_write_blocklist(updated)
_rebuild_dictionary(force=True)
return added, updated
def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
normalized = _normalize_words(words)
if not normalized:
return [], get_blocklist()
with _dictionary_lock:
current = _load_blocklist()
removed = sorted(word for word in normalized if word in current)
if not removed:
return [], sorted(current)
updated = sorted(current - normalized)
_write_blocklist(updated)
_rebuild_dictionary(force=True)
return removed, updated
+2 -1
View File
@@ -30,4 +30,5 @@ coverage
test_results/ test_results/
out out
data data
logs
+5 -2
View File
@@ -14,12 +14,15 @@ FROM python:3.12-slim AS runtime
WORKDIR /app WORKDIR /app
RUN useradd -u 1000 app && \ RUN useradd -u 1000 app && \
chown -R app /app chown -R app /app
USER app
# 2.2. Copy content and create dirs # 2.2. Copy content and create dirs
COPY --chown=app backend . COPY --chown=app backend .
COPY --from=builder --chown=app /app/.venv .venv COPY --from=builder --chown=app /app/.venv .venv
RUN mkdir -p /app/data RUN mkdir -p /app/data && \
printf '#!/bin/sh\nexec /app/.venv/bin/python /app/admin_cli.py "$@"\n' > /usr/local/bin/admin-cli && \
chmod +x /usr/local/bin/admin-cli
USER app
# 3. Final command # 3. Final command
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
+6 -2
View File
@@ -9,7 +9,9 @@ services:
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
volumes: volumes:
- "data:/app/data" - data:/app/data
- logs:/app/logs
develop: develop:
watch: watch:
- action: sync+restart - action: sync+restart
@@ -41,4 +43,6 @@ services:
volumes: volumes:
data: data:
name: fromchat-data name: fromchat-data
logs:
name: fromchat-logs
+5 -3
View File
@@ -3,7 +3,7 @@ import type { Attachment, Message as MessageType, Reaction } from "@/core/types"
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import Quote from "@/core/components/Quote"; import Quote from "@/core/components/Quote";
import { parse } from "marked"; import { parse } from "marked";
import DOMPurify from "dompurify"; import { escape as escapeHtml } from "he";
import { useEffect, useState, useRef, useMemo } from "react"; import { useEffect, useState, useRef, useMemo } from "react";
import { getCurrentKeys } from "@/core/api/authApi"; import { getCurrentKeys } from "@/core/api/authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
@@ -170,7 +170,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
const formattedMessage = useMemo(() => { const formattedMessage = useMemo(() => {
// First, temporarily replace existing fromchat.ru links to avoid conflicts // First, temporarily replace existing fromchat.ru links to avoid conflicts
const linkPlaceholders: string[] = []; const linkPlaceholders: string[] = [];
let content = message.content.replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => { let content = escapeHtml(message.content).replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => {
const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`; const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`;
linkPlaceholders.push(match); linkPlaceholders.push(match);
return placeholder; return placeholder;
@@ -186,8 +186,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link); content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link);
}); });
const rendered = parse(content, { async: false }).trim();
return { return {
__html: DOMPurify.sanitize(parse(content, { async: false })).trim() __html: rendered
}; };
}, [message.content, styles.mentionLink]); }, [message.content, styles.mentionLink]);
+2 -1
View File
@@ -44,6 +44,7 @@
"@electron-forge/plugin-auto-unpack-natives": "^7.9.0", "@electron-forge/plugin-auto-unpack-natives": "^7.9.0",
"@electron-forge/plugin-fuses": "^7.9.0", "@electron-forge/plugin-fuses": "^7.9.0",
"@electron/fuses": "^1.0.0", "@electron/fuses": "^1.0.0",
"@types/he": "^1.2.3",
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.3",
@@ -64,9 +65,9 @@
"vite-plugin-sass-dts": "^1.3.34" "vite-plugin-sass-dts": "^1.3.34"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.2.7",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
"he": "^1.2.0",
"marked": "^16.3.0", "marked": "^16.3.0",
"mdui": "^2.1.4", "mdui": "^2.1.4",
"motion": "^12.23.24", "motion": "^12.23.24",