mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
983 lines
34 KiB
Python
983 lines
34 KiB
Python
from datetime import datetime
|
|
from collections import defaultdict, deque
|
|
import time
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import inspect, text
|
|
import uuid
|
|
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_db
|
|
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession, SentMessagePlaintext
|
|
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
|
|
import os
|
|
|
|
from security.audit import log_security
|
|
from security.profanity import contains_profanity
|
|
from security.rate_limit import rate_limit_per_ip
|
|
router = APIRouter()
|
|
|
|
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
|
_FAILED_ATTEMPT_THRESHOLD = 5
|
|
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
|
|
|
|
|
|
def _record_failed_login(identifier: str) -> bool:
|
|
now = time.time()
|
|
attempts = _failed_login_attempts[identifier]
|
|
attempts.append(now)
|
|
|
|
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
|
|
attempts.popleft()
|
|
|
|
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
|
|
|
|
|
|
def _reset_failed_logins(identifier: str) -> None:
|
|
_failed_login_attempts.pop(identifier, None)
|
|
|
|
def convert_user(user: User) -> dict:
|
|
return {
|
|
"id": user.id,
|
|
"created_at": user.created_at.isoformat(),
|
|
"last_seen": user.last_seen.isoformat(),
|
|
"online": user.online,
|
|
"username": user.username,
|
|
"display_name": user.display_name,
|
|
"profile_picture": user.profile_picture,
|
|
"bio": user.bio,
|
|
"admin": user.username == OWNER_USERNAME,
|
|
"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("/check_auth")
|
|
def check_auth(current_user: User = Depends(get_current_user)):
|
|
return {
|
|
"authenticated": True,
|
|
"username": current_user.username,
|
|
"admin": current_user.username == OWNER_USERNAME
|
|
}
|
|
|
|
|
|
@router.post("/login")
|
|
@rate_limit_per_ip("5/minute")
|
|
def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)):
|
|
username = login_request.username.strip()
|
|
client_ip = get_client_ip(request)
|
|
raw_ua = request.headers.get("user-agent")
|
|
|
|
user = db.query(User).filter(User.username == username).first()
|
|
|
|
if not user or not verify_password(login_request.password.strip(), user.password_hash):
|
|
log_security(
|
|
"login_failed",
|
|
severity="warning",
|
|
username=username,
|
|
ip=client_ip,
|
|
reason="invalid_credentials",
|
|
)
|
|
identifiers = [f"user:{username}"]
|
|
if client_ip:
|
|
identifiers.append(f"ip:{client_ip}")
|
|
|
|
suspicious = False
|
|
for identifier in identifiers:
|
|
if _record_failed_login(identifier):
|
|
suspicious = True
|
|
|
|
if suspicious:
|
|
total_failures = {
|
|
identifier: len(_failed_login_attempts.get(identifier, []))
|
|
for identifier in identifiers
|
|
}
|
|
log_security(
|
|
"auth_bruteforce_detected",
|
|
severity="warning",
|
|
username=username,
|
|
ip=client_ip,
|
|
failures=total_failures,
|
|
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
|
|
)
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Неверное имя пользователя или пароль"
|
|
)
|
|
|
|
# Create device session and embed into JWT
|
|
raw_ua = request.headers.get("user-agent")
|
|
device_name = request.headers.get("x-device-name")
|
|
ua = parse_ua(raw_ua or "")
|
|
session_id = uuid.uuid4().hex
|
|
|
|
device = DeviceSession(
|
|
user_id=user.id,
|
|
raw_user_agent=raw_ua,
|
|
device_name=device_name,
|
|
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
|
os_name=(ua.os.family or None),
|
|
os_version=(ua.os.version_string or None),
|
|
browser_name=(ua.browser.family or None),
|
|
browser_version=(ua.browser.version_string or None),
|
|
brand=(ua.device.brand or None),
|
|
model=(ua.device.model or None),
|
|
session_id=session_id,
|
|
created_at=datetime.now(),
|
|
last_seen=datetime.now(),
|
|
revoked=False,
|
|
)
|
|
db.add(device)
|
|
|
|
user.online = True
|
|
user.last_seen = datetime.now()
|
|
db.commit()
|
|
|
|
token = create_token(user.id, user.username, session_id)
|
|
|
|
identifiers = [f"user:{username}"]
|
|
if client_ip:
|
|
identifiers.append(f"ip:{client_ip}")
|
|
for identifier in identifiers:
|
|
_reset_failed_logins(identifier)
|
|
|
|
log_security(
|
|
"login_success",
|
|
username=user.username,
|
|
user_id=user.id,
|
|
ip=client_ip,
|
|
session_id=session_id,
|
|
device=device.device_type,
|
|
os=device.os_name,
|
|
browser=device.browser_name,
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Login successful",
|
|
"token": token,
|
|
"user": convert_user(user)
|
|
}
|
|
|
|
|
|
@router.post("/register")
|
|
@rate_limit_per_ip("3/hour")
|
|
def register(request: Request, register_request: RegisterRequest, db: Session = Depends(get_db)):
|
|
username = register_request.username.strip()
|
|
display_name = register_request.display_name.strip()
|
|
password = register_request.password.strip()
|
|
confirm_password = register_request.confirm_password.strip()
|
|
client_ip = get_client_ip(request)
|
|
raw_ua = request.headers.get("user-agent")
|
|
|
|
# Determine if owner already exists
|
|
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
|
|
|
# If owner not yet registered, only allow the owner to register
|
|
if not owner_exists and username != OWNER_USERNAME:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Регистрация временно закрыта до регистрации владельца"
|
|
)
|
|
|
|
# Validate input
|
|
if not is_valid_username(username):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
|
)
|
|
if contains_profanity(username):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Имя пользователя содержит запрещённые слова"
|
|
)
|
|
|
|
if not is_valid_display_name(display_name):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
|
)
|
|
if contains_profanity(display_name):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Отображаемое имя содержит запрещённые слова"
|
|
)
|
|
|
|
if not is_valid_password(password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Пароль должен быть от 5 до 50 символов и не содержать пробелов"
|
|
)
|
|
|
|
if password != confirm_password:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Пароли не совпадают"
|
|
)
|
|
|
|
# After owner exists, disallow registering the reserved owner username via public registration
|
|
if owner_exists and username == OWNER_USERNAME:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Это имя пользователя зарезервировано"
|
|
)
|
|
|
|
existing_user = db.query(User).filter(User.username == username).first()
|
|
if existing_user:
|
|
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)
|
|
is_owner = not owner_exists and username == OWNER_USERNAME
|
|
|
|
new_user = User(
|
|
username=username,
|
|
display_name=display_name,
|
|
password_hash=hashed_password,
|
|
online=True,
|
|
last_seen=datetime.now(),
|
|
verified=is_owner
|
|
)
|
|
|
|
db.add(new_user)
|
|
db.commit()
|
|
db.refresh(new_user)
|
|
|
|
# Create initial device session
|
|
raw_ua = request.headers.get("user-agent")
|
|
device_name = request.headers.get("x-device-name")
|
|
ua = parse_ua(raw_ua or "")
|
|
session_id = uuid.uuid4().hex
|
|
device = DeviceSession(
|
|
user_id=new_user.id,
|
|
raw_user_agent=raw_ua,
|
|
device_name=device_name,
|
|
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
|
os_name=(ua.os.family or None),
|
|
os_version=(ua.os.version_string or None),
|
|
browser_name=(ua.browser.family or None),
|
|
browser_version=(ua.browser.version_string or None),
|
|
brand=(ua.device.brand or None),
|
|
model=(ua.device.model or None),
|
|
session_id=session_id,
|
|
created_at=datetime.now(),
|
|
last_seen=datetime.now(),
|
|
revoked=False,
|
|
)
|
|
db.add(device)
|
|
db.commit()
|
|
|
|
token = create_token(new_user.id, new_user.username, session_id)
|
|
|
|
os_name = ua.os.family or "Unknown OS"
|
|
if ua.os.version_string:
|
|
os_name = f"{os_name} {ua.os.version_string}"
|
|
browser_name = ua.browser.family or "Unknown browser"
|
|
if ua.browser.version_string:
|
|
browser_name = f"{browser_name} {ua.browser.version_string}"
|
|
user_agent_summary = f"{os_name}, {browser_name}"
|
|
|
|
log_security(
|
|
"registration_success",
|
|
username=new_user.username,
|
|
display_name=new_user.display_name,
|
|
user_id=new_user.id,
|
|
ip=client_ip,
|
|
user_agent=user_agent_summary,
|
|
owner=is_owner,
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Регистрация прошла успешно",
|
|
"token": token,
|
|
"user": convert_user(new_user)
|
|
}
|
|
|
|
@router.get("/crypto/public-key")
|
|
def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
|
return {"publicKey": row.public_key_b64 if row else None}
|
|
|
|
|
|
@router.post("/crypto/public-key")
|
|
def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
pk = payload.get("publicKey")
|
|
if not pk:
|
|
raise HTTPException(status_code=400, detail="publicKey required")
|
|
if not isinstance(pk, str) or len(pk) > 10000 or len(pk) < 10:
|
|
raise HTTPException(status_code=400, detail="Invalid publicKey format")
|
|
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
|
if row:
|
|
row.public_key_b64 = pk
|
|
else:
|
|
row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk)
|
|
db.add(row)
|
|
db.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/crypto/backup")
|
|
def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
|
return {"blob": row.blob_json if row else None}
|
|
|
|
|
|
@router.post("/crypto/backup")
|
|
def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
blob = payload.get("blob")
|
|
if not blob:
|
|
raise HTTPException(status_code=400, detail="blob required")
|
|
if not isinstance(blob, str) or len(blob) > 1000000: # 1MB limit
|
|
raise HTTPException(status_code=400, detail="Invalid blob format or size exceeds 1MB")
|
|
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
|
if row:
|
|
row.blob_json = blob
|
|
else:
|
|
row = CryptoBackup(user_id=current_user.id, blob_json=blob)
|
|
db.add(row)
|
|
db.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/admin/user/{user_id}")
|
|
def delete_user_as_owner(
|
|
user_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
# Only owner can delete users
|
|
if current_user.username != OWNER_USERNAME:
|
|
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Prevent deleting the owner account via API
|
|
if user.username == OWNER_USERNAME:
|
|
raise HTTPException(status_code=400, detail="Cannot delete owner account")
|
|
|
|
# Manually delete user's messages to satisfy FK constraints
|
|
from models import Message # local import to avoid circular
|
|
db.query(Message).filter(Message.user_id == user.id).delete()
|
|
|
|
db.delete(user)
|
|
db.commit()
|
|
|
|
log_security(
|
|
"admin_delete_user",
|
|
severity="warning",
|
|
actor=current_user.username,
|
|
actor_id=current_user.id,
|
|
target_username=user.username,
|
|
target_id=user.id,
|
|
)
|
|
|
|
return {"status": "success", "deleted_user_id": user_id}
|
|
|
|
@router.get("/logout")
|
|
def logout(
|
|
http: Request,
|
|
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
|
current_user: User = Depends(get_current_user),
|
|
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})
|
|
|
|
current_user.online = False
|
|
current_user.last_seen = datetime.now()
|
|
db.commit()
|
|
|
|
client_ip = get_client_ip(http)
|
|
log_security(
|
|
"logout",
|
|
username=current_user.username,
|
|
user_id=current_user.id,
|
|
ip=client_ip,
|
|
session_id=payload.get("session_id") if payload else None,
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Logged out successfully"
|
|
}
|
|
|
|
|
|
@router.post("/change-password")
|
|
@rate_limit_per_ip("5/hour")
|
|
def change_password(
|
|
request: Request,
|
|
password_request: ChangePasswordRequest,
|
|
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
# Verify current derived password against stored hash
|
|
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
|
|
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
|
|
|
|
# Update password hash to hash of new derived password
|
|
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip())
|
|
db.commit()
|
|
|
|
# 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")
|
|
db.query(DeviceSession).filter(
|
|
DeviceSession.user_id == current_user.id,
|
|
DeviceSession.session_id != current_session_id,
|
|
).update({DeviceSession.revoked: True})
|
|
db.commit()
|
|
|
|
client_ip = get_client_ip(request)
|
|
log_security(
|
|
"password_changed",
|
|
username=current_user.username,
|
|
user_id=current_user.id,
|
|
ip=client_ip,
|
|
logout_others=bool(password_request.logoutAllExceptCurrent),
|
|
)
|
|
|
|
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), 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
|
|
]
|
|
}
|
|
|
|
|
|
@router.get("/crypto/public-key/of/{user_id}")
|
|
@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)):
|
|
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
|
|
return {"publicKey": row.public_key_b64 if row else None}
|
|
|
|
|
|
@router.post("/crypto/signal/prekey-bundle")
|
|
@rate_limit_per_ip("10/minute")
|
|
def upload_prekey_bundle(
|
|
request: Request,
|
|
payload: dict,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Upload Signal Protocol prekey bundle for the current user"""
|
|
from models import SignalPreKeyBundle, SignalPreKey
|
|
import json
|
|
|
|
bundle = payload.get("bundle")
|
|
if not bundle:
|
|
raise HTTPException(status_code=400, detail="bundle required")
|
|
|
|
# Validate bundle structure
|
|
if not isinstance(bundle, dict):
|
|
raise HTTPException(status_code=400, detail="bundle must be a JSON object")
|
|
|
|
# Validate required fields
|
|
required_fields = ["registrationId", "identityKey", "signedPreKey"]
|
|
for field in required_fields:
|
|
if field not in bundle:
|
|
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
|
|
|
if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]:
|
|
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
|
|
|
|
# Store bundle (identity key, signed prekey, registration ID) - without the one-time prekey
|
|
bundle_without_prekey = {
|
|
"registrationId": bundle["registrationId"],
|
|
"identityKey": bundle["identityKey"],
|
|
"signedPreKey": bundle["signedPreKey"]
|
|
}
|
|
bundle_json = json.dumps(bundle_without_prekey)
|
|
if len(bundle_json) > 50000: # 50KB limit
|
|
raise HTTPException(status_code=400, detail="Bundle too large")
|
|
|
|
# Store or update the bundle
|
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
|
if row:
|
|
row.bundle_json = bundle_json
|
|
row.updated_at = datetime.now()
|
|
else:
|
|
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
|
|
db.add(row)
|
|
|
|
# Store the one-time prekey if provided
|
|
if "preKey" in bundle and bundle["preKey"]:
|
|
prekey = bundle["preKey"]
|
|
if isinstance(prekey, dict) and "keyId" in prekey and "publicKey" in prekey:
|
|
# Check if this prekey already exists
|
|
existing = db.query(SignalPreKey).filter(
|
|
SignalPreKey.user_id == current_user.id,
|
|
SignalPreKey.prekey_id == prekey["keyId"]
|
|
).first()
|
|
|
|
if existing:
|
|
# Update existing prekey (mark as unused if it was used)
|
|
existing.public_key = prekey["publicKey"]
|
|
existing.used = False
|
|
existing.created_at = datetime.now()
|
|
else:
|
|
# Add new prekey
|
|
new_prekey = SignalPreKey(
|
|
user_id=current_user.id,
|
|
prekey_id=prekey["keyId"],
|
|
public_key=prekey["publicKey"],
|
|
used=False
|
|
)
|
|
db.add(new_prekey)
|
|
|
|
db.commit()
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/crypto/signal/prekeys/bulk")
|
|
@rate_limit_per_ip("10/minute")
|
|
def upload_prekeys_bulk(
|
|
request: Request,
|
|
payload: dict,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Upload multiple Signal Protocol prekeys in one request"""
|
|
from models import SignalPreKeyBundle, SignalPreKey
|
|
import json
|
|
|
|
base_bundle = payload.get("baseBundle")
|
|
prekeys = payload.get("prekeys", [])
|
|
|
|
if not base_bundle:
|
|
raise HTTPException(status_code=400, detail="baseBundle required")
|
|
|
|
if not isinstance(prekeys, list):
|
|
raise HTTPException(status_code=400, detail="prekeys must be an array")
|
|
|
|
# Validate base bundle structure
|
|
if not isinstance(base_bundle, dict):
|
|
raise HTTPException(status_code=400, detail="baseBundle must be a JSON object")
|
|
|
|
# Validate required fields
|
|
required_fields = ["registrationId", "identityKey", "signedPreKey"]
|
|
for field in required_fields:
|
|
if field not in base_bundle:
|
|
raise HTTPException(status_code=400, detail=f"Missing required field in baseBundle: {field}")
|
|
|
|
if not isinstance(base_bundle["signedPreKey"], dict) or "keyId" not in base_bundle["signedPreKey"]:
|
|
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
|
|
|
|
# Store or update the base bundle (identity key, signed prekey, registration ID)
|
|
bundle_without_prekey = {
|
|
"registrationId": base_bundle["registrationId"],
|
|
"identityKey": base_bundle["identityKey"],
|
|
"signedPreKey": base_bundle["signedPreKey"]
|
|
}
|
|
bundle_json = json.dumps(bundle_without_prekey)
|
|
if len(bundle_json) > 50000: # 50KB limit
|
|
raise HTTPException(status_code=400, detail="Bundle too large")
|
|
|
|
# Store or update the bundle
|
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
|
if row:
|
|
row.bundle_json = bundle_json
|
|
row.updated_at = datetime.now()
|
|
else:
|
|
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
|
|
db.add(row)
|
|
|
|
# Store all prekeys
|
|
for prekey in prekeys:
|
|
if not isinstance(prekey, dict) or "keyId" not in prekey or "publicKey" not in prekey:
|
|
continue # Skip invalid prekeys
|
|
|
|
# Check if this prekey already exists
|
|
existing = db.query(SignalPreKey).filter(
|
|
SignalPreKey.user_id == current_user.id,
|
|
SignalPreKey.prekey_id == prekey["keyId"]
|
|
).first()
|
|
|
|
if existing:
|
|
# Update existing prekey (mark as unused if it was used)
|
|
existing.public_key = prekey["publicKey"]
|
|
existing.used = False
|
|
existing.created_at = datetime.now()
|
|
else:
|
|
# Add new prekey
|
|
new_prekey = SignalPreKey(
|
|
user_id=current_user.id,
|
|
prekey_id=prekey["keyId"],
|
|
public_key=prekey["publicKey"],
|
|
used=False
|
|
)
|
|
db.add(new_prekey)
|
|
|
|
db.commit()
|
|
|
|
return {"status": "ok", "uploaded": len(prekeys)}
|
|
|
|
|
|
@router.get("/crypto/signal/prekey-bundle")
|
|
def get_prekey_bundle(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get Signal Protocol prekey bundle for the current user"""
|
|
from models import SignalPreKeyBundle
|
|
import json
|
|
|
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
|
|
|
try:
|
|
bundle = json.loads(row.bundle_json)
|
|
return {"bundle": bundle}
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
|
|
|
|
|
@router.get("/crypto/signal/prekey-bundle/of/{user_id}")
|
|
@rate_limit_per_ip("100/minute")
|
|
def get_prekey_bundle_of(
|
|
request: Request,
|
|
user_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get Signal Protocol prekey bundle for another user with prekey rotation"""
|
|
from models import SignalPreKeyBundle, SignalPreKey
|
|
import json
|
|
|
|
# Get the base bundle (identity key, signed prekey, registration ID)
|
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
|
|
|
try:
|
|
bundle = json.loads(row.bundle_json)
|
|
|
|
# Find an unused prekey for this user
|
|
unused_prekey = db.query(SignalPreKey).filter(
|
|
SignalPreKey.user_id == user_id,
|
|
SignalPreKey.used == False
|
|
).order_by(SignalPreKey.created_at.asc()).first()
|
|
|
|
if unused_prekey:
|
|
# Mark this prekey as used (atomic operation)
|
|
unused_prekey.used = True
|
|
db.commit()
|
|
|
|
# Add the prekey to the bundle
|
|
bundle["preKey"] = {
|
|
"keyId": unused_prekey.prekey_id,
|
|
"publicKey": unused_prekey.public_key
|
|
}
|
|
else:
|
|
# No unused prekeys available - return bundle without prekey
|
|
# The client will need to establish a session using the signed prekey only
|
|
pass
|
|
|
|
return {"bundle": bundle}
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
|
|
|
|
|
@router.post("/crypto/signal/sessions")
|
|
@rate_limit_per_ip("100/minute")
|
|
def upload_signal_sessions(
|
|
request: Request,
|
|
payload: dict,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Upload encrypted Signal Protocol sessions for the current user"""
|
|
import json
|
|
from datetime import datetime
|
|
|
|
sessions = payload.get("sessions")
|
|
if not isinstance(sessions, list):
|
|
raise HTTPException(status_code=400, detail="sessions must be a list")
|
|
|
|
uploaded_count = 0
|
|
for session_data in sessions:
|
|
if not isinstance(session_data, dict):
|
|
continue
|
|
|
|
recipient_id = session_data.get("recipientId")
|
|
device_id = session_data.get("deviceId", 1)
|
|
encrypted_data = session_data.get("encryptedData")
|
|
|
|
if not recipient_id or not encrypted_data:
|
|
continue
|
|
|
|
try:
|
|
# Validate encrypted_data is valid JSON
|
|
json.loads(encrypted_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
|
|
# Store or update session
|
|
existing = db.query(SignalSession).filter(
|
|
SignalSession.user_id == current_user.id,
|
|
SignalSession.recipient_id == recipient_id,
|
|
SignalSession.device_id == device_id
|
|
).first()
|
|
|
|
if existing:
|
|
existing.encrypted_session_data = encrypted_data
|
|
existing.updated_at = datetime.now()
|
|
else:
|
|
new_session = SignalSession(
|
|
user_id=current_user.id,
|
|
recipient_id=recipient_id,
|
|
device_id=device_id,
|
|
encrypted_session_data=encrypted_data
|
|
)
|
|
db.add(new_session)
|
|
uploaded_count += 1
|
|
|
|
db.commit()
|
|
return {"status": "ok", "uploaded_count": uploaded_count}
|
|
|
|
|
|
@router.get("/crypto/signal/sessions")
|
|
@rate_limit_per_ip("60/minute")
|
|
def get_signal_sessions(
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all encrypted Signal Protocol sessions for the current user"""
|
|
sessions = db.query(SignalSession).filter(
|
|
SignalSession.user_id == current_user.id
|
|
).all()
|
|
|
|
return {
|
|
"sessions": [
|
|
{
|
|
"recipientId": s.recipient_id,
|
|
"deviceId": s.device_id,
|
|
"encryptedData": s.encrypted_session_data,
|
|
"updatedAt": s.updated_at.isoformat()
|
|
}
|
|
for s in sessions
|
|
]
|
|
}
|
|
|
|
|
|
@router.post("/crypto/signal/message-plaintexts")
|
|
@rate_limit_per_ip("100/minute")
|
|
def upload_message_plaintexts(
|
|
request: Request,
|
|
payload: dict,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Upload encrypted plaintexts of sent messages"""
|
|
import json
|
|
from datetime import datetime
|
|
|
|
messages = payload.get("messages")
|
|
if not isinstance(messages, list):
|
|
raise HTTPException(status_code=400, detail="messages must be a list")
|
|
|
|
uploaded_count = 0
|
|
for msg_data in messages:
|
|
if not isinstance(msg_data, dict):
|
|
continue
|
|
|
|
message_id = msg_data.get("messageId")
|
|
recipient_id = msg_data.get("recipientId")
|
|
encrypted_data = msg_data.get("encryptedData")
|
|
|
|
if not message_id or not recipient_id or not encrypted_data:
|
|
continue
|
|
|
|
try:
|
|
# Validate encrypted_data is valid JSON
|
|
json.loads(encrypted_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
|
|
# Store or update plaintext
|
|
existing = db.query(SentMessagePlaintext).filter(
|
|
SentMessagePlaintext.user_id == current_user.id,
|
|
SentMessagePlaintext.message_id == message_id
|
|
).first()
|
|
|
|
if existing:
|
|
existing.encrypted_data = encrypted_data
|
|
else:
|
|
new_plaintext = SentMessagePlaintext(
|
|
user_id=current_user.id,
|
|
message_id=message_id,
|
|
recipient_id=recipient_id,
|
|
encrypted_data=encrypted_data
|
|
)
|
|
db.add(new_plaintext)
|
|
uploaded_count += 1
|
|
|
|
db.commit()
|
|
return {"status": "ok", "uploaded_count": uploaded_count}
|
|
|
|
|
|
@router.get("/crypto/signal/message-plaintexts")
|
|
@rate_limit_per_ip("60/minute")
|
|
def get_message_plaintexts(
|
|
request: Request,
|
|
recipient_id: int | None = None, # Optional filter by recipient
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get encrypted plaintexts of sent messages for the current user"""
|
|
query = db.query(SentMessagePlaintext).filter(
|
|
SentMessagePlaintext.user_id == current_user.id
|
|
)
|
|
|
|
if recipient_id is not None:
|
|
query = query.filter(SentMessagePlaintext.recipient_id == recipient_id)
|
|
|
|
plaintexts = query.all()
|
|
|
|
return {
|
|
"messages": [
|
|
{
|
|
"messageId": p.message_id,
|
|
"recipientId": p.recipient_id,
|
|
"encryptedData": p.encrypted_data,
|
|
"createdAt": p.created_at.isoformat()
|
|
}
|
|
for p in plaintexts
|
|
]
|
|
}
|
|
|
|
|
|
@router.get("/users/search")
|
|
@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)):
|
|
if len(q.strip()) < 2:
|
|
return {"users": []}
|
|
|
|
# Case-insensitive partial match on username
|
|
users = db.query(User).filter(
|
|
User.username.ilike(f"%{q.strip()}%"),
|
|
User.id != current_user.id # Exclude current user
|
|
).order_by(User.username.asc()).limit(20).all()
|
|
|
|
return {
|
|
"users": [convert_user(u) for u in users]
|
|
}
|
|
|
|
|
|
async def _delete_user_data(user: User, db: Session):
|
|
"""
|
|
Helper function to delete user data - marks user as deleted, clears sensitive data,
|
|
deletes profile picture, removes non-whitelist user data, and sends WebSocket message.
|
|
"""
|
|
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
|
|
|
|
# Delete profile picture file if exists
|
|
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"):
|
|
try:
|
|
filename = user.profile_picture.split("/")[-1]
|
|
filepath = os.path.join("data/uploads/pfp", filename)
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
except Exception as e:
|
|
# Log error but don't fail the request
|
|
pass
|
|
|
|
# Dynamic deletion of all non-whitelist data
|
|
WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"}
|
|
|
|
try:
|
|
inspector = inspect(db.bind)
|
|
all_tables = inspector.get_table_names()
|
|
|
|
for table_name in all_tables:
|
|
if table_name in WHITELIST_TABLES or table_name == "user":
|
|
continue
|
|
|
|
# Check if table has user_id column
|
|
columns = inspector.get_columns(table_name)
|
|
has_user_id = any(col['name'] == 'user_id' for col in columns)
|
|
|
|
if has_user_id:
|
|
# Delete all records for this user
|
|
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
|
|
|
|
db.commit()
|
|
except Exception as e:
|
|
# Log error and rollback
|
|
db.rollback()
|
|
raise HTTPException(status_code=500, detail="Failed to delete user data")
|
|
|
|
# Send WebSocket deletion message
|
|
try:
|
|
from .messaging import messagingManager
|
|
await messagingManager.send_deletion_to_user(user_id)
|
|
except Exception as e:
|
|
# Log error but don't fail the request
|
|
pass
|
|
|
|
|
|
@router.post("/delete")
|
|
async def delete_account(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Delete the current user's own account - preserves messages/DMs/reactions/files
|
|
"""
|
|
# Prevent admin/owner account self-deletion
|
|
if current_user.username == OWNER_USERNAME or current_user.id == 1:
|
|
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
|
|
|
|
await _delete_user_data(current_user, db)
|
|
|
|
log_security(
|
|
"self_delete_account",
|
|
severity="warning",
|
|
user_id=current_user.id,
|
|
username=current_user.username,
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Account deleted successfully"
|
|
} |