mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'fix/security'
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -1,16 +1,18 @@
|
||||
from fastapi import FastAPI
|
||||
import time
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from constants import DATABASE_URL
|
||||
from routes import account, messaging, profile, push, webrtc, devices
|
||||
from routes import account, messaging, profile, push, webrtc, devices, moderation
|
||||
import logging
|
||||
from models import User
|
||||
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")
|
||||
|
||||
@@ -33,11 +35,7 @@ async def lifespan(app: FastAPI):
|
||||
raise
|
||||
|
||||
try:
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
with SessionLocal() as db:
|
||||
# Find the owner user
|
||||
owner = db.query(User).filter(User.username == OWNER_USERNAME).first()
|
||||
if owner and not owner.verified:
|
||||
owner.verified = True
|
||||
@@ -47,10 +45,18 @@ async def lifespan(app: FastAPI):
|
||||
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
|
||||
else:
|
||||
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
|
||||
|
||||
except Exception as 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
|
||||
try:
|
||||
from routes.messaging import messagingManager
|
||||
@@ -66,6 +72,41 @@ async def lifespan(app: FastAPI):
|
||||
# Инициализация FastAPI
|
||||
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
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -89,4 +130,5 @@ app.include_router(messaging.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
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
@@ -6,5 +6,35 @@ from constants import DATABASE_URL
|
||||
# Ensure data directory exists
|
||||
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)
|
||||
+12
-2
@@ -1,4 +1,4 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from utils import *
|
||||
@@ -17,8 +17,9 @@ def get_db():
|
||||
|
||||
# Зависимость для получения текущего пользователя
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
payload = verify_token(token)
|
||||
@@ -36,6 +37,12 @@ def get_current_user(
|
||||
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
|
||||
session_id = payload.get("session_id")
|
||||
if not session_id:
|
||||
@@ -77,4 +84,7 @@ def get_current_user(
|
||||
detail="Account deleted",
|
||||
)
|
||||
|
||||
request.state.current_user = user
|
||||
request.state.session_id = session_id
|
||||
|
||||
return user
|
||||
@@ -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")
|
||||
|
||||
@@ -11,3 +11,5 @@ cryptography>=41.0.0
|
||||
alembic>=1.13.2
|
||||
better-profanity>=0.7.0
|
||||
user-agents>=2.2.0
|
||||
httpx>=0.27.2
|
||||
rich>=13.9.4
|
||||
|
||||
+145
-8
@@ -1,4 +1,6 @@
|
||||
from datetime import datetime
|
||||
from collections import defaultdict, deque
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import inspect, text
|
||||
@@ -8,14 +10,34 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user, get_db
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
||||
from utils import create_token, get_password_hash, verify_password
|
||||
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||
import os
|
||||
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
router = APIRouter()
|
||||
|
||||
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
||||
_FAILED_ATTEMPT_THRESHOLD = 5
|
||||
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
def _record_failed_login(identifier: str) -> bool:
|
||||
now = time.time()
|
||||
attempts = _failed_login_attempts[identifier]
|
||||
attempts.append(now)
|
||||
|
||||
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
|
||||
attempts.popleft()
|
||||
|
||||
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
|
||||
|
||||
|
||||
def _reset_failed_logins(identifier: str) -> None:
|
||||
_failed_login_attempts.pop(identifier, None)
|
||||
|
||||
def convert_user(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
@@ -43,18 +65,51 @@ def check_auth(current_user: User = Depends(get_current_user)):
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = None):
|
||||
user = db.query(User).filter(User.username == request.username.strip()).first()
|
||||
def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)):
|
||||
username = request.username.strip()
|
||||
x_forwarded_for = http.headers.get("x-forwarded-for") if http else None
|
||||
client_ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else (http.client.host if http and http.client else None)
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user or not verify_password(request.password.strip(), user.password_hash):
|
||||
log_security(
|
||||
"login_failed",
|
||||
severity="warning",
|
||||
username=username,
|
||||
ip=client_ip,
|
||||
reason="invalid_credentials",
|
||||
)
|
||||
identifiers = [f"user:{username}"]
|
||||
if client_ip:
|
||||
identifiers.append(f"ip:{client_ip}")
|
||||
|
||||
suspicious = False
|
||||
for identifier in identifiers:
|
||||
if _record_failed_login(identifier):
|
||||
suspicious = True
|
||||
|
||||
if suspicious:
|
||||
total_failures = {
|
||||
identifier: len(_failed_login_attempts.get(identifier, []))
|
||||
for identifier in identifiers
|
||||
}
|
||||
log_security(
|
||||
"auth_bruteforce_detected",
|
||||
severity="warning",
|
||||
username=username,
|
||||
ip=client_ip,
|
||||
failures=total_failures,
|
||||
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверное имя пользователя или пароль"
|
||||
)
|
||||
|
||||
# Create device session and embed into JWT
|
||||
raw_ua = http.headers.get("user-agent") if http else None
|
||||
device_name = http.headers.get("x-device-name") if http else None
|
||||
raw_ua = http.headers.get("user-agent")
|
||||
device_name = http.headers.get("x-device-name")
|
||||
ua = parse_ua(raw_ua or "")
|
||||
session_id = uuid.uuid4().hex
|
||||
|
||||
@@ -82,6 +137,23 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
|
||||
|
||||
token = create_token(user.id, user.username, session_id)
|
||||
|
||||
identifiers = [f"user:{username}"]
|
||||
if client_ip:
|
||||
identifiers.append(f"ip:{client_ip}")
|
||||
for identifier in identifiers:
|
||||
_reset_failed_logins(identifier)
|
||||
|
||||
log_security(
|
||||
"login_success",
|
||||
username=user.username,
|
||||
user_id=user.id,
|
||||
ip=client_ip,
|
||||
session_id=session_id,
|
||||
device=device.device_type,
|
||||
os=device.os_name,
|
||||
browser=device.browser_name,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Login successful",
|
||||
@@ -91,11 +163,12 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(request: RegisterRequest, db: Session = Depends(get_db), http: Request = None):
|
||||
def register(request: RegisterRequest, http: Request, db: Session = Depends(get_db)):
|
||||
username = request.username.strip()
|
||||
display_name = request.display_name.strip()
|
||||
password = request.password.strip()
|
||||
confirm_password = request.confirm_password.strip()
|
||||
client_ip = http.client.host if http.client else None
|
||||
|
||||
# Determine if owner already exists
|
||||
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
||||
@@ -113,12 +186,22 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
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):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||
)
|
||||
if contains_profanity(display_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Отображаемое имя содержит запрещённые слова"
|
||||
)
|
||||
|
||||
if not is_valid_password(password):
|
||||
raise HTTPException(
|
||||
@@ -165,8 +248,8 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
|
||||
db.refresh(new_user)
|
||||
|
||||
# Create initial device session
|
||||
raw_ua = http.headers.get("user-agent") if http else None
|
||||
device_name = http.headers.get("x-device-name") if http else None
|
||||
raw_ua = http.headers.get("user-agent")
|
||||
device_name = http.headers.get("x-device-name")
|
||||
ua = parse_ua(raw_ua or "")
|
||||
session_id = uuid.uuid4().hex
|
||||
device = DeviceSession(
|
||||
@@ -190,6 +273,24 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
|
||||
|
||||
token = create_token(new_user.id, new_user.username, session_id)
|
||||
|
||||
os_name = ua.os.family or "Unknown OS"
|
||||
if ua.os.version_string:
|
||||
os_name = f"{os_name} {ua.os.version_string}"
|
||||
browser_name = ua.browser.family or "Unknown browser"
|
||||
if ua.browser.version_string:
|
||||
browser_name = f"{browser_name} {ua.browser.version_string}"
|
||||
user_agent_summary = f"{os_name}, {browser_name}"
|
||||
|
||||
log_security(
|
||||
"registration_success",
|
||||
username=new_user.username,
|
||||
display_name=new_user.display_name,
|
||||
user_id=new_user.id,
|
||||
ip=client_ip,
|
||||
user_agent=user_agent_summary,
|
||||
owner=is_owner,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Регистрация прошла успешно",
|
||||
@@ -264,10 +365,20 @@ def delete_user_as_owner(
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
log_security(
|
||||
"admin_delete_user",
|
||||
severity="warning",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=user.username,
|
||||
target_id=user.id,
|
||||
)
|
||||
|
||||
return {"status": "success", "deleted_user_id": user_id}
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(
|
||||
http: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
@@ -285,6 +396,15 @@ def logout(
|
||||
current_user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
client_ip = http.client.host if http.client else None
|
||||
log_security(
|
||||
"logout",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip=client_ip,
|
||||
session_id=payload.get("session_id") if payload else None,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Logged out successfully"
|
||||
@@ -294,6 +414,7 @@ def logout(
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
request: ChangePasswordRequest,
|
||||
http: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
@@ -319,6 +440,15 @@ def change_password(
|
||||
).update({DeviceSession.revoked: True})
|
||||
db.commit()
|
||||
|
||||
client_ip = http.client.host if http.client else None
|
||||
log_security(
|
||||
"password_changed",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip=client_ip,
|
||||
logout_others=bool(request.logoutAllExceptCurrent),
|
||||
)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@@ -430,6 +560,13 @@ async def delete_account(
|
||||
|
||||
await _delete_user_data(current_user, db)
|
||||
|
||||
log_security(
|
||||
"self_delete_account",
|
||||
severity="warning",
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Account deleted successfully"
|
||||
|
||||
+326
-55
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
import html
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import os
|
||||
@@ -6,6 +7,11 @@ import re
|
||||
import uuid
|
||||
import asyncio
|
||||
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.responses import FileResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
@@ -19,6 +25,8 @@ from PIL import Image
|
||||
import io
|
||||
import json
|
||||
from better_profanity import profanity as _bp
|
||||
from security.audit import log_access, log_dm, log_public_chat, log_security
|
||||
from security.profanity import censor_text
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
@@ -32,6 +40,110 @@ FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
|
||||
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
|
||||
|
||||
_SPAM_WINDOW_SECONDS = 45
|
||||
_SPAM_SIMILARITY_THRESHOLD = 0.88
|
||||
_SPAM_MESSAGE_LIMIT = 5
|
||||
_BURST_WINDOW_SECONDS = 30
|
||||
_BURST_COUNT_THRESHOLD = 20
|
||||
_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:
|
||||
# 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")
|
||||
async def send_message(
|
||||
request: SendMessageRequest | None = None,
|
||||
@@ -204,23 +276,26 @@ async def send_message(
|
||||
if not original_message:
|
||||
raise HTTPException(status_code=404, detail="Original message not found")
|
||||
|
||||
if not request.content.strip():
|
||||
raw_content = request.content.strip()
|
||||
|
||||
if not raw_content:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No content provided"
|
||||
)
|
||||
|
||||
# Apply profanity filter before storing
|
||||
filtered_content = filter_profanity(request.content.strip())
|
||||
filtered_content = censor_text(raw_content)
|
||||
escaped_content = html.escape(filtered_content, quote=False)
|
||||
|
||||
if len(filtered_content) > 4096:
|
||||
if len(escaped_content) > 4096:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Message too long"
|
||||
)
|
||||
|
||||
new_message = Message(
|
||||
content=filtered_content,
|
||||
content=escaped_content,
|
||||
user_id=current_user.id,
|
||||
reply_to_id=request.reply_to_id,
|
||||
timestamp=datetime.now()
|
||||
@@ -293,7 +368,6 @@ async def send_message(
|
||||
|
||||
# Realtime broadcast for HTTP uploads as well
|
||||
try:
|
||||
from .messaging import messagingManager # self import safe here
|
||||
await messagingManager.broadcast({
|
||||
"type": "newMessage",
|
||||
"data": convert_message(new_message)
|
||||
@@ -301,7 +375,22 @@ async def send_message(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "message": convert_message(new_message)}
|
||||
_monitor_public_message_activity(current_user, filtered_content, db)
|
||||
|
||||
message_payload = convert_message(new_message)
|
||||
log_public_chat(
|
||||
"message_created",
|
||||
message_id=new_message.id,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
reply_to=new_message.reply_to_id,
|
||||
attachments=len(new_message.files or []),
|
||||
length=len(new_message.content),
|
||||
suspended=current_user.suspended,
|
||||
content=new_message.content,
|
||||
)
|
||||
|
||||
return {"status": "success", "message": message_payload}
|
||||
|
||||
|
||||
@router.get("/get_messages")
|
||||
@@ -404,6 +493,7 @@ async def dm_send(
|
||||
)
|
||||
db.add(df)
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
|
||||
# Send push notification for DM
|
||||
try:
|
||||
@@ -433,6 +523,16 @@ async def dm_send(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log_dm(
|
||||
"message_sent",
|
||||
dm_envelope_id=env.id,
|
||||
sender_id=current_user.id,
|
||||
sender_username=current_user.username,
|
||||
recipient_id=env.recipient_id,
|
||||
attachment_count=len(env.files or []),
|
||||
reply_to=env.reply_to_id,
|
||||
)
|
||||
|
||||
return {"status": "ok", "id": env.id}
|
||||
|
||||
def convert_envelopes(envs: list[DMEnvelope]):
|
||||
@@ -531,15 +631,35 @@ async def edit_message(
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
if message.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
||||
if not request.content.strip():
|
||||
raw_content = request.content.strip()
|
||||
|
||||
if not raw_content:
|
||||
raise HTTPException(status_code=400, detail="Message content cannot be empty")
|
||||
message.content = request.content.strip()
|
||||
|
||||
original_content = message.content
|
||||
sanitized_content = censor_text(raw_content)
|
||||
escaped_content = html.escape(sanitized_content, quote=False)
|
||||
if len(escaped_content) > 4096:
|
||||
raise HTTPException(status_code=400, detail="Message too long")
|
||||
|
||||
message.content = escaped_content
|
||||
message.is_edited = True
|
||||
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
return {"status": "success", "message": convert_message(message)}
|
||||
payload = convert_message(message)
|
||||
log_public_chat(
|
||||
"message_edited",
|
||||
message_id=message.id,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
reply_to=message.reply_to_id,
|
||||
content=message.content,
|
||||
previous_content=original_content,
|
||||
)
|
||||
|
||||
return {"status": "success", "message": payload}
|
||||
|
||||
|
||||
@router.delete("/delete_message/{message_id}")
|
||||
@@ -557,9 +677,19 @@ async def delete_message(
|
||||
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
||||
|
||||
original_content = message.content
|
||||
db.delete(message)
|
||||
db.commit()
|
||||
|
||||
log_public_chat(
|
||||
"message_deleted",
|
||||
message_id=message_id,
|
||||
actor_id=current_user.id,
|
||||
actor_username=current_user.username,
|
||||
original_author_id=message.user_id,
|
||||
content=original_content,
|
||||
)
|
||||
|
||||
return {"status": "success", "message_id": message_id}
|
||||
|
||||
|
||||
@@ -600,9 +730,10 @@ async def add_reaction(
|
||||
# Refresh message to get updated reactions
|
||||
db.refresh(message)
|
||||
|
||||
message_data = convert_message(message)
|
||||
|
||||
# Broadcast reaction update
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.broadcast({
|
||||
"type": "reactionUpdate",
|
||||
"data": {
|
||||
@@ -611,13 +742,22 @@ async def add_reaction(
|
||||
"action": action,
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": convert_message(message)["reactions"]
|
||||
"reactions": message_data["reactions"]
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]}
|
||||
log_public_chat(
|
||||
"reaction_update",
|
||||
message_id=request.message_id,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action=action,
|
||||
emoji=request.emoji,
|
||||
)
|
||||
|
||||
return {"status": "success", "action": action, "reactions": message_data["reactions"]}
|
||||
|
||||
|
||||
@router.post("/dm/add_reaction")
|
||||
@@ -661,6 +801,8 @@ async def add_dm_reaction(
|
||||
# Refresh envelope to get updated reactions
|
||||
db.refresh(envelope)
|
||||
|
||||
envelope_data = convert_dm_envelope(envelope)
|
||||
|
||||
# Broadcast reaction update to both participants
|
||||
try:
|
||||
await messagingManager.broadcast({
|
||||
@@ -671,13 +813,22 @@ async def add_dm_reaction(
|
||||
"action": action,
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": convert_dm_envelope(envelope)["reactions"]
|
||||
"reactions": envelope_data["reactions"]
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
|
||||
log_dm(
|
||||
"reaction_update",
|
||||
dm_envelope_id=request.dm_envelope_id,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action=action,
|
||||
emoji=request.emoji,
|
||||
)
|
||||
|
||||
return {"status": "success", "action": action, "reactions": envelope_data["reactions"]}
|
||||
|
||||
|
||||
class MessaggingSocketManager:
|
||||
@@ -697,13 +848,37 @@ class MessaggingSocketManager:
|
||||
# Initialize subscriptions for this connection
|
||||
self.ws_subscriptions[websocket] = set()
|
||||
|
||||
ws_path = getattr(getattr(websocket, "url", None), "path", None)
|
||||
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
|
||||
ws_path = websocket.scope.get("path")
|
||||
ws_path = ws_path or "unknown"
|
||||
headers = {}
|
||||
if isinstance(getattr(websocket, "scope", None), dict):
|
||||
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
|
||||
xff = headers.get("x-forwarded-for")
|
||||
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
|
||||
|
||||
def _log_ws(event: str, user: User | None, **extra: Any) -> None:
|
||||
log_access(
|
||||
"ws_event",
|
||||
path=ws_path,
|
||||
event=event,
|
||||
user=user.username if user else None,
|
||||
user_id=user.id if user else None,
|
||||
ip=client_ip,
|
||||
**extra,
|
||||
)
|
||||
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
type = data["type"]
|
||||
|
||||
def get_current_user_inner() -> User | None:
|
||||
if data["credentials"]:
|
||||
dummy_request = SimpleNamespace()
|
||||
dummy_request.state = SimpleNamespace()
|
||||
return get_current_user(
|
||||
dummy_request,
|
||||
HTTPAuthorizationCredentials(
|
||||
scheme=data["credentials"]["scheme"],
|
||||
credentials=data["credentials"]["credentials"]
|
||||
@@ -714,6 +889,7 @@ class MessaggingSocketManager:
|
||||
return None
|
||||
|
||||
if type == "ping":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if current_user:
|
||||
@@ -737,6 +913,7 @@ class MessaggingSocketManager:
|
||||
}
|
||||
}
|
||||
})
|
||||
_log_ws("ping_error", current_user)
|
||||
except HTTPException:
|
||||
await websocket.send_json({
|
||||
"type": "ping",
|
||||
@@ -748,8 +925,11 @@ class MessaggingSocketManager:
|
||||
}
|
||||
}
|
||||
})
|
||||
_log_ws("ping_error", current_user)
|
||||
await websocket.send_json({"type": "ping", "data": {"status": "success"}})
|
||||
_log_ws("ping", current_user)
|
||||
elif type == "getMessages":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -757,9 +937,12 @@ class MessaggingSocketManager:
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
|
||||
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
|
||||
_log_ws("getMessages", current_user)
|
||||
except HTTPException as e:
|
||||
_log_ws("getMessages_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "sendMessage":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -775,9 +958,12 @@ class MessaggingSocketManager:
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
_log_ws("sendMessage", current_user, message_id=response["message"]["id"])
|
||||
except HTTPException as e:
|
||||
_log_ws("sendMessage_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "dmSend":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -827,9 +1013,21 @@ class MessaggingSocketManager:
|
||||
await self.send_to_user(env.recipient_id, payload);
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
|
||||
await self.send_to_user(env.sender_id, payload);
|
||||
|
||||
_log_ws("dmSend", current_user, dm_envelope_id=env.id, recipient_id=env.recipient_id)
|
||||
log_dm(
|
||||
"message_sent_ws",
|
||||
dm_envelope_id=env.id,
|
||||
sender_id=current_user.id,
|
||||
sender_username=current_user.username,
|
||||
recipient_id=env.recipient_id,
|
||||
reply_to=env.reply_to_id,
|
||||
)
|
||||
except HTTPException as e:
|
||||
_log_ws("dmSend_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "editMessage":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -845,9 +1043,12 @@ class MessaggingSocketManager:
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
_log_ws("editMessage", current_user, message_id=message_id)
|
||||
except HTTPException as e:
|
||||
_log_ws("editMessage_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "dmEdit":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -887,9 +1088,19 @@ class MessaggingSocketManager:
|
||||
await self.send_to_user(env.recipient_id, payload_ws)
|
||||
await self.send_to_user(env.sender_id, payload_ws)
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
|
||||
|
||||
_log_ws("dmEdit", current_user, dm_envelope_id=env.id)
|
||||
log_dm(
|
||||
"message_edited",
|
||||
dm_envelope_id=env.id,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
)
|
||||
except HTTPException as e:
|
||||
_log_ws("dmEdit_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "dmDelete":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -917,9 +1128,20 @@ class MessaggingSocketManager:
|
||||
await self.send_to_user(env.recipient_id, payload_ws)
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}})
|
||||
await self.send_to_user(env.sender_id, payload_ws)
|
||||
|
||||
_log_ws("dmDelete", current_user, dm_envelope_id=env_id)
|
||||
log_dm(
|
||||
"message_deleted",
|
||||
dm_envelope_id=env_id,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
recipient_id=env.recipient_id,
|
||||
)
|
||||
except HTTPException as e:
|
||||
_log_ws("dmDelete_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "deleteMessage":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -933,9 +1155,12 @@ class MessaggingSocketManager:
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
_log_ws("deleteMessage", current_user, message_id=message_id)
|
||||
except HTTPException as e:
|
||||
_log_ws("deleteMessage_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "addReaction":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -963,9 +1188,12 @@ class MessaggingSocketManager:
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
_log_ws("addReaction", current_user, message_id=request_data["message_id"], emoji=request_data["emoji"], action=response["action"])
|
||||
except HTTPException as e:
|
||||
_log_ws("addReaction_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "addDmReaction":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -993,10 +1221,13 @@ class MessaggingSocketManager:
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
_log_ws("addDmReaction", current_user, dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"], action=response["action"])
|
||||
except HTTPException as e:
|
||||
_log_ws("addDmReaction_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "call_signaling":
|
||||
# Forward WebRTC signaling between peers
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -1019,10 +1250,13 @@ class MessaggingSocketManager:
|
||||
|
||||
# Optional ack
|
||||
await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}})
|
||||
_log_ws("call_signaling", current_user, to_user_id=to_user_id)
|
||||
except HTTPException as e:
|
||||
_log_ws("call_signaling_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "call_video_toggle":
|
||||
# Forward video toggle state between peers
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -1049,9 +1283,13 @@ class MessaggingSocketManager:
|
||||
|
||||
await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
_log_ws("call_video_toggle_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
else:
|
||||
_log_ws("call_video_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
||||
elif type == "call_screen_share_toggle":
|
||||
# Forward screen share toggle state between peers
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -1078,8 +1316,12 @@ class MessaggingSocketManager:
|
||||
|
||||
await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
_log_ws("call_screen_share_toggle_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
else:
|
||||
_log_ws("call_screen_share_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
||||
elif type == "subscribeStatus":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -1096,7 +1338,7 @@ class MessaggingSocketManager:
|
||||
"data": {
|
||||
"userId": user_id_to_subscribe,
|
||||
"online": target_user.online,
|
||||
"lastSeen": target_user.last_seen.isoformat()
|
||||
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
|
||||
}
|
||||
})
|
||||
else:
|
||||
@@ -1105,8 +1347,12 @@ class MessaggingSocketManager:
|
||||
"data": {"status": "error", "error": "User not found"}
|
||||
})
|
||||
except HTTPException as e:
|
||||
_log_ws("subscribeStatus_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
else:
|
||||
_log_ws("subscribeStatus", current_user, target_user_id=user_id_to_subscribe)
|
||||
elif type == "unsubscribeStatus":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -1117,8 +1363,12 @@ class MessaggingSocketManager:
|
||||
|
||||
await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
_log_ws("unsubscribeStatus_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
else:
|
||||
_log_ws("unsubscribeStatus", current_user, target_user_id=user_id_to_unsubscribe)
|
||||
elif type == "typing":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -1137,8 +1387,12 @@ class MessaggingSocketManager:
|
||||
|
||||
await websocket.send_json({"type": "typing", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
_log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
else:
|
||||
_log_ws("typing", current_user)
|
||||
elif type == "stopTyping":
|
||||
current_user: User | None = None
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
@@ -1158,7 +1412,10 @@ class MessaggingSocketManager:
|
||||
|
||||
await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
_log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||
await self.send_error(websocket, type, e)
|
||||
else:
|
||||
_log_ws("stopTyping", current_user)
|
||||
elif type == "dmTyping":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
@@ -1219,11 +1476,25 @@ class MessaggingSocketManager:
|
||||
|
||||
async def connect(self, websocket: WebSocket, db: Session):
|
||||
await websocket.accept()
|
||||
client_ip = websocket.client.host if websocket.client else None
|
||||
log_access(
|
||||
"ws_connect",
|
||||
path=str(websocket.url.path),
|
||||
ip=client_ip,
|
||||
)
|
||||
self.connections.append(websocket)
|
||||
try:
|
||||
await self.handle_connection(websocket, db)
|
||||
except WebSocketDisconnect as e:
|
||||
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
||||
log_access(
|
||||
"ws_disconnect",
|
||||
severity="warning" if e.code != 1000 else "info",
|
||||
path=str(websocket.url.path),
|
||||
ip=client_ip,
|
||||
code=e.code,
|
||||
reason=e.reason,
|
||||
)
|
||||
finally:
|
||||
# Cleanup connection
|
||||
self.connections.remove(websocket)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user
|
||||
from models import User
|
||||
from security.audit import log_security
|
||||
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||
|
||||
|
||||
class BlocklistUpdateRequest(BaseModel):
|
||||
words: List[str] = Field(default_factory=list, min_items=1)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/moderation", tags=["moderation"])
|
||||
|
||||
|
||||
def _ensure_owner(user: User) -> None:
|
||||
if user.username != OWNER_USERNAME:
|
||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
||||
|
||||
|
||||
@router.get("/blocklist")
|
||||
def list_blocklist(current_user: User = Depends(get_current_user)):
|
||||
_ensure_owner(current_user)
|
||||
return {"words": get_blocklist()}
|
||||
|
||||
|
||||
@router.post("/blocklist")
|
||||
def append_blocklist(
|
||||
request: BlocklistUpdateRequest,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
_ensure_owner(current_user)
|
||||
added, updated = add_to_blocklist(request.words)
|
||||
log_security(
|
||||
"blocklist_add",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
added=added,
|
||||
)
|
||||
return {"added": added, "words": updated}
|
||||
|
||||
|
||||
@router.delete("/blocklist")
|
||||
def delete_from_blocklist(
|
||||
request: BlocklistUpdateRequest,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
_ensure_owner(current_user)
|
||||
removed, updated = remove_from_blocklist(request.words)
|
||||
log_security(
|
||||
"blocklist_remove",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
removed=removed,
|
||||
)
|
||||
return {"removed": removed, "words": updated}
|
||||
|
||||
+114
-10
@@ -3,7 +3,6 @@ import re
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import inspect, text
|
||||
from PIL import Image
|
||||
import os
|
||||
import uuid
|
||||
@@ -15,9 +14,19 @@ from pydantic import BaseModel
|
||||
from validation import is_valid_username, is_valid_display_name
|
||||
from similarity import is_user_similar_to_verified
|
||||
from .messaging import messagingManager
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ensure_owner_unsuspended(user: User | None, db: Session):
|
||||
if user and user.id == 1 and user.suspended:
|
||||
user.suspended = False
|
||||
user.suspension_reason = None
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Request models
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
username: str | None = None
|
||||
@@ -104,15 +113,53 @@ async def get_user_profile(
|
||||
"""
|
||||
Get current user's profile information
|
||||
"""
|
||||
_ensure_owner_unsuspended(current_user, db)
|
||||
|
||||
return UserProfileResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
display_name=current_user.display_name,
|
||||
profile_picture=current_user.profile_picture,
|
||||
bio=current_user.bio,
|
||||
online=current_user.online,
|
||||
last_seen=current_user.last_seen,
|
||||
created_at=current_user.created_at,
|
||||
verified=current_user.verified,
|
||||
suspended=current_user.suspended or False,
|
||||
suspension_reason=current_user.suspension_reason,
|
||||
deleted=current_user.deleted or False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/user/list")
|
||||
async def list_users(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if current_user.id != 1:
|
||||
raise HTTPException(status_code=403, detail="Only admin can list users")
|
||||
|
||||
_ensure_owner_unsuspended(current_user, db)
|
||||
|
||||
users = db.query(User).order_by(User.username.asc()).all()
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"display_name": current_user.display_name,
|
||||
"profile_picture": current_user.profile_picture,
|
||||
"bio": current_user.bio,
|
||||
"online": current_user.online,
|
||||
"last_seen": current_user.last_seen,
|
||||
"created_at": current_user.created_at
|
||||
"users": [
|
||||
UserProfileResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
profile_picture=user.profile_picture,
|
||||
bio=user.bio,
|
||||
online=user.online,
|
||||
last_seen=user.last_seen,
|
||||
created_at=user.created_at,
|
||||
verified=user.verified,
|
||||
suspended=user.suspended or False,
|
||||
suspension_reason=user.suspension_reason,
|
||||
deleted=user.deleted or False,
|
||||
).model_dump()
|
||||
for user in users
|
||||
]
|
||||
}
|
||||
|
||||
@router.put("/user/profile")
|
||||
@@ -134,6 +181,11 @@ async def update_user_profile(
|
||||
status_code=400,
|
||||
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
||||
)
|
||||
if contains_profanity(username):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Имя пользователя содержит запрещённые слова"
|
||||
)
|
||||
|
||||
# Check if username is already taken by another user
|
||||
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,
|
||||
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||
)
|
||||
if contains_profanity(display_name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Отображаемое имя содержит запрещённые слова"
|
||||
)
|
||||
|
||||
current_user.display_name = display_name
|
||||
updated = True
|
||||
@@ -214,6 +271,8 @@ async def get_user_by_username(
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
_ensure_owner_unsuspended(user, db)
|
||||
|
||||
return UserProfileResponse(
|
||||
id=user.id,
|
||||
@@ -223,7 +282,11 @@ async def get_user_by_username(
|
||||
bio=user.bio,
|
||||
online=user.online,
|
||||
last_seen=user.last_seen,
|
||||
created_at=user.created_at
|
||||
created_at=user.created_at,
|
||||
verified=user.verified,
|
||||
suspended=user.suspended or False,
|
||||
suspension_reason=user.suspension_reason,
|
||||
deleted=user.deleted or False,
|
||||
)
|
||||
|
||||
@router.get("/user/id/{user_id}")
|
||||
@@ -238,6 +301,8 @@ async def get_user_by_id(
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
_ensure_owner_unsuspended(user, db)
|
||||
|
||||
# Handle deleted users
|
||||
if user.deleted:
|
||||
@@ -293,6 +358,15 @@ async def verify_user(
|
||||
target_user.verified = not target_user.verified
|
||||
db.commit()
|
||||
|
||||
log_security(
|
||||
"admin_verify_toggle",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=target_user.username,
|
||||
target_id=target_user.id,
|
||||
verified=target_user.verified,
|
||||
)
|
||||
|
||||
return {
|
||||
"verified": target_user.verified,
|
||||
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
|
||||
@@ -363,6 +437,15 @@ async def suspend_user(
|
||||
target_user.suspension_reason = request.reason
|
||||
db.commit()
|
||||
|
||||
log_security(
|
||||
"admin_suspend_user",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=target_user.username,
|
||||
target_id=target_user.id,
|
||||
reason=request.reason,
|
||||
)
|
||||
|
||||
# Send WebSocket suspension message
|
||||
try:
|
||||
await messagingManager.send_suspension_to_user(user_id, request.reason)
|
||||
@@ -399,6 +482,14 @@ async def unsuspend_user(
|
||||
target_user.suspension_reason = None
|
||||
db.commit()
|
||||
|
||||
log_security(
|
||||
"admin_unsuspend_user",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=target_user.username,
|
||||
target_id=target_user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"User {target_user.username} has been unsuspended"
|
||||
@@ -426,9 +517,22 @@ async def delete_user(
|
||||
if target_user.id == 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin account")
|
||||
|
||||
snapshot_username = target_user.username
|
||||
snapshot_display_name = target_user.display_name
|
||||
|
||||
from .account import _delete_user_data
|
||||
await _delete_user_data(target_user, db)
|
||||
|
||||
log_security(
|
||||
"admin_delete_user",
|
||||
severity="warning",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=snapshot_username,
|
||||
target_display_name=snapshot_display_name,
|
||||
target_id=target_user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"User {target_user.username} has been deleted"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Package marker for security utilities
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,4 +30,5 @@ coverage
|
||||
test_results/
|
||||
out
|
||||
|
||||
data
|
||||
data
|
||||
logs
|
||||
@@ -14,12 +14,15 @@ FROM python:3.12-slim AS runtime
|
||||
WORKDIR /app
|
||||
RUN useradd -u 1000 app && \
|
||||
chown -R app /app
|
||||
USER app
|
||||
|
||||
# 2.2. Copy content and create dirs
|
||||
COPY --chown=app backend .
|
||||
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
|
||||
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
|
||||
@@ -9,7 +9,9 @@ services:
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
volumes:
|
||||
- "data:/app/data"
|
||||
- data:/app/data
|
||||
- logs:/app/logs
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- action: sync+restart
|
||||
@@ -41,4 +43,6 @@ services:
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: fromchat-data
|
||||
name: fromchat-data
|
||||
logs:
|
||||
name: fromchat-logs
|
||||
@@ -3,7 +3,7 @@ import type { Attachment, Message as MessageType, Reaction } from "@/core/types"
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import Quote from "@/core/components/Quote";
|
||||
import { parse } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { escape as escapeHtml } from "he";
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { getCurrentKeys } from "@/core/api/authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
@@ -170,7 +170,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
const formattedMessage = useMemo(() => {
|
||||
// First, temporarily replace existing fromchat.ru links to avoid conflicts
|
||||
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}__`;
|
||||
linkPlaceholders.push(match);
|
||||
return placeholder;
|
||||
@@ -186,8 +186,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link);
|
||||
});
|
||||
|
||||
const rendered = parse(content, { async: false }).trim();
|
||||
|
||||
return {
|
||||
__html: DOMPurify.sanitize(parse(content, { async: false })).trim()
|
||||
__html: rendered
|
||||
};
|
||||
}, [message.content, styles.mentionLink]);
|
||||
|
||||
|
||||
+2
-1
@@ -44,6 +44,7 @@
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.9.0",
|
||||
"@electron-forge/plugin-fuses": "^7.9.0",
|
||||
"@electron/fuses": "^1.0.0",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
@@ -64,9 +65,9 @@
|
||||
"vite-plugin-sass-dts": "^1.3.34"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.7",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"he": "^1.2.0",
|
||||
"marked": "^16.3.0",
|
||||
"mdui": "^2.1.4",
|
||||
"motion": "^12.23.24",
|
||||
|
||||
Reference in New Issue
Block a user