Fix suspension status

This commit is contained in:
2026-04-21 16:25:27 +03:00
Unverified
parent 3079ff944e
commit b7d884df58
8 changed files with 211 additions and 107 deletions
+9 -8
View File
@@ -5,26 +5,27 @@ FromChat - полностью открытый мессенджер.
Его можно попробовать на [сайте](http://fromchat.ru). Его можно попробовать на [сайте](http://fromchat.ru).
## Содержание: ## Содержание:
- [Основные моменты](#highlights) - [Основные моменты](#highlights)
- [Использование](#usage) - [Использование](#usage)
- [Часто задаваемые вопросы](#faq) - [Часто задаваемые вопросы](#faq)
- [Внос вклада](#contributing) - [Внос вклада](#contributing)
## Основные моменты ## Основные моменты
- Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд). - Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд).
- 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя. - 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя.
## Использование ## Использование
_В разработке._
*В разработке.*
## Часто задаваемые вопросы ## Часто задаваемые вопросы
<!--
Вопрос: __Чему равно 2+2?__
Ответ: __4__
Вопрос: __Какая цитата Джейсона Стетхема на ваш взгляд является лучшей?__ *В разработке.*
Ответ: __"Одна ошибка, и ты ошибся."__
-->
_В разработке._
## Внос вклада ## Внос вклада
Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev). Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev).
+24 -5
View File
@@ -18,11 +18,12 @@ def get_db():
finally: finally:
db.close() db.close()
# Зависимость для получения текущего пользователя # Internal dependency helper to reuse auth/session resolution
def get_current_user( def _get_current_user(
request: Request, request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials,
db: Session = Depends(get_db), db: Session,
allow_suspended: bool = False,
) -> User: ) -> User:
token = credentials.credentials token = credentials.credentials
try: try:
@@ -98,7 +99,7 @@ def get_current_user(
db.commit() db.commit()
# Check if user is suspended # Check if user is suspended
if user.suspended: if user.suspended and not allow_suspended:
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason) logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@@ -118,3 +119,21 @@ def get_current_user(
request.state.session_id = session_id request.state.session_id = session_id
return user return user
# Dependency for all standard routes: suspended users are blocked
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
return _get_current_user(request, credentials, db, allow_suspended=False)
# Dependency for read/crypto endpoints that remain accessible for suspended users
def get_current_user_allow_suspended(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
return _get_current_user(request, credentials, db, allow_suspended=True)
+91 -3
View File
@@ -6,13 +6,56 @@ Uses MESSAGE_RETENTION_DAYS from the environment (see services.shared.message_re
import logging import logging
from datetime import datetime from datetime import datetime
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import DMEnvelope, MessageEditHistory, DMEditHistory from .models import DMEnvelope, DMReaction, DMFile, MessageEditHistory, DMEditHistory
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
def _is_empty_wrapped_key(value: str | None) -> bool:
return value in (None, "")
def _is_decryptable_keyless(dm_envelope: DMEnvelope) -> bool:
return (
_is_empty_wrapped_key(dm_envelope.sender_wrapped_mek_b64)
and _is_empty_wrapped_key(dm_envelope.recipient_wrapped_mek_b64)
and _is_empty_wrapped_key(dm_envelope.compliance_wrapped_mek_b64)
)
def _delete_dm_envelopes_and_related(db: Session, envelope_ids: list[int]) -> int:
if not envelope_ids:
return 0
unique_ids = list(dict.fromkeys(envelope_ids))
deleted_reactions = db.query(DMReaction).filter(
DMReaction.dm_envelope_id.in_(unique_ids)
).delete(synchronize_session=False)
deleted_files = db.query(DMFile).filter(DMFile.message_id.in_(unique_ids)).delete(synchronize_session=False)
deleted_dm_edits = db.query(DMEditHistory).filter(
or_(
DMEditHistory.message_id.in_(unique_ids),
DMEditHistory.dm_envelope_id.in_(unique_ids),
)
).delete(synchronize_session=False)
deleted_messages = db.query(DMEnvelope).filter(
DMEnvelope.id.in_(unique_ids)
).delete(synchronize_session=False)
logger.info(
"Purging %s keyless DM envelopes. reactions=%s files=%s edit_history_rows=%s",
deleted_messages,
deleted_reactions,
deleted_files,
deleted_dm_edits,
)
return deleted_messages
def _retention_timedelta_or_skip(): def _retention_timedelta_or_skip():
try: try:
from services.shared.message_retention import get_message_retention from services.shared.message_retention import get_message_retention
@@ -66,6 +109,38 @@ def destroy_compliance_keys_for_dm_envelope(db: Session, dm_envelope_id: int) ->
return False return False
def destroy_message_keys_for_user(db: Session, user_id: int, *, commit: bool = True) -> int:
try:
envelopes = db.query(DMEnvelope).filter(
(DMEnvelope.sender_id == user_id) | (DMEnvelope.recipient_id == user_id)
).all()
destroyed_count = 0
for envelope in envelopes:
if envelope.sender_id == user_id and envelope.sender_wrapped_mek_b64 not in (None, ""):
envelope.sender_wrapped_mek_b64 = ""
destroyed_count += 1
if envelope.recipient_id == user_id and envelope.recipient_wrapped_mek_b64 not in (None, ""):
envelope.recipient_wrapped_mek_b64 = ""
destroyed_count += 1
if destroyed_count > 0 and commit:
db.commit()
logger.info(
"Destroyed sender/recipient keys that belonged to user %s in %s DM envelopes (%s keys)",
len(envelopes),
destroyed_count,
user_id,
)
return destroyed_count
except Exception as e:
logger.error("Failed to destroy sender/recipient keys for user %s: %s", user_id, e)
db.rollback()
return 0
def cleanup_expired_compliance_keys(db: Session) -> int: def cleanup_expired_compliance_keys(db: Session) -> int:
delta = _retention_timedelta_or_skip() delta = _retention_timedelta_or_skip()
if delta is None: if delta is None:
@@ -113,11 +188,15 @@ def cleanup_expired_message_keys(db: Session) -> int:
return 0 return 0
keys_destroyed = 0 keys_destroyed = 0
keyless_message_ids: list[int] = []
for message in expired_messages: for message in expired_messages:
if not _is_empty_wrapped_key(message.sender_wrapped_mek_b64):
message.sender_wrapped_mek_b64 = "" message.sender_wrapped_mek_b64 = ""
keys_destroyed += 1
if not _is_empty_wrapped_key(message.recipient_wrapped_mek_b64):
message.recipient_wrapped_mek_b64 = "" message.recipient_wrapped_mek_b64 = ""
keys_destroyed += 2 keys_destroyed += 1
logger.debug( logger.debug(
"Destroyed keys for soft-deleted message id=%s (deleted %s)", "Destroyed keys for soft-deleted message id=%s (deleted %s)",
@@ -125,11 +204,20 @@ def cleanup_expired_message_keys(db: Session) -> int:
message.deleted_at.isoformat(), message.deleted_at.isoformat(),
) )
if _is_decryptable_keyless(message):
keyless_message_ids.append(message.id)
if keyless_message_ids:
deleted_messages = _delete_dm_envelopes_and_related(db, keyless_message_ids)
else:
deleted_messages = 0
db.commit() db.commit()
logger.info( logger.info(
"Message key cleanup: destroyed %s keys across %s messages", "Message key cleanup: destroyed %s keys across %s messages; purged %s keyless messages",
keys_destroyed, keys_destroyed,
len(expired_messages), len(expired_messages),
deleted_messages,
) )
return keys_destroyed return keys_destroyed
+16 -8
View File
@@ -9,7 +9,7 @@ from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 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_current_user_allow_suspended, get_db
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, get_client_ip 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 ..validation import is_valid_password, is_valid_username, is_valid_display_name
@@ -18,6 +18,7 @@ import os
from ..security.audit import log_security from ..security.audit import log_security
from ..security.profanity import contains_profanity from ..security.profanity import contains_profanity
from ..security.rate_limit import rate_limit_per_ip from ..security.rate_limit import rate_limit_per_ip
from ..key_lifecycle import destroy_message_keys_for_user
router = APIRouter() router = APIRouter()
_FAILED_ATTEMPT_WINDOW_SECONDS = 300 _FAILED_ATTEMPT_WINDOW_SECONDS = 300
@@ -314,13 +315,13 @@ def register(
} }
@router.get("/crypto/public-key") @router.get("/crypto/public-key")
def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def get_public_key(current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
return {"publicKey": row.public_key_b64 if row else None} return {"publicKey": row.public_key_b64 if row else None}
@router.post("/crypto/public-key") @router.post("/crypto/public-key")
def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def set_public_key(payload: dict, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
pk = payload.get("publicKey") pk = payload.get("publicKey")
if not pk: if not pk:
raise HTTPException(status_code=400, detail="publicKey required") raise HTTPException(status_code=400, detail="publicKey required")
@@ -337,13 +338,13 @@ def set_public_key(payload: dict, current_user: User = Depends(get_current_user)
@router.get("/crypto/backup") @router.get("/crypto/backup")
def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def get_backup(current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
return {"blob": row.blob_json if row else None} return {"blob": row.blob_json if row else None}
@router.post("/crypto/backup") @router.post("/crypto/backup")
def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def set_backup(payload: dict, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
blob = payload.get("blob") blob = payload.get("blob")
if not blob: if not blob:
raise HTTPException(status_code=400, detail="blob required") raise HTTPException(status_code=400, detail="blob required")
@@ -475,7 +476,7 @@ def change_password(
@router.get("/users") @router.get("/users")
@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse
def list_users(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): 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() users = db.query(User).order_by(User.username.asc()).all()
return { return {
"users": [ "users": [
@@ -486,14 +487,19 @@ def list_users(request: Request, current_user: User = Depends(get_current_user),
@router.get("/crypto/public-key/of/{user_id}") @router.get("/crypto/public-key/of/{user_id}")
@rate_limit_per_ip("100/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("100/minute") # Per-IP limit to prevent abuse
def get_public_key_of(request: Request, user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def get_public_key_of(
request: Request,
user_id: int,
current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db),
):
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first() row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
return {"publicKey": row.public_key_b64 if row else None} return {"publicKey": row.public_key_b64 if row else None}
@router.get("/users/search") @router.get("/users/search")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def search_users(request: Request, q: str, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
if len(q.strip()) < 2: if len(q.strip()) < 2:
return {"users": []} return {"users": []}
@@ -555,6 +561,8 @@ async def _delete_user_data(user: User, db: Session):
# Delete all records for this user # Delete all records for this user
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id}) db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
destroy_message_keys_for_user(db, user_id, commit=False)
db.commit() db.commit()
except Exception as e: except Exception as e:
# Log error and rollback # Log error and rollback
@@ -23,7 +23,7 @@ from pydantic import BaseModel, Field
from ..db import get_db from ..db import get_db
from ..models import User, DMEnvelope, DMFile, DMEditHistory, EditMessageRequest from ..models import User, DMEnvelope, DMFile, DMEditHistory, EditMessageRequest
from ..dependencies import get_current_user from ..dependencies import get_current_user, get_current_user_allow_suspended
from ..security.audit import log_security from ..security.audit import log_security
from ..service_calls import ( from ..service_calls import (
get_messaging_transport_public_key, get_messaging_transport_public_key,
@@ -538,7 +538,7 @@ async def get_encrypted_conversation(
other_user_id: int, other_user_id: int,
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
+13 -9
View File
@@ -14,7 +14,7 @@ from typing import Any
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request, status from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..dependencies import get_current_user, get_db from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db
from .account import convert_user from .account import convert_user
from ..constants import OWNER_USERNAME from ..constants import OWNER_USERNAME
from ..models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog, MessageEditHistory, MessageEditHistoryResponse from ..models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog, MessageEditHistory, MessageEditHistoryResponse
@@ -607,7 +607,7 @@ async def push_test(request: Request, current_user: User = Depends(get_current_u
@router.get("/get_messages") @router.get("/get_messages")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
async def get_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): async def get_messages(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
messages = db.query(Message).order_by(Message.timestamp.asc()).all() messages = db.query(Message).order_by(Message.timestamp.asc()).all()
messages_data = [] messages_data = []
@@ -626,7 +626,7 @@ class MarkReadRequest(BaseModel):
@router.get("/messages/new") @router.get("/messages/new")
@rate_limit_per_ip("60/minute") @rate_limit_per_ip("60/minute")
async def get_new_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): async def get_new_messages(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
""" """
Return unread public messages (Message.is_read == False). Return unread public messages (Message.is_read == False).
""" """
@@ -661,7 +661,7 @@ async def mark_messages_read(request: Request, read_request: MarkReadRequest, cu
@router.get("/dm/fetch") @router.get("/dm/fetch")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
async def dm_fetch(request: Request, since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): async def dm_fetch(request: Request, since: int | None = None, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
envelopes = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id) envelopes = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id)
if since: if since:
envelopes = envelopes.filter(DMEnvelope.id > since) envelopes = envelopes.filter(DMEnvelope.id > since)
@@ -675,7 +675,7 @@ async def dm_fetch(request: Request, since: int | None = None, current_user: Use
@router.get("/dm/history/{other_user_id}") @router.get("/dm/history/{other_user_id}")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
if other_user_id <= 0: if other_user_id <= 0:
raise HTTPException(status_code=400, detail="Invalid user ID") raise HTTPException(status_code=400, detail="Invalid user ID")
@@ -684,7 +684,7 @@ async def dm_history(request: Request, other_user_id: int, current_user: User =
# Verify other user exists # Verify other user exists
other_user = db.query(User).filter(User.id == other_user_id).first() other_user = db.query(User).filter(User.id == other_user_id).first()
if not other_user or other_user.deleted or other_user.suspended: if not other_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
envelopes = db.query(DMEnvelope).filter( envelopes = db.query(DMEnvelope).filter(
@@ -700,7 +700,7 @@ async def dm_history(request: Request, other_user_id: int, current_user: User =
@router.get("/dm/conversations") @router.get("/dm/conversations")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
async def get_dm_conversations(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): async def get_dm_conversations(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
# Get all DM conversations where current user is involved # Get all DM conversations where current user is involved
conversations_query = db.query(DMEnvelope).filter( conversations_query = db.query(DMEnvelope).filter(
(DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id) (DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id)
@@ -1361,6 +1361,10 @@ class MessaggingSocketManager:
"reason": reason "reason": reason
}) })
async def send_unsuspension_to_user(self, user_id: int):
"""Send unsuspension message to user's WebSocket connections (as batched update)"""
await self.send_update_to_user(user_id, "unsuspended", {})
async def send_deletion_to_user(self, user_id: int): async def send_deletion_to_user(self, user_id: int):
"""Send account deletion message to user's WebSocket connections (as batched update)""" """Send account deletion message to user's WebSocket connections (as batched update)"""
await self.send_update_to_user(user_id, "account_deleted", {}) await self.send_update_to_user(user_id, "account_deleted", {})
@@ -1478,7 +1482,7 @@ import httpx
async def proxy_normal_file( async def proxy_normal_file(
request: Request, request: Request,
filename: str, filename: str,
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user_allow_suspended)
): ):
"""Proxy file requests to file_storage service.""" """Proxy file requests to file_storage service."""
mod = service_calls._get_file_storage_module() mod = service_calls._get_file_storage_module()
@@ -1531,7 +1535,7 @@ async def test_proxy():
async def proxy_encrypted_file( async def proxy_encrypted_file(
request: Request, request: Request,
filename: str, filename: str,
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user_allow_suspended)
): ):
"""Proxy file requests to file_storage service.""" """Proxy file requests to file_storage service."""
mod = service_calls._get_file_storage_module() mod = service_calls._get_file_storage_module()
+52 -67
View File
@@ -9,7 +9,7 @@ import uuid
import io import io
from fastapi import Request from fastapi import Request
from ..dependencies import get_db, get_current_user from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db
from ..models import User, UpdateBioRequest, UserProfileResponse from ..models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel from pydantic import BaseModel
from ..validation import is_valid_username, is_valid_display_name from ..validation import is_valid_username, is_valid_display_name
@@ -22,6 +22,40 @@ from ..security.rate_limit import rate_limit_per_ip
router = APIRouter() 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)
if not should_hide_profile:
return UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=user.deleted or False,
)
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,
)
def _ensure_owner_unsuspended(user: User | None, db: Session): def _ensure_owner_unsuspended(user: User | None, db: Session):
if user and user.id == 1 and user.suspended: if user and user.id == 1 and user.suspended:
user.suspended = False user.suspended = False
@@ -111,7 +145,7 @@ async def get_profile_picture(filename: str):
@router.get("/user/profile") @router.get("/user/profile")
async def get_user_profile( async def get_user_profile(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -132,7 +166,7 @@ async def get_user_profile(
verified=current_user.verified, verified=current_user.verified,
suspended=current_user.suspended or False, suspended=current_user.suspended or False,
suspension_reason=current_user.suspension_reason, suspension_reason=current_user.suspension_reason,
deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted deleted=current_user.deleted or False,
) )
except Exception as e: except Exception as e:
# Log and return a consistent HTTP 500 error with minimal details # Log and return a consistent HTTP 500 error with minimal details
@@ -278,7 +312,7 @@ async def update_user_bio(
@router.get("/user/stats/registered-count") @router.get("/user/stats/registered-count")
def get_registered_user_count( def get_registered_user_count(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Number of registered accounts (non-deleted users).""" """Number of registered accounts (non-deleted users)."""
@@ -289,6 +323,7 @@ def get_registered_user_count(
@router.get("/user/{username}") @router.get("/user/{username}")
async def get_user_by_username( async def get_user_by_username(
username: str, username: str,
current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -304,41 +339,13 @@ async def get_user_by_username(
_ensure_owner_unsuspended(user, db) _ensure_owner_unsuspended(user, db)
# Handle deleted or suspended users is_owner_request = current_user.id == user.id or current_user.id == 1
if user.deleted or user.suspended: return _build_user_profile_response(user, is_owner_request=is_owner_request)
return UserProfileResponse(
id=user.id,
username="deleted",
display_name="Deleted User",
profile_picture=None,
bio=None,
online=False,
last_seen=None, # Clear last seen timestamp
created_at=None, # Clear member since timestamp
verified=False,
suspended=False,
suspension_reason=None,
deleted=True
)
return UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
)
@router.get("/user/id/{user_id}") @router.get("/user/id/{user_id}")
async def get_user_by_id( async def get_user_by_id(
user_id: int, user_id: int,
current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -354,37 +361,8 @@ async def get_user_by_id(
_ensure_owner_unsuspended(user, db) _ensure_owner_unsuspended(user, db)
# Handle deleted or suspended users is_owner_request = current_user.id == user.id or current_user.id == 1
if user.deleted or user.suspended: return _build_user_profile_response(user, is_owner_request=is_owner_request)
return UserProfileResponse(
id=user.id,
username="deleted",
display_name="Deleted User",
profile_picture=None,
bio=None,
online=False,
last_seen=None, # Clear last seen timestamp
created_at=None, # Clear member since timestamp
verified=False,
suspended=False,
suspension_reason=None,
deleted=True
)
return UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=user.deleted or False
)
@router.post("/user/{user_id}/verify") @router.post("/user/{user_id}/verify")
@@ -426,7 +404,7 @@ async def verify_user(
@router.get("/user/check-similarity/{user_id}") @router.get("/user/check-similarity/{user_id}")
async def check_user_similarity( async def check_user_similarity(
user_id: int, user_id: int,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -532,6 +510,13 @@ async def unsuspend_user(
target_user.suspension_reason = None target_user.suspension_reason = None
db.commit() db.commit()
# Send WebSocket unsuspension message
try:
await messagingManager.send_unsuspension_to_user(user_id)
except Exception:
# Log error but don't fail the request
pass
log_security( log_security(
"admin_unsuspend_user", "admin_unsuspend_user",
actor=current_user.username, actor=current_user.username,
-1
View File
@@ -51,7 +51,6 @@
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.3",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.11",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"electron": "^39.2.7", "electron": "^39.2.7",