Implement legal terms, Android client compatibility and more
@@ -666,7 +666,161 @@ async def get_file(filename: str, request: Request):
|
||||
return FileResponse(str(path), media_type="application/octet-stream", filename=filename)
|
||||
|
||||
|
||||
@app.post("/uploads/files/normal/store", response_model=None)
|
||||
async def store_normal_file(request: Request, file: UploadFile = File(...)):
|
||||
"""Store a plain public-chat attachment at a fixed stored name."""
|
||||
stored_name = (await request.form()).get("stored_name")
|
||||
if not stored_name or not str(stored_name).strip():
|
||||
raise HTTPException(status_code=400, detail="stored_name is required")
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
data = await file.read()
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
return await store_normal_file_from_path_internal(str(stored_name).strip(), tmp_path)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# File serving routes (moved from main service)
|
||||
async def store_normal_file_from_path_internal(stored_name: str, source_path: Path) -> dict:
|
||||
"""Copy a plain public-chat attachment into FILES_NORMAL_DIR."""
|
||||
import shutil
|
||||
|
||||
_ensure_dirs()
|
||||
safe_name = Path(stored_name).name
|
||||
if stored_name != safe_name:
|
||||
raise HTTPException(status_code=400, detail="Invalid stored name")
|
||||
src = Path(source_path)
|
||||
if not src.is_file():
|
||||
raise HTTPException(status_code=400, detail="source_path is not a file")
|
||||
dest = FILES_NORMAL_DIR / safe_name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(src, dest)
|
||||
dest.chmod(0o600)
|
||||
return {
|
||||
"stored_name": safe_name,
|
||||
"size": int(dest.stat().st_size),
|
||||
"path": f"/uploads/files/normal/{safe_name}",
|
||||
}
|
||||
|
||||
|
||||
def _thumb_jpeg_path(stored_name: str) -> Path:
|
||||
return THUMBS_DIR / f"{Path(stored_name).stem}.jpg"
|
||||
|
||||
|
||||
def _thumb_meta_path(stored_name: str) -> Path:
|
||||
return THUMBS_DIR / f"{Path(stored_name).stem}.json"
|
||||
|
||||
|
||||
async def store_public_thumb_internal(
|
||||
stored_name: str,
|
||||
jpeg_bytes: bytes,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
file_size: int,
|
||||
) -> dict:
|
||||
"""Persist a public-chat image thumbnail next to normal attachments."""
|
||||
_ensure_dirs()
|
||||
safe_name = Path(stored_name).name
|
||||
if stored_name != safe_name:
|
||||
raise HTTPException(status_code=400, detail="Invalid stored name")
|
||||
if not jpeg_bytes:
|
||||
raise HTTPException(status_code=400, detail="Empty thumbnail")
|
||||
thumb_path = _thumb_jpeg_path(safe_name)
|
||||
meta_path = _thumb_meta_path(safe_name)
|
||||
thumb_path.write_bytes(jpeg_bytes)
|
||||
thumb_path.chmod(0o600)
|
||||
meta = {
|
||||
"stored_name": safe_name,
|
||||
"width": int(width),
|
||||
"height": int(height),
|
||||
"file_size": int(file_size),
|
||||
"thumb_path": f"/uploads/files/thumbs/{thumb_path.name}",
|
||||
}
|
||||
meta_path.write_text(json.dumps(meta), encoding="utf-8")
|
||||
meta_path.chmod(0o600)
|
||||
return meta
|
||||
|
||||
|
||||
async def store_public_image_dimensions_internal(
|
||||
stored_name: str,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
file_size: int,
|
||||
) -> dict:
|
||||
"""Persist image dimensions for large public attachments (no JPEG thumbnail)."""
|
||||
_ensure_dirs()
|
||||
safe_name = Path(stored_name).name
|
||||
if stored_name != safe_name:
|
||||
raise HTTPException(status_code=400, detail="Invalid stored name")
|
||||
if width <= 0 or height <= 0:
|
||||
raise HTTPException(status_code=400, detail="Invalid image dimensions")
|
||||
meta_path = _thumb_meta_path(safe_name)
|
||||
meta = {
|
||||
"stored_name": safe_name,
|
||||
"width": int(width),
|
||||
"height": int(height),
|
||||
"file_size": int(file_size),
|
||||
"thumb_path": "",
|
||||
}
|
||||
meta_path.write_text(json.dumps(meta), encoding="utf-8")
|
||||
meta_path.chmod(0o600)
|
||||
return meta
|
||||
|
||||
|
||||
def get_public_thumb_meta_internal(stored_name: str) -> dict | None:
|
||||
"""Load thumbnail metadata + base64 JPEG for a normal attachment basename."""
|
||||
import base64
|
||||
|
||||
_ensure_dirs()
|
||||
safe_name = Path(stored_name).name
|
||||
if stored_name != safe_name:
|
||||
return None
|
||||
thumb_path = _thumb_jpeg_path(safe_name)
|
||||
meta_path = _thumb_meta_path(safe_name)
|
||||
if not meta_path.is_file():
|
||||
return None
|
||||
width, height, file_size = 1, 1, 0
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
width = int(meta.get("width") or 1)
|
||||
height = int(meta.get("height") or 1)
|
||||
file_size = int(meta.get("file_size") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
thumbnail_b64 = ""
|
||||
if thumb_path.is_file():
|
||||
jpeg = thumb_path.read_bytes()
|
||||
thumbnail_b64 = base64.b64encode(jpeg).decode("ascii")
|
||||
return {
|
||||
"stored_name": safe_name,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"file_size": file_size,
|
||||
"thumbnail_b64": thumbnail_b64,
|
||||
"thumb_path": f"/uploads/files/thumbs/{thumb_path.name}" if thumb_path.is_file() else "",
|
||||
}
|
||||
|
||||
|
||||
async def get_file_thumb_internal(filename: str):
|
||||
"""Internal: serve public-chat thumbnail JPEGs."""
|
||||
safe_name = Path(filename).name
|
||||
if filename != safe_name:
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
# Accept either "{stem}.jpg" or a normal attachment basename.
|
||||
path = THUMBS_DIR / safe_name
|
||||
if not path.exists() and not safe_name.lower().endswith(".jpg"):
|
||||
path = _thumb_jpeg_path(safe_name)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Thumbnail not found")
|
||||
return FileResponse(str(path), media_type="image/jpeg")
|
||||
|
||||
|
||||
async def get_file_normal_internal(filename: str):
|
||||
"""Internal: serve normal (unencrypted) files. Used by proxy when in-process."""
|
||||
safe_name = Path(filename).name
|
||||
@@ -675,7 +829,31 @@ async def get_file_normal_internal(filename: str):
|
||||
path = FILES_NORMAL_DIR / safe_name
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(str(path))
|
||||
return FileResponse(str(path), media_type="application/octet-stream")
|
||||
|
||||
|
||||
def get_normal_file_path_internal(stored_name: str) -> Path | None:
|
||||
"""Resolve a stored public attachment basename to its on-disk path."""
|
||||
safe_name = Path(stored_name).name
|
||||
if stored_name != safe_name:
|
||||
return None
|
||||
path = FILES_NORMAL_DIR / safe_name
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def read_image_dimensions_from_path(path: Path) -> list[int] | None:
|
||||
try:
|
||||
from ..main.public_image_dimensions import read_image_dimensions_from_path as read_dims
|
||||
except ImportError:
|
||||
try:
|
||||
from backend.services.main.public_image_dimensions import (
|
||||
read_image_dimensions_from_path as read_dims,
|
||||
)
|
||||
except ImportError:
|
||||
from services.main.public_image_dimensions import (
|
||||
read_image_dimensions_from_path as read_dims,
|
||||
)
|
||||
return read_dims(path)
|
||||
|
||||
|
||||
@app.get("/uploads/files/normal/{filename}", response_model=None)
|
||||
@@ -684,6 +862,46 @@ async def get_file_normal(filename: str):
|
||||
return await get_file_normal_internal(filename)
|
||||
|
||||
|
||||
@app.get("/uploads/files/thumbs/{filename}", response_model=None)
|
||||
async def get_file_thumb(filename: str):
|
||||
"""Serve public-chat thumbnail JPEGs from THUMBS_DIR."""
|
||||
return await get_file_thumb_internal(filename)
|
||||
|
||||
|
||||
@app.post("/uploads/files/thumbs/store", response_model=None)
|
||||
async def store_public_thumb(request: Request, file: UploadFile = File(...)):
|
||||
"""HTTP entry for storing a public-chat thumbnail (used when not in-process)."""
|
||||
form = await request.form()
|
||||
stored_name = str(form.get("stored_name") or "").strip()
|
||||
width = int(form.get("width") or 1)
|
||||
height = int(form.get("height") or 1)
|
||||
file_size = int(form.get("file_size") or 0)
|
||||
jpeg_bytes = await file.read()
|
||||
return await store_public_thumb_internal(
|
||||
stored_name,
|
||||
jpeg_bytes,
|
||||
width=width,
|
||||
height=height,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/uploads/files/thumbs/dimensions", response_model=None)
|
||||
async def store_public_image_dimensions(request: Request):
|
||||
"""HTTP entry for storing image dimensions without a JPEG thumbnail."""
|
||||
form = await request.form()
|
||||
stored_name = str(form.get("stored_name") or "").strip()
|
||||
width = int(form.get("width") or 1)
|
||||
height = int(form.get("height") or 1)
|
||||
file_size = int(form.get("file_size") or 0)
|
||||
return await store_public_image_dimensions_internal(
|
||||
stored_name,
|
||||
width=width,
|
||||
height=height,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
|
||||
async def get_file_encrypted_internal(filename: str, user_id: int):
|
||||
"""Internal: serve encrypted files with permission checking. Used by proxy when in-process."""
|
||||
safe_name = Path(filename).name
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Shared constants and helpers for deleted / suspended user API surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .models import User
|
||||
from .verification_service import VerificationStatus
|
||||
|
||||
DELETED_LAST_SEEN = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def deleted_username_for(user_id: int) -> str:
|
||||
"""Placeholder username with an illegal character so it cannot be claimed."""
|
||||
return f"#deleted{user_id}"
|
||||
|
||||
|
||||
def is_deleted_user(user: User) -> bool:
|
||||
return bool(user.deleted)
|
||||
|
||||
|
||||
def is_suspended_user(user: User) -> bool:
|
||||
return bool(user.suspended) and not user.deleted
|
||||
|
||||
|
||||
def is_deleted_or_suspended(user: User) -> bool:
|
||||
return is_deleted_user(user) or is_suspended_user(user)
|
||||
|
||||
|
||||
def apply_deleted_user_db_fields(user: User) -> None:
|
||||
user.deleted = True
|
||||
user.username = deleted_username_for(user.id)
|
||||
user.display_name = ""
|
||||
user.bio = None
|
||||
user.password_hash = ""
|
||||
user.profile_picture = None
|
||||
user.last_seen = DELETED_LAST_SEEN
|
||||
user.created_at = None
|
||||
user.online = False
|
||||
|
||||
|
||||
def deleted_user_api_fields(user_id: int) -> dict:
|
||||
"""Static API fields for deleted users. Ignores all DB columns except id."""
|
||||
return {
|
||||
"username": deleted_username_for(user_id),
|
||||
"display_name": "",
|
||||
"profile_picture": None,
|
||||
"bio": None,
|
||||
"online": False,
|
||||
"last_seen": DELETED_LAST_SEEN.isoformat(),
|
||||
"created_at": None,
|
||||
"verified": False,
|
||||
"verification_status": VerificationStatus.NONE.value,
|
||||
"suspended": False,
|
||||
"suspension_reason": None,
|
||||
"deleted": True,
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import logging
|
||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||
|
||||
# Import from same directory
|
||||
from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging, livekit
|
||||
from .routes import account, messaging, profile, public_chat, push, webrtc, devices, moderation, download, keys, envelope_messaging, livekit, static as static_routes
|
||||
from .routes.account import get_server_instance_id
|
||||
from .models import User
|
||||
from .constants import OWNER_USERNAME
|
||||
@@ -325,6 +325,7 @@ app.add_middleware(
|
||||
app.include_router(account.router)
|
||||
app.include_router(envelope_messaging.router)
|
||||
app.include_router(messaging.router)
|
||||
app.include_router(public_chat.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
app.include_router(webrtc.router, prefix="/webrtc")
|
||||
@@ -333,6 +334,7 @@ app.include_router(devices.router, prefix="/devices")
|
||||
app.include_router(moderation.router)
|
||||
app.include_router(download.router)
|
||||
app.include_router(keys.router)
|
||||
app.include_router(static_routes.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -160,6 +160,22 @@ class DMReaction(Base):
|
||||
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
||||
|
||||
|
||||
class DmConversationPreference(Base):
|
||||
"""Per-user DM list preferences (archive state, read cursor)."""
|
||||
|
||||
__tablename__ = "dm_conversation_preference"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
other_user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
archived = Column(Boolean, default=False, nullable=False)
|
||||
last_read_envelope_id = Column(Integer, default=0, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "other_user_id", name="unique_dm_conversation_preference"),
|
||||
)
|
||||
|
||||
|
||||
# Tracks authenticated device sessions per user
|
||||
class DeviceSession(Base):
|
||||
__tablename__ = "device_session"
|
||||
@@ -202,6 +218,7 @@ class RegisterRequest(BaseModel):
|
||||
display_name: str
|
||||
password: str
|
||||
confirm_password: str
|
||||
bio: str | None = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
@@ -210,9 +227,19 @@ class ChangePasswordRequest(BaseModel):
|
||||
logoutAllExceptCurrent: bool = False
|
||||
|
||||
|
||||
class VerifyPasswordRequest(BaseModel):
|
||||
passwordDerived: str
|
||||
|
||||
|
||||
class DeleteAccountRequest(BaseModel):
|
||||
passwordDerived: str
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
content: str
|
||||
reply_to_id: int | None = None
|
||||
client_message_id: str | None = None
|
||||
uploaded_file_ids: list[str] | None = None
|
||||
|
||||
|
||||
class EditMessageRequest(BaseModel):
|
||||
@@ -270,6 +297,7 @@ class UserProfileResponse(BaseModel):
|
||||
last_seen: datetime | None
|
||||
created_at: datetime | None
|
||||
verified: bool
|
||||
verification_status: str
|
||||
suspended: bool
|
||||
suspension_reason: str | None
|
||||
deleted: bool
|
||||
@@ -278,6 +306,13 @@ class UserProfileResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PublicChatProfileResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
bio: str | None
|
||||
member_count: int
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""In-memory user presence derived from WebSocket connections only."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class PresenceService:
|
||||
def __init__(self) -> None:
|
||||
self._connections: dict[int, set[WebSocket]] = {}
|
||||
self._last_seen: dict[int, datetime] = {}
|
||||
|
||||
def register_connection(self, user_id: int, websocket: WebSocket) -> bool:
|
||||
"""Track a live connection. Returns True if the user became online."""
|
||||
connections = self._connections.setdefault(user_id, set())
|
||||
was_online = bool(connections)
|
||||
connections.add(websocket)
|
||||
return not was_online
|
||||
|
||||
def unregister_connection(self, user_id: int, websocket: WebSocket) -> tuple[bool, datetime | None]:
|
||||
"""Remove a connection. Returns (became_offline, last_seen) when the last conn drops."""
|
||||
connections = self._connections.get(user_id)
|
||||
if not connections:
|
||||
return False, self._last_seen.get(user_id)
|
||||
|
||||
connections.discard(websocket)
|
||||
if connections:
|
||||
return False, None
|
||||
|
||||
del self._connections[user_id]
|
||||
last_seen = datetime.now()
|
||||
self._last_seen[user_id] = last_seen
|
||||
return True, last_seen
|
||||
|
||||
def touch(self, user_id: int) -> None:
|
||||
"""Refresh activity timestamp while online."""
|
||||
if self.is_online(user_id):
|
||||
self._last_seen[user_id] = datetime.now()
|
||||
|
||||
def is_online(self, user_id: int) -> bool:
|
||||
connections = self._connections.get(user_id)
|
||||
return bool(connections)
|
||||
|
||||
def get_last_seen(self, user_id: int) -> datetime | None:
|
||||
if self.is_online(user_id):
|
||||
return self._last_seen.get(user_id) or datetime.now()
|
||||
return self._last_seen.get(user_id)
|
||||
|
||||
def get_presence(self, user_id: int) -> tuple[bool, datetime | None]:
|
||||
online = self.is_online(user_id)
|
||||
if online:
|
||||
return True, self.get_last_seen(user_id)
|
||||
last_seen = self._last_seen.get(user_id)
|
||||
return False, last_seen
|
||||
|
||||
def remove_user(self, user_id: int) -> None:
|
||||
"""Drop all presence state for a deleted user."""
|
||||
self._connections.pop(user_id, None)
|
||||
self._last_seen.pop(user_id, None)
|
||||
|
||||
|
||||
presence_service = PresenceService()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Server-side metadata for the instance public chat (title, bio).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
_STATIC_PROFILE_PATH = Path(__file__).resolve().parent / "static" / "public_chat_profile.json"
|
||||
|
||||
|
||||
class PublicChatStaticProfile(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
bio: str
|
||||
|
||||
|
||||
def load_public_chat_static_profile() -> PublicChatStaticProfile:
|
||||
if not _STATIC_PROFILE_PATH.is_file():
|
||||
raise FileNotFoundError(f"public chat profile config missing: {_STATIC_PROFILE_PATH}")
|
||||
with _STATIC_PROFILE_PATH.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
chat_id = str(data.get("id", "")).strip()
|
||||
title = str(data.get("title", "")).strip()
|
||||
bio = str(data.get("bio", "")).strip()
|
||||
if not chat_id or not title:
|
||||
raise ValueError("public chat profile config must include non-empty id and title")
|
||||
return PublicChatStaticProfile(id=chat_id, title=title, bio=bio)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Header-only image dimension reads for very large public-chat attachments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
_HEADER_READ_BYTES = 4 * 1024 * 1024
|
||||
_HEADER_READ_MAX_BYTES = 16 * 1024 * 1024
|
||||
_JPEG_SOF_MARKERS = frozenset(
|
||||
{0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
|
||||
)
|
||||
|
||||
|
||||
def is_placeholder_dimensions(width: int, height: int) -> bool:
|
||||
return width <= 1 and height <= 1
|
||||
|
||||
|
||||
def read_image_dimensions_from_path(path: Path) -> list[int] | None:
|
||||
"""Read pixel width/height without decoding multi-hundred-MP images."""
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
header = handle.read(_HEADER_READ_BYTES)
|
||||
result = read_image_dimensions_from_bytes(header, path.suffix)
|
||||
if result is not None:
|
||||
return result
|
||||
while len(header) < _HEADER_READ_MAX_BYTES:
|
||||
extra = handle.read(_HEADER_READ_BYTES)
|
||||
if not extra:
|
||||
break
|
||||
header += extra
|
||||
result = read_image_dimensions_from_bytes(header, path.suffix)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
except Exception as error:
|
||||
logger.warning("PUBLIC THUMB: header read failed for %s: %s", path, error)
|
||||
return None
|
||||
|
||||
|
||||
def read_image_dimensions_from_bytes(data: bytes, suffix: str = "") -> list[int] | None:
|
||||
if not data:
|
||||
return None
|
||||
ext = suffix.lower()
|
||||
wh: tuple[int, int] | None = None
|
||||
if data.startswith(b"\xff\xd8"):
|
||||
wh = _jpeg_dimensions(data)
|
||||
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
wh = _png_dimensions(data)
|
||||
elif data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
|
||||
wh = _gif_dimensions(data)
|
||||
elif data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP":
|
||||
wh = _webp_dimensions(data)
|
||||
elif ext in {".jpg", ".jpeg"}:
|
||||
wh = _jpeg_dimensions(data)
|
||||
elif ext == ".png":
|
||||
wh = _png_dimensions(data)
|
||||
elif ext == ".gif":
|
||||
wh = _gif_dimensions(data)
|
||||
elif ext == ".webp":
|
||||
wh = _webp_dimensions(data)
|
||||
if wh is None:
|
||||
wh = _pil_dimensions_fallback(data)
|
||||
if wh is None:
|
||||
return None
|
||||
width, height = wh
|
||||
if is_placeholder_dimensions(width, height):
|
||||
return None
|
||||
return [width, height]
|
||||
|
||||
|
||||
def _apply_exif_orientation(width: int, height: int, orientation: int) -> tuple[int, int]:
|
||||
if orientation in {5, 6, 7, 8}:
|
||||
return height, width
|
||||
return width, height
|
||||
|
||||
|
||||
def _parse_exif_orientation(exif_bytes: bytes) -> int | None:
|
||||
try:
|
||||
if len(exif_bytes) < 8:
|
||||
return None
|
||||
endian = exif_bytes[0:2]
|
||||
if endian == b"II":
|
||||
endianness = "<"
|
||||
elif endian == b"MM":
|
||||
endianness = ">"
|
||||
else:
|
||||
return None
|
||||
ifd_offset = struct.unpack(endianness + "I", exif_bytes[4:8])[0]
|
||||
if ifd_offset + 2 > len(exif_bytes):
|
||||
return None
|
||||
count = struct.unpack(endianness + "H", exif_bytes[ifd_offset : ifd_offset + 2])[0]
|
||||
cursor = ifd_offset + 2
|
||||
for _ in range(count):
|
||||
if cursor + 12 > len(exif_bytes):
|
||||
break
|
||||
tag, field_type, value_count = struct.unpack(endianness + "HHI", exif_bytes[cursor : cursor + 8])
|
||||
value_offset = struct.unpack(endianness + "I", exif_bytes[cursor + 8 : cursor + 12])[0]
|
||||
if tag == 0x0112:
|
||||
if field_type == 3 and value_count == 1:
|
||||
if value_offset <= 0xFFFF:
|
||||
return value_offset & 0xFFFF
|
||||
if value_offset + 2 <= len(exif_bytes):
|
||||
return struct.unpack(endianness + "H", exif_bytes[value_offset : value_offset + 2])[0]
|
||||
cursor += 12
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _jpeg_dimensions(data: bytes) -> tuple[int, int] | None:
|
||||
"""Read JPEG SOF dimensions and apply EXIF orientation when present.
|
||||
|
||||
EXIF APP1 may appear after the SOF segment; scan the full header before returning.
|
||||
"""
|
||||
if len(data) < 4 or data[0:2] != b"\xff\xd8":
|
||||
return None
|
||||
orientation = 1
|
||||
sof_width: int | None = None
|
||||
sof_height: int | None = None
|
||||
index = 2
|
||||
while index + 4 < len(data):
|
||||
if data[index] != 0xFF:
|
||||
index += 1
|
||||
continue
|
||||
while index < len(data) and data[index] == 0xFF:
|
||||
index += 1
|
||||
if index >= len(data):
|
||||
break
|
||||
marker = data[index]
|
||||
index += 1
|
||||
if marker in {0xD8, 0xD9}:
|
||||
continue
|
||||
if index + 2 > len(data):
|
||||
break
|
||||
segment_length = struct.unpack(">H", data[index : index + 2])[0]
|
||||
if segment_length < 2:
|
||||
break
|
||||
segment_start = index + 2
|
||||
segment_end = index + segment_length
|
||||
if segment_end > len(data):
|
||||
break
|
||||
if marker == 0xE1 and segment_end - segment_start > 8:
|
||||
exif = data[segment_start:segment_end]
|
||||
if exif[:6] == b"Exif\x00\x00":
|
||||
parsed = _parse_exif_orientation(exif[6:])
|
||||
if parsed is not None:
|
||||
orientation = parsed
|
||||
if (
|
||||
sof_width is None
|
||||
and marker in _JPEG_SOF_MARKERS
|
||||
and segment_end - segment_start >= 7
|
||||
):
|
||||
sof_height = struct.unpack(">H", data[segment_start + 3 : segment_start + 5])[0]
|
||||
sof_width = struct.unpack(">H", data[segment_start + 5 : segment_start + 7])[0]
|
||||
index = segment_end
|
||||
if sof_width is None or sof_height is None:
|
||||
return None
|
||||
return _apply_exif_orientation(sof_width, sof_height, orientation)
|
||||
|
||||
|
||||
def _png_dimensions(data: bytes) -> tuple[int, int] | None:
|
||||
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
return None
|
||||
width = struct.unpack(">I", data[16:20])[0]
|
||||
height = struct.unpack(">I", data[20:24])[0]
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return width, height
|
||||
|
||||
|
||||
def _gif_dimensions(data: bytes) -> tuple[int, int] | None:
|
||||
if len(data) < 10:
|
||||
return None
|
||||
width = struct.unpack("<H", data[6:8])[0]
|
||||
height = struct.unpack("<H", data[8:10])[0]
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return width, height
|
||||
|
||||
|
||||
def _webp_dimensions(data: bytes) -> tuple[int, int] | None:
|
||||
if len(data) < 30 or data[8:12] != b"WEBP":
|
||||
return None
|
||||
chunk = data[12:16]
|
||||
if chunk == b"VP8 " and len(data) >= 30:
|
||||
width = struct.unpack("<H", data[26:28])[0] & 0x3FFF
|
||||
height = struct.unpack("<H", data[28:30])[0] & 0x3FFF
|
||||
if width > 0 and height > 0:
|
||||
return width, height
|
||||
if chunk == b"VP8L" and len(data) >= 25:
|
||||
bits = struct.unpack("<I", data[21:25])[0]
|
||||
width = (bits & 0x3FFF) + 1
|
||||
height = ((bits >> 14) & 0x3FFF) + 1
|
||||
if width > 0 and height > 0:
|
||||
return width, height
|
||||
if chunk == b"VP8X" and len(data) >= 30:
|
||||
width = 1 + (data[24] | (data[25] << 8) | (data[26] << 16))
|
||||
height = 1 + (data[27] | (data[28] << 8) | (data[29] << 16))
|
||||
if width > 1 and height > 1:
|
||||
return width, height
|
||||
return None
|
||||
|
||||
|
||||
def _pil_dimensions_fallback(data: bytes) -> tuple[int, int] | None:
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
with Image.open(__import__("io").BytesIO(data)) as image:
|
||||
image = ImageOps.exif_transpose(image)
|
||||
width, height = image.size
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return width, height
|
||||
except Exception as error:
|
||||
logger.warning("PUBLIC THUMB: PIL fallback failed: %s", error)
|
||||
return None
|
||||
@@ -9,13 +9,28 @@ from sqlalchemy import inspect, text
|
||||
import uuid
|
||||
import secrets
|
||||
from user_agents import parse as parse_ua
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from ..constants import OWNER_USERNAME
|
||||
from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db
|
||||
from ..models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
||||
from ..models import (
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
ChangePasswordRequest,
|
||||
VerifyPasswordRequest,
|
||||
DeleteAccountRequest,
|
||||
User,
|
||||
CryptoPublicKey,
|
||||
CryptoBackup,
|
||||
DeviceSession,
|
||||
)
|
||||
from ..utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||
from ..validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||
from ..deleted_user import (
|
||||
apply_deleted_user_db_fields,
|
||||
deleted_user_api_fields,
|
||||
is_deleted_user,
|
||||
is_suspended_user,
|
||||
)
|
||||
import os
|
||||
|
||||
from ..security.audit import log_security
|
||||
@@ -99,23 +114,70 @@ def _reset_failed_logins(identifier: str) -> None:
|
||||
def _is_admin(user: User) -> bool:
|
||||
return user.id == 1
|
||||
|
||||
def convert_user(user: User) -> dict:
|
||||
def convert_user(user: User, db: Session) -> dict:
|
||||
from ..presence_service import presence_service
|
||||
from ..verification_service import compute_verification_status, get_verified_users_data
|
||||
|
||||
if is_deleted_user(user):
|
||||
return {
|
||||
"id": user.id,
|
||||
"admin": _is_admin(user),
|
||||
**deleted_user_api_fields(user.id),
|
||||
}
|
||||
|
||||
online, last_seen = presence_service.get_presence(user.id)
|
||||
verified_users_data = get_verified_users_data(db)
|
||||
verification_status = compute_verification_status(user, verified_users_data)
|
||||
effective_last_seen = last_seen or user.last_seen or user.created_at
|
||||
return {
|
||||
"id": user.id,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_seen": user.last_seen.isoformat(),
|
||||
"online": user.online,
|
||||
"last_seen": effective_last_seen.isoformat(),
|
||||
"online": online,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"profile_picture": user.profile_picture,
|
||||
"bio": user.bio,
|
||||
"admin": _is_admin(user),
|
||||
"verified": user.verified,
|
||||
"verification_status": verification_status.value,
|
||||
"suspended": user.suspended or False,
|
||||
"suspension_reason": user.suspension_reason,
|
||||
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
||||
"deleted": False,
|
||||
}
|
||||
|
||||
|
||||
def convert_user_for_dm_conversation(user: User, db: Session) -> dict:
|
||||
"""Minimal user payload for DM conversation list entries."""
|
||||
from ..presence_service import presence_service
|
||||
from ..verification_service import compute_verification_status, get_verified_users_data
|
||||
|
||||
if is_deleted_user(user):
|
||||
return {
|
||||
"id": user.id,
|
||||
**deleted_user_api_fields(user.id),
|
||||
}
|
||||
|
||||
online, last_seen = presence_service.get_presence(user.id)
|
||||
verified_users_data = get_verified_users_data(db)
|
||||
verification_status = compute_verification_status(user, verified_users_data)
|
||||
effective_last_seen = last_seen or user.last_seen or user.created_at
|
||||
payload = {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"profile_picture": user.profile_picture,
|
||||
"deleted": False,
|
||||
"verification_status": verification_status.value,
|
||||
"online": online,
|
||||
"last_seen": effective_last_seen.isoformat(),
|
||||
}
|
||||
if is_suspended_user(user):
|
||||
payload["suspended"] = True
|
||||
payload["suspension_reason"] = user.suspension_reason
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/instance_id")
|
||||
def get_instance_id_public():
|
||||
"""Public deploy fingerprint (used when the client changes server host/port)."""
|
||||
@@ -131,6 +193,19 @@ def check_auth(current_user: User = Depends(get_current_user)):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/check_username")
|
||||
@rate_limit_per_ip("30/minute")
|
||||
def check_username(request: Request, username: str, db: Session = Depends(get_db)):
|
||||
u = username.strip()
|
||||
if not is_valid_username(u):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username must be 3 to 20 characters and contain only English letters, digits, hyphens, and underscores",
|
||||
)
|
||||
exists = db.query(User).filter(User.username == u).first() is not None
|
||||
return {"exists": exists}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@rate_limit_per_ip("5/minute")
|
||||
def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)):
|
||||
@@ -173,6 +248,10 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
failures=total_failures,
|
||||
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again in a few minutes.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверное имя пользователя или пароль"
|
||||
@@ -201,9 +280,6 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
revoked=False,
|
||||
)
|
||||
db.add(device)
|
||||
|
||||
user.online = True
|
||||
user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
logging.getLogger("uvicorn.error").info("Login DB commit complete for user_id=%s", user.id)
|
||||
|
||||
@@ -230,7 +306,7 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
"status": "success",
|
||||
"message": "Login successful",
|
||||
"token": token,
|
||||
"user": convert_user(user)
|
||||
"user": convert_user(user, db)
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +370,18 @@ def register(
|
||||
detail="Это имя пользователя уже занято"
|
||||
)
|
||||
|
||||
bio_text = (register_request.bio or "").strip() or None
|
||||
if bio_text and len(bio_text) > 500:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Описание должно быть не длиннее 500 символов",
|
||||
)
|
||||
if bio_text and contains_profanity(bio_text):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Описание содержит запрещённые слова",
|
||||
)
|
||||
|
||||
hashed_password = get_password_hash(password)
|
||||
|
||||
# Set verified=True for the owner (first user to register)
|
||||
@@ -304,8 +392,7 @@ def register(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
password_hash=hashed_password,
|
||||
online=True,
|
||||
last_seen=datetime.now(),
|
||||
bio=bio_text,
|
||||
verified=is_owner
|
||||
)
|
||||
|
||||
@@ -363,7 +450,7 @@ def register(
|
||||
"status": "success",
|
||||
"message": "Регистрация прошла успешно",
|
||||
"token": token,
|
||||
"user": convert_user(new_user)
|
||||
"user": convert_user(new_user, db)
|
||||
}
|
||||
|
||||
@router.get("/crypto/public-key")
|
||||
@@ -448,38 +535,49 @@ def delete_user_as_owner(
|
||||
|
||||
return {"status": "success", "deleted_user_id": user_id}
|
||||
|
||||
def _revoke_device_session(db: Session, user_id: int, session_id: str) -> int:
|
||||
"""Mark a device session revoked. Returns the number of rows updated."""
|
||||
return (
|
||||
db.query(DeviceSession)
|
||||
.filter(
|
||||
DeviceSession.user_id == user_id,
|
||||
DeviceSession.session_id == session_id,
|
||||
)
|
||||
.update({DeviceSession.revoked: True}, synchronize_session=False)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(
|
||||
http: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# Revoke current session
|
||||
from utils import verify_token as _verify_token
|
||||
payload = _verify_token(credentials.credentials)
|
||||
if payload and payload.get("session_id"):
|
||||
db.query(DeviceSession).filter(
|
||||
DeviceSession.user_id == current_user.id,
|
||||
DeviceSession.session_id == payload["session_id"],
|
||||
).update({DeviceSession.revoked: True})
|
||||
session_id = getattr(request.state, "session_id", None)
|
||||
if session_id:
|
||||
updated = _revoke_device_session(db, current_user.id, session_id)
|
||||
db.commit()
|
||||
if updated == 0:
|
||||
_logger.warning(
|
||||
"logout: session_id=%s not found for user_id=%s",
|
||||
session_id,
|
||||
current_user.id,
|
||||
)
|
||||
else:
|
||||
_logger.warning("logout: missing session_id for user_id=%s", current_user.id)
|
||||
|
||||
current_user.online = False
|
||||
current_user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
client_ip = get_client_ip(http)
|
||||
client_ip = get_client_ip(request)
|
||||
log_security(
|
||||
"logout",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip=client_ip,
|
||||
session_id=payload.get("session_id") if payload else None,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Logged out successfully"
|
||||
"message": "Logged out successfully",
|
||||
}
|
||||
|
||||
|
||||
@@ -488,9 +586,8 @@ def logout(
|
||||
def change_password(
|
||||
request: Request,
|
||||
password_request: ChangePasswordRequest,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# Verify current derived password against stored hash
|
||||
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
|
||||
@@ -503,15 +600,13 @@ def change_password(
|
||||
|
||||
# Optionally revoke all other sessions, keeping the current one
|
||||
if password_request.logoutAllExceptCurrent:
|
||||
from utils import verify_token as _verify_token
|
||||
payload = _verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
current_session_id = payload.get("session_id")
|
||||
current_session_id = getattr(request.state, "session_id", None)
|
||||
if not current_session_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid session")
|
||||
db.query(DeviceSession).filter(
|
||||
DeviceSession.user_id == current_user.id,
|
||||
DeviceSession.session_id != current_session_id,
|
||||
).update({DeviceSession.revoked: True})
|
||||
).update({DeviceSession.revoked: True}, synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
@@ -526,13 +621,37 @@ def change_password(
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
def _verify_derived_password(user: User, password_derived: str) -> None:
|
||||
if not verify_password(password_derived.strip(), user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Wrong password")
|
||||
|
||||
|
||||
@router.post("/verify-password")
|
||||
@rate_limit_per_ip("10/minute")
|
||||
def verify_password_endpoint(
|
||||
request: Request,
|
||||
body: VerifyPasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# 400 (not 401): mobile client treats 401 as global auth failure and clears the session.
|
||||
_verify_derived_password(current_user, body.passwordDerived)
|
||||
client_ip = get_client_ip(request)
|
||||
log_security(
|
||||
"password_verified",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip=client_ip,
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse
|
||||
def list_users(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
|
||||
users = db.query(User).order_by(User.username.asc()).all()
|
||||
return {
|
||||
"users": [
|
||||
convert_user(u) for u in users if u.id != current_user.id
|
||||
convert_user(u, db) for u in users if u.id != current_user.id
|
||||
]
|
||||
}
|
||||
|
||||
@@ -562,7 +681,7 @@ def search_users(request: Request, q: str, current_user: User = Depends(get_curr
|
||||
).order_by(User.username.asc()).limit(20).all()
|
||||
|
||||
return {
|
||||
"users": [convert_user(u) for u in users]
|
||||
"users": [convert_user(u, db) for u in users]
|
||||
}
|
||||
|
||||
|
||||
@@ -573,15 +692,10 @@ async def _delete_user_data(user: User, db: Session):
|
||||
"""
|
||||
user_id = user.id
|
||||
|
||||
# Mark user as deleted and clear sensitive data
|
||||
user.deleted = True
|
||||
user.display_name = f"Deleted User #{user_id}"
|
||||
user.bio = None
|
||||
user.password_hash = ""
|
||||
user.username = f"deleted_{user_id}"
|
||||
user.profile_picture = None
|
||||
user.last_seen = None # Clear last seen timestamp
|
||||
user.created_at = None # Clear member since timestamp
|
||||
from ..presence_service import presence_service
|
||||
|
||||
apply_deleted_user_db_fields(user)
|
||||
presence_service.remove_user(user_id)
|
||||
|
||||
# Delete profile picture file if exists
|
||||
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"):
|
||||
@@ -629,6 +743,12 @@ async def _delete_user_data(user: User, db: Session):
|
||||
# Log error but don't fail the request
|
||||
pass
|
||||
|
||||
try:
|
||||
from .profile import broadcast_profile_update
|
||||
await broadcast_profile_update(user, db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.broadcast_registered_user_count(db)
|
||||
@@ -636,18 +756,19 @@ async def _delete_user_data(user: User, db: Session):
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
async def delete_account(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
async def _delete_account_impl(
|
||||
body: DeleteAccountRequest,
|
||||
current_user: User,
|
||||
db: Session,
|
||||
) -> dict:
|
||||
"""
|
||||
Delete the current user's own account - preserves messages/DMs/reactions/files
|
||||
"""
|
||||
# Prevent admin/owner account self-deletion
|
||||
if _is_admin(current_user):
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
|
||||
|
||||
|
||||
_verify_derived_password(current_user, body.passwordDerived)
|
||||
|
||||
await _delete_user_data(current_user, db)
|
||||
|
||||
log_security(
|
||||
@@ -659,5 +780,23 @@ async def delete_account(
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Account deleted successfully"
|
||||
}
|
||||
"message": "Account deleted successfully",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
async def delete_account(
|
||||
body: DeleteAccountRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return await _delete_account_impl(body, current_user, db)
|
||||
|
||||
|
||||
@router.post("/account/delete")
|
||||
async def delete_account_alias(
|
||||
body: DeleteAccountRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return await _delete_account_impl(body, current_user, db)
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -10,49 +11,75 @@ import io
|
||||
from fastapi import Request
|
||||
|
||||
from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db
|
||||
from ..presence_service import presence_service
|
||||
from ..models import User, UpdateBioRequest, UserProfileResponse
|
||||
from pydantic import BaseModel
|
||||
from ..validation import is_valid_username, is_valid_display_name
|
||||
from ..similarity import is_user_similar_to_verified
|
||||
from ..verification_service import (
|
||||
VerificationStatus,
|
||||
compute_verification_status,
|
||||
get_verified_users_data,
|
||||
)
|
||||
from .messaging import messagingManager
|
||||
from ..security.audit import log_security
|
||||
from ..security.profanity import contains_profanity
|
||||
from ..security.rate_limit import rate_limit_per_ip
|
||||
from ..deleted_user import DELETED_LAST_SEEN, deleted_user_api_fields, is_deleted_user
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_user_profile_response(user: User, is_owner_request: bool = False) -> UserProfileResponse:
|
||||
should_hide_profile = (not is_owner_request) and (user.deleted or user.suspended)
|
||||
def _build_user_profile_response(
|
||||
user: User,
|
||||
is_owner_request: bool = False,
|
||||
*,
|
||||
verified_users_data: list[dict[str, str]] | None = None,
|
||||
) -> UserProfileResponse:
|
||||
should_hide_profile = (not is_owner_request) and is_deleted_user(user)
|
||||
if not should_hide_profile:
|
||||
online, last_seen = presence_service.get_presence(user.id)
|
||||
verification_status = (
|
||||
compute_verification_status(user, verified_users_data)
|
||||
if verified_users_data is not None
|
||||
else (
|
||||
VerificationStatus.VERIFIED
|
||||
if user.verified
|
||||
else VerificationStatus.NONE
|
||||
)
|
||||
)
|
||||
return UserProfileResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
display_name=user.display_name or user.username,
|
||||
profile_picture=user.profile_picture,
|
||||
bio=user.bio,
|
||||
online=user.online,
|
||||
last_seen=user.last_seen,
|
||||
online=online,
|
||||
last_seen=last_seen,
|
||||
created_at=user.created_at,
|
||||
verified=user.verified,
|
||||
suspended=user.suspended or False,
|
||||
verified=bool(user.verified),
|
||||
verification_status=verification_status.value,
|
||||
suspended=bool(user.suspended),
|
||||
suspension_reason=user.suspension_reason,
|
||||
deleted=user.deleted or False,
|
||||
deleted=bool(user.deleted),
|
||||
)
|
||||
|
||||
hidden = deleted_user_api_fields(user.id)
|
||||
return UserProfileResponse(
|
||||
id=user.id,
|
||||
username="deleted",
|
||||
display_name="Deleted User",
|
||||
profile_picture=None,
|
||||
bio=None,
|
||||
online=False,
|
||||
last_seen=None,
|
||||
created_at=None,
|
||||
verified=False,
|
||||
suspended=False,
|
||||
suspension_reason=None,
|
||||
deleted=True,
|
||||
username=hidden["username"],
|
||||
display_name=hidden["display_name"],
|
||||
profile_picture=hidden["profile_picture"],
|
||||
bio=hidden["bio"],
|
||||
online=hidden["online"],
|
||||
last_seen=DELETED_LAST_SEEN,
|
||||
created_at=hidden["created_at"],
|
||||
verified=hidden["verified"],
|
||||
verification_status=hidden["verification_status"],
|
||||
suspended=hidden["suspended"],
|
||||
suspension_reason=hidden["suspension_reason"],
|
||||
deleted=hidden["deleted"],
|
||||
)
|
||||
|
||||
|
||||
@@ -63,6 +90,40 @@ def _ensure_owner_unsuspended(user: User | None, db: Session):
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
|
||||
async def broadcast_profile_update(user: User, db: Session) -> None:
|
||||
"""Notify clients subscribed to this user that their public profile changed."""
|
||||
try:
|
||||
payload = build_profile_update_payload(user, viewer_id=None, db=db)
|
||||
subscriber_count = sum(
|
||||
1
|
||||
for ws, subs in messagingManager.ws_subscriptions.items()
|
||||
if user.id in subs
|
||||
)
|
||||
logger.info(
|
||||
"broadcast_profile_update user_id=%s bio=%r subscribers=%s",
|
||||
user.id,
|
||||
user.bio,
|
||||
subscriber_count,
|
||||
)
|
||||
await messagingManager.broadcast_profile_update(user.id, payload, db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_profile_update_payload(
|
||||
user: User,
|
||||
viewer_id: int | None,
|
||||
db: Session,
|
||||
) -> dict:
|
||||
verified_users_data = get_verified_users_data(db)
|
||||
is_owner_request = viewer_id is not None and (viewer_id == user.id or viewer_id == 1)
|
||||
return _build_user_profile_response(
|
||||
user,
|
||||
is_owner_request=is_owner_request,
|
||||
verified_users_data=verified_users_data,
|
||||
).model_dump(mode="json")
|
||||
|
||||
# Request models
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
username: str | None = None
|
||||
@@ -118,7 +179,10 @@ async def upload_profile_picture(
|
||||
profile_picture_url = f"/api/profile-picture/{filename}"
|
||||
current_user.profile_picture = profile_picture_url
|
||||
db.commit()
|
||||
|
||||
db.refresh(current_user)
|
||||
|
||||
await broadcast_profile_update(current_user, db)
|
||||
|
||||
return {
|
||||
"message": "Profile picture uploaded successfully",
|
||||
"profile_picture_url": profile_picture_url
|
||||
@@ -154,16 +218,20 @@ async def get_user_profile(
|
||||
try:
|
||||
_ensure_owner_unsuspended(current_user, db)
|
||||
|
||||
online, last_seen = presence_service.get_presence(current_user.id)
|
||||
verified_users_data = get_verified_users_data(db)
|
||||
verification_status = compute_verification_status(current_user, verified_users_data)
|
||||
return UserProfileResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
display_name=current_user.display_name,
|
||||
profile_picture=current_user.profile_picture,
|
||||
bio=current_user.bio,
|
||||
online=current_user.online,
|
||||
last_seen=current_user.last_seen,
|
||||
online=online,
|
||||
last_seen=last_seen,
|
||||
created_at=current_user.created_at,
|
||||
verified=current_user.verified,
|
||||
verification_status=verification_status.value,
|
||||
suspended=current_user.suspended or False,
|
||||
suspension_reason=current_user.suspension_reason,
|
||||
deleted=current_user.deleted or False,
|
||||
@@ -189,25 +257,29 @@ async def list_users(
|
||||
_ensure_owner_unsuspended(current_user, db)
|
||||
|
||||
users = db.query(User).order_by(User.username.asc()).all()
|
||||
return {
|
||||
"users": [
|
||||
verified_users_data = get_verified_users_data(db)
|
||||
profile_items = []
|
||||
for user in users:
|
||||
online, last_seen = presence_service.get_presence(user.id)
|
||||
verification_status = compute_verification_status(user, verified_users_data)
|
||||
profile_items.append(
|
||||
UserProfileResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
profile_picture=user.profile_picture,
|
||||
bio=user.bio,
|
||||
online=user.online,
|
||||
last_seen=user.last_seen,
|
||||
online=online,
|
||||
last_seen=last_seen,
|
||||
created_at=user.created_at,
|
||||
verified=user.verified,
|
||||
verification_status=verification_status.value,
|
||||
suspended=user.suspended or False,
|
||||
suspension_reason=user.suspension_reason,
|
||||
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
|
||||
deleted=user.deleted or False,
|
||||
).model_dump()
|
||||
for user in users
|
||||
]
|
||||
}
|
||||
)
|
||||
return {"users": profile_items}
|
||||
|
||||
@router.put("/user/profile")
|
||||
@rate_limit_per_ip("10/minute")
|
||||
@@ -272,6 +344,8 @@ async def update_user_profile(
|
||||
|
||||
if updated:
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
await broadcast_profile_update(current_user, db)
|
||||
return {
|
||||
"message": "Profile updated successfully",
|
||||
"username": current_user.username,
|
||||
@@ -303,7 +377,10 @@ async def update_user_bio(
|
||||
|
||||
current_user.bio = bio_request.bio.strip()
|
||||
db.commit()
|
||||
|
||||
db.refresh(current_user)
|
||||
|
||||
await broadcast_profile_update(current_user, db)
|
||||
|
||||
return {
|
||||
"message": "Bio updated successfully",
|
||||
"bio": current_user.bio
|
||||
@@ -340,7 +417,12 @@ async def get_user_by_username(
|
||||
_ensure_owner_unsuspended(user, db)
|
||||
|
||||
is_owner_request = current_user.id == user.id or current_user.id == 1
|
||||
return _build_user_profile_response(user, is_owner_request=is_owner_request)
|
||||
verified_users_data = get_verified_users_data(db)
|
||||
return _build_user_profile_response(
|
||||
user,
|
||||
is_owner_request=is_owner_request,
|
||||
verified_users_data=verified_users_data,
|
||||
)
|
||||
|
||||
@router.get("/user/id/{user_id}")
|
||||
async def get_user_by_id(
|
||||
@@ -362,7 +444,12 @@ async def get_user_by_id(
|
||||
_ensure_owner_unsuspended(user, db)
|
||||
|
||||
is_owner_request = current_user.id == user.id or current_user.id == 1
|
||||
return _build_user_profile_response(user, is_owner_request=is_owner_request)
|
||||
verified_users_data = get_verified_users_data(db)
|
||||
return _build_user_profile_response(
|
||||
user,
|
||||
is_owner_request=is_owner_request,
|
||||
verified_users_data=verified_users_data,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/user/{user_id}/verify")
|
||||
@@ -386,6 +473,9 @@ async def verify_user(
|
||||
target_user.verified = not target_user.verified
|
||||
db.commit()
|
||||
|
||||
verified_users_data = get_verified_users_data(db)
|
||||
verification_status = compute_verification_status(target_user, verified_users_data)
|
||||
|
||||
log_security(
|
||||
"admin_verify_toggle",
|
||||
actor=current_user.username,
|
||||
@@ -395,45 +485,15 @@ async def verify_user(
|
||||
verified=target_user.verified,
|
||||
)
|
||||
|
||||
await broadcast_profile_update(target_user, db)
|
||||
|
||||
return {
|
||||
"verified": target_user.verified,
|
||||
"verification_status": verification_status.value,
|
||||
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/user/check-similarity/{user_id}")
|
||||
async def check_user_similarity(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user_allow_suspended),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Check if a user is similar to any verified user
|
||||
"""
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Get all verified users
|
||||
verified_users = db.query(User).filter(User.verified == True).all()
|
||||
verified_users_data = [
|
||||
{"username": user.username, "display_name": user.display_name}
|
||||
for user in verified_users
|
||||
]
|
||||
|
||||
# Check similarity
|
||||
is_similar, similar_to = is_user_similar_to_verified(
|
||||
target_user.username,
|
||||
target_user.display_name,
|
||||
verified_users_data
|
||||
)
|
||||
|
||||
return {
|
||||
"isSimilar": is_similar,
|
||||
"similarTo": similar_to if is_similar else None
|
||||
}
|
||||
|
||||
|
||||
# Admin endpoints for user management
|
||||
class SuspendUserRequest(BaseModel):
|
||||
reason: str
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..dependencies import get_current_user_allow_suspended, get_db
|
||||
from ..models import PublicChatProfileResponse, User
|
||||
from ..public_chat_config import load_public_chat_static_profile
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/public-chat/profile", response_model=PublicChatProfileResponse)
|
||||
def get_public_chat_profile(
|
||||
current_user: User = Depends(get_current_user_allow_suspended),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Metadata for the instance public chat (title, bio, member count)."""
|
||||
del current_user
|
||||
try:
|
||||
static_profile = load_public_chat_static_profile()
|
||||
except (FileNotFoundError, ValueError, OSError) as exc:
|
||||
raise HTTPException(status_code=500, detail="Public chat profile is not configured") from exc
|
||||
|
||||
member_count = db.query(User).filter(User.deleted.is_(False)).count()
|
||||
bio = static_profile["bio"].strip() or None
|
||||
|
||||
return PublicChatProfileResponse(
|
||||
id=static_profile["id"],
|
||||
title=static_profile["title"],
|
||||
bio=bio,
|
||||
member_count=member_count,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Static legal documents and expressive icons served from the instance deploy.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
router = APIRouter(tags=["static"])
|
||||
|
||||
_STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
_ICONS_DIR = _STATIC_DIR / "icons"
|
||||
|
||||
|
||||
@router.get("/static/PRIVACY.md")
|
||||
async def privacy_markdown() -> FileResponse:
|
||||
path = _STATIC_DIR / "PRIVACY.md"
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="PRIVACY.md not found")
|
||||
return FileResponse(path, media_type="text/markdown; charset=utf-8")
|
||||
|
||||
|
||||
@router.get("/static/TERMS.md")
|
||||
async def terms_markdown() -> FileResponse:
|
||||
path = _STATIC_DIR / "TERMS.md"
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="TERMS.md not found")
|
||||
return FileResponse(path, media_type="text/markdown; charset=utf-8")
|
||||
|
||||
|
||||
@router.get("/static/icons/{name}.webp")
|
||||
async def static_icon(name: str) -> FileResponse:
|
||||
safe = Path(name).name
|
||||
if safe != name or ".." in name:
|
||||
raise HTTPException(status_code=400, detail="Invalid icon name")
|
||||
path = _ICONS_DIR / f"{safe}.webp"
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Icon not found")
|
||||
return FileResponse(path, media_type="image/webp")
|
||||
@@ -9,6 +9,7 @@ from typing import Optional, Dict, Any
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
@@ -638,6 +639,172 @@ async def get_resumable_upload_data_in_storage(
|
||||
return r.json()
|
||||
|
||||
|
||||
async def store_normal_file_from_path_in_storage(
|
||||
stored_name: str,
|
||||
source_path: str | Path,
|
||||
timeout: float = 120.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Persist a plain public-chat attachment where file downloads are served from."""
|
||||
mod = _get_file_storage_module()
|
||||
src = Path(source_path)
|
||||
if mod:
|
||||
try:
|
||||
return await mod.store_normal_file_from_path_internal(stored_name, src)
|
||||
except Exception as e:
|
||||
logger.error("In-process file_storage.store_normal_file_from_path failed: %s", e)
|
||||
raise
|
||||
|
||||
file_storage_url = (
|
||||
os.getenv("FILE_STORAGE_URL")
|
||||
or os.getenv("FILE_STORAGE_SERVICE_URL")
|
||||
or _default_file_storage_base_url()
|
||||
)
|
||||
url = f"{file_storage_url.rstrip('/')}/uploads/files/normal/store"
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
with open(src, "rb") as file_handle:
|
||||
r = await client.post(
|
||||
url,
|
||||
data={"stored_name": stored_name},
|
||||
files={"file": (Path(stored_name).name, file_handle, "application/octet-stream")},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def store_public_thumb_in_storage(
|
||||
stored_name: str,
|
||||
jpeg_bytes: bytes,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
file_size: int,
|
||||
timeout: float = 30.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Persist a public-chat thumbnail under file_storage THUMBS_DIR."""
|
||||
mod = _get_file_storage_module()
|
||||
if mod:
|
||||
try:
|
||||
return await mod.store_public_thumb_internal(
|
||||
stored_name,
|
||||
jpeg_bytes,
|
||||
width=width,
|
||||
height=height,
|
||||
file_size=file_size,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("In-process file_storage.store_public_thumb failed: %s", e)
|
||||
raise
|
||||
|
||||
file_storage_url = (
|
||||
os.getenv("FILE_STORAGE_URL")
|
||||
or os.getenv("FILE_STORAGE_SERVICE_URL")
|
||||
or _default_file_storage_base_url()
|
||||
)
|
||||
url = f"{file_storage_url.rstrip('/')}/uploads/files/thumbs/store"
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.post(
|
||||
url,
|
||||
data={
|
||||
"stored_name": stored_name,
|
||||
"width": str(width),
|
||||
"height": str(height),
|
||||
"file_size": str(file_size),
|
||||
},
|
||||
files={"file": (f"{Path(stored_name).stem}.jpg", jpeg_bytes, "image/jpeg")},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def store_public_image_dimensions_in_storage(
|
||||
stored_name: str,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
file_size: int,
|
||||
timeout: float = 30.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Persist image dimensions for large public attachments (no JPEG thumbnail)."""
|
||||
mod = _get_file_storage_module()
|
||||
if mod:
|
||||
try:
|
||||
return await mod.store_public_image_dimensions_internal(
|
||||
stored_name,
|
||||
width=width,
|
||||
height=height,
|
||||
file_size=file_size,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("In-process file_storage.store_public_image_dimensions failed: %s", e)
|
||||
raise
|
||||
|
||||
file_storage_url = (
|
||||
os.getenv("FILE_STORAGE_URL")
|
||||
or os.getenv("FILE_STORAGE_SERVICE_URL")
|
||||
or _default_file_storage_base_url()
|
||||
)
|
||||
url = f"{file_storage_url.rstrip('/')}/uploads/files/thumbs/dimensions"
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.post(
|
||||
url,
|
||||
data={
|
||||
"stored_name": stored_name,
|
||||
"width": str(width),
|
||||
"height": str(height),
|
||||
"file_size": str(file_size),
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def get_public_thumb_meta_in_storage(
|
||||
stored_name: str,
|
||||
timeout: float = 10.0,
|
||||
) -> Dict[str, Any] | None:
|
||||
"""Load thumbnail base64 + dimensions for a normal attachment basename."""
|
||||
mod = _get_file_storage_module()
|
||||
if mod:
|
||||
try:
|
||||
return mod.get_public_thumb_meta_internal(stored_name)
|
||||
except Exception as e:
|
||||
logger.error("In-process file_storage.get_public_thumb_meta failed: %s", e)
|
||||
return None
|
||||
|
||||
file_storage_url = (
|
||||
os.getenv("FILE_STORAGE_URL")
|
||||
or os.getenv("FILE_STORAGE_SERVICE_URL")
|
||||
or _default_file_storage_base_url()
|
||||
)
|
||||
stem = Path(stored_name).stem
|
||||
url = f"{file_storage_url.rstrip('/')}/uploads/files/thumbs/{stem}.jpg"
|
||||
import base64
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.get(url)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
return {
|
||||
"stored_name": Path(stored_name).name,
|
||||
"width": 1,
|
||||
"height": 1,
|
||||
"file_size": 0,
|
||||
"thumbnail_b64": base64.b64encode(r.content).decode("ascii"),
|
||||
"thumb_path": f"/uploads/files/thumbs/{stem}.jpg",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Remote file_storage.get_public_thumb_meta failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def delete_resumable_upload_in_storage(
|
||||
upload_id: str,
|
||||
user_id: int,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
<!-- fc:shape=Circle icon=privacy -->
|
||||
## Общее
|
||||
|
||||
Здесь политика конфиденциальности FromChat. Я знаю, что 99% ее даже читать не будут, сделал только для того, чтобы ко мне не было вопросов и чтобы те, кому реально интерессно знали, что происходит с данными.
|
||||
|
||||
Эта политика действует только на официальном сервере [fromchat.ru](https://fromchat.ru). На других серверах политика ставится их админами.
|
||||
|
||||
Вы можете свободно использовать этот текст в любых целях без указания авторства.
|
||||
|
||||
Текст может меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если что-то изменится, я напишу об этом в Telegram-канале.
|
||||
|
||||
<!-- fc:shape=Cookie4Sided icon=storage -->
|
||||
## Ваши данные
|
||||
|
||||
### Какие данные собираются?
|
||||
|
||||
- Логин, имя и прочие данные профиля — без них мессенджер не может существовать. Эти данные видны всем, кто общается с вами.
|
||||
- Пароль — на сервере хранится только односторонний хеш, который используется для проверки. Сервер никогда не видит пароль открытым текстом.
|
||||
- Сообщения в общем чате — они публичны. Любой пользователь на сервере может их увидеть. Они хранятся открытым текстом в базе данных.
|
||||
- Личные сообщения — вкратце: они хранятся в зашифрованном виде, но сервер во время обработки кратко видит открытый текст сообщения. Они могут быть переданы по официальному запросу уполномоченных органов. Если интересно, как именно шифруются сообщения — читайте ниже.
|
||||
- Статус «в сети» и время последней активности — чтобы собеседник видел, когда вы были в сети. К сожалению, скрыть его пока нельзя.
|
||||
- Информация об устройствах (тип, ОС, браузер) — видна только вам, нужно для того, чтобы вы легко распознали взлом и его нейтрализовали.
|
||||
- Звонки — идут в зашифрованном виде через WebRTC-сервер, могут быть записаны в целях соблюдения законодательства и предоставлены уполномоченным органам по запросу.
|
||||
|
||||
<!-- fc:shape=Cookie7Sided icon=chat -->
|
||||
## Больше про личные сообщения
|
||||
|
||||
Если вы очень беспокоетесь за безопасность ваших сообщений, сразу говорю — защита несовершенна и любую защиту можно взломать. Но я постарался сделать доступ к вашим перепискам максимально сложным для хакеров.
|
||||
|
||||
### Весь путь сообщения от вас к собеседнику
|
||||
|
||||
Ваше устройство:
|
||||
1. Вы отправляете сообщение.
|
||||
2. Приложение (клиент) запрашивает открытый ключ у сервера обработки сообщений.
|
||||
3. Приложение скачивает ваш открытый ключ и открытый ключ вашего собеседника.
|
||||
3. Сообщение шифруется этим открытым ключем и отсылается на сервер вместе с открытыми ключами, полученными в предыдущем шаге.
|
||||
|
||||
Сервер:
|
||||
1. Сервер получает ваш запрос на отправку сообщения и пересылает его в изолированный контейнер для обработки сообщений.
|
||||
2. Контейнер расшифровывает ваше сообщение своим закрытым ключем и хранит его в оперативной памяти.
|
||||
3. Создается строка из случайных чисел (MEK).
|
||||
4. Текст вашего сообщения шифруется алгоритмом AES-256, MEK используется как ключ.
|
||||
5. MEK шифруется три раза с помощью вашего открытого ключа и открытых ключей собеседника и официальных запросов.
|
||||
6. Открытый текст вашего сообщения полностью удаляется из оперативной памяти.
|
||||
7. Контейнер возвращает главному серверу зашифрованное сообщение вместе с тремя экземплярами MEK.
|
||||
8. Сообщение записывается в базу данных.
|
||||
|
||||
Устройство собеседника:
|
||||
1. Оно получает ваше сообщение и расшифровывает MEK закрытым ключем, сохраненном в аккаунте собеседника в зашифрованном виде, где пароль от аккаунта используется как ключ.
|
||||
2. Зашифрованный текст сообщения расшифровывается с MEK как ключ.
|
||||
3. Собеседник прочитал ваше сообщение.
|
||||
|
||||
<!-- fc:shape=Cookie9Sided icon=shield -->
|
||||
## Реклама и продажа данных
|
||||
|
||||
Никакой рекламы с моей стороны и продажи ваших данных нет и никогда не будет. Мне нет смысла злить вас ради собственной выгоды.
|
||||
|
||||
На данный момент приложение не собирает никакой аналитики.
|
||||
|
||||
В каналах теоритически может быть реклама от их админов. Я в ней не виноват и контролировать не могу.
|
||||
|
||||
<!-- fc:shape=Cookie4Sided icon=delete -->
|
||||
## Удаление данных
|
||||
|
||||
Если вы хотите удалить сообщение, удерживайте и нажмите "Удалить". Тогда сообщение пропадет из публичного доступа. Зашифрованная копия сообщения останется в целях соблюдения законодательства на 6 месяцев.
|
||||
|
||||
Если вам нужно удалить ваши данные профиля из публичного доступа, вы можете удалить аккаунт в настройках приложения.
|
||||
|
||||
В таком случае все сообщения, которые вы отправили будут анонимизированы, но не удалены.
|
||||
|
||||
Если вам нужно удалить ВСЕ, что связано с вашим профилем из публичного доступа, напишите в Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true)
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
|
||||
<!-- fc:shape=Circle icon=terms -->
|
||||
## Общее
|
||||
|
||||
**FromChat** — 100% бесплатный и открытый мессенджер. Я создал эти правила, чтобы вы точно знали, что можно, а что нельзя.
|
||||
|
||||
Эти правила действуют только на официальном сервере [fromchat.ru](https://fromchat.ru). Админы других серверов устанавливают свои правила.
|
||||
|
||||
Вы можете свободно использовать этот текст в любых целях без указания авторства.
|
||||
|
||||
Сервис предоставляется как есть, перебои и сбои будут гарантированно из-за слабенькой малинки.
|
||||
|
||||
Правила могут меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если правила изменятся, я напишу об этом в Telegram-канале.
|
||||
|
||||
<!-- fc:shape=Cookie4Sided icon=person -->
|
||||
## Ваш аккаунт
|
||||
|
||||
Условия вступают в силу, когда вы создаете аккаунт. Также советую прочитать [политику конфиденциальности](/api/static/PRIVACY.md), поверьте, это очень важно.
|
||||
|
||||
Вы полностью отвечаете за все, что происходит в вашем аккаунте. Если поставите пароль `12345` — вас точно взломают :)
|
||||
|
||||
Если вы нарушите правила, я вас заблокирую. В таком случае вы сможете только читать сообщения, а отправка и реакции будут заблокированы. Если считаете, что я не прав — пишите в Telegram: [@denis0001_dev](https://t.me/denis0001_dev).
|
||||
|
||||
<!-- fc:shape=Cookie7Sided icon=terms -->
|
||||
## Правила
|
||||
|
||||
### Для общего чата
|
||||
Общий чат — это площадка для общения между всеми пользователями на этом сервере. По очевидным причинам, тут запрещено:
|
||||
- Материться, использовать 18+ и другие неприличные слова;
|
||||
- Разговаривать на тему политики, религии, нелегальных действий и неприличия;
|
||||
- Оскорблять других;
|
||||
- Сливать персональные данные (адрес, номер, ФИО и прочее);
|
||||
- Рекламировать любые продукты, сервисы и прочее без моего согласия;
|
||||
- Популяризировать VPN и другие способы обхода блокировок (это закон, не мое личное правило);
|
||||
- Угрожать в любом виде;
|
||||
- Спамить или засорять чат.
|
||||
|
||||
В целях защиты от спама количество сообщений в минуту ограничено и нельзя отправлять слишком много сообщений с одинаковым текстом. При нарушении вы будете автоматически заблокированы. Алгоритм очень примитивный, поэтому ошибки будут. Если это была ошибка, я вас разблокирую.
|
||||
|
||||
### Для личных сообщений
|
||||
|
||||
За личными сообщениями я не шпионю, но могу предоставить по официальному запросу. Поэтому я пока не могу выявлять там нарушения. Я скоро сделаю механизм жалоб.
|
||||
|
||||
В личке правил гораздо меньше. Мне лень писать снова длинный список, поэтому просто прошу вас, не занимайтесь нелегальными вещами и не спамьте. В личке можно обсуждать все остальное и материться.
|
||||
|
||||
### Глобальные правила
|
||||
|
||||
Пожалуйста, не используйте мессенджер для спама и не устраивайте DDoS или любые другие атаки.
|
||||
|
||||
<!-- fc:shape=Cookie9Sided icon=phone -->
|
||||
## Контакты
|
||||
|
||||
### Если у вас возникли любые вопросы, пишите сюда:
|
||||
|
||||
Почта: [support@fromchat.ru](mailto:support@fromchat.ru)
|
||||
|
||||
Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true)
|
||||
|
||||
FromChat: [@denis0001-dev](https://fromchat.ru/@denis0001-dev)
|
||||
|
||||
### Вопросы по безопасности, сообщения об узвимостях
|
||||
|
||||
Если вдруг вы найдете уязвимость или есть вопрос про безопасность, срочно пишите сюда:
|
||||
|
||||
[security@fromchat.ru](mailto:security@fromchat.ru)
|
||||
|
||||
О шифровании договоримся, если надо.
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 794 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1018 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 980 B |
|
After Width: | Height: | Size: 944 B |
|
After Width: | Height: | Size: 838 B |
|
After Width: | Height: | Size: 856 B |
|
After Width: | Height: | Size: 516 B |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "general",
|
||||
"title": "Общий чат",
|
||||
"bio": "Общаемся со всеми пользователями FromChat!"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Server-side verification status computation."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import User
|
||||
from .similarity import is_user_similar_to_verified
|
||||
|
||||
|
||||
class VerificationStatus(str, Enum):
|
||||
VERIFIED = "verified"
|
||||
WARNING = "warning"
|
||||
BLOCKED = "blocked"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
def get_verified_users_data(db: Session) -> list[dict[str, str]]:
|
||||
verified_users = (
|
||||
db.query(User)
|
||||
.filter(
|
||||
User.verified.is_(True),
|
||||
User.deleted.is_(False),
|
||||
User.suspended.is_(False),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{"username": user.username, "display_name": user.display_name}
|
||||
for user in verified_users
|
||||
]
|
||||
|
||||
|
||||
def compute_verification_status(
|
||||
user: User,
|
||||
verified_users_data: list[dict[str, str]],
|
||||
) -> VerificationStatus:
|
||||
if user.deleted:
|
||||
return VerificationStatus.NONE
|
||||
if user.suspended:
|
||||
return VerificationStatus.BLOCKED
|
||||
if user.verified:
|
||||
return VerificationStatus.VERIFIED
|
||||
|
||||
is_similar, _ = is_user_similar_to_verified(
|
||||
user.username,
|
||||
user.display_name,
|
||||
verified_users_data,
|
||||
)
|
||||
return VerificationStatus.WARNING if is_similar else VerificationStatus.NONE
|
||||
@@ -11,6 +11,7 @@ from ..routes.messaging import (
|
||||
MessaggingSocketManager,
|
||||
_send_message_internal,
|
||||
_edit_message_internal,
|
||||
_mark_dm_conversation_read,
|
||||
get_messages,
|
||||
edit_message,
|
||||
delete_message,
|
||||
@@ -26,6 +27,7 @@ from ..models import (
|
||||
DMReactionRequest,
|
||||
UpdateLog,
|
||||
)
|
||||
from ..routes.profile import build_profile_update_payload
|
||||
from ..security.audit import log_access, log_dm
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
@@ -110,15 +112,13 @@ async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db:
|
||||
@websocket_handler("ping", authRequired=True)
|
||||
async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Handle ping - authenticate and set user online."""
|
||||
# Set user online in DB
|
||||
user.online = True
|
||||
user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
# Add to online users
|
||||
manager.online_users.add(user.id)
|
||||
# Broadcast status change
|
||||
await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db)
|
||||
|
||||
became_online = presence_service.register_connection(user.id, websocket)
|
||||
presence_service.touch(user.id)
|
||||
if became_online:
|
||||
_, last_seen = presence_service.get_presence(user.id)
|
||||
last_seen_iso = last_seen.isoformat() if last_seen else datetime.now().isoformat()
|
||||
await manager.broadcast_status_change(user.id, True, last_seen_iso, db)
|
||||
|
||||
log(manager, websocket, user, "ping")
|
||||
return {"status": "success"}
|
||||
|
||||
@@ -138,10 +138,6 @@ async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db
|
||||
|
||||
# Call internal function directly (rate limiting is handled at infrastructure level via Caddy)
|
||||
response = await _send_message_internal(message_request, user, db, [])
|
||||
await manager.broadcast({
|
||||
"type": "newMessage",
|
||||
"data": response["message"]
|
||||
}, db)
|
||||
|
||||
log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"])
|
||||
return response
|
||||
@@ -340,6 +336,32 @@ async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: S
|
||||
username=user.username,
|
||||
recipient_id=env.recipient_id,
|
||||
)
|
||||
|
||||
return {"status": "ok", "id": env_id}
|
||||
|
||||
|
||||
@websocket_handler("dmMarkRead", authRequired=True)
|
||||
async def dmMarkRead(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Mark DM envelopes up to the given id as read for the current user."""
|
||||
envelope_id = int(data["id"])
|
||||
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == envelope_id).first()
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="DM not found")
|
||||
if env.sender_id != user.id and env.recipient_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not a participant in this conversation")
|
||||
|
||||
other_user_id = env.recipient_id if env.sender_id == user.id else env.sender_id
|
||||
last_read = _mark_dm_conversation_read(
|
||||
db,
|
||||
user.id,
|
||||
other_user_id,
|
||||
up_to_envelope_id=envelope_id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
log(manager, websocket, user, "dmMarkRead", dm_envelope_id=envelope_id, other_user_id=other_user_id)
|
||||
return {"status": "ok", "lastReadEnvelopeId": last_read}
|
||||
|
||||
|
||||
return {"status": "ok", "id": env_id}
|
||||
|
||||
@@ -475,26 +497,39 @@ async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket:
|
||||
async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Subscribe to status updates for a user."""
|
||||
user_id_to_subscribe = int(data["userId"])
|
||||
manager.ws_subscriptions[websocket].add(user_id_to_subscribe)
|
||||
|
||||
# Get current status of the user
|
||||
manager.ws_subscriptions.setdefault(websocket, set()).add(user_id_to_subscribe)
|
||||
|
||||
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
|
||||
if target_user:
|
||||
# Send current status directly (not through return value)
|
||||
await websocket.send_json({
|
||||
"type": "statusUpdate",
|
||||
"data": {
|
||||
"userId": user_id_to_subscribe,
|
||||
"online": target_user.online,
|
||||
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
|
||||
}
|
||||
})
|
||||
log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe)
|
||||
return {"status": "ok"}
|
||||
else:
|
||||
if not target_user:
|
||||
log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found")
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
online, last_seen = presence_service.get_presence(user_id_to_subscribe)
|
||||
await websocket.send_json({
|
||||
"type": "statusUpdate",
|
||||
"data": {
|
||||
"userId": user_id_to_subscribe,
|
||||
"online": online,
|
||||
"lastSeen": last_seen.isoformat() if last_seen else None,
|
||||
},
|
||||
})
|
||||
|
||||
try:
|
||||
profile_payload = build_profile_update_payload(target_user, user.id, db)
|
||||
await websocket.send_json({
|
||||
"type": "profileUpdate",
|
||||
"data": profile_payload,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"subscribeStatus profile snapshot failed subscriber=%s target=%s",
|
||||
user.id,
|
||||
user_id_to_subscribe,
|
||||
)
|
||||
|
||||
log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@websocket_handler("unsubscribeStatus", authRequired=True)
|
||||
async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
|
||||