mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Add better profanity filter, better logging, admin CLI
This commit is contained in:
@@ -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 fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from constants import DATABASE_URL
|
from routes import account, messaging, profile, push, webrtc, devices, moderation
|
||||||
from routes import account, messaging, profile, push, webrtc, devices
|
|
||||||
import logging
|
import logging
|
||||||
from models import User
|
from models import User
|
||||||
from constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
from db import POOL_CONFIG, SessionLocal
|
||||||
|
from logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||||
|
from security.audit import log_access
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
@@ -33,11 +35,7 @@ async def lifespan(app: FastAPI):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
# Find the owner user
|
|
||||||
owner = db.query(User).filter(User.username == OWNER_USERNAME).first()
|
owner = db.query(User).filter(User.username == OWNER_USERNAME).first()
|
||||||
if owner and not owner.verified:
|
if owner and not owner.verified:
|
||||||
owner.verified = True
|
owner.verified = True
|
||||||
@@ -47,10 +45,18 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
|
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
|
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to ensure owner verification: {e}")
|
logger.error(f"Failed to ensure owner verification: {e}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
|
||||||
|
POOL_CONFIG["pool_size"],
|
||||||
|
POOL_CONFIG["max_overflow"],
|
||||||
|
POOL_CONFIG["pool_timeout"],
|
||||||
|
POOL_CONFIG["pool_recycle"],
|
||||||
|
POOL_CONFIG["pool_pre_ping"],
|
||||||
|
)
|
||||||
|
|
||||||
# Start the messaging cleanup task
|
# Start the messaging cleanup task
|
||||||
try:
|
try:
|
||||||
from routes.messaging import messagingManager
|
from routes.messaging import messagingManager
|
||||||
@@ -66,6 +72,41 @@ async def lifespan(app: FastAPI):
|
|||||||
# Инициализация FastAPI
|
# Инициализация FastAPI
|
||||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def access_logging_middleware(request: Request, call_next):
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception as exc:
|
||||||
|
duration = time.perf_counter() - start
|
||||||
|
user = getattr(getattr(request, "state", None), "current_user", None)
|
||||||
|
log_access(
|
||||||
|
"http_error",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
status="error",
|
||||||
|
user=getattr(user, "username", None),
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
duration=f"{duration:.3f}s",
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
duration = time.perf_counter() - start
|
||||||
|
user = getattr(getattr(request, "state", None), "current_user", None)
|
||||||
|
log_access(
|
||||||
|
"http_request",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
status=response.status_code,
|
||||||
|
user=getattr(user, "username", None),
|
||||||
|
ip=request.headers.get("x-forwarded-for") or (request.client.host if request.client else None),
|
||||||
|
duration=f"{duration:.3f}s",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -89,4 +130,5 @@ app.include_router(messaging.router)
|
|||||||
app.include_router(profile.router)
|
app.include_router(profile.router)
|
||||||
app.include_router(push.router, prefix="/push")
|
app.include_router(push.router, prefix="/push")
|
||||||
app.include_router(webrtc.router, prefix="/webrtc")
|
app.include_router(webrtc.router, prefix="/webrtc")
|
||||||
app.include_router(devices.router, prefix="/devices")
|
app.include_router(devices.router, prefix="/devices")
|
||||||
|
app.include_router(moderation.router)
|
||||||
+31
-1
@@ -6,5 +6,35 @@ from constants import DATABASE_URL
|
|||||||
# Ensure data directory exists
|
# Ensure data directory exists
|
||||||
os.makedirs("data", exist_ok=True)
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
|
||||||
|
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
|
||||||
|
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||||
|
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
||||||
|
|
||||||
|
POOL_CONFIG = {
|
||||||
|
"pool_size": POOL_SIZE,
|
||||||
|
"max_overflow": MAX_OVERFLOW,
|
||||||
|
"pool_recycle": POOL_RECYCLE,
|
||||||
|
"pool_timeout": POOL_TIMEOUT,
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
engine_kwargs = {
|
||||||
|
"pool_size": POOL_SIZE,
|
||||||
|
"max_overflow": MAX_OVERFLOW,
|
||||||
|
"pool_recycle": POOL_RECYCLE,
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
"pool_timeout": POOL_TIMEOUT,
|
||||||
|
}
|
||||||
|
|
||||||
|
connect_args = {}
|
||||||
|
if DATABASE_URL.startswith("sqlite"):
|
||||||
|
connect_args["check_same_thread"] = False
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
connect_args=connect_args,
|
||||||
|
**engine_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
+12
-2
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from utils import *
|
from utils import *
|
||||||
@@ -17,8 +17,9 @@ def get_db():
|
|||||||
|
|
||||||
# Зависимость для получения текущего пользователя
|
# Зависимость для получения текущего пользователя
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
) -> User:
|
) -> User:
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
@@ -36,6 +37,12 @@ def get_current_user(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if user.id == 1 and user.suspended:
|
||||||
|
user.suspended = False
|
||||||
|
user.suspension_reason = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
# Validate device session from JWT
|
# Validate device session from JWT
|
||||||
session_id = payload.get("session_id")
|
session_id = payload.get("session_id")
|
||||||
if not session_id:
|
if not session_id:
|
||||||
@@ -77,4 +84,7 @@ def get_current_user(
|
|||||||
detail="Account deleted",
|
detail="Account deleted",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
request.state.current_user = user
|
||||||
|
request.state.session_id = session_id
|
||||||
|
|
||||||
return user
|
return user
|
||||||
@@ -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
|
alembic>=1.13.2
|
||||||
better-profanity>=0.7.0
|
better-profanity>=0.7.0
|
||||||
user-agents>=2.2.0
|
user-agents>=2.2.0
|
||||||
|
httpx>=0.27.2
|
||||||
|
rich>=13.9.4
|
||||||
|
|||||||
+134
-8
@@ -1,4 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
import time
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
@@ -8,14 +10,33 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|||||||
|
|
||||||
from constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
from dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
||||||
from utils import create_token, get_password_hash, verify_password
|
from utils import create_token, get_password_hash, verify_password
|
||||||
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from security.audit import log_security
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
||||||
|
_FAILED_ATTEMPT_THRESHOLD = 5
|
||||||
|
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_failed_login(identifier: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
attempts = _failed_login_attempts[identifier]
|
||||||
|
attempts.append(now)
|
||||||
|
|
||||||
|
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
|
||||||
|
attempts.popleft()
|
||||||
|
|
||||||
|
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_failed_logins(identifier: str) -> None:
|
||||||
|
_failed_login_attempts.pop(identifier, None)
|
||||||
|
|
||||||
def convert_user(user: User) -> dict:
|
def convert_user(user: User) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
@@ -43,18 +64,51 @@ def check_auth(current_user: User = Depends(get_current_user)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = None):
|
def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)):
|
||||||
user = db.query(User).filter(User.username == request.username.strip()).first()
|
username = request.username.strip()
|
||||||
|
x_forwarded_for = http.headers.get("x-forwarded-for") if http else None
|
||||||
|
client_ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else (http.client.host if http and http.client else None)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
|
||||||
if not user or not verify_password(request.password.strip(), user.password_hash):
|
if not user or not verify_password(request.password.strip(), user.password_hash):
|
||||||
|
log_security(
|
||||||
|
"login_failed",
|
||||||
|
severity="warning",
|
||||||
|
username=username,
|
||||||
|
ip=client_ip,
|
||||||
|
reason="invalid_credentials",
|
||||||
|
)
|
||||||
|
identifiers = [f"user:{username}"]
|
||||||
|
if client_ip:
|
||||||
|
identifiers.append(f"ip:{client_ip}")
|
||||||
|
|
||||||
|
suspicious = False
|
||||||
|
for identifier in identifiers:
|
||||||
|
if _record_failed_login(identifier):
|
||||||
|
suspicious = True
|
||||||
|
|
||||||
|
if suspicious:
|
||||||
|
total_failures = {
|
||||||
|
identifier: len(_failed_login_attempts.get(identifier, []))
|
||||||
|
for identifier in identifiers
|
||||||
|
}
|
||||||
|
log_security(
|
||||||
|
"auth_bruteforce_detected",
|
||||||
|
severity="warning",
|
||||||
|
username=username,
|
||||||
|
ip=client_ip,
|
||||||
|
failures=total_failures,
|
||||||
|
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Неверное имя пользователя или пароль"
|
detail="Неверное имя пользователя или пароль"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create device session and embed into JWT
|
# Create device session and embed into JWT
|
||||||
raw_ua = http.headers.get("user-agent") if http else None
|
raw_ua = http.headers.get("user-agent")
|
||||||
device_name = http.headers.get("x-device-name") if http else None
|
device_name = http.headers.get("x-device-name")
|
||||||
ua = parse_ua(raw_ua or "")
|
ua = parse_ua(raw_ua or "")
|
||||||
session_id = uuid.uuid4().hex
|
session_id = uuid.uuid4().hex
|
||||||
|
|
||||||
@@ -82,6 +136,23 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
|
|||||||
|
|
||||||
token = create_token(user.id, user.username, session_id)
|
token = create_token(user.id, user.username, session_id)
|
||||||
|
|
||||||
|
identifiers = [f"user:{username}"]
|
||||||
|
if client_ip:
|
||||||
|
identifiers.append(f"ip:{client_ip}")
|
||||||
|
for identifier in identifiers:
|
||||||
|
_reset_failed_logins(identifier)
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"login_success",
|
||||||
|
username=user.username,
|
||||||
|
user_id=user.id,
|
||||||
|
ip=client_ip,
|
||||||
|
session_id=session_id,
|
||||||
|
device=device.device_type,
|
||||||
|
os=device.os_name,
|
||||||
|
browser=device.browser_name,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Login successful",
|
"message": "Login successful",
|
||||||
@@ -91,11 +162,12 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
def register(request: RegisterRequest, db: Session = Depends(get_db), http: Request = None):
|
def register(request: RegisterRequest, http: Request, db: Session = Depends(get_db)):
|
||||||
username = request.username.strip()
|
username = request.username.strip()
|
||||||
display_name = request.display_name.strip()
|
display_name = request.display_name.strip()
|
||||||
password = request.password.strip()
|
password = request.password.strip()
|
||||||
confirm_password = request.confirm_password.strip()
|
confirm_password = request.confirm_password.strip()
|
||||||
|
client_ip = http.client.host if http.client else None
|
||||||
|
|
||||||
# Determine if owner already exists
|
# Determine if owner already exists
|
||||||
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
||||||
@@ -165,8 +237,8 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
|
|||||||
db.refresh(new_user)
|
db.refresh(new_user)
|
||||||
|
|
||||||
# Create initial device session
|
# Create initial device session
|
||||||
raw_ua = http.headers.get("user-agent") if http else None
|
raw_ua = http.headers.get("user-agent")
|
||||||
device_name = http.headers.get("x-device-name") if http else None
|
device_name = http.headers.get("x-device-name")
|
||||||
ua = parse_ua(raw_ua or "")
|
ua = parse_ua(raw_ua or "")
|
||||||
session_id = uuid.uuid4().hex
|
session_id = uuid.uuid4().hex
|
||||||
device = DeviceSession(
|
device = DeviceSession(
|
||||||
@@ -190,6 +262,24 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
|
|||||||
|
|
||||||
token = create_token(new_user.id, new_user.username, session_id)
|
token = create_token(new_user.id, new_user.username, session_id)
|
||||||
|
|
||||||
|
os_name = ua.os.family or "Unknown OS"
|
||||||
|
if ua.os.version_string:
|
||||||
|
os_name = f"{os_name} {ua.os.version_string}"
|
||||||
|
browser_name = ua.browser.family or "Unknown browser"
|
||||||
|
if ua.browser.version_string:
|
||||||
|
browser_name = f"{browser_name} {ua.browser.version_string}"
|
||||||
|
user_agent_summary = f"{os_name}, {browser_name}"
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"registration_success",
|
||||||
|
username=new_user.username,
|
||||||
|
display_name=new_user.display_name,
|
||||||
|
user_id=new_user.id,
|
||||||
|
ip=client_ip,
|
||||||
|
user_agent=user_agent_summary,
|
||||||
|
owner=is_owner,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Регистрация прошла успешно",
|
"message": "Регистрация прошла успешно",
|
||||||
@@ -264,10 +354,20 @@ def delete_user_as_owner(
|
|||||||
db.delete(user)
|
db.delete(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"admin_delete_user",
|
||||||
|
severity="warning",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_username=user.username,
|
||||||
|
target_id=user.id,
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "success", "deleted_user_id": user_id}
|
return {"status": "success", "deleted_user_id": user_id}
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
def logout(
|
def logout(
|
||||||
|
http: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
@@ -285,6 +385,15 @@ def logout(
|
|||||||
current_user.last_seen = datetime.now()
|
current_user.last_seen = datetime.now()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
client_ip = http.client.host if http.client else None
|
||||||
|
log_security(
|
||||||
|
"logout",
|
||||||
|
username=current_user.username,
|
||||||
|
user_id=current_user.id,
|
||||||
|
ip=client_ip,
|
||||||
|
session_id=payload.get("session_id") if payload else None,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Logged out successfully"
|
"message": "Logged out successfully"
|
||||||
@@ -294,6 +403,7 @@ def logout(
|
|||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
def change_password(
|
def change_password(
|
||||||
request: ChangePasswordRequest,
|
request: ChangePasswordRequest,
|
||||||
|
http: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
@@ -319,6 +429,15 @@ def change_password(
|
|||||||
).update({DeviceSession.revoked: True})
|
).update({DeviceSession.revoked: True})
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
client_ip = http.client.host if http.client else None
|
||||||
|
log_security(
|
||||||
|
"password_changed",
|
||||||
|
username=current_user.username,
|
||||||
|
user_id=current_user.id,
|
||||||
|
ip=client_ip,
|
||||||
|
logout_others=bool(request.logoutAllExceptCurrent),
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
@@ -430,6 +549,13 @@ async def delete_account(
|
|||||||
|
|
||||||
await _delete_user_data(current_user, db)
|
await _delete_user_data(current_user, db)
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"self_delete_account",
|
||||||
|
severity="warning",
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Account deleted successfully"
|
"message": "Account deleted successfully"
|
||||||
|
|||||||
+283
-55
@@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
@@ -6,6 +7,10 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
@@ -19,6 +24,8 @@ from PIL import Image
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
from better_profanity import profanity as _bp
|
from better_profanity import profanity as _bp
|
||||||
|
from security.audit import log_access, log_dm, log_public_chat, log_security
|
||||||
|
from security.profanity import censor_text
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
@@ -32,6 +39,68 @@ FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
|||||||
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
|
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
|
||||||
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
|
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
_SPAM_WINDOW_SECONDS = 45
|
||||||
|
_SPAM_SIMILARITY_THRESHOLD = 0.88
|
||||||
|
_SPAM_MESSAGE_LIMIT = 5
|
||||||
|
_BURST_WINDOW_SECONDS = 30
|
||||||
|
_BURST_COUNT_THRESHOLD = 20
|
||||||
|
|
||||||
|
_recent_message_cache: dict[int, deque[tuple[str, float]]] = defaultdict(deque)
|
||||||
|
_message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
|
||||||
|
_burst_last_logged: dict[int, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_public_message_activity(user: User, content: str, db: Session) -> None:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Rate tracking for burst detection
|
||||||
|
rate_bucket = _message_rate_cache[user.id]
|
||||||
|
rate_bucket.append(now)
|
||||||
|
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
|
||||||
|
rate_bucket.popleft()
|
||||||
|
|
||||||
|
if len(rate_bucket) >= _BURST_COUNT_THRESHOLD:
|
||||||
|
last_logged = _burst_last_logged.get(user.id)
|
||||||
|
if not last_logged or now - last_logged > _BURST_WINDOW_SECONDS:
|
||||||
|
log_security(
|
||||||
|
"public_message_burst",
|
||||||
|
severity="warning",
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
count=len(rate_bucket),
|
||||||
|
window_seconds=_BURST_WINDOW_SECONDS,
|
||||||
|
)
|
||||||
|
_burst_last_logged[user.id] = now
|
||||||
|
|
||||||
|
# Similarity-based spam detection
|
||||||
|
history = _recent_message_cache[user.id]
|
||||||
|
history.append((content, now))
|
||||||
|
while history and now - history[0][1] > _SPAM_WINDOW_SECONDS:
|
||||||
|
history.popleft()
|
||||||
|
|
||||||
|
similar_messages = sum(
|
||||||
|
1 for previous_content, _ in history
|
||||||
|
if SequenceMatcher(None, content, previous_content).ratio() >= _SPAM_SIMILARITY_THRESHOLD
|
||||||
|
)
|
||||||
|
|
||||||
|
if similar_messages >= _SPAM_MESSAGE_LIMIT and not user.suspended and user.id != 1:
|
||||||
|
reason = "Automatic suspension: repeated similar public messages"
|
||||||
|
user.suspended = True
|
||||||
|
user.suspension_reason = reason
|
||||||
|
db.commit()
|
||||||
|
log_security(
|
||||||
|
"auto_suspension_public_spam",
|
||||||
|
severity="warning",
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
similar_messages=similar_messages,
|
||||||
|
window_seconds=_SPAM_WINDOW_SECONDS,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def convert_message(msg: Message) -> dict:
|
def convert_message(msg: Message) -> dict:
|
||||||
# Group reactions by emoji
|
# Group reactions by emoji
|
||||||
@@ -138,46 +207,6 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# для тех кто читает этот код я эти маты не писал
|
|
||||||
# мат писал ии а я сам не матерюсь))
|
|
||||||
# - denis0001-dev
|
|
||||||
_RU_EXTRA = [
|
|
||||||
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
|
||||||
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
|
|
||||||
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
|
|
||||||
"долбоёб", "долбоеб", "дебил"
|
|
||||||
]
|
|
||||||
|
|
||||||
_bp.load_censor_words()
|
|
||||||
_bp.add_censor_words(_RU_EXTRA)
|
|
||||||
|
|
||||||
# Additional phrase-level filters (case-insensitive)
|
|
||||||
_PHRASE_PATTERNS: list[re.Pattern] = [
|
|
||||||
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
]
|
|
||||||
|
|
||||||
def _mask_span(text: str, start: int, end: int) -> str:
|
|
||||||
return text[:start] + ("\\*" * (end - start)) + text[end:]
|
|
||||||
|
|
||||||
def _apply_phrase_filters(text: str) -> str:
|
|
||||||
result = text
|
|
||||||
for pattern in _PHRASE_PATTERNS:
|
|
||||||
# Replace all occurrences; iterate until no more matches to avoid overlapping issues
|
|
||||||
while True:
|
|
||||||
m = pattern.search(result)
|
|
||||||
if not m:
|
|
||||||
break
|
|
||||||
result = _mask_span(result, m.start(), m.end())
|
|
||||||
return result
|
|
||||||
|
|
||||||
def filter_profanity(text: str) -> str:
|
|
||||||
preprocessed = _apply_phrase_filters(text)
|
|
||||||
return _bp.censor(preprocessed, censor_char="\\*")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send_message")
|
@router.post("/send_message")
|
||||||
async def send_message(
|
async def send_message(
|
||||||
request: SendMessageRequest | None = None,
|
request: SendMessageRequest | None = None,
|
||||||
@@ -204,23 +233,26 @@ async def send_message(
|
|||||||
if not original_message:
|
if not original_message:
|
||||||
raise HTTPException(status_code=404, detail="Original message not found")
|
raise HTTPException(status_code=404, detail="Original message not found")
|
||||||
|
|
||||||
if not request.content.strip():
|
raw_content = request.content.strip()
|
||||||
|
|
||||||
|
if not raw_content:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="No content provided"
|
detail="No content provided"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply profanity filter before storing
|
# Apply profanity filter before storing
|
||||||
filtered_content = filter_profanity(request.content.strip())
|
filtered_content = censor_text(raw_content)
|
||||||
|
escaped_content = html.escape(filtered_content, quote=False)
|
||||||
|
|
||||||
if len(filtered_content) > 4096:
|
if len(escaped_content) > 4096:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Message too long"
|
detail="Message too long"
|
||||||
)
|
)
|
||||||
|
|
||||||
new_message = Message(
|
new_message = Message(
|
||||||
content=filtered_content,
|
content=escaped_content,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
reply_to_id=request.reply_to_id,
|
reply_to_id=request.reply_to_id,
|
||||||
timestamp=datetime.now()
|
timestamp=datetime.now()
|
||||||
@@ -293,7 +325,6 @@ async def send_message(
|
|||||||
|
|
||||||
# Realtime broadcast for HTTP uploads as well
|
# Realtime broadcast for HTTP uploads as well
|
||||||
try:
|
try:
|
||||||
from .messaging import messagingManager # self import safe here
|
|
||||||
await messagingManager.broadcast({
|
await messagingManager.broadcast({
|
||||||
"type": "newMessage",
|
"type": "newMessage",
|
||||||
"data": convert_message(new_message)
|
"data": convert_message(new_message)
|
||||||
@@ -301,7 +332,22 @@ async def send_message(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {"status": "success", "message": convert_message(new_message)}
|
_monitor_public_message_activity(current_user, filtered_content, db)
|
||||||
|
|
||||||
|
message_payload = convert_message(new_message)
|
||||||
|
log_public_chat(
|
||||||
|
"message_created",
|
||||||
|
message_id=new_message.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
reply_to=new_message.reply_to_id,
|
||||||
|
attachments=len(new_message.files or []),
|
||||||
|
length=len(new_message.content),
|
||||||
|
suspended=current_user.suspended,
|
||||||
|
content=new_message.content,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "message": message_payload}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/get_messages")
|
@router.get("/get_messages")
|
||||||
@@ -404,6 +450,7 @@ async def dm_send(
|
|||||||
)
|
)
|
||||||
db.add(df)
|
db.add(df)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
db.refresh(env)
|
||||||
|
|
||||||
# Send push notification for DM
|
# Send push notification for DM
|
||||||
try:
|
try:
|
||||||
@@ -433,6 +480,16 @@ async def dm_send(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
log_dm(
|
||||||
|
"message_sent",
|
||||||
|
dm_envelope_id=env.id,
|
||||||
|
sender_id=current_user.id,
|
||||||
|
sender_username=current_user.username,
|
||||||
|
recipient_id=env.recipient_id,
|
||||||
|
attachment_count=len(env.files or []),
|
||||||
|
reply_to=env.reply_to_id,
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "ok", "id": env.id}
|
return {"status": "ok", "id": env.id}
|
||||||
|
|
||||||
def convert_envelopes(envs: list[DMEnvelope]):
|
def convert_envelopes(envs: list[DMEnvelope]):
|
||||||
@@ -531,15 +588,35 @@ async def edit_message(
|
|||||||
raise HTTPException(status_code=404, detail="Message not found")
|
raise HTTPException(status_code=404, detail="Message not found")
|
||||||
if message.user_id != current_user.id:
|
if message.user_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
||||||
if not request.content.strip():
|
raw_content = request.content.strip()
|
||||||
|
|
||||||
|
if not raw_content:
|
||||||
raise HTTPException(status_code=400, detail="Message content cannot be empty")
|
raise HTTPException(status_code=400, detail="Message content cannot be empty")
|
||||||
message.content = request.content.strip()
|
|
||||||
|
original_content = message.content
|
||||||
|
sanitized_content = censor_text(raw_content)
|
||||||
|
escaped_content = html.escape(sanitized_content, quote=False)
|
||||||
|
if len(escaped_content) > 4096:
|
||||||
|
raise HTTPException(status_code=400, detail="Message too long")
|
||||||
|
|
||||||
|
message.content = escaped_content
|
||||||
message.is_edited = True
|
message.is_edited = True
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(message)
|
db.refresh(message)
|
||||||
|
|
||||||
return {"status": "success", "message": convert_message(message)}
|
payload = convert_message(message)
|
||||||
|
log_public_chat(
|
||||||
|
"message_edited",
|
||||||
|
message_id=message.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
reply_to=message.reply_to_id,
|
||||||
|
content=message.content,
|
||||||
|
previous_content=original_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "message": payload}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/delete_message/{message_id}")
|
@router.delete("/delete_message/{message_id}")
|
||||||
@@ -557,9 +634,19 @@ async def delete_message(
|
|||||||
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id:
|
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
||||||
|
|
||||||
|
original_content = message.content
|
||||||
db.delete(message)
|
db.delete(message)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
log_public_chat(
|
||||||
|
"message_deleted",
|
||||||
|
message_id=message_id,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
actor_username=current_user.username,
|
||||||
|
original_author_id=message.user_id,
|
||||||
|
content=original_content,
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "success", "message_id": message_id}
|
return {"status": "success", "message_id": message_id}
|
||||||
|
|
||||||
|
|
||||||
@@ -600,9 +687,10 @@ async def add_reaction(
|
|||||||
# Refresh message to get updated reactions
|
# Refresh message to get updated reactions
|
||||||
db.refresh(message)
|
db.refresh(message)
|
||||||
|
|
||||||
|
message_data = convert_message(message)
|
||||||
|
|
||||||
# Broadcast reaction update
|
# Broadcast reaction update
|
||||||
try:
|
try:
|
||||||
from .messaging import messagingManager
|
|
||||||
await messagingManager.broadcast({
|
await messagingManager.broadcast({
|
||||||
"type": "reactionUpdate",
|
"type": "reactionUpdate",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -611,13 +699,22 @@ async def add_reaction(
|
|||||||
"action": action,
|
"action": action,
|
||||||
"user_id": current_user.id,
|
"user_id": current_user.id,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
"reactions": convert_message(message)["reactions"]
|
"reactions": message_data["reactions"]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]}
|
log_public_chat(
|
||||||
|
"reaction_update",
|
||||||
|
message_id=request.message_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
action=action,
|
||||||
|
emoji=request.emoji,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "action": action, "reactions": message_data["reactions"]}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/dm/add_reaction")
|
@router.post("/dm/add_reaction")
|
||||||
@@ -661,6 +758,8 @@ async def add_dm_reaction(
|
|||||||
# Refresh envelope to get updated reactions
|
# Refresh envelope to get updated reactions
|
||||||
db.refresh(envelope)
|
db.refresh(envelope)
|
||||||
|
|
||||||
|
envelope_data = convert_dm_envelope(envelope)
|
||||||
|
|
||||||
# Broadcast reaction update to both participants
|
# Broadcast reaction update to both participants
|
||||||
try:
|
try:
|
||||||
await messagingManager.broadcast({
|
await messagingManager.broadcast({
|
||||||
@@ -671,13 +770,22 @@ async def add_dm_reaction(
|
|||||||
"action": action,
|
"action": action,
|
||||||
"user_id": current_user.id,
|
"user_id": current_user.id,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
"reactions": convert_dm_envelope(envelope)["reactions"]
|
"reactions": envelope_data["reactions"]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
|
log_dm(
|
||||||
|
"reaction_update",
|
||||||
|
dm_envelope_id=request.dm_envelope_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
action=action,
|
||||||
|
emoji=request.emoji,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "action": action, "reactions": envelope_data["reactions"]}
|
||||||
|
|
||||||
|
|
||||||
class MessaggingSocketManager:
|
class MessaggingSocketManager:
|
||||||
@@ -697,13 +805,37 @@ class MessaggingSocketManager:
|
|||||||
# Initialize subscriptions for this connection
|
# Initialize subscriptions for this connection
|
||||||
self.ws_subscriptions[websocket] = set()
|
self.ws_subscriptions[websocket] = set()
|
||||||
|
|
||||||
|
ws_path = getattr(getattr(websocket, "url", None), "path", None)
|
||||||
|
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
|
||||||
|
ws_path = websocket.scope.get("path")
|
||||||
|
ws_path = ws_path or "unknown"
|
||||||
|
headers = {}
|
||||||
|
if isinstance(getattr(websocket, "scope", None), dict):
|
||||||
|
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
|
||||||
|
xff = headers.get("x-forwarded-for")
|
||||||
|
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
|
||||||
|
|
||||||
|
def _log_ws(event: str, user: User | None, **extra: Any) -> None:
|
||||||
|
log_access(
|
||||||
|
"ws_event",
|
||||||
|
path=ws_path,
|
||||||
|
event=event,
|
||||||
|
user=user.username if user else None,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
ip=client_ip,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_json()
|
data = await websocket.receive_json()
|
||||||
type = data["type"]
|
type = data["type"]
|
||||||
|
|
||||||
def get_current_user_inner() -> User | None:
|
def get_current_user_inner() -> User | None:
|
||||||
if data["credentials"]:
|
if data["credentials"]:
|
||||||
|
dummy_request = SimpleNamespace()
|
||||||
|
dummy_request.state = SimpleNamespace()
|
||||||
return get_current_user(
|
return get_current_user(
|
||||||
|
dummy_request,
|
||||||
HTTPAuthorizationCredentials(
|
HTTPAuthorizationCredentials(
|
||||||
scheme=data["credentials"]["scheme"],
|
scheme=data["credentials"]["scheme"],
|
||||||
credentials=data["credentials"]["credentials"]
|
credentials=data["credentials"]["credentials"]
|
||||||
@@ -714,6 +846,7 @@ class MessaggingSocketManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if type == "ping":
|
if type == "ping":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if current_user:
|
if current_user:
|
||||||
@@ -737,6 +870,7 @@ class MessaggingSocketManager:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
_log_ws("ping_error", current_user)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "ping",
|
"type": "ping",
|
||||||
@@ -748,8 +882,11 @@ class MessaggingSocketManager:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
_log_ws("ping_error", current_user)
|
||||||
await websocket.send_json({"type": "ping", "data": {"status": "success"}})
|
await websocket.send_json({"type": "ping", "data": {"status": "success"}})
|
||||||
|
_log_ws("ping", current_user)
|
||||||
elif type == "getMessages":
|
elif type == "getMessages":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -757,9 +894,12 @@ class MessaggingSocketManager:
|
|||||||
self.user_by_ws[websocket] = current_user.id
|
self.user_by_ws[websocket] = current_user.id
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
|
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
|
||||||
|
_log_ws("getMessages", current_user)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("getMessages_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "sendMessage":
|
elif type == "sendMessage":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -775,9 +915,12 @@ class MessaggingSocketManager:
|
|||||||
})
|
})
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
await websocket.send_json({"type": type, "data": response})
|
||||||
|
_log_ws("sendMessage", current_user, message_id=response["message"]["id"])
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("sendMessage_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "dmSend":
|
elif type == "dmSend":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -827,9 +970,21 @@ class MessaggingSocketManager:
|
|||||||
await self.send_to_user(env.recipient_id, payload);
|
await self.send_to_user(env.recipient_id, payload);
|
||||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
|
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
|
||||||
await self.send_to_user(env.sender_id, payload);
|
await self.send_to_user(env.sender_id, payload);
|
||||||
|
|
||||||
|
_log_ws("dmSend", current_user, dm_envelope_id=env.id, recipient_id=env.recipient_id)
|
||||||
|
log_dm(
|
||||||
|
"message_sent_ws",
|
||||||
|
dm_envelope_id=env.id,
|
||||||
|
sender_id=current_user.id,
|
||||||
|
sender_username=current_user.username,
|
||||||
|
recipient_id=env.recipient_id,
|
||||||
|
reply_to=env.reply_to_id,
|
||||||
|
)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("dmSend_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "editMessage":
|
elif type == "editMessage":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -845,9 +1000,12 @@ class MessaggingSocketManager:
|
|||||||
})
|
})
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
await websocket.send_json({"type": type, "data": response})
|
||||||
|
_log_ws("editMessage", current_user, message_id=message_id)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("editMessage_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "dmEdit":
|
elif type == "dmEdit":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -887,9 +1045,19 @@ class MessaggingSocketManager:
|
|||||||
await self.send_to_user(env.recipient_id, payload_ws)
|
await self.send_to_user(env.recipient_id, payload_ws)
|
||||||
await self.send_to_user(env.sender_id, payload_ws)
|
await self.send_to_user(env.sender_id, payload_ws)
|
||||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
|
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
|
||||||
|
|
||||||
|
_log_ws("dmEdit", current_user, dm_envelope_id=env.id)
|
||||||
|
log_dm(
|
||||||
|
"message_edited",
|
||||||
|
dm_envelope_id=env.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("dmEdit_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "dmDelete":
|
elif type == "dmDelete":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -917,9 +1085,20 @@ class MessaggingSocketManager:
|
|||||||
await self.send_to_user(env.recipient_id, payload_ws)
|
await self.send_to_user(env.recipient_id, payload_ws)
|
||||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}})
|
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}})
|
||||||
await self.send_to_user(env.sender_id, payload_ws)
|
await self.send_to_user(env.sender_id, payload_ws)
|
||||||
|
|
||||||
|
_log_ws("dmDelete", current_user, dm_envelope_id=env_id)
|
||||||
|
log_dm(
|
||||||
|
"message_deleted",
|
||||||
|
dm_envelope_id=env_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
recipient_id=env.recipient_id,
|
||||||
|
)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("dmDelete_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "deleteMessage":
|
elif type == "deleteMessage":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -933,9 +1112,12 @@ class MessaggingSocketManager:
|
|||||||
})
|
})
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
await websocket.send_json({"type": type, "data": response})
|
||||||
|
_log_ws("deleteMessage", current_user, message_id=message_id)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("deleteMessage_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "addReaction":
|
elif type == "addReaction":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -963,9 +1145,12 @@ class MessaggingSocketManager:
|
|||||||
})
|
})
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
await websocket.send_json({"type": type, "data": response})
|
||||||
|
_log_ws("addReaction", current_user, message_id=request_data["message_id"], emoji=request_data["emoji"], action=response["action"])
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("addReaction_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "addDmReaction":
|
elif type == "addDmReaction":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -993,10 +1178,13 @@ class MessaggingSocketManager:
|
|||||||
})
|
})
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
await websocket.send_json({"type": type, "data": response})
|
||||||
|
_log_ws("addDmReaction", current_user, dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"], action=response["action"])
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("addDmReaction_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "call_signaling":
|
elif type == "call_signaling":
|
||||||
# Forward WebRTC signaling between peers
|
# Forward WebRTC signaling between peers
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -1019,10 +1207,13 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
# Optional ack
|
# Optional ack
|
||||||
await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}})
|
await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}})
|
||||||
|
_log_ws("call_signaling", current_user, to_user_id=to_user_id)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("call_signaling_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "call_video_toggle":
|
elif type == "call_video_toggle":
|
||||||
# Forward video toggle state between peers
|
# Forward video toggle state between peers
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -1049,9 +1240,13 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}})
|
await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}})
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("call_video_toggle_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
|
else:
|
||||||
|
_log_ws("call_video_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
||||||
elif type == "call_screen_share_toggle":
|
elif type == "call_screen_share_toggle":
|
||||||
# Forward screen share toggle state between peers
|
# Forward screen share toggle state between peers
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -1078,8 +1273,12 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}})
|
await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}})
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("call_screen_share_toggle_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
|
else:
|
||||||
|
_log_ws("call_screen_share_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
||||||
elif type == "subscribeStatus":
|
elif type == "subscribeStatus":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -1096,7 +1295,7 @@ class MessaggingSocketManager:
|
|||||||
"data": {
|
"data": {
|
||||||
"userId": user_id_to_subscribe,
|
"userId": user_id_to_subscribe,
|
||||||
"online": target_user.online,
|
"online": target_user.online,
|
||||||
"lastSeen": target_user.last_seen.isoformat()
|
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -1105,8 +1304,12 @@ class MessaggingSocketManager:
|
|||||||
"data": {"status": "error", "error": "User not found"}
|
"data": {"status": "error", "error": "User not found"}
|
||||||
})
|
})
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("subscribeStatus_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
|
else:
|
||||||
|
_log_ws("subscribeStatus", current_user, target_user_id=user_id_to_subscribe)
|
||||||
elif type == "unsubscribeStatus":
|
elif type == "unsubscribeStatus":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -1117,8 +1320,12 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}})
|
await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}})
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("unsubscribeStatus_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
|
else:
|
||||||
|
_log_ws("unsubscribeStatus", current_user, target_user_id=user_id_to_unsubscribe)
|
||||||
elif type == "typing":
|
elif type == "typing":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -1137,8 +1344,12 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
await websocket.send_json({"type": "typing", "data": {"status": "ok"}})
|
await websocket.send_json({"type": "typing", "data": {"status": "ok"}})
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
|
else:
|
||||||
|
_log_ws("typing", current_user)
|
||||||
elif type == "stopTyping":
|
elif type == "stopTyping":
|
||||||
|
current_user: User | None = None
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -1158,7 +1369,10 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}})
|
await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}})
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
_log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e)))
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
|
else:
|
||||||
|
_log_ws("stopTyping", current_user)
|
||||||
elif type == "dmTyping":
|
elif type == "dmTyping":
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
@@ -1219,11 +1433,25 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
async def connect(self, websocket: WebSocket, db: Session):
|
async def connect(self, websocket: WebSocket, db: Session):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
client_ip = websocket.client.host if websocket.client else None
|
||||||
|
log_access(
|
||||||
|
"ws_connect",
|
||||||
|
path=str(websocket.url.path),
|
||||||
|
ip=client_ip,
|
||||||
|
)
|
||||||
self.connections.append(websocket)
|
self.connections.append(websocket)
|
||||||
try:
|
try:
|
||||||
await self.handle_connection(websocket, db)
|
await self.handle_connection(websocket, db)
|
||||||
except WebSocketDisconnect as e:
|
except WebSocketDisconnect as e:
|
||||||
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
||||||
|
log_access(
|
||||||
|
"ws_disconnect",
|
||||||
|
severity="warning" if e.code != 1000 else "info",
|
||||||
|
path=str(websocket.url.path),
|
||||||
|
ip=client_ip,
|
||||||
|
code=e.code,
|
||||||
|
reason=e.reason,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
# Cleanup connection
|
# Cleanup connection
|
||||||
self.connections.remove(websocket)
|
self.connections.remove(websocket)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from constants import OWNER_USERNAME
|
||||||
|
from dependencies import get_current_user
|
||||||
|
from models import User
|
||||||
|
from security.audit import log_security
|
||||||
|
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||||
|
|
||||||
|
|
||||||
|
class BlocklistUpdateRequest(BaseModel):
|
||||||
|
words: List[str] = Field(default_factory=list, min_items=1)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/moderation", tags=["moderation"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_owner(user: User) -> None:
|
||||||
|
if user.username != OWNER_USERNAME:
|
||||||
|
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/blocklist")
|
||||||
|
def list_blocklist(current_user: User = Depends(get_current_user)):
|
||||||
|
_ensure_owner(current_user)
|
||||||
|
return {"words": get_blocklist()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/blocklist")
|
||||||
|
def append_blocklist(
|
||||||
|
request: BlocklistUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
_ensure_owner(current_user)
|
||||||
|
added, updated = add_to_blocklist(request.words)
|
||||||
|
log_security(
|
||||||
|
"blocklist_add",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
added=added,
|
||||||
|
)
|
||||||
|
return {"added": added, "words": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/blocklist")
|
||||||
|
def delete_from_blocklist(
|
||||||
|
request: BlocklistUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
_ensure_owner(current_user)
|
||||||
|
removed, updated = remove_from_blocklist(request.words)
|
||||||
|
log_security(
|
||||||
|
"blocklist_remove",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
removed=removed,
|
||||||
|
)
|
||||||
|
return {"removed": removed, "words": updated}
|
||||||
|
|
||||||
+103
-10
@@ -3,7 +3,6 @@ import re
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import inspect, text
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
@@ -15,9 +14,18 @@ from pydantic import BaseModel
|
|||||||
from validation import is_valid_username, is_valid_display_name
|
from validation import is_valid_username, is_valid_display_name
|
||||||
from similarity import is_user_similar_to_verified
|
from similarity import is_user_similar_to_verified
|
||||||
from .messaging import messagingManager
|
from .messaging import messagingManager
|
||||||
|
from security.audit import log_security
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_owner_unsuspended(user: User | None, db: Session):
|
||||||
|
if user and user.id == 1 and user.suspended:
|
||||||
|
user.suspended = False
|
||||||
|
user.suspension_reason = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
# Request models
|
# Request models
|
||||||
class UpdateProfileRequest(BaseModel):
|
class UpdateProfileRequest(BaseModel):
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
@@ -104,15 +112,53 @@ async def get_user_profile(
|
|||||||
"""
|
"""
|
||||||
Get current user's profile information
|
Get current user's profile information
|
||||||
"""
|
"""
|
||||||
|
_ensure_owner_unsuspended(current_user, db)
|
||||||
|
|
||||||
|
return UserProfileResponse(
|
||||||
|
id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
display_name=current_user.display_name,
|
||||||
|
profile_picture=current_user.profile_picture,
|
||||||
|
bio=current_user.bio,
|
||||||
|
online=current_user.online,
|
||||||
|
last_seen=current_user.last_seen,
|
||||||
|
created_at=current_user.created_at,
|
||||||
|
verified=current_user.verified,
|
||||||
|
suspended=current_user.suspended or False,
|
||||||
|
suspension_reason=current_user.suspension_reason,
|
||||||
|
deleted=current_user.deleted or False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/list")
|
||||||
|
async def list_users(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
if current_user.id != 1:
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin can list users")
|
||||||
|
|
||||||
|
_ensure_owner_unsuspended(current_user, db)
|
||||||
|
|
||||||
|
users = db.query(User).order_by(User.username.asc()).all()
|
||||||
return {
|
return {
|
||||||
"id": current_user.id,
|
"users": [
|
||||||
"username": current_user.username,
|
UserProfileResponse(
|
||||||
"display_name": current_user.display_name,
|
id=user.id,
|
||||||
"profile_picture": current_user.profile_picture,
|
username=user.username,
|
||||||
"bio": current_user.bio,
|
display_name=user.display_name,
|
||||||
"online": current_user.online,
|
profile_picture=user.profile_picture,
|
||||||
"last_seen": current_user.last_seen,
|
bio=user.bio,
|
||||||
"created_at": current_user.created_at
|
online=user.online,
|
||||||
|
last_seen=user.last_seen,
|
||||||
|
created_at=user.created_at,
|
||||||
|
verified=user.verified,
|
||||||
|
suspended=user.suspended or False,
|
||||||
|
suspension_reason=user.suspension_reason,
|
||||||
|
deleted=user.deleted or False,
|
||||||
|
).model_dump()
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.put("/user/profile")
|
@router.put("/user/profile")
|
||||||
@@ -214,6 +260,8 @@ async def get_user_by_username(
|
|||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
_ensure_owner_unsuspended(user, db)
|
||||||
|
|
||||||
return UserProfileResponse(
|
return UserProfileResponse(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
@@ -223,7 +271,11 @@ async def get_user_by_username(
|
|||||||
bio=user.bio,
|
bio=user.bio,
|
||||||
online=user.online,
|
online=user.online,
|
||||||
last_seen=user.last_seen,
|
last_seen=user.last_seen,
|
||||||
created_at=user.created_at
|
created_at=user.created_at,
|
||||||
|
verified=user.verified,
|
||||||
|
suspended=user.suspended or False,
|
||||||
|
suspension_reason=user.suspension_reason,
|
||||||
|
deleted=user.deleted or False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/user/id/{user_id}")
|
@router.get("/user/id/{user_id}")
|
||||||
@@ -238,6 +290,8 @@ async def get_user_by_id(
|
|||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
_ensure_owner_unsuspended(user, db)
|
||||||
|
|
||||||
# Handle deleted users
|
# Handle deleted users
|
||||||
if user.deleted:
|
if user.deleted:
|
||||||
@@ -293,6 +347,15 @@ async def verify_user(
|
|||||||
target_user.verified = not target_user.verified
|
target_user.verified = not target_user.verified
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"admin_verify_toggle",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_username=target_user.username,
|
||||||
|
target_id=target_user.id,
|
||||||
|
verified=target_user.verified,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"verified": target_user.verified,
|
"verified": target_user.verified,
|
||||||
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
|
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
|
||||||
@@ -363,6 +426,15 @@ async def suspend_user(
|
|||||||
target_user.suspension_reason = request.reason
|
target_user.suspension_reason = request.reason
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"admin_suspend_user",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_username=target_user.username,
|
||||||
|
target_id=target_user.id,
|
||||||
|
reason=request.reason,
|
||||||
|
)
|
||||||
|
|
||||||
# Send WebSocket suspension message
|
# Send WebSocket suspension message
|
||||||
try:
|
try:
|
||||||
await messagingManager.send_suspension_to_user(user_id, request.reason)
|
await messagingManager.send_suspension_to_user(user_id, request.reason)
|
||||||
@@ -399,6 +471,14 @@ async def unsuspend_user(
|
|||||||
target_user.suspension_reason = None
|
target_user.suspension_reason = None
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"admin_unsuspend_user",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_username=target_user.username,
|
||||||
|
target_id=target_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"User {target_user.username} has been unsuspended"
|
"message": f"User {target_user.username} has been unsuspended"
|
||||||
@@ -426,9 +506,22 @@ async def delete_user(
|
|||||||
if target_user.id == 1:
|
if target_user.id == 1:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete admin account")
|
raise HTTPException(status_code=400, detail="Cannot delete admin account")
|
||||||
|
|
||||||
|
snapshot_username = target_user.username
|
||||||
|
snapshot_display_name = target_user.display_name
|
||||||
|
|
||||||
from .account import _delete_user_data
|
from .account import _delete_user_data
|
||||||
await _delete_user_data(target_user, db)
|
await _delete_user_data(target_user, db)
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"admin_delete_user",
|
||||||
|
severity="warning",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_username=snapshot_username,
|
||||||
|
target_display_name=snapshot_display_name,
|
||||||
|
target_id=target_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"User {target_user.username} has been deleted"
|
"message": f"User {target_user.username} has been deleted"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Package marker for security utilities
|
||||||
|
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
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)}",
|
||||||
|
f"Similar messages detected: {fields.get('similar_messages')}",
|
||||||
|
]
|
||||||
|
if fields.get("window_seconds"):
|
||||||
|
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
||||||
|
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,149 @@
|
|||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
_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():]
|
||||||
|
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 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
|
||||||
|
|
||||||
@@ -14,12 +14,15 @@ FROM python:3.12-slim AS runtime
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN useradd -u 1000 app && \
|
RUN useradd -u 1000 app && \
|
||||||
chown -R app /app
|
chown -R app /app
|
||||||
USER app
|
|
||||||
|
|
||||||
# 2.2. Copy content and create dirs
|
# 2.2. Copy content and create dirs
|
||||||
COPY --chown=app backend .
|
COPY --chown=app backend .
|
||||||
COPY --from=builder --chown=app /app/.venv .venv
|
COPY --from=builder --chown=app /app/.venv .venv
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data && \
|
||||||
|
printf '#!/bin/sh\nexec /app/.venv/bin/python /app/admin_cli.py "$@"\n' > /usr/local/bin/admin-cli && \
|
||||||
|
chmod +x /usr/local/bin/admin-cli
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
# 3. Final command
|
# 3. Final command
|
||||||
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
|
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
|
||||||
@@ -9,7 +9,9 @@ services:
|
|||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- "data:/app/data"
|
- data:/app/data
|
||||||
|
- logs:/app/logs
|
||||||
|
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- action: sync+restart
|
- action: sync+restart
|
||||||
@@ -41,4 +43,6 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
name: fromchat-data
|
name: fromchat-data
|
||||||
|
logs:
|
||||||
|
name: fromchat-logs
|
||||||
@@ -3,7 +3,7 @@ import type { Attachment, Message as MessageType, Reaction } from "@/core/types"
|
|||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
import Quote from "@/core/components/Quote";
|
import Quote from "@/core/components/Quote";
|
||||||
import { parse } from "marked";
|
import { parse } from "marked";
|
||||||
import DOMPurify from "dompurify";
|
import { escape as escapeHtml } from "he";
|
||||||
import { useEffect, useState, useRef, useMemo } from "react";
|
import { useEffect, useState, useRef, useMemo } from "react";
|
||||||
import { getCurrentKeys } from "@/core/api/authApi";
|
import { getCurrentKeys } from "@/core/api/authApi";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||||
@@ -170,7 +170,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
const formattedMessage = useMemo(() => {
|
const formattedMessage = useMemo(() => {
|
||||||
// First, temporarily replace existing fromchat.ru links to avoid conflicts
|
// First, temporarily replace existing fromchat.ru links to avoid conflicts
|
||||||
const linkPlaceholders: string[] = [];
|
const linkPlaceholders: string[] = [];
|
||||||
let content = message.content.replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => {
|
let content = escapeHtml(message.content).replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => {
|
||||||
const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`;
|
const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`;
|
||||||
linkPlaceholders.push(match);
|
linkPlaceholders.push(match);
|
||||||
return placeholder;
|
return placeholder;
|
||||||
@@ -186,8 +186,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link);
|
content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rendered = parse(content, { async: false }).trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
__html: DOMPurify.sanitize(parse(content, { async: false })).trim()
|
__html: rendered
|
||||||
};
|
};
|
||||||
}, [message.content, styles.mentionLink]);
|
}, [message.content, styles.mentionLink]);
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -44,6 +44,7 @@
|
|||||||
"@electron-forge/plugin-auto-unpack-natives": "^7.9.0",
|
"@electron-forge/plugin-auto-unpack-natives": "^7.9.0",
|
||||||
"@electron-forge/plugin-fuses": "^7.9.0",
|
"@electron-forge/plugin-fuses": "^7.9.0",
|
||||||
"@electron/fuses": "^1.0.0",
|
"@electron/fuses": "^1.0.0",
|
||||||
|
"@types/he": "^1.2.3",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
@@ -64,9 +65,9 @@
|
|||||||
"vite-plugin-sass-dts": "^1.3.34"
|
"vite-plugin-sass-dts": "^1.3.34"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.2.7",
|
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
|
"he": "^1.2.0",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
"mdui": "^2.1.4",
|
"mdui": "^2.1.4",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
|||||||
Reference in New Issue
Block a user