mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Fix suspension status
This commit is contained in:
@@ -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).
|
||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
message.sender_wrapped_mek_b64 = ""
|
if not _is_empty_wrapped_key(message.sender_wrapped_mek_b64):
|
||||||
message.recipient_wrapped_mek_b64 = ""
|
message.sender_wrapped_mek_b64 = ""
|
||||||
keys_destroyed += 2
|
keys_destroyed += 1
|
||||||
|
if not _is_empty_wrapped_key(message.recipient_wrapped_mek_b64):
|
||||||
|
message.recipient_wrapped_mek_b64 = ""
|
||||||
|
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
|
||||||
|
|||||||
@@ -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),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user