Implement legal terms, Android client compatibility and more

This commit is contained in:
2026-07-13 13:13:05 +03:00
Unverified
parent 54cf37df6c
commit 6de18d0ddc
73 changed files with 3922 additions and 635 deletions
+198 -59
View File
@@ -9,13 +9,28 @@ from sqlalchemy import inspect, text
import uuid
import secrets
from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from ..constants import OWNER_USERNAME
from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db
from ..models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
from ..models import (
LoginRequest,
RegisterRequest,
ChangePasswordRequest,
VerifyPasswordRequest,
DeleteAccountRequest,
User,
CryptoPublicKey,
CryptoBackup,
DeviceSession,
)
from ..utils import create_token, get_password_hash, verify_password, get_client_ip
from ..validation import is_valid_password, is_valid_username, is_valid_display_name
from ..deleted_user import (
apply_deleted_user_db_fields,
deleted_user_api_fields,
is_deleted_user,
is_suspended_user,
)
import os
from ..security.audit import log_security
@@ -99,23 +114,70 @@ def _reset_failed_logins(identifier: str) -> None:
def _is_admin(user: User) -> bool:
return user.id == 1
def convert_user(user: User) -> dict:
def convert_user(user: User, db: Session) -> dict:
from ..presence_service import presence_service
from ..verification_service import compute_verification_status, get_verified_users_data
if is_deleted_user(user):
return {
"id": user.id,
"admin": _is_admin(user),
**deleted_user_api_fields(user.id),
}
online, last_seen = presence_service.get_presence(user.id)
verified_users_data = get_verified_users_data(db)
verification_status = compute_verification_status(user, verified_users_data)
effective_last_seen = last_seen or user.last_seen or user.created_at
return {
"id": user.id,
"created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(),
"online": user.online,
"last_seen": effective_last_seen.isoformat(),
"online": online,
"username": user.username,
"display_name": user.display_name,
"profile_picture": user.profile_picture,
"bio": user.bio,
"admin": _is_admin(user),
"verified": user.verified,
"verification_status": verification_status.value,
"suspended": user.suspended or False,
"suspension_reason": user.suspension_reason,
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
"deleted": False,
}
def convert_user_for_dm_conversation(user: User, db: Session) -> dict:
"""Minimal user payload for DM conversation list entries."""
from ..presence_service import presence_service
from ..verification_service import compute_verification_status, get_verified_users_data
if is_deleted_user(user):
return {
"id": user.id,
**deleted_user_api_fields(user.id),
}
online, last_seen = presence_service.get_presence(user.id)
verified_users_data = get_verified_users_data(db)
verification_status = compute_verification_status(user, verified_users_data)
effective_last_seen = last_seen or user.last_seen or user.created_at
payload = {
"id": user.id,
"username": user.username,
"display_name": user.display_name,
"profile_picture": user.profile_picture,
"deleted": False,
"verification_status": verification_status.value,
"online": online,
"last_seen": effective_last_seen.isoformat(),
}
if is_suspended_user(user):
payload["suspended"] = True
payload["suspension_reason"] = user.suspension_reason
return payload
@router.get("/instance_id")
def get_instance_id_public():
"""Public deploy fingerprint (used when the client changes server host/port)."""
@@ -131,6 +193,19 @@ def check_auth(current_user: User = Depends(get_current_user)):
}
@router.get("/check_username")
@rate_limit_per_ip("30/minute")
def check_username(request: Request, username: str, db: Session = Depends(get_db)):
u = username.strip()
if not is_valid_username(u):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username must be 3 to 20 characters and contain only English letters, digits, hyphens, and underscores",
)
exists = db.query(User).filter(User.username == u).first() is not None
return {"exists": exists}
@router.post("/login")
@rate_limit_per_ip("5/minute")
def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)):
@@ -173,6 +248,10 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
failures=total_failures,
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again in a few minutes.",
)
raise HTTPException(
status_code=401,
detail="Неверное имя пользователя или пароль"
@@ -201,9 +280,6 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
revoked=False,
)
db.add(device)
user.online = True
user.last_seen = datetime.now()
db.commit()
logging.getLogger("uvicorn.error").info("Login DB commit complete for user_id=%s", user.id)
@@ -230,7 +306,7 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
"status": "success",
"message": "Login successful",
"token": token,
"user": convert_user(user)
"user": convert_user(user, db)
}
@@ -294,6 +370,18 @@ def register(
detail="Это имя пользователя уже занято"
)
bio_text = (register_request.bio or "").strip() or None
if bio_text and len(bio_text) > 500:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Описание должно быть не длиннее 500 символов",
)
if bio_text and contains_profanity(bio_text):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Описание содержит запрещённые слова",
)
hashed_password = get_password_hash(password)
# Set verified=True for the owner (first user to register)
@@ -304,8 +392,7 @@ def register(
username=username,
display_name=display_name,
password_hash=hashed_password,
online=True,
last_seen=datetime.now(),
bio=bio_text,
verified=is_owner
)
@@ -363,7 +450,7 @@ def register(
"status": "success",
"message": "Регистрация прошла успешно",
"token": token,
"user": convert_user(new_user)
"user": convert_user(new_user, db)
}
@router.get("/crypto/public-key")
@@ -448,38 +535,49 @@ def delete_user_as_owner(
return {"status": "success", "deleted_user_id": user_id}
def _revoke_device_session(db: Session, user_id: int, session_id: str) -> int:
"""Mark a device session revoked. Returns the number of rows updated."""
return (
db.query(DeviceSession)
.filter(
DeviceSession.user_id == user_id,
DeviceSession.session_id == session_id,
)
.update({DeviceSession.revoked: True}, synchronize_session=False)
)
@router.get("/logout")
def logout(
http: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
):
# Revoke current session
from utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials)
if payload and payload.get("session_id"):
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id == payload["session_id"],
).update({DeviceSession.revoked: True})
session_id = getattr(request.state, "session_id", None)
if session_id:
updated = _revoke_device_session(db, current_user.id, session_id)
db.commit()
if updated == 0:
_logger.warning(
"logout: session_id=%s not found for user_id=%s",
session_id,
current_user.id,
)
else:
_logger.warning("logout: missing session_id for user_id=%s", current_user.id)
current_user.online = False
current_user.last_seen = datetime.now()
db.commit()
client_ip = get_client_ip(http)
client_ip = get_client_ip(request)
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,
session_id=session_id,
)
return {
"status": "success",
"message": "Logged out successfully"
"message": "Logged out successfully",
}
@@ -488,9 +586,8 @@ def logout(
def change_password(
request: Request,
password_request: ChangePasswordRequest,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
):
# Verify current derived password against stored hash
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
@@ -503,15 +600,13 @@ def change_password(
# Optionally revoke all other sessions, keeping the current one
if password_request.logoutAllExceptCurrent:
from utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
current_session_id = payload.get("session_id")
current_session_id = getattr(request.state, "session_id", None)
if not current_session_id:
raise HTTPException(status_code=401, detail="Invalid session")
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id != current_session_id,
).update({DeviceSession.revoked: True})
).update({DeviceSession.revoked: True}, synchronize_session=False)
db.commit()
client_ip = get_client_ip(request)
@@ -526,13 +621,37 @@ def change_password(
return {"status": "success"}
def _verify_derived_password(user: User, password_derived: str) -> None:
if not verify_password(password_derived.strip(), user.password_hash):
raise HTTPException(status_code=400, detail="Wrong password")
@router.post("/verify-password")
@rate_limit_per_ip("10/minute")
def verify_password_endpoint(
request: Request,
body: VerifyPasswordRequest,
current_user: User = Depends(get_current_user),
):
# 400 (not 401): mobile client treats 401 as global auth failure and clears the session.
_verify_derived_password(current_user, body.passwordDerived)
client_ip = get_client_ip(request)
log_security(
"password_verified",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
)
return {"status": "success"}
@router.get("/users")
@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse
def list_users(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
users = db.query(User).order_by(User.username.asc()).all()
return {
"users": [
convert_user(u) for u in users if u.id != current_user.id
convert_user(u, db) for u in users if u.id != current_user.id
]
}
@@ -562,7 +681,7 @@ def search_users(request: Request, q: str, current_user: User = Depends(get_curr
).order_by(User.username.asc()).limit(20).all()
return {
"users": [convert_user(u) for u in users]
"users": [convert_user(u, db) for u in users]
}
@@ -573,15 +692,10 @@ async def _delete_user_data(user: User, db: Session):
"""
user_id = user.id
# Mark user as deleted and clear sensitive data
user.deleted = True
user.display_name = f"Deleted User #{user_id}"
user.bio = None
user.password_hash = ""
user.username = f"deleted_{user_id}"
user.profile_picture = None
user.last_seen = None # Clear last seen timestamp
user.created_at = None # Clear member since timestamp
from ..presence_service import presence_service
apply_deleted_user_db_fields(user)
presence_service.remove_user(user_id)
# Delete profile picture file if exists
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"):
@@ -629,6 +743,12 @@ async def _delete_user_data(user: User, db: Session):
# Log error but don't fail the request
pass
try:
from .profile import broadcast_profile_update
await broadcast_profile_update(user, db)
except Exception:
pass
try:
from .messaging import messagingManager
await messagingManager.broadcast_registered_user_count(db)
@@ -636,18 +756,19 @@ async def _delete_user_data(user: User, db: Session):
pass
@router.post("/delete")
async def delete_account(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
async def _delete_account_impl(
body: DeleteAccountRequest,
current_user: User,
db: Session,
) -> dict:
"""
Delete the current user's own account - preserves messages/DMs/reactions/files
"""
# Prevent admin/owner account self-deletion
if _is_admin(current_user):
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
_verify_derived_password(current_user, body.passwordDerived)
await _delete_user_data(current_user, db)
log_security(
@@ -659,5 +780,23 @@ async def delete_account(
return {
"status": "success",
"message": "Account deleted successfully"
}
"message": "Account deleted successfully",
}
@router.post("/delete")
async def delete_account(
body: DeleteAccountRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return await _delete_account_impl(body, current_user, db)
@router.post("/account/delete")
async def delete_account_alias(
body: DeleteAccountRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return await _delete_account_impl(body, current_user, db)
File diff suppressed because it is too large Load Diff
+127 -67
View File
@@ -1,4 +1,5 @@
from pathlib import Path
import logging
import re
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
@@ -10,49 +11,75 @@ import io
from fastapi import Request
from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db
from ..presence_service import presence_service
from ..models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel
from ..validation import is_valid_username, is_valid_display_name
from ..similarity import is_user_similar_to_verified
from ..verification_service import (
VerificationStatus,
compute_verification_status,
get_verified_users_data,
)
from .messaging import messagingManager
from ..security.audit import log_security
from ..security.profanity import contains_profanity
from ..security.rate_limit import rate_limit_per_ip
from ..deleted_user import DELETED_LAST_SEEN, deleted_user_api_fields, is_deleted_user
logger = logging.getLogger("uvicorn.error")
router = APIRouter()
def _build_user_profile_response(user: User, is_owner_request: bool = False) -> UserProfileResponse:
should_hide_profile = (not is_owner_request) and (user.deleted or user.suspended)
def _build_user_profile_response(
user: User,
is_owner_request: bool = False,
*,
verified_users_data: list[dict[str, str]] | None = None,
) -> UserProfileResponse:
should_hide_profile = (not is_owner_request) and is_deleted_user(user)
if not should_hide_profile:
online, last_seen = presence_service.get_presence(user.id)
verification_status = (
compute_verification_status(user, verified_users_data)
if verified_users_data is not None
else (
VerificationStatus.VERIFIED
if user.verified
else VerificationStatus.NONE
)
)
return UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
display_name=user.display_name or user.username,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
online=online,
last_seen=last_seen,
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
verified=bool(user.verified),
verification_status=verification_status.value,
suspended=bool(user.suspended),
suspension_reason=user.suspension_reason,
deleted=user.deleted or False,
deleted=bool(user.deleted),
)
hidden = deleted_user_api_fields(user.id)
return UserProfileResponse(
id=user.id,
username="deleted",
display_name="Deleted User",
profile_picture=None,
bio=None,
online=False,
last_seen=None,
created_at=None,
verified=False,
suspended=False,
suspension_reason=None,
deleted=True,
username=hidden["username"],
display_name=hidden["display_name"],
profile_picture=hidden["profile_picture"],
bio=hidden["bio"],
online=hidden["online"],
last_seen=DELETED_LAST_SEEN,
created_at=hidden["created_at"],
verified=hidden["verified"],
verification_status=hidden["verification_status"],
suspended=hidden["suspended"],
suspension_reason=hidden["suspension_reason"],
deleted=hidden["deleted"],
)
@@ -63,6 +90,40 @@ def _ensure_owner_unsuspended(user: User | None, db: Session):
db.commit()
db.refresh(user)
async def broadcast_profile_update(user: User, db: Session) -> None:
"""Notify clients subscribed to this user that their public profile changed."""
try:
payload = build_profile_update_payload(user, viewer_id=None, db=db)
subscriber_count = sum(
1
for ws, subs in messagingManager.ws_subscriptions.items()
if user.id in subs
)
logger.info(
"broadcast_profile_update user_id=%s bio=%r subscribers=%s",
user.id,
user.bio,
subscriber_count,
)
await messagingManager.broadcast_profile_update(user.id, payload, db)
except Exception:
pass
def build_profile_update_payload(
user: User,
viewer_id: int | None,
db: Session,
) -> dict:
verified_users_data = get_verified_users_data(db)
is_owner_request = viewer_id is not None and (viewer_id == user.id or viewer_id == 1)
return _build_user_profile_response(
user,
is_owner_request=is_owner_request,
verified_users_data=verified_users_data,
).model_dump(mode="json")
# Request models
class UpdateProfileRequest(BaseModel):
username: str | None = None
@@ -118,7 +179,10 @@ async def upload_profile_picture(
profile_picture_url = f"/api/profile-picture/{filename}"
current_user.profile_picture = profile_picture_url
db.commit()
db.refresh(current_user)
await broadcast_profile_update(current_user, db)
return {
"message": "Profile picture uploaded successfully",
"profile_picture_url": profile_picture_url
@@ -154,16 +218,20 @@ async def get_user_profile(
try:
_ensure_owner_unsuspended(current_user, db)
online, last_seen = presence_service.get_presence(current_user.id)
verified_users_data = get_verified_users_data(db)
verification_status = compute_verification_status(current_user, verified_users_data)
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,
online=online,
last_seen=last_seen,
created_at=current_user.created_at,
verified=current_user.verified,
verification_status=verification_status.value,
suspended=current_user.suspended or False,
suspension_reason=current_user.suspension_reason,
deleted=current_user.deleted or False,
@@ -189,25 +257,29 @@ async def list_users(
_ensure_owner_unsuspended(current_user, db)
users = db.query(User).order_by(User.username.asc()).all()
return {
"users": [
verified_users_data = get_verified_users_data(db)
profile_items = []
for user in users:
online, last_seen = presence_service.get_presence(user.id)
verification_status = compute_verification_status(user, verified_users_data)
profile_items.append(
UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
online=online,
last_seen=last_seen,
created_at=user.created_at,
verified=user.verified,
verification_status=verification_status.value,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
deleted=user.deleted or False,
).model_dump()
for user in users
]
}
)
return {"users": profile_items}
@router.put("/user/profile")
@rate_limit_per_ip("10/minute")
@@ -272,6 +344,8 @@ async def update_user_profile(
if updated:
db.commit()
db.refresh(current_user)
await broadcast_profile_update(current_user, db)
return {
"message": "Profile updated successfully",
"username": current_user.username,
@@ -303,7 +377,10 @@ async def update_user_bio(
current_user.bio = bio_request.bio.strip()
db.commit()
db.refresh(current_user)
await broadcast_profile_update(current_user, db)
return {
"message": "Bio updated successfully",
"bio": current_user.bio
@@ -340,7 +417,12 @@ async def get_user_by_username(
_ensure_owner_unsuspended(user, db)
is_owner_request = current_user.id == user.id or current_user.id == 1
return _build_user_profile_response(user, is_owner_request=is_owner_request)
verified_users_data = get_verified_users_data(db)
return _build_user_profile_response(
user,
is_owner_request=is_owner_request,
verified_users_data=verified_users_data,
)
@router.get("/user/id/{user_id}")
async def get_user_by_id(
@@ -362,7 +444,12 @@ async def get_user_by_id(
_ensure_owner_unsuspended(user, db)
is_owner_request = current_user.id == user.id or current_user.id == 1
return _build_user_profile_response(user, is_owner_request=is_owner_request)
verified_users_data = get_verified_users_data(db)
return _build_user_profile_response(
user,
is_owner_request=is_owner_request,
verified_users_data=verified_users_data,
)
@router.post("/user/{user_id}/verify")
@@ -386,6 +473,9 @@ async def verify_user(
target_user.verified = not target_user.verified
db.commit()
verified_users_data = get_verified_users_data(db)
verification_status = compute_verification_status(target_user, verified_users_data)
log_security(
"admin_verify_toggle",
actor=current_user.username,
@@ -395,45 +485,15 @@ async def verify_user(
verified=target_user.verified,
)
await broadcast_profile_update(target_user, db)
return {
"verified": target_user.verified,
"verification_status": verification_status.value,
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
}
@router.get("/user/check-similarity/{user_id}")
async def check_user_similarity(
user_id: int,
current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db)
):
"""
Check if a user is similar to any verified user
"""
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Get all verified users
verified_users = db.query(User).filter(User.verified == True).all()
verified_users_data = [
{"username": user.username, "display_name": user.display_name}
for user in verified_users
]
# Check similarity
is_similar, similar_to = is_user_similar_to_verified(
target_user.username,
target_user.display_name,
verified_users_data
)
return {
"isSimilar": is_similar,
"similarTo": similar_to if is_similar else None
}
# Admin endpoints for user management
class SuspendUserRequest(BaseModel):
reason: str
@@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..dependencies import get_current_user_allow_suspended, get_db
from ..models import PublicChatProfileResponse, User
from ..public_chat_config import load_public_chat_static_profile
router = APIRouter()
@router.get("/public-chat/profile", response_model=PublicChatProfileResponse)
def get_public_chat_profile(
current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db),
):
"""Metadata for the instance public chat (title, bio, member count)."""
del current_user
try:
static_profile = load_public_chat_static_profile()
except (FileNotFoundError, ValueError, OSError) as exc:
raise HTTPException(status_code=500, detail="Public chat profile is not configured") from exc
member_count = db.query(User).filter(User.deleted.is_(False)).count()
bio = static_profile["bio"].strip() or None
return PublicChatProfileResponse(
id=static_profile["id"],
title=static_profile["title"],
bio=bio,
member_count=member_count,
)
+40
View File
@@ -0,0 +1,40 @@
"""
Static legal documents and expressive icons served from the instance deploy.
"""
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
router = APIRouter(tags=["static"])
_STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
_ICONS_DIR = _STATIC_DIR / "icons"
@router.get("/static/PRIVACY.md")
async def privacy_markdown() -> FileResponse:
path = _STATIC_DIR / "PRIVACY.md"
if not path.is_file():
raise HTTPException(status_code=404, detail="PRIVACY.md not found")
return FileResponse(path, media_type="text/markdown; charset=utf-8")
@router.get("/static/TERMS.md")
async def terms_markdown() -> FileResponse:
path = _STATIC_DIR / "TERMS.md"
if not path.is_file():
raise HTTPException(status_code=404, detail="TERMS.md not found")
return FileResponse(path, media_type="text/markdown; charset=utf-8")
@router.get("/static/icons/{name}.webp")
async def static_icon(name: str) -> FileResponse:
safe = Path(name).name
if safe != name or ".." in name:
raise HTTPException(status_code=400, detail="Invalid icon name")
path = _ICONS_DIR / f"{safe}.webp"
if not path.is_file():
raise HTTPException(status_code=404, detail="Icon not found")
return FileResponse(path, media_type="image/webp")