Add member count API for public chat

This commit is contained in:
2026-04-03 19:20:21 +03:00
Unverified
parent 579b55477e
commit 3b45cfda5c
4 changed files with 60 additions and 3 deletions
@@ -0,0 +1,10 @@
---
description: Do not filter cache, API, or DB fields by matching fixed UI/placeholder English strings
alwaysApply: true
---
# No magic-string “sanitization” of user or message data
- **Never** strip, null out, or rewrite stored or displayed values by comparing them to hard-coded UI strings (e.g. `"Direct messages"`, `"Direct message"`, `"User 123"`, etc.). Those strings can be legitimate **usernames, display names, or message text**.
- **Prefer**: fix the source (dont persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
- Applies especially to: local storage/cache layers, list previews, and any code that “cleans” strings before show or read.
+27 -3
View File
@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from collections import defaultdict, deque from collections import defaultdict, deque
import time import time
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
import uuid import uuid
@@ -25,6 +25,17 @@ _FAILED_ATTEMPT_THRESHOLD = 5
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque) _failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
async def _broadcast_registered_user_count_task():
from ..db import SessionLocal
from .messaging import messagingManager
db = SessionLocal()
try:
await messagingManager.broadcast_registered_user_count(db)
finally:
db.close()
def _record_failed_login(identifier: str) -> bool: def _record_failed_login(identifier: str) -> bool:
now = time.time() now = time.time()
attempts = _failed_login_attempts[identifier] attempts = _failed_login_attempts[identifier]
@@ -173,7 +184,12 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
@router.post("/register") @router.post("/register")
@rate_limit_per_ip("3/hour") @rate_limit_per_ip("3/hour")
def register(request: Request, register_request: RegisterRequest, db: Session = Depends(get_db)): def register(
request: Request,
register_request: RegisterRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
username = register_request.username.strip() username = register_request.username.strip()
display_name = register_request.display_name.strip() display_name = register_request.display_name.strip()
password = register_request.password.strip() password = register_request.password.strip()
@@ -288,6 +304,8 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
owner=is_owner, owner=is_owner,
) )
background_tasks.add_task(_broadcast_registered_user_count_task)
return { return {
"status": "success", "status": "success",
"message": "Регистрация прошла успешно", "message": "Регистрация прошла успешно",
@@ -550,6 +568,12 @@ async def _delete_user_data(user: User, db: Session):
# Log error but don't fail the request # Log error but don't fail the request
pass pass
try:
from .messaging import messagingManager
await messagingManager.broadcast_registered_user_count(db)
except Exception:
pass
@router.post("/delete") @router.post("/delete")
async def delete_account( async def delete_account(
@@ -564,7 +588,7 @@ async def delete_account(
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account") raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
await _delete_user_data(current_user, db) await _delete_user_data(current_user, db)
log_security( log_security(
"self_delete_account", "self_delete_account",
severity="warning", severity="warning",
+13
View File
@@ -1045,6 +1045,8 @@ class MessaggingSocketManager:
elif update_type == "statusUpdate": elif update_type == "statusUpdate":
# Deduplicate by user ID # Deduplicate by user ID
sig_data = {"type": update_type, "userId": data.get("userId")} sig_data = {"type": update_type, "userId": data.get("userId")}
elif update_type == "registeredUserCount":
sig_data = {"type": update_type, "count": data.get("count")}
else: else:
# For unknown types, use full data (less efficient but safe) # For unknown types, use full data (less efficient but safe)
sig_data = {"type": update_type, "data": data} sig_data = {"type": update_type, "data": data}
@@ -1297,6 +1299,17 @@ class MessaggingSocketManager:
if websocket in self.user_by_ws: if websocket in self.user_by_ws:
await self._send_update(websocket, message_type, update_data, db) await self._send_update(websocket, message_type, update_data, db)
async def broadcast_registered_user_count(self, db: Session):
"""Notify all clients of the current non-deleted user count (public chat member count)."""
try:
n = db.query(User).filter(User.deleted.is_(False)).count()
except Exception:
return
try:
await self.broadcast({"type": "registeredUserCount", "data": {"count": n}}, db)
except Exception:
pass
async def send_update_to_user(self, user_id: int, update_type: str, update_data: dict, db: Session | None = None): async def send_update_to_user(self, user_id: int, update_type: str, update_data: dict, db: Session | None = None):
"""Send an update to a specific user (batched)""" """Send an update to a specific user (batched)"""
for websocket in self.connections: for websocket in self.connections:
+10
View File
@@ -276,6 +276,16 @@ async def update_user_bio(
} }
@router.get("/user/stats/registered-count")
def get_registered_user_count(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Number of registered accounts (non-deleted users)."""
n = db.query(User).filter(User.deleted.is_(False)).count()
return {"count": n}
@router.get("/user/{username}") @router.get("/user/{username}")
async def get_user_by_username( async def get_user_by_username(
username: str, username: str,