From 6de18d0ddc55ac44ed38c4842d4f54be0f9f058f Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 13 Jul 2026 13:13:05 +0300 Subject: [PATCH] Implement legal terms, Android client compatibility and more --- .cursor/commands/security-audit.md | 158 +- backend/services/file_storage/main.py | 220 ++- backend/services/main/deleted_user.py | 57 + backend/services/main/main.py | 4 +- backend/services/main/models.py | 35 + backend/services/main/presence_service.py | 63 + backend/services/main/public_chat_config.py | 30 + .../services/main/public_image_dimensions.py | 220 +++ backend/services/main/routes/account.py | 257 +++- backend/services/main/routes/messaging.py | 1287 +++++++++++++++-- backend/services/main/routes/profile.py | 194 ++- backend/services/main/routes/public_chat.py | 31 + backend/services/main/routes/static.py | 40 + backend/services/main/service_calls.py | 167 +++ backend/services/main/static/PRIVACY.md | 72 + backend/services/main/static/TERMS.md | 68 + backend/services/main/static/icons/block.webp | Bin 0 -> 1070 bytes backend/services/main/static/icons/call.webp | Bin 0 -> 1094 bytes backend/services/main/static/icons/chat.webp | Bin 0 -> 1072 bytes .../services/main/static/icons/delete.webp | Bin 0 -> 1048 bytes .../main/static/icons/description.webp | Bin 0 -> 982 bytes backend/services/main/static/icons/lock.webp | Bin 0 -> 794 bytes .../main/static/icons/notifications.webp | Bin 0 -> 1056 bytes .../services/main/static/icons/person.webp | Bin 0 -> 1018 bytes .../main/static/icons/person_add.webp | Bin 0 -> 1048 bytes backend/services/main/static/icons/phone.webp | Bin 0 -> 1116 bytes .../services/main/static/icons/privacy.webp | Bin 0 -> 980 bytes .../services/main/static/icons/shield.webp | Bin 0 -> 944 bytes .../services/main/static/icons/storage.webp | Bin 0 -> 838 bytes backend/services/main/static/icons/terms.webp | Bin 0 -> 856 bytes .../main/static/icons/visibility_off.webp | Bin 0 -> 516 bytes .../main/static/public_chat_profile.json | 5 + backend/services/main/verification_service.py | 50 + backend/services/main/websocket/handlers.py | 93 +- frontend/src/App.tsx | 4 + frontend/src/core/DeletedUserAvatar.tsx | 23 + frontend/src/core/api/account/profile.ts | 39 - frontend/src/core/api/profileApi.ts | 39 - frontend/src/core/api/user/profile.ts | 39 - frontend/src/core/avatarGradient.ts | 22 + frontend/src/core/components/StatusBadge.tsx | 60 +- frontend/src/core/legal/LegalInlineLinks.tsx | 13 + frontend/src/core/legal/LegalMarkdownPage.tsx | 197 +++ frontend/src/core/legal/LegalPageShell.tsx | 26 + frontend/src/core/legal/fcDirective.ts | 84 ++ frontend/src/core/legal/legal.module.scss | 236 +++ .../src/core/legal/legalDocumentLoader.ts | 81 ++ frontend/src/core/legal/legalLinks.ts | 19 + .../core/legal/materialShapes.generated.ts | 39 + frontend/src/core/legal/materialShapes.ts | 130 ++ frontend/src/core/types.d.ts | 7 + frontend/src/core/userDisplay.ts | 51 + frontend/src/pages/auth/RegisterForm.tsx | 3 + .../src/pages/chat/css/Message.module.scss | 15 + .../chat/css/deleted-user-avatar.module.scss | 15 + .../src/pages/chat/css/left-panel.module.scss | 14 + .../pages/chat/css/profile-dialog.module.scss | 17 + .../pages/chat/css/right-panel.module.scss | 6 + frontend/src/pages/chat/ui/ProfileDialog.tsx | 60 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 78 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 4 +- .../chat/ui/left/settings/AccountPanel.tsx | 8 +- .../chat/ui/left/settings/DevicesPanel.tsx | 3 +- frontend/src/pages/chat/ui/right/Message.tsx | 40 +- .../chat/ui/right/MessagePanelRenderer.tsx | 50 +- .../src/pages/chat/ui/right/OnlineStatus.tsx | 7 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 1 + .../pages/download-app/DownloadAppPage.tsx | 6 + frontend/src/pages/home/HomeFooter.tsx | 10 + frontend/src/pages/legal/LegalPages.tsx | 6 + frontend/src/state/types.ts | 3 +- frontend/src/state/user.ts | 19 +- frontend/src/utils/utils.ts | 32 +- 73 files changed, 3922 insertions(+), 635 deletions(-) create mode 100644 backend/services/main/deleted_user.py create mode 100644 backend/services/main/presence_service.py create mode 100644 backend/services/main/public_chat_config.py create mode 100644 backend/services/main/public_image_dimensions.py create mode 100644 backend/services/main/routes/public_chat.py create mode 100644 backend/services/main/routes/static.py create mode 100644 backend/services/main/static/PRIVACY.md create mode 100644 backend/services/main/static/TERMS.md create mode 100644 backend/services/main/static/icons/block.webp create mode 100644 backend/services/main/static/icons/call.webp create mode 100644 backend/services/main/static/icons/chat.webp create mode 100644 backend/services/main/static/icons/delete.webp create mode 100644 backend/services/main/static/icons/description.webp create mode 100644 backend/services/main/static/icons/lock.webp create mode 100644 backend/services/main/static/icons/notifications.webp create mode 100644 backend/services/main/static/icons/person.webp create mode 100644 backend/services/main/static/icons/person_add.webp create mode 100644 backend/services/main/static/icons/phone.webp create mode 100644 backend/services/main/static/icons/privacy.webp create mode 100644 backend/services/main/static/icons/shield.webp create mode 100644 backend/services/main/static/icons/storage.webp create mode 100644 backend/services/main/static/icons/terms.webp create mode 100644 backend/services/main/static/icons/visibility_off.webp create mode 100644 backend/services/main/static/public_chat_profile.json create mode 100644 backend/services/main/verification_service.py create mode 100644 frontend/src/core/DeletedUserAvatar.tsx create mode 100644 frontend/src/core/avatarGradient.ts create mode 100644 frontend/src/core/legal/LegalInlineLinks.tsx create mode 100644 frontend/src/core/legal/LegalMarkdownPage.tsx create mode 100644 frontend/src/core/legal/LegalPageShell.tsx create mode 100644 frontend/src/core/legal/fcDirective.ts create mode 100644 frontend/src/core/legal/legal.module.scss create mode 100644 frontend/src/core/legal/legalDocumentLoader.ts create mode 100644 frontend/src/core/legal/legalLinks.ts create mode 100644 frontend/src/core/legal/materialShapes.generated.ts create mode 100644 frontend/src/core/legal/materialShapes.ts create mode 100644 frontend/src/core/userDisplay.ts create mode 100644 frontend/src/pages/chat/css/deleted-user-avatar.module.scss create mode 100644 frontend/src/pages/legal/LegalPages.tsx diff --git a/.cursor/commands/security-audit.md b/.cursor/commands/security-audit.md index 9eb0008..a7f9ac7 100644 --- a/.cursor/commands/security-audit.md +++ b/.cursor/commands/security-audit.md @@ -1,90 +1,51 @@ # Security Audit Command -Perform a comprehensive security audit of the FromChat application codebase. +Perform a comprehensive security audit of the FromChat **Android application** only. ## Project Context -**FromChat** is a 100% open source secure messaging application with: +**FromChat Android** is a 100% open source secure messaging mobile application built with: -- React/TypeScript frontend -- Python FastAPI backend -- Caddy reverse proxy with security headers +- Kotlin Multiplatform (KMP) shared code +- Jetpack Compose UI framework +- End-to-End Encryption (NaCl, AES-GCM) - WebSocket support for real-time features -- Electron support for desktop app +- LiveKit integration for calls +- Local database storage (SQLite) + +## Scope: Android Only + +**OUT OF SCOPE:** + +- Web backend (Python FastAPI, Caddy infrastructure) +- React/TypeScript frontend + +**IN SCOPE:** + +- Android app code (`app/android`, `app/shared/src/androidMain`) +- Shared cross-platform code (`app/shared/src/commonMain`) +- Local encryption implementation (NaCl, AES-GCM) +- Secure storage (Android Keystore, encrypted SharedPreferences) +- WebSocket client security +- Permission usage and handling +- Call security (LiveKit integration) +- Memory safety and injection attacks +- Backend ## Important Design Decisions (NOT Vulnerabilities) When auditing, remember these are **intentional design choices**: -1. **Public messages endpoint** - Open forum accessible without authentication (by design) - - The public chat is meant to be an open forum - - Private DMs are properly E2E encrypted and require authentication -2. **Public user list** - All users visible in DMs tab (by design) - - Users can see all registered accounts - - This is intentional for a community-based chat app -3. **XSS protection** - Multi-layer defense already implemented: - - React auto-escaping - - DOMPurify for sanitization - - Caddy CSP headers - - Do NOT flag localStorage key storage as critical (already well-protected) -4. **File upload security** - Docker isolation in place: - - Server runs in Docker without executable flags - - Files cannot execute on server - - PIL re-encodes images - - Do NOT flag Content-Type validation as critical -5. **CSRF protection** - Not needed: - - No cookies used - - JWT tokens in Authorization headers only - - CSRF attacks don't apply to this auth model -6. **Beta domain CSP** - 'unsafe-inline' is required: - - Beta domain (beta.fromchat.ru) points to development machine - - Vite dev server requires 'unsafe-inline' to function - - Production domain has strict CSP -7. **Security logging** - Already implemented: - - All events are logged including security-related activity - - Do NOT flag as missing -8. **100% Open Source** - This is a security strength: - - Full transparency - - Community review capability - - No hidden backdoors - -## Android App - -**EXCLUDE from all audits** - Android app is not production-ready and out of scope. - -## Infrastructure (Caddy) - -The application runs behind Caddy reverse proxy with comprehensive security controls: - -### Key Infrastructure Protections - -- ✅ **HTTPS enforcement** - Automatic SSL/TLS with Caddy -- ✅ **HSTS** - Strict-Transport-Security with preload -- ✅ **CSP** - Content Security Policy (strict on production, 'unsafe-inline' for scripts on beta for Vite) -- ✅ **Rate limiting** - 500 events/min (production), 1000 events/min (beta) -- ✅ **X-Frame-Options: DENY** - Prevents clickjacking -- ✅ **X-Content-Type-Options: nosniff** - Prevents MIME sniffing -- ✅ **X-XSS-Protection: 1; mode=block** - XSS protection -- ✅ **Permissions-Policy** - Restricts geolocation, allows camera/mic for calls - -**Important:** These protections are already in place at the infrastructure level. Don't flag missing security headers or rate limiting in the application code. - -## Audit Process - -1. **Read the Caddyfile first** to understand infrastructure protections -2. **Check backend code** for authentication, authorization, input validation -3. **Review frontend code** for XSS protections, crypto implementation -4. **Verify E2E encryption** implementation (NaCl for DMs, AES-GCM for calls) -5. **Test CORS configuration** in backend/app.py -6. **Review password policies** in backend/validation.py -7. **Check file upload handling** in backend/routes/messaging.py and profile.py - -## Rating Guidelines - -- **Infrastructure (Caddy):** Should be 9/10 or higher (excellent security headers) -- **Cryptography:** Should be 8-9/10 (uses industry-standard libraries) -- **Frontend Security:** Should be 7-8/10 (multi-layer XSS protection) -- **Backend API:** Focus on CORS, password policies, rate limiting +1. **Local message caching** - Messages downloaded and stored locally (by design) + - Messages are end-to-end encrypted at rest in local DB + - Public DMs are not encrypted (messages are public) + - Private DMs use NaCl encryption + - Cache persists across app restarts for offline access +2. **Local key storage** - Encryption keys stored on device (by design) + - Keys protected by Android Keystore (hardware-backed when available) + - Encrypted with device-specific secrets + - User data never leaves device in plaintext + - Do NOT flag key storage as critical (Keystore is production-ready) ## Output Format @@ -94,8 +55,8 @@ Provide a **clean, concise report** with: 2. **Security Status** - Critical issues (if any) and recommendations 3. **Security Strengths** - What's done well 4. **Component Ratings** - Table format for quick reference -5. **Design Decisions** - Clarify what's intentional vs vulnerable -6. **Threat Analysis** - Current realistic threats only +5. **Architecture Review** - Data flow, encryption boundaries +6. **Threat Analysis** - Current realistic threats (e.g., rooted device, malicious APK) 7. **Recommendations** - Prioritized with time estimates 8. **Conclusion** - Clear production readiness statement @@ -105,44 +66,7 @@ Provide a **clean, concise report** with: ❌ **DO NOT FLAG THESE AS ISSUES:** -- Public messages endpoint (intentional) -- Username enumeration (users list is public by design) -- Keys in localStorage (XSS is well-protected) -- Content-Type validation (Docker isolation prevents execution) -- CSRF protection (not applicable - no cookies) -- Beta CSP 'unsafe-inline' (required for Vite) -- Security logging (already implemented) -- Android app security (out of scope) - -## Key Security Features to Verify - -✅ **MUST CHECK:** - -- CORS configuration in backend/app.py -- Password validation in backend/validation.py -- JWT token generation and validation -- Encryption implementation (NaCl, AES-GCM) -- File upload sanitization -- Authorization checks on sensitive endpoints -- Rate limiting configuration -- Security headers in Caddyfile - -## Example Good Finding Format - -```markdown -### Password Policy (HIGH PRIORITY - Non-blocking) -**Current:** 5 character minimum -**Recommended:** 12+ characters with complexity requirements -**Risk:** Brute force attacks (mitigated by rate limiting) -**Estimated Fix:** 4-6 hours -**Code Location:** backend/validation.py:11-16 -``` - -## Notes from Developer - -- Application is production-ready after CORS fix -- Focus on practical, actionable improvements -- Don't overthink things that are already well-protected -- Open source is a feature, not a concern -- Community can audit the code themselves +- Local message caching (intentional for offline access) +- Public message viewing without auth (intentional design) +- Debuggable APK (only relevant if signed/released) diff --git a/backend/services/file_storage/main.py b/backend/services/file_storage/main.py index 769f56d..41eac1c 100644 --- a/backend/services/file_storage/main.py +++ b/backend/services/file_storage/main.py @@ -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 diff --git a/backend/services/main/deleted_user.py b/backend/services/main/deleted_user.py new file mode 100644 index 0000000..c084b6c --- /dev/null +++ b/backend/services/main/deleted_user.py @@ -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, + } diff --git a/backend/services/main/main.py b/backend/services/main/main.py index 0a9cb7e..573af5c 100644 --- a/backend/services/main/main.py +++ b/backend/services/main/main.py @@ -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") diff --git a/backend/services/main/models.py b/backend/services/main/models.py index d4ecbfe..2f6d3ef 100644 --- a/backend/services/main/models.py +++ b/backend/services/main/models.py @@ -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 diff --git a/backend/services/main/presence_service.py b/backend/services/main/presence_service.py new file mode 100644 index 0000000..ea66023 --- /dev/null +++ b/backend/services/main/presence_service.py @@ -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() diff --git a/backend/services/main/public_chat_config.py b/backend/services/main/public_chat_config.py new file mode 100644 index 0000000..8a0d30b --- /dev/null +++ b/backend/services/main/public_chat_config.py @@ -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) diff --git a/backend/services/main/public_image_dimensions.py b/backend/services/main/public_image_dimensions.py new file mode 100644 index 0000000..e57b89f --- /dev/null +++ b/backend/services/main/public_image_dimensions.py @@ -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(" 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(" 0 and height > 0: + return width, height + if chunk == b"VP8L" and len(data) >= 25: + bits = struct.unpack("> 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 diff --git a/backend/services/main/routes/account.py b/backend/services/main/routes/account.py index 15f0252..df0848a 100644 --- a/backend/services/main/routes/account.py +++ b/backend/services/main/routes/account.py @@ -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" - } \ No newline at end of file + "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) \ No newline at end of file diff --git a/backend/services/main/routes/messaging.py b/backend/services/main/routes/messaging.py index 9a6da84..5e6630b 100644 --- a/backend/services/main/routes/messaging.py +++ b/backend/services/main/routes/messaging.py @@ -11,15 +11,23 @@ import unicodedata from collections import defaultdict, deque from difflib import SequenceMatcher from typing import Any +import json import httpx from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request, status from sqlalchemy.orm import Session from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db -from .account import convert_user +from .account import convert_user_for_dm_conversation +from ..deleted_user import deleted_username_for, is_deleted_user, is_suspended_user from ..constants import OWNER_USERNAME -from ..models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog, MessageEditHistory, MessageEditHistoryResponse +from ..models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, DmConversationPreference, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog, MessageEditHistory, MessageEditHistoryResponse +from ..presence_service import presence_service from ..push_service import push_service -from PIL import Image +from ..public_image_dimensions import ( + is_placeholder_dimensions, + read_image_dimensions_from_bytes, + read_image_dimensions_from_path, +) +from PIL import Image, ImageOps import io import json from pydantic import BaseModel @@ -27,6 +35,11 @@ from better_profanity import profanity as _bp from ..security.audit import log_access, log_dm, log_public_chat, log_security from ..security.profanity import contains_profanity from ..security.rate_limit import rate_limit_per_ip +from ..verification_service import ( + VerificationStatus, + compute_verification_status, + get_verified_users_data, +) from ..websocket.utils import authenticate_user from ..models import FcmToken @@ -37,6 +50,36 @@ logger = logging.getLogger("uvicorn.error") MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB +# region agent log +_DEBUG_LOG_PATH = Path("/Volumes/Data/Projects/Programming/FromChat/Android/.cursor/debug-72e992.log") + + +def _agent_debug_log(hypothesis_id: str, location: str, message: str, data: dict) -> None: + """ + Append a structured NDJSON log line for pagination debugging. + Never raises: logging must not affect request handling. + """ + try: + payload = { + "sessionId": "72e992", + "runId": "backend-pagination", + "hypothesisId": hypothesis_id, + "location": location, + "message": message, + "data": data, + "timestamp": int(time.time() * 1000), + } + _DEBUG_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with _DEBUG_LOG_PATH.open("a", encoding="utf-8") as f: + f.write(json.dumps(payload, ensure_ascii=False) + "\n") + except Exception: + # Swallow all errors – debug-only path. + pass + +# endregion agent log + +# Legacy local fallback only — canonical public attachments live in file_storage: +# files/data/uploads/files/normal/{name} (served via /api/uploads/files/normal/...). FILES_BASE_DIR = Path("data/uploads/files") FILES_NORMAL_DIR = FILES_BASE_DIR / "normal" FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" @@ -44,6 +87,87 @@ FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" os.makedirs(FILES_NORMAL_DIR, exist_ok=True) os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True) +_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +_THUMB_SIZE = 80 +_LARGE_FILE_THUMB_BYTES = 32 * 1024 * 1024 + + +def _generate_public_thumbnail(image_bytes: bytes) -> tuple[bytes | None, list[int]]: + """Tiny JPEG thumbnail for public chat. Returns (jpeg_bytes, [w, h]) or (None, [1, 1]).""" + try: + img = ImageOps.exif_transpose(Image.open(io.BytesIO(image_bytes))) + img = img.convert("RGB") + if hasattr(img, "info") and img.info: + img.info.pop("icc_profile", None) + w, h = img.size + aspect_wh = [w, h] + if w > _THUMB_SIZE or h > _THUMB_SIZE: + scale = min(_THUMB_SIZE / w, _THUMB_SIZE / h) + new_w = max(1, int(w * scale)) + new_h = max(1, int(h * scale)) + img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=85, optimize=True) + return buf.getvalue(), aspect_wh + except Exception as e: + logger.warning("PUBLIC THUMB: Generation failed: %s", e) + return None, [1, 1] + + +def _resolve_public_file_media(mod, stored_name: str, original_name: str) -> tuple[str, list[int], int]: + """Return (thumbnail_b64, [width, height], file_size). Never emits placeholder [1, 1].""" + thumb_b64 = "" + size = 0 + dimensions: list[int] | None = None + + if mod is not None: + try: + meta = mod.get_public_thumb_meta_internal(stored_name) + except Exception as error: + logger.warning("PUBLIC THUMB: meta load failed for %s: %s", stored_name, error) + meta = None + if meta: + thumb_b64 = str(meta.get("thumbnail_b64") or "") + size = int(meta.get("file_size") or 0) + + path = mod.get_normal_file_path_internal(stored_name) + if path is not None: + fresh = mod.read_image_dimensions_from_path(path) + if fresh is not None and not is_placeholder_dimensions(fresh[0], fresh[1]): + dimensions = fresh + if size <= 0: + size = int(path.stat().st_size) + + if dimensions is None and Path(original_name).suffix.lower() in _IMAGE_EXTENSIONS: + logger.error("PUBLIC THUMB: could not resolve dimensions for %s", stored_name) + + if dimensions is None: + dimensions = [1, 1] + + return thumb_b64, dimensions, size + + +def _public_attachment_media_fields_sync(msg: Message) -> dict: + """Build fileThumbnails / fileAspectRatios / fileSizes for public messages.""" + files = list(msg.files or []) + if not files: + return {} + mod = service_calls._get_file_storage_module() + thumbnails: list[str] = [] + aspect_ratios: list[list[int]] = [] + sizes: list[int] = [] + for f in files: + stored_name = Path(f.path).name + thumb_b64, dimensions, size = _resolve_public_file_media(mod, stored_name, f.name) + thumbnails.append(thumb_b64) + aspect_ratios.append(dimensions) + sizes.append(size) + return { + "fileThumbnails": thumbnails, + "fileAspectRatios": aspect_ratios, + "fileSizes": sizes, + } + def _get_file_storage_url() -> str: lan = os.getenv("LAN_IP", "").strip() @@ -191,7 +315,11 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int, ) -def convert_message(msg: Message) -> dict: +def convert_message( + msg: Message, + verified_users_data: list[dict[str, str]] | None = None, +) -> dict: + vdata = verified_users_data or [] # Group reactions by emoji reactions_dict = {} if msg.reactions: @@ -209,15 +337,22 @@ def convert_message(msg: Message) -> dict: "username": reaction.user.display_name }) - # Handle deleted or suspended users - if msg.author.deleted or msg.author.suspended: - username = f"Deleted User #{msg.author.id}" + # Handle deleted or suspended authors + if is_deleted_user(msg.author): + username = deleted_username_for(msg.author.id) profile_picture = None verified = False + verification_status = VerificationStatus.NONE.value + elif is_suspended_user(msg.author): + username = msg.author.display_name + profile_picture = msg.author.profile_picture + verified = False + verification_status = VerificationStatus.BLOCKED.value else: username = msg.author.display_name profile_picture = msg.author.profile_picture verified = msg.author.verified + verification_status = compute_verification_status(msg.author, vdata).value return { "id": msg.id, @@ -229,7 +364,8 @@ def convert_message(msg: Message) -> dict: "username": username, "profile_picture": profile_picture, "verified": verified, - "reply_to": convert_message(msg.reply_to) if msg.reply_to else None, + "verification_status": verification_status, + "reply_to": convert_message(msg.reply_to, verified_users_data) if msg.reply_to else None, "reactions": list(reactions_dict.values()), "files": [ { @@ -239,10 +375,32 @@ def convert_message(msg: Message) -> dict: "message_id": f.message_id } for f in (msg.files or []) - ] + ], + **_public_attachment_media_fields_sync(msg), } +def convert_message_for_user( + msg: Message, + viewer_user_id: int | None, + *, + sender_client_message_id: str | None = None, + verified_users_data: list[dict[str, str]] | None = None, +) -> dict: + """ + Per-user public chat payload. [sender_client_message_id] is included only for the sender + so clients can match optimistic rows to the server ack; never exposed to other viewers. + """ + payload = convert_message(msg, verified_users_data) + if ( + sender_client_message_id + and viewer_user_id is not None + and viewer_user_id == msg.user_id + ): + payload["client_message_id"] = sender_client_message_id + return payload + + def convert_dm_envelope(db: Session, envelope: DMEnvelope, user_id: int | None = None) -> dict: # Group reactions by emoji reactions_dict = {} @@ -263,12 +421,25 @@ def convert_dm_envelope(db: Session, envelope: DMEnvelope, user_id: int | None = # Get sender info for verified status sender = db.query(User).filter(User.id == envelope.sender_id).first() + verified_users_data = get_verified_users_data(db) - # Handle deleted or suspended users - if sender and (sender.deleted or sender.suspended): + # Handle deleted or suspended senders + if sender and is_deleted_user(sender): sender_verified = False + verification_status = VerificationStatus.NONE.value + sender_username = deleted_username_for(sender.id) + elif sender and is_suspended_user(sender): + sender_verified = False + verification_status = VerificationStatus.BLOCKED.value + sender_username = sender.display_name or sender.username else: sender_verified = sender.verified if sender else False + verification_status = ( + compute_verification_status(sender, verified_users_data).value + if sender + else VerificationStatus.NONE.value + ) + sender_username = sender.username if sender else f"user_{envelope.sender_id}" # Return only the MEK wrapped with the requesting user's key if user_id == envelope.sender_id: @@ -286,12 +457,13 @@ def convert_dm_envelope(db: Session, envelope: DMEnvelope, user_id: int | None = "id": envelope.id, "senderId": envelope.sender_id, "recipientId": envelope.recipient_id, - "sender_username": sender.username if sender else f"user_{envelope.sender_id}", + "sender_username": sender_username, "iv_b64": envelope.iv_b64, "ciphertext_b64": envelope.ciphertext_b64, "wrapped_mek_b64": wrapped_mek_b64, "timestamp": envelope.timestamp.isoformat(), "verified": sender_verified, + "verification_status": verification_status, "reactions": list(reactions_dict.values()), "files": [] } @@ -314,6 +486,32 @@ def convert_dm_envelope(db: Session, envelope: DMEnvelope, user_id: int | None = return result +def convert_dm_envelope_for_conversation_preview( + db: Session, + envelope: DMEnvelope, + user_id: int | None = None, +) -> dict: + """Minimal last-message payload for DM conversation list previews.""" + if user_id == envelope.sender_id: + wrapped_mek_b64 = envelope.sender_wrapped_mek_b64 + elif user_id == envelope.recipient_id: + wrapped_mek_b64 = envelope.recipient_wrapped_mek_b64 + elif user_id == 1: + wrapped_mek_b64 = envelope.compliance_wrapped_mek_b64 + else: + wrapped_mek_b64 = None + + return { + "id": envelope.id, + "senderId": envelope.sender_id, + "recipientId": envelope.recipient_id, + "iv_b64": envelope.iv_b64, + "ciphertext_b64": envelope.ciphertext_b64, + "wrapped_mek_b64": wrapped_mek_b64, + "timestamp": envelope.timestamp.isoformat(), + } + + def convert_dm_envelope_for_user( db: Session, envelope: DMEnvelope, @@ -335,6 +533,287 @@ def convert_dm_envelope_for_user( return payload +class PublicInitResumableUploadRequest(BaseModel): + filename: str + total_size: int + chunk_size: int | None = None + + +class PublicUploadChunkRequest(BaseModel): + offset: int + data_b64: str + + +@router.post("/public/upload/init") +async def init_public_resumable_upload( + request: PublicInitResumableUploadRequest, + current_user: User = Depends(get_current_user), +): + if request.total_size <= 0: + raise HTTPException(status_code=400, detail="total_size must be > 0") + + return await service_calls.init_resumable_upload_in_storage( + filename=request.filename, + total_size=request.total_size, + allowed_user_ids=[current_user.id], + chunk_size=request.chunk_size, + ) + + +@router.get("/public/upload/{upload_id}") +async def get_public_resumable_upload_status( + upload_id: str, + current_user: User = Depends(get_current_user), +): + return await service_calls.get_resumable_upload_status_in_storage(upload_id, current_user.id) + + +@router.patch("/public/upload/{upload_id}") +async def upload_public_resumable_chunk( + upload_id: str, + request: PublicUploadChunkRequest, + current_user: User = Depends(get_current_user), +): + return await service_calls.upload_resumable_chunk_in_storage( + upload_id=upload_id, + user_id=current_user.id, + offset=request.offset, + data_b64=request.data_b64, + ) + + +@router.post("/public/upload/{upload_id}/complete") +async def complete_public_resumable_upload( + upload_id: str, + current_user: User = Depends(get_current_user), +): + return await service_calls.complete_resumable_upload_in_storage(upload_id, current_user.id) + + +@router.delete("/public/upload/{upload_id}") +async def delete_public_resumable_upload( + upload_id: str, + current_user: User = Depends(get_current_user), +): + return await service_calls.delete_resumable_upload_in_storage(upload_id, current_user.id) + + +def _optimize_image_bytes_if_possible(content: bytes, original_name: str) -> bytes: + ext = Path(original_name).suffix.lower() + try: + image = ImageOps.exif_transpose(Image.open(io.BytesIO(content))) + img_format = image.format or ("PNG" if ext == ".png" else "JPEG") + buf = io.BytesIO() + save_kwargs = {"optimize": True} + if img_format.upper() == "JPEG": + save_kwargs["quality"] = 95 + image.save(buf, format=img_format, **save_kwargs) + buf.seek(0) + return buf.read() + except Exception: + return content + + +def _read_image_dimensions( + *, + content: bytes | None = None, + source_path: Path | None = None, + original_name: str = "", +) -> list[int]: + """Read pixel size without decoding full multi-hundred-MP payloads when possible.""" + try: + if source_path is not None: + dimensions = read_image_dimensions_from_path(source_path) + if dimensions is not None: + return dimensions + if content is not None: + dimensions = read_image_dimensions_from_bytes(content, Path(original_name).suffix) + if dimensions is not None: + return dimensions + except Exception as error: + logger.warning("PUBLIC THUMB: dimension read failed: %s", error) + return [1, 1] + + +async def _maybe_store_public_thumbnail( + stored_name: str, + original_name: str, + *, + content: bytes | None = None, + source_path: Path | None = None, + file_size: int, +) -> None: + """Generate and store a thumbnail under file_storage THUMBS_DIR when possible.""" + if Path(original_name).suffix.lower() not in _IMAGE_EXTENSIONS: + return + if file_size <= 0: + return + try: + wh = _read_image_dimensions( + content=content, + source_path=source_path, + original_name=original_name, + ) + if is_placeholder_dimensions(wh[0], wh[1]): + logger.warning("PUBLIC THUMB: skipping meta for %s — dimensions unknown", stored_name) + return + if file_size > _LARGE_FILE_THUMB_BYTES: + await service_calls.store_public_image_dimensions_in_storage( + stored_name, + width=wh[0], + height=wh[1], + file_size=file_size, + ) + return + if content is not None: + image_bytes = content + elif source_path is not None: + image_bytes = Path(source_path).read_bytes() + else: + return + jpeg, thumb_wh = _generate_public_thumbnail(image_bytes) + if not jpeg: + await service_calls.store_public_image_dimensions_in_storage( + stored_name, + width=wh[0], + height=wh[1], + file_size=file_size, + ) + return + await service_calls.store_public_thumb_in_storage( + stored_name, + jpeg, + width=thumb_wh[0], + height=thumb_wh[1], + file_size=file_size, + ) + except Exception as error: + logger.warning("PUBLIC THUMB: store failed for %s: %s", stored_name, error) + + +async def _store_public_normal_attachment( + message_id: int, + original_name: str, + *, + content: bytes | None = None, + source_path: Path | None = None, +) -> MessageFile: + """Write a public attachment to file_storage so download proxy can serve it.""" + import tempfile + + ext = Path(original_name).suffix.lower() + uid = uuid.uuid4().hex + safe_name = f"{message_id}_{uid}{ext or ''}" + + if content is not None: + payload = _optimize_image_bytes_if_possible(content, original_name) + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write(payload) + tmp_path = Path(tmp.name) + try: + stored = await service_calls.store_normal_file_from_path_in_storage(safe_name, tmp_path) + finally: + tmp_path.unlink(missing_ok=True) + file_size = int(stored.get("size") or len(payload)) + await _maybe_store_public_thumbnail( + safe_name, + original_name, + content=payload, + file_size=file_size, + ) + elif source_path is not None: + src = Path(source_path) + if not src.is_file(): + raise HTTPException(status_code=404, detail="Upload payload not found") + src_size = int(src.stat().st_size) + # Avoid loading huge non-image blobs into memory just to recompress. + if ( + Path(original_name).suffix.lower() in _IMAGE_EXTENSIONS + and src_size <= _LARGE_FILE_THUMB_BYTES + ): + payload = _optimize_image_bytes_if_possible(src.read_bytes(), original_name) + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write(payload) + tmp_path = Path(tmp.name) + try: + stored = await service_calls.store_normal_file_from_path_in_storage(safe_name, tmp_path) + finally: + tmp_path.unlink(missing_ok=True) + file_size = int(stored.get("size") or len(payload)) + await _maybe_store_public_thumbnail( + safe_name, + original_name, + content=payload, + file_size=file_size, + ) + else: + stored = await service_calls.store_normal_file_from_path_in_storage(safe_name, src) + file_size = int(stored.get("size") or src_size) + mod = service_calls._get_file_storage_module() + dimension_path = ( + mod.get_normal_file_path_internal(safe_name) + if mod is not None + else src + ) + await _maybe_store_public_thumbnail( + safe_name, + original_name, + source_path=dimension_path, + file_size=file_size, + ) + else: + raise HTTPException(status_code=500, detail="Attachment payload missing") + + # Canonical path matches file_storage layout so clients/proxies resolve by basename. + stored_path = str(stored.get("path") or f"/uploads/files/normal/{safe_name}") + return MessageFile( + message_id=message_id, + name=original_name, + path=stored_path, + ) + + +async def _attach_resumable_uploads_to_message( + message: Message, + upload_ids: list[str], + current_user: User, + db: Session, +) -> None: + if not upload_ids: + return + + total_size = 0 + payloads: list[dict] = [] + for upload_id in upload_ids: + uploaded_payload = await service_calls.get_resumable_upload_blob_path_in_storage( + upload_id, current_user.id + ) + file_size = int(uploaded_payload.get("file_size", 0)) + total_size += file_size + payloads.append(uploaded_payload) + if total_size > MAX_TOTAL_SIZE: + raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB") + + for upload_id, uploaded_payload in zip(upload_ids, payloads): + source_path = Path(uploaded_payload["encrypted_file_path"]) + original_name = Path(uploaded_payload.get("filename", "file")).name + mf = await _store_public_normal_attachment( + message.id, + original_name, + source_path=source_path, + ) + db.add(mf) + + db.commit() + db.refresh(message) + + for upload_id in upload_ids: + try: + await service_calls.delete_resumable_upload_in_storage(upload_id, current_user.id) + except Exception as cleanup_error: + logger.warning("Failed to cleanup resumable upload %s: %s", upload_id, cleanup_error) + + async def _send_message_internal( message_request: SendMessageRequest, current_user: User, @@ -352,8 +831,13 @@ async def _send_message_internal( raise HTTPException(status_code=404, detail="Original message not found") raw_content = message_request.content.strip() + uploaded_file_ids = [ + uid.strip() + for uid in (message_request.uploaded_file_ids or []) + if uid and uid.strip() + ] - if not raw_content and not files: + if not raw_content and not files and not uploaded_file_ids: raise HTTPException( status_code=400, detail="No content provided" @@ -402,45 +886,32 @@ async def _send_message_internal( raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB") for up in files: - # Sanitize filename original_name = Path(up.filename or "file").name - ext = Path(original_name).suffix.lower() - uid = uuid.uuid4().hex - safe_name = f"{new_message.id}_{uid}{ext or ''}" - out_path = FILES_NORMAL_DIR / safe_name - content = await up.read() up.file.seek(0) - - # If image, try lossless optimization - try: - if up.content_type and up.content_type.startswith("image/"): - image = Image.open(io.BytesIO(content)) - img_format = image.format or ("PNG" if ext == ".png" else "JPEG") - buf = io.BytesIO() - save_kwargs = {"optimize": True} - if img_format.upper() == "JPEG": - # Use quality=95 with optimize to keep high quality (not truly lossless but near) - save_kwargs["quality"] = 95 - image.save(buf, format=img_format, **save_kwargs) - buf.seek(0) - content = buf.read() - except Exception: - # Fallback to original content - pass - - with open(out_path, "wb") as f: - f.write(content) - - mf = MessageFile( - message_id=new_message.id, - name=original_name, - path=str(out_path) + mf = await _store_public_normal_attachment( + new_message.id, + original_name, + content=content, ) db.add(mf) db.commit() db.refresh(new_message) + if uploaded_file_ids: + await _attach_resumable_uploads_to_message( + new_message, + uploaded_file_ids, + current_user, + db, + ) + + client_message_id = None + if message_request.client_message_id: + raw_client_id = message_request.client_message_id.strip() + if raw_client_id: + client_message_id = raw_client_id + # Send push notifications for public messages try: logger.info( @@ -455,16 +926,22 @@ async def _send_message_internal( # Realtime broadcast for HTTP uploads as well try: - await messagingManager.broadcast({ - "type": "newMessage", - "data": convert_message(new_message) - }, db) + await messagingManager.broadcast_new_message( + new_message, + db, + sender_client_message_id=client_message_id, + ) except Exception: pass _monitor_public_message_activity(current_user, raw_content, new_message.id, db) - message_payload = convert_message(new_message) + message_payload = convert_message_for_user( + new_message, + current_user.id, + sender_client_message_id=client_message_id, + verified_users_data=get_verified_users_data(db), + ) # Prepare log fields log_fields = { @@ -501,7 +978,14 @@ async def send_message( obj = json.loads(payload) content = obj.get("content", "") reply_to_id = obj.get("reply_to_id", None) - message_request = SendMessageRequest(content=content, reply_to_id=reply_to_id) + client_message_id = obj.get("client_message_id") + uploaded_file_ids = obj.get("uploaded_file_ids") + message_request = SendMessageRequest( + content=content, + reply_to_id=reply_to_id, + client_message_id=client_message_id, + uploaded_file_ids=uploaded_file_ids, + ) except Exception: raise HTTPException(status_code=400, detail="Invalid payload JSON") @@ -628,19 +1112,250 @@ async def push_test(request: Request, current_user: User = Depends(get_current_u raise HTTPException(status_code=500, detail="Internal error") +MAX_MESSAGE_PAGE_LIMIT = 200 + + +def _normalize_page_limit(limit: int | None, *, max_limit: int = MAX_MESSAGE_PAGE_LIMIT) -> int | None: + if limit is None: + return None + return max(1, min(limit, max_limit)) + + +def _paginate_rows_by_id( + query, + id_column, + *, + limit: int | None = None, + before_id: int | None = None, + after_id: int | None = None, + around_id: int | None = None, +) -> tuple[list[Any], bool, bool, bool]: + """ + Paginate rows by monotonic id. Always returns rows in ascending id order. + + Returns (rows, has_more, has_more_before, has_more_after). + has_more mirrors has_more_before for before/around pages and has_more_after for after pages. + """ + if limit is None: + rows = query.order_by(id_column.asc()).all() + # region agent log + _agent_debug_log( + hypothesis_id="H1_after_pagination", + location="messaging._paginate_rows_by_id", + message="unbounded pagination", + data={ + "mode": "all", + "limit": None, + "before_id": before_id, + "after_id": after_id, + "around_id": around_id, + "row_count": len(rows), + "min_id": min((getattr(r, "id", None) for r in rows), default=None), + "max_id": max((getattr(r, "id", None) for r in rows), default=None), + }, + ) + # endregion agent log + return rows, False, False, False + + if around_id is not None: + anchor = query.filter(id_column == around_id).first() + if anchor is None: + # region agent log + _agent_debug_log( + hypothesis_id="H2_around_pagination", + location="messaging._paginate_rows_by_id", + message="around_id anchor missing", + data={"limit": limit, "around_id": around_id}, + ) + # endregion agent log + return [], False, False, False + + half = limit // 2 + older_count = half + newer_count = max(0, limit - half - 1) + + older = ( + query.filter(id_column < around_id) + .order_by(id_column.desc()) + .limit(older_count) + .all() + ) + older.reverse() + + newer = ( + query.filter(id_column > around_id) + .order_by(id_column.asc()) + .limit(newer_count) + .all() + ) + + rows = older + [anchor] + newer + if not rows: + return [], False, False, False + + min_id = min(getattr(row, "id") for row in rows) + max_id = max(getattr(row, "id") for row in rows) + has_more_before = ( + query.filter(id_column < min_id).limit(1).first() is not None + ) + has_more_after = ( + query.filter(id_column > max_id).limit(1).first() is not None + ) + # region agent log + _agent_debug_log( + hypothesis_id="H2_around_pagination", + location="messaging._paginate_rows_by_id", + message="around_id window", + data={ + "limit": limit, + "around_id": around_id, + "row_count": len(rows), + "min_id": min_id, + "max_id": max_id, + "has_more_before": has_more_before, + "has_more_after": has_more_after, + }, + ) + # endregion agent log + return rows, has_more_before, has_more_before, has_more_after + + if after_id is not None: + filtered = query.filter(id_column > after_id) + probe = filtered.order_by(id_column.asc()).limit(limit + 1).all() + has_more_after = len(probe) > limit + rows = probe[:limit] + if not rows: + # region agent log + _agent_debug_log( + hypothesis_id="H1_after_pagination", + location="messaging._paginate_rows_by_id", + message="after_id page empty", + data={ + "limit": limit, + "after_id": after_id, + }, + ) + # endregion agent log + return [], False, False, False + min_id = getattr(rows[0], "id") + has_more_before = ( + query.filter(id_column < min_id).limit(1).first() is not None + ) + # region agent log + _agent_debug_log( + hypothesis_id="H1_after_pagination", + location="messaging._paginate_rows_by_id", + message="after_id page", + data={ + "limit": limit, + "after_id": after_id, + "row_count": len(rows), + "first_id": getattr(rows[0], "id", None), + "last_id": getattr(rows[-1], "id", None), + "has_more_before": has_more_before, + "has_more_after": has_more_after, + }, + ) + # endregion agent log + return rows, has_more_after, has_more_before, has_more_after + + filtered = query + if before_id is not None and query.filter(id_column == before_id).first() is not None: + filtered = query.filter(id_column < before_id) + + probe = filtered.order_by(id_column.desc()).limit(limit + 1).all() + has_more_before = len(probe) > limit + rows = probe[:limit] + rows.reverse() + # region agent log + _agent_debug_log( + hypothesis_id="H3_before_pagination", + location="messaging._paginate_rows_by_id", + message="before/initial page", + data={ + "limit": limit, + "before_id": before_id, + "row_count": len(rows), + "first_id": getattr(rows[0], "id", None) if rows else None, + "last_id": getattr(rows[-1], "id", None) if rows else None, + "has_more_before": has_more_before, + }, + ) + # endregion agent log + return rows, has_more_before, has_more_before, False + + +def _message_page_response( + messages_data: list[dict], + *, + has_more: bool, + has_more_before: bool, + has_more_after: bool, + status: str = "success", +) -> dict: + return { + "status": status, + "messages": messages_data, + "has_more": has_more, + "has_more_before": has_more_before, + "has_more_after": has_more_after, + } + + @router.get("/get_messages") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse -async def get_messages(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): - messages = db.query(Message).order_by(Message.timestamp.asc()).all() +async def get_messages( + request: Request, + limit: int | None = None, + before_id: int | None = None, + after_id: int | None = None, + around_id: int | None = None, + current_user: User = Depends(get_current_user_allow_suspended), + db: Session = Depends(get_db), +): + page_limit = _normalize_page_limit(limit) + # region agent log + _agent_debug_log( + hypothesis_id="H3_get_messages_params", + location="messaging.get_messages", + message="incoming get_messages request", + data={ + "client_host": request.client.host if request.client else None, + "limit": page_limit, + "before_id": before_id, + "after_id": after_id, + "around_id": around_id, + "path": str(request.url.path), + "query": str(request.url.query), + }, + ) + # endregion agent log + if sum(x is not None for x in (before_id, after_id, around_id)) > 1: + if around_id is not None: + before_id = None + after_id = None + elif before_id is not None and after_id is not None: + after_id = None - messages_data = [] - for msg in messages: - messages_data.append(convert_message(msg)) + base_query = db.query(Message) + rows, has_more, has_more_before, has_more_after = _paginate_rows_by_id( + base_query, + Message.id, + limit=page_limit, + before_id=before_id, + after_id=after_id, + around_id=around_id, + ) - return { - "status": "success", - "messages": messages_data - } + verified_users_data = get_verified_users_data(db) + messages_data = [convert_message(msg, verified_users_data) for msg in rows] + + return _message_page_response( + messages_data, + has_more=has_more, + has_more_before=has_more_before, + has_more_after=has_more_after, + ) class MarkReadRequest(BaseModel): @@ -654,7 +1369,8 @@ async def get_new_messages(request: Request, current_user: User = Depends(get_cu Return unread public messages (Message.is_read == False). """ new_messages = db.query(Message).filter(Message.is_read == False).order_by(Message.timestamp.asc()).all() - messages_data = [convert_message(msg) for msg in new_messages] + verified_users_data = get_verified_users_data(db) + messages_data = [convert_message(msg, verified_users_data) for msg in new_messages] return {"status": "success", "messages": messages_data} @@ -698,69 +1414,288 @@ async def dm_fetch(request: Request, since: int | None = None, current_user: Use @router.get("/dm/history/{other_user_id}") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse -async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): +async def dm_history( + request: Request, + other_user_id: int, + limit: int | None = None, + before_id: int | None = None, + after_id: int | None = None, + around_id: int | None = None, + current_user: User = Depends(get_current_user_allow_suspended), + db: Session = Depends(get_db), +): if other_user_id <= 0: raise HTTPException(status_code=400, detail="Invalid user ID") - + if other_user_id == current_user.id: raise HTTPException(status_code=400, detail="Cannot get history with yourself") - + # Verify other user exists other_user = db.query(User).filter(User.id == other_user_id).first() if not other_user: raise HTTPException(status_code=404, detail="User not found") - - envelopes = db.query(DMEnvelope).filter( - ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) - | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id)) - ).order_by(DMEnvelope.id.asc()).all() - return { - "status": "ok", - "messages": [convert_dm_envelope(db, envelope, current_user.id) for envelope in envelopes] + page_limit = _normalize_page_limit(limit) + if sum(x is not None for x in (before_id, after_id, around_id)) > 1: + if around_id is not None: + before_id = None + after_id = None + elif before_id is not None and after_id is not None: + after_id = None + + base_query = db.query(DMEnvelope).filter( + ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) + | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id)), + DMEnvelope.deleted_at.is_(None), + ) + + rows, has_more, has_more_before, has_more_after = _paginate_rows_by_id( + base_query, + DMEnvelope.id, + limit=page_limit, + before_id=before_id, + after_id=after_id, + around_id=around_id, + ) + + messages_data = [ + convert_dm_envelope(db, envelope, current_user.id) for envelope in rows + ] + + return _message_page_response( + messages_data, + has_more=has_more, + has_more_before=has_more_before, + has_more_after=has_more_after, + status="ok", + ) + + +def _get_dm_conversation_preference( + db: Session, + user_id: int, + other_user_id: int, +) -> DmConversationPreference: + pref = db.query(DmConversationPreference).filter( + DmConversationPreference.user_id == user_id, + DmConversationPreference.other_user_id == other_user_id, + ).first() + if pref is not None: + return pref + pref = DmConversationPreference( + user_id=user_id, + other_user_id=other_user_id, + archived=False, + last_read_envelope_id=0, + ) + db.add(pref) + db.flush() + return pref + + +def _count_dm_unread( + db: Session, + user_id: int, + other_user_id: int, + last_read_envelope_id: int, +) -> int: + return db.query(DMEnvelope).filter( + DMEnvelope.sender_id == other_user_id, + DMEnvelope.recipient_id == user_id, + DMEnvelope.id > last_read_envelope_id, + DMEnvelope.deleted_at.is_(None), + ).count() + + +def _build_dm_conversation_list( + db: Session, + current_user: User, + *, + archived: bool, +) -> list[dict]: + conversations_query = db.query(DMEnvelope).filter( + (DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id), + DMEnvelope.deleted_at.is_(None), + ).order_by(DMEnvelope.timestamp.desc()) + + latest_by_other_user: dict[int, DMEnvelope] = {} + for envelope in conversations_query: + other_user_id = ( + envelope.recipient_id + if envelope.sender_id == current_user.id + else envelope.sender_id + ) + if other_user_id not in latest_by_other_user: + latest_by_other_user[other_user_id] = envelope + + prefs = { + pref.other_user_id: pref + for pref in db.query(DmConversationPreference).filter( + DmConversationPreference.user_id == current_user.id, + ).all() } + result: list[dict] = [] + for other_user_id, latest_message in latest_by_other_user.items(): + pref = prefs.get(other_user_id) + is_archived = bool(pref.archived) if pref is not None else False + if is_archived != archived: + continue + + other_user = db.query(User).filter(User.id == other_user_id).first() + if not other_user: + continue + + last_read_id = pref.last_read_envelope_id if pref is not None else 0 + unread_count = _count_dm_unread(db, current_user.id, other_user_id, last_read_id) + + result.append({ + "user": convert_user_for_dm_conversation(other_user, db), + "lastMessage": convert_dm_envelope_for_conversation_preview( + db, latest_message, current_user.id + ), + "unreadCount": unread_count, + }) + + result.sort(key=lambda x: x["lastMessage"]["timestamp"], reverse=True) + return result + @router.get("/dm/conversations") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse async def get_dm_conversations(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): - # Get all DM conversations where current user is involved - conversations_query = db.query(DMEnvelope).filter( - (DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id) - ).order_by(DMEnvelope.timestamp.desc()) + return { + "status": "success", + "conversations": _build_dm_conversation_list(db, current_user, archived=False), + } - # Group by the "other user" (not current user) and get latest message - conversations = {} - for envelope in conversations_query: - other_user_id = envelope.recipient_id if envelope.sender_id == current_user.id else envelope.sender_id - if other_user_id not in conversations: - conversations[other_user_id] = envelope +@router.get("/dm/conversations/archived") +@rate_limit_per_ip("60/minute") +async def get_archived_dm_conversations(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): + return { + "status": "success", + "conversations": _build_dm_conversation_list(db, current_user, archived=True), + } - # Get user info for each conversation - result = [] - for other_user_id, latest_message in conversations.items(): - other_user = db.query(User).filter(User.id == other_user_id).first() - if other_user: - # Calculate unread count for this conversation - unread_count = db.query(DMEnvelope).filter( - DMEnvelope.sender_id == other_user_id, - DMEnvelope.recipient_id == current_user.id, - DMEnvelope.id > getattr(latest_message, 'last_read_id', 0) # This would need to be stored somewhere - ).count() - result.append({ - "user": convert_user(other_user), - "lastMessage": convert_dm_envelope(db, latest_message, current_user.id), - "unreadCount": unread_count - }) +class DmMarkReadRequest(BaseModel): + upToEnvelopeId: int | None = None - # Sort by latest message timestamp - result.sort(key=lambda x: x["lastMessage"]["timestamp"], reverse=True) + +def _mark_dm_conversation_read( + db: Session, + user_id: int, + other_user_id: int, + *, + up_to_envelope_id: int | None = None, +) -> int: + """Advance read cursor for a DM thread; returns the new last_read_envelope_id.""" + pref = _get_dm_conversation_preference(db, user_id, other_user_id) + if up_to_envelope_id is not None and up_to_envelope_id > 0: + pref.last_read_envelope_id = max(pref.last_read_envelope_id, up_to_envelope_id) + else: + latest = db.query(DMEnvelope).filter( + ((DMEnvelope.sender_id == user_id) & (DMEnvelope.recipient_id == other_user_id)) + | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == user_id)), + DMEnvelope.deleted_at.is_(None), + ).order_by(DMEnvelope.id.desc()).first() + if latest is not None: + pref.last_read_envelope_id = max(pref.last_read_envelope_id, latest.id) + db.flush() + return int(pref.last_read_envelope_id) + + +class DmArchiveRequest(BaseModel): + archived: bool + + +@router.post("/dm/conversations/{other_user_id}/archive") +@rate_limit_per_ip("60/minute") +async def set_dm_conversation_archived( + request: Request, + other_user_id: int, + body: DmArchiveRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if other_user_id <= 0: + raise HTTPException(status_code=400, detail="Invalid user ID") + if other_user_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot archive conversation with yourself") + + other_user = db.query(User).filter(User.id == other_user_id).first() + if not other_user: + raise HTTPException(status_code=404, detail="User not found") + + has_messages = db.query(DMEnvelope).filter( + ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) + | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id)), + DMEnvelope.deleted_at.is_(None), + ).first() + if not has_messages: + raise HTTPException(status_code=404, detail="Conversation not found") + + pref = _get_dm_conversation_preference(db, current_user.id, other_user_id) + pref.archived = bool(body.archived) + db.commit() + + await messagingManager.send_update_to_user( + current_user.id, + "dmConversationArchive", + { + "otherUserId": other_user_id, + "archived": pref.archived, + }, + db, + ) return { "status": "success", - "conversations": result + "otherUserId": other_user_id, + "archived": pref.archived, + } + + +@router.post("/dm/conversations/{other_user_id}/read") +@rate_limit_per_ip("60/minute") +async def mark_dm_conversation_read( + request: Request, + other_user_id: int, + body: DmMarkReadRequest | None = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if other_user_id <= 0: + raise HTTPException(status_code=400, detail="Invalid user ID") + if other_user_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot mark conversation with yourself as read") + + other_user = db.query(User).filter(User.id == other_user_id).first() + if not other_user: + raise HTTPException(status_code=404, detail="User not found") + + has_messages = db.query(DMEnvelope).filter( + ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) + | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id)), + DMEnvelope.deleted_at.is_(None), + ).first() + if not has_messages: + raise HTTPException(status_code=404, detail="Conversation not found") + + up_to = body.upToEnvelopeId if body is not None else None + last_read = _mark_dm_conversation_read( + db, + current_user.id, + other_user_id, + up_to_envelope_id=up_to, + ) + db.commit() + + return { + "status": "success", + "otherUserId": other_user_id, + "lastReadEnvelopeId": last_read, } @@ -813,7 +1748,8 @@ async def _edit_message_internal( db.commit() db.refresh(message) - payload = convert_message(message) + verified_users_data = get_verified_users_data(db) + payload = convert_message(message, verified_users_data) # Prepare log fields log_fields = { @@ -912,7 +1848,8 @@ async def add_reaction( # Refresh message to get updated reactions db.refresh(message) - message_data = convert_message(message) + verified_users_data = get_verified_users_data(db) + message_data = convert_message(message, verified_users_data) # Broadcast reaction update try: @@ -1019,7 +1956,6 @@ class MessaggingSocketManager: def __init__(self) -> None: self.connections: list[WebSocket] = [] self.user_by_ws: dict[WebSocket, int] = {} - self.online_users: set[int] = set() self.typing_users: dict[int, float] = {} # user_id -> timestamp self.dm_typing_users: dict[int, dict[int, float]] = {} # user_id -> {recipient_id -> timestamp} self.typing_state: dict[int, bool] = {} # user_id -> is_typing (for public chat) @@ -1101,8 +2037,23 @@ class MessaggingSocketManager: elif update_type == "statusUpdate": # Deduplicate by user ID sig_data = {"type": update_type, "userId": data.get("userId")} + elif update_type == "profileUpdate": + sig_data = { + "type": update_type, + "userId": data.get("id"), + "username": data.get("username"), + "display_name": data.get("display_name"), + "bio": data.get("bio"), + "profile_picture": data.get("profile_picture"), + } elif update_type == "registeredUserCount": sig_data = {"type": update_type, "count": data.get("count")} + elif update_type == "dmConversationArchive": + sig_data = { + "type": update_type, + "otherUserId": data.get("otherUserId"), + "archived": data.get("archived"), + } else: # For unknown types, use full data (less efficient but safe) sig_data = {"type": update_type, "data": data} @@ -1310,29 +2261,17 @@ class MessaggingSocketManager: self.connections.remove(websocket) if websocket in self.user_by_ws: user_id = self.user_by_ws[websocket] - # Set user offline in DB try: - # Ensure session is in a usable state - try: - db.rollback() - except Exception: - pass - - user = db.query(User).filter(User.id == user_id).first() - if user: - user.online = False - user.last_seen = datetime.now() - db.commit() - # Remove from online users - self.online_users.discard(user_id) - # Broadcast status change - await self.broadcast_status_change(user_id, False, user.last_seen.isoformat(), db) + became_offline, last_seen = presence_service.unregister_connection(user_id, websocket) + if became_offline and last_seen is not None: + await self.broadcast_status_change( + user_id, + False, + last_seen.isoformat(), + db, + ) except Exception as e: logger.error(f"Failed to set user offline during cleanup: {e}") - try: - db.rollback() - except Exception: - pass finally: del self.user_by_ws[websocket] # Cleanup subscriptions @@ -1355,6 +2294,28 @@ class MessaggingSocketManager: if websocket in self.user_by_ws: await self._send_update(websocket, message_type, update_data, db) + async def broadcast_new_message( + self, + message: Message, + db: Session | None = None, + *, + sender_client_message_id: str | None = None, + ): + """Broadcast newMessage; only the sender receives client_message_id when provided.""" + sender_id = message.user_id + verified_users_data = get_verified_users_data(db) + for websocket in self.connections: + viewer_id = self.user_by_ws.get(websocket) + if viewer_id is None: + continue + payload = convert_message_for_user( + message, + viewer_id, + sender_client_message_id=sender_client_message_id, + verified_users_data=verified_users_data, + ) + await self._send_update(websocket, "newMessage", payload, db) + async def broadcast_registered_user_count(self, db: Session): """Notify all clients of the current non-deleted user count (public chat member count).""" try: @@ -1403,6 +2364,12 @@ class MessaggingSocketManager: "lastSeen": last_seen }, db) + async def broadcast_profile_update(self, user_id: int, update_data: dict, db: Session | None = None): + """Broadcast profile update to connections subscribed to this user.""" + for websocket in self.connections: + if websocket in self.ws_subscriptions and user_id in self.ws_subscriptions[websocket]: + await self._send_update(websocket, "profileUpdate", update_data, db) + async def cleanup_stale_typing_indicators(self, db: Session): """Periodically cleanup typing indicators that haven't been updated in 3+ seconds""" while True: @@ -1508,31 +2475,91 @@ async def proxy_normal_file( current_user: User = Depends(get_current_user_allow_suspended) ): """Proxy file requests to file_storage service.""" + from fastapi.responses import FileResponse, Response + + safe_name = Path(filename).name + if filename != safe_name: + raise HTTPException(status_code=400, detail="Invalid file name") + mod = service_calls._get_file_storage_module() if mod: try: - return await mod.get_file_normal_internal(filename) - except HTTPException: - raise + return await mod.get_file_normal_internal(safe_name) + except HTTPException as exc: + if exc.status_code != 404: + raise except Exception as e: logger.error("In-process file_storage.get_file_normal failed: %s", e) raise HTTPException(status_code=500, detail="File service unavailable") + else: + file_storage_url = _get_file_storage_url() + target_url = f"{file_storage_url}/uploads/files/normal/{safe_name}" + headers = {k: v for k, v in request.headers.items() if k.lower() != "host"} + async with httpx.AsyncClient() as client: + try: + response = await client.get(target_url, headers=headers) + if response.status_code == 200: + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type") + ) + if response.status_code != 404: + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type") + ) + except httpx.RequestError as e: + logger.error("Failed to proxy file request: %s", e) + raise HTTPException(status_code=500, detail="File service unavailable") + + legacy_path = FILES_NORMAL_DIR / safe_name + if legacy_path.is_file(): + return FileResponse(str(legacy_path)) + + raise HTTPException(status_code=404, detail="File not found") + + +@router.api_route("/uploads/files/thumbs/{filename:path}", methods=["GET"]) +async def proxy_thumb_file( + request: Request, + filename: str, + current_user: User = Depends(get_current_user_allow_suspended), +): + """Proxy public-chat thumbnail requests to file_storage THUMBS_DIR.""" + from fastapi.responses import Response + + safe_name = Path(filename).name + if filename != safe_name: + raise HTTPException(status_code=400, detail="Invalid file name") + + mod = service_calls._get_file_storage_module() + if mod: + try: + return await mod.get_file_thumb_internal(safe_name) + except HTTPException: + raise + except Exception as e: + logger.error("In-process file_storage.get_file_thumb failed: %s", e) + raise HTTPException(status_code=500, detail="File service unavailable") file_storage_url = _get_file_storage_url() - target_url = f"{file_storage_url}/uploads/files/normal/{filename}" + target_url = f"{file_storage_url}/uploads/files/thumbs/{safe_name}" headers = {k: v for k, v in request.headers.items() if k.lower() != "host"} async with httpx.AsyncClient() as client: try: response = await client.get(target_url, headers=headers) - from fastapi.responses import Response return Response( content=response.content, status_code=response.status_code, headers=dict(response.headers), - media_type=response.headers.get("content-type") + media_type=response.headers.get("content-type", "image/jpeg"), ) except httpx.RequestError as e: - logger.error("Failed to proxy file request: %s", e) + logger.error("Failed to proxy thumb request: %s", e) raise HTTPException(status_code=500, detail="File service unavailable") diff --git a/backend/services/main/routes/profile.py b/backend/services/main/routes/profile.py index e92399b..e853231 100644 --- a/backend/services/main/routes/profile.py +++ b/backend/services/main/routes/profile.py @@ -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 diff --git a/backend/services/main/routes/public_chat.py b/backend/services/main/routes/public_chat.py new file mode 100644 index 0000000..9c2f2e5 --- /dev/null +++ b/backend/services/main/routes/public_chat.py @@ -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, + ) diff --git a/backend/services/main/routes/static.py b/backend/services/main/routes/static.py new file mode 100644 index 0000000..66ea3db --- /dev/null +++ b/backend/services/main/routes/static.py @@ -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") diff --git a/backend/services/main/service_calls.py b/backend/services/main/service_calls.py index 82ada02..de19dce 100644 --- a/backend/services/main/service_calls.py +++ b/backend/services/main/service_calls.py @@ -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, diff --git a/backend/services/main/static/PRIVACY.md b/backend/services/main/static/PRIVACY.md new file mode 100644 index 0000000..61d456e --- /dev/null +++ b/backend/services/main/static/PRIVACY.md @@ -0,0 +1,72 @@ + + +## Общее + +Здесь политика конфиденциальности FromChat. Я знаю, что 99% ее даже читать не будут, сделал только для того, чтобы ко мне не было вопросов и чтобы те, кому реально интерессно знали, что происходит с данными. + +Эта политика действует только на официальном сервере [fromchat.ru](https://fromchat.ru). На других серверах политика ставится их админами. + +Вы можете свободно использовать этот текст в любых целях без указания авторства. + +Текст может меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если что-то изменится, я напишу об этом в Telegram-канале. + + +## Ваши данные + +### Какие данные собираются? + +- Логин, имя и прочие данные профиля — без них мессенджер не может существовать. Эти данные видны всем, кто общается с вами. +- Пароль — на сервере хранится только односторонний хеш, который используется для проверки. Сервер никогда не видит пароль открытым текстом. +- Сообщения в общем чате — они публичны. Любой пользователь на сервере может их увидеть. Они хранятся открытым текстом в базе данных. +- Личные сообщения — вкратце: они хранятся в зашифрованном виде, но сервер во время обработки кратко видит открытый текст сообщения. Они могут быть переданы по официальному запросу уполномоченных органов. Если интересно, как именно шифруются сообщения — читайте ниже. +- Статус «в сети» и время последней активности — чтобы собеседник видел, когда вы были в сети. К сожалению, скрыть его пока нельзя. +- Информация об устройствах (тип, ОС, браузер) — видна только вам, нужно для того, чтобы вы легко распознали взлом и его нейтрализовали. +- Звонки — идут в зашифрованном виде через WebRTC-сервер, могут быть записаны в целях соблюдения законодательства и предоставлены уполномоченным органам по запросу. + + +## Больше про личные сообщения + +Если вы очень беспокоетесь за безопасность ваших сообщений, сразу говорю — защита несовершенна и любую защиту можно взломать. Но я постарался сделать доступ к вашим перепискам максимально сложным для хакеров. + +### Весь путь сообщения от вас к собеседнику + +Ваше устройство: +1. Вы отправляете сообщение. +2. Приложение (клиент) запрашивает открытый ключ у сервера обработки сообщений. +3. Приложение скачивает ваш открытый ключ и открытый ключ вашего собеседника. +3. Сообщение шифруется этим открытым ключем и отсылается на сервер вместе с открытыми ключами, полученными в предыдущем шаге. + +Сервер: +1. Сервер получает ваш запрос на отправку сообщения и пересылает его в изолированный контейнер для обработки сообщений. +2. Контейнер расшифровывает ваше сообщение своим закрытым ключем и хранит его в оперативной памяти. +3. Создается строка из случайных чисел (MEK). +4. Текст вашего сообщения шифруется алгоритмом AES-256, MEK используется как ключ. +5. MEK шифруется три раза с помощью вашего открытого ключа и открытых ключей собеседника и официальных запросов. +6. Открытый текст вашего сообщения полностью удаляется из оперативной памяти. +7. Контейнер возвращает главному серверу зашифрованное сообщение вместе с тремя экземплярами MEK. +8. Сообщение записывается в базу данных. + +Устройство собеседника: +1. Оно получает ваше сообщение и расшифровывает MEK закрытым ключем, сохраненном в аккаунте собеседника в зашифрованном виде, где пароль от аккаунта используется как ключ. +2. Зашифрованный текст сообщения расшифровывается с MEK как ключ. +3. Собеседник прочитал ваше сообщение. + + +## Реклама и продажа данных + +Никакой рекламы с моей стороны и продажи ваших данных нет и никогда не будет. Мне нет смысла злить вас ради собственной выгоды. + +На данный момент приложение не собирает никакой аналитики. + +В каналах теоритически может быть реклама от их админов. Я в ней не виноват и контролировать не могу. + + +## Удаление данных + +Если вы хотите удалить сообщение, удерживайте и нажмите "Удалить". Тогда сообщение пропадет из публичного доступа. Зашифрованная копия сообщения останется в целях соблюдения законодательства на 6 месяцев. + +Если вам нужно удалить ваши данные профиля из публичного доступа, вы можете удалить аккаунт в настройках приложения. + +В таком случае все сообщения, которые вы отправили будут анонимизированы, но не удалены. + +Если вам нужно удалить ВСЕ, что связано с вашим профилем из публичного доступа, напишите в Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true) \ No newline at end of file diff --git a/backend/services/main/static/TERMS.md b/backend/services/main/static/TERMS.md new file mode 100644 index 0000000..a189674 --- /dev/null +++ b/backend/services/main/static/TERMS.md @@ -0,0 +1,68 @@ + + + +## Общее + +**FromChat** — 100% бесплатный и открытый мессенджер. Я создал эти правила, чтобы вы точно знали, что можно, а что нельзя. + +Эти правила действуют только на официальном сервере [fromchat.ru](https://fromchat.ru). Админы других серверов устанавливают свои правила. + +Вы можете свободно использовать этот текст в любых целях без указания авторства. + +Сервис предоставляется как есть, перебои и сбои будут гарантированно из-за слабенькой малинки. + +Правила могут меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если правила изменятся, я напишу об этом в Telegram-канале. + + +## Ваш аккаунт + +Условия вступают в силу, когда вы создаете аккаунт. Также советую прочитать [политику конфиденциальности](/api/static/PRIVACY.md), поверьте, это очень важно. + +Вы полностью отвечаете за все, что происходит в вашем аккаунте. Если поставите пароль `12345` — вас точно взломают :) + +Если вы нарушите правила, я вас заблокирую. В таком случае вы сможете только читать сообщения, а отправка и реакции будут заблокированы. Если считаете, что я не прав — пишите в Telegram: [@denis0001_dev](https://t.me/denis0001_dev). + + +## Правила + +### Для общего чата +Общий чат — это площадка для общения между всеми пользователями на этом сервере. По очевидным причинам, тут запрещено: +- Материться, использовать 18+ и другие неприличные слова; +- Разговаривать на тему политики, религии, нелегальных действий и неприличия; +- Оскорблять других; +- Сливать персональные данные (адрес, номер, ФИО и прочее); +- Рекламировать любые продукты, сервисы и прочее без моего согласия; +- Популяризировать VPN и другие способы обхода блокировок (это закон, не мое личное правило); +- Угрожать в любом виде; +- Спамить или засорять чат. + +В целях защиты от спама количество сообщений в минуту ограничено и нельзя отправлять слишком много сообщений с одинаковым текстом. При нарушении вы будете автоматически заблокированы. Алгоритм очень примитивный, поэтому ошибки будут. Если это была ошибка, я вас разблокирую. + +### Для личных сообщений + +За личными сообщениями я не шпионю, но могу предоставить по официальному запросу. Поэтому я пока не могу выявлять там нарушения. Я скоро сделаю механизм жалоб. + +В личке правил гораздо меньше. Мне лень писать снова длинный список, поэтому просто прошу вас, не занимайтесь нелегальными вещами и не спамьте. В личке можно обсуждать все остальное и материться. + +### Глобальные правила + +Пожалуйста, не используйте мессенджер для спама и не устраивайте DDoS или любые другие атаки. + + +## Контакты + +### Если у вас возникли любые вопросы, пишите сюда: + +Почта: [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) + +О шифровании договоримся, если надо. \ No newline at end of file diff --git a/backend/services/main/static/icons/block.webp b/backend/services/main/static/icons/block.webp new file mode 100644 index 0000000000000000000000000000000000000000..9fd078094b43962058bfa4b5749b6ee009a5526c GIT binary patch literal 1070 zcmV+}1kw9aNk&E{1ONb6MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AUp#A0Pq$7odGI<0Du5K zF&2nJA|W9c3QRa40|c=*a9{v3%_N_%e*pce)~w+_hX0pc4SPe|45mI zz97Cg_5l7T{MUTE;xjP;hv17;>|Q;=jl-*pcPSGWG_DuVGFU@#m^Z>{UBW(DUVGKP zUfi9GA#AFy#`DTr%0K(fCkS>41iYwh0092+#(+EdtxlORSHjzSiY_7-qQ!rR&rd0msyD3C#dkVc`E?~3+%D~fY2!}#wpCt(SBdK9HH=`)IHM#Db2y8BE3I@!Zbh0Vo z%IiF(^{8#IXbZyDX^*}FF}MFo!wRy(fPYy1Elr+c*9*f+FPF|sxRs;v=F>0$IHeqX zF!IqeIeqa0suE%uuch{s1nyo>?^Qqw&*bMr|DAMyw7d~azRdS5R_MGA%X7S37p`&X zPiV3kp#{&8MovU@=^j)xxU?&yg{uz)g%HZJAE3_;1t z=OZXz(@cK~+1zqFoA})S#7PBq?b6fsj)2vA_tT!}704S|=S6!|Xt$*xie)iGk=l{- zK3S#)gDbV6em(=rn;iScQD;Ogkl$Y{llu657>MYB3J*66$ySv+do7AS!Jp)r=^cgX z_kF5qMOJ?Q;T!M2>8<14xtIzgKmX;Y&?0ql4;%%=7qXyOIIL=3k_A9~=B^;HoS}t> z#n(ri70KsPnsgyAP*h`jm$?9c&~&o2@eoLvz}@*g!it!fAV;Z5PpT=Abl*Xg`~KT) z#p+TY|AlQ*u_DQwbeaGclOsHpE{=&?tp!zqrlWcEbWc4%6gbc064o)ZiM|vHgCHV% z>h-!wW_tQgbG6l=F22(hr-klg8746i{($|`N!CEqJ5m%#;U~8!Y+%PcQ|izB6ktD= zRGGY}8bj0f$L%orSHa%>E&pk4J^kZIh$86(p}vG*K-k&@0+HP%ue@AJF8j!$Xwt@9 oE=TB;|MA#xRTwmzI^5*7Y?ZUnR9O)~YxLL0AG7M*I|MKQ03h%L&Hw-a literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/call.webp b/backend/services/main/static/icons/call.webp new file mode 100644 index 0000000000000000000000000000000000000000..9aeda2462f020b69208b002709d2d99b58f9f2c0 GIT binary patch literal 1094 zcmV-M1iAZCNk&FK1ONb6MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AXEbY01y@codGI<0Du5K zF&2nJA|W9c3QRa40|c=*a9{v3qGz9?ep~Gm@Snqf$j(WQ%0=_fFHEpke~Da()bbg zo8VKZKMw4iYtLG)PSqi?Pyhh_@y3809lJKK1KHb8Slt?U@)>KR0<85h^IGi(t(Qi8 z6O^}UQ&Yl?7^@uD^LeL@-5PiQS-GiDmG$(lEUYDnt(Ob4w$Axzre=!1@yG6iAN4yB zqq%JFlbocY7!BQW?m#M?hS}olqGJ6&y;bgQx7}0Y-|y&X4T99j<{VI_H=+gIX||WP z@PGhB9@AU~j_nqJq5qZ@f>6bd_8LcB;f~)Ohf4gVc3s<(=Q( zj*K^29M|fv6XR1(EQNlWq{1pn`MbkVJ@FQI4Cz{aYbhxF!|BKLN8*KbK1#>jP*15= zu(j}U*7`0=+jrXdCkmE#{W|3I0IsuHuIY|#P~9UK(47K7>=(v`_W>qrdT{G!p=LGQ#5(xLuNidA`k3q z<_P|tmp_Wi(l7sIHy{{26Cy;3Z;0g_mSZ)ZD^``Tmso+ZGzV8;E zL++a$>o1^K)cgE;WgBuB^%tJzE{e$^U)MiApUw{Tc2drhBCB%kI}D$x*G3!>jofO@ zFtT1~ZTO+CQuD(`hcEE_`qSJkeqjhj%5^bu*$iu(4+8GW>b_^2&C3-PoR9La!Q23L z*a9T)ri6Gr&r6H+2&~1o%V@BT5arWhH_v4M;cB@wO3q`1Q;f(Xhf M=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AU*>C0I(JSodGI<0Du5K zF&2tLA|W9biQG^i0|c=*a9{v3+gV?k{$Ok}aUaB=;2)-a2%6E`zr#Lxv-i-a;{PcB z$#4LEfPattjNk$Kh5H@vA<_Z;6ZoJ1|7Sn+e!w1pAGUsyzpOt3AFrRd-vQst|FM7V zCcynd_5l7b{MUTE*J&#f-mTjs+Xy}A<}6XKHFC8J2w?f>zI}t4K~pyaHWJk~T3H>R zZ@dCsy}rz!>KMBq4|0vztEh^3XMP|6{_c7}BRs}U4O!PJ?~Z+(@k_3AaV|ws?g6%6 zKR@9pYNGIM2tl5O#NqkHF`4tgvTIbv-z2zX3HQk$ElWRj|2of_r-eKfYC|2!+RyQ8 zYD}ES?(XgupiaQZ{AKVa0G6EcKS1kC840-O^jh6I3cx6%9Gm9v1b_f9=a6x^HMEMj zmSO*;M2SNnP%E%vIHSav{;Y^p?brW4eLwzn(f-wW0M75?LYdX&BML0?JSq&&&OIsa zNP%o}AuH`WQtA;8X1W@wf#agR2fu%58S*I8poJ9d7hmNDz+$%bQvV)v*#2UJ>v_2X zk-w&x{v3bj7`tuh?oqu1;$3m%4nd#SzMThQvb?9j-tQg+D``}HL6t4npHkbfbFRZz z+c%sruX-r(6!yWC1R6Lkx;6_d7?dXycPbaNZ2aZ*;Sp;Z?$1Unr!rb~iVqr!I#M{tFRd zf1fLV0JQ}@pv#~)nUL-xQ&9f5QGHLa*hxqMRhlbNc^(8bk4*sCfdK|2fyL%F`esduwTnCJ$Q(wE%m#aTMVr)c0001RMgNHa literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/delete.webp b/backend/services/main/static/icons/delete.webp new file mode 100644 index 0000000000000000000000000000000000000000..2812bfce42285575f643b44dbe42278af1716d12 GIT binary patch literal 1048 zcmV+z1n2uwNk&Ex1ONb6MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`ASMF<0FV{{odGI<0Du5K zF&2nJA|W9c3IsSH0|ch-3;+gMq^tKWT5fgttNB6HnW??r!wIf;2))5 z<$t68&h~)w0s6=LE$<=d1JDEdC-iShKVT0)58Dq*ANs%ZJ_mnozhOQD|4{$*l5rcU03T>$;^THhQI*+@y38V`m^HZvg6jES?r64R55>;Wp4!dt{@!# ze~`pX#yEG*#H4`uZw0^Tbc~Qp&tv=tp&yv|Ua@2_h@+2;sdi+Y zf7j#RzdNR;UE~_tBE(*m!}q)X-TW&ulJkD`xZnJdZ~cYSxx4CxTJH1fXzRb~f-C19 zl@4DsY8Yd~n3IPA=?+=D;@$9U@DNjc#Wwl--ckWmC4Q`rGcLqP? zV|u|iz4)SBcM7$TGA?(O@7mPu89dAM|DpsaJ1&?0H_b!6kafht$~VeOlWWGuQ+GK= z|M-99L(Ye`am2kkzyI=7_oK9nq3-)BumDoI!=2CckoGdeisC0ad;jpPkLKlEFas>R z$>*qjkGj}yWxog4i!!;#V(xfr{ye9QEWsRGhc+1~d_eYp+KHl`vup_`oQXXFiH!)? z&j9L^H0G~i*%*i1pS3$IH{~VhQU1@@AZap}IAaFaU)oObo{SM7=3kOR&-XmURs`Gn zek>{JHpq7$6~ac68q&pl%`ByF1TtFTy$~0dt7+{6ioTsuw~=4`vbEWA1Fj!N+=yHC z?zTDa&RYq9nJ@qXi$s*zlv4RAJJkCc8!hpC10>}u=YIeEOnXh3f`_c-1GF=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`Amjo70I(DQodGI<0Du5K zF&2qKA|W9c3IsSH0|ch-3;+gJ7Ss8iU+$s#N6YoeJ`v{)u|Lv%z;=M~I{z!w1I!2Q zSL#o_2clm<59&YcdcZvZJhQ)Cz6t+ne_%bO|NoO@{+0Uxe-{2({@40n31931$Yx># z55M7B2$O5nYHhpxUCPufX(@Nfo>-~e0TQ5O#m60a*UpLi}W&A}gOrRgHH5Xy*)@}iTe6=#)5*A+9E3JjVbIG3LALohD zV=-fv?9ARw01xYv9UG6wcd~`hBlUlo-3cB}@R6U-C@%_(g^P>o5M72uK5B zK>e5n!OYib0aZ>#8Vfpz;dTCI0r;hS`^a_TmP)!_vKXDwf8g^kM-Hb+GW}%khTGYE zWq%R->s&!;so$8N9KU-lp0@Ugl2U_R#5YDF;=1x5Cw zQ4{pIA`Vq4-0|GtrEW#m4eNsWER2qr75}8t*qMjN#(d278qfVsU_}*hZSX9|OwTS2 zav-Ep*00o{aV=}o9tgPRUQovW*Q8|3>-!HF72Z%=EO|*RsihS4YOy=`;SBPJ( z8tAIUvSkreNj%;`wYy-6l212~ZEn~iVJa`&A@WQF*Xp1#V5<q^$iC_aZm=fo)U@Cq!WBuz>uZB+%F6>tJCxBvhE E0H8zFdH?_b literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/lock.webp b/backend/services/main/static/icons/lock.webp new file mode 100644 index 0000000000000000000000000000000000000000..fbf61e39e891982120e38af64e625232502c2b71 GIT binary patch literal 794 zcmV+#1LgcuNk&Ez0{{S5MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`ASeO=08kJBodGI<0Du5K zF&KzLA|as{3Y0h?0|c|Ta9{v3qK2=p{U`IDW!@^>1M?>E4=^5f{)#`#?|^SF5{Z0S ze0}Tz{7?C=`G4$jN{Qvj*qIf?tc6^LIVDcU zGT}vZ9>LaY5Qn4yS8Fzgi%?i+@x~Yc0RHMNKoY(qtO8{7IZ#?x2tI&;U{VSCZn4ED zqDf}60w^CQ)`fT)&FhR5-MeHo!h69nYafT&Tu$7$@00QURyF0fi03Z=0L620NViPA zy-8ihE_^mVXDw?#lbsL#by6=?zQ*oGYR|_)g11jQo+2iAhdk#T zJwETriRtz!j9FO}=L}YSDJ5^5WFT20KZwnq^i!KyzQCj8F&y=CvndR)TYQ`s%NE|h zfB5Iuq^!^tV=z0DHcCb^R|y-NPz2FP)WIfN_MQY|kp*!cNcapO&y>{Q4?zTy1tZ{Y z8dG(Vz2a}zSsT`@dnMIgp;=?`G>i;|Wsk(tFfcR@y7Lgrx#RbQZvy1Ah?Ci4->78A YW9{;@ylaTXUV{#|{x53scsT$70M+qsKmY&$ literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/notifications.webp b/backend/services/main/static/icons/notifications.webp new file mode 100644 index 0000000000000000000000000000000000000000..609c510ad1a687cd801944368e8f197aaeaab530 GIT binary patch literal 1056 zcmV+*1mF8oNk&E(1ONb6MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AT9#{0MHcxodGI<0Du5K zF&2nJA|W9c3IsSH0|c=*a9{v3zUQB!eo1Twau36Q%07fmHSKrp?p_atekuLq^1J%i z00-zd_pi%e>s%l|Sby1nfb0S20sQa!ucUXd2cQRR2_`d*@gwN|Y(GyKY%ZS zGTyxbEG=7wLP}~a-$rhy-K;#Ubx^e$7WoP1d|GilfIMcv z06JD5AN9Vxe5c6@&)oTc!(W;ie8Q{BBK}gxoc|NF0L3b)5AlCK%TI2;AR3!5(V6zi zG`EsU%cxXp3nJ<%L6rKlRo*t8N2g6n*SmRVjPy(u)V?WNBiT^$r@|JD(iQe8b-&R} z`x;8$(0~DPI9*UekJ<{v=;ds6U{s1t5gV7SnJD`>e}+atrLeEhB_SL|eH}z-0l_l* zg25{-2mu)HNElp)1#6OsCVzwkSh&9@)_!kC`J0An13epy3hsH&=Ra3FO_Sowxynfu zUGIteo&3a}88AaT|Nk~c`mAB%;Hsm3YO5e5uK_|L=Bb(1Y23r3PF^Bpb)U%gfBo@T zg=gu#9d^9VzjahfXE;vuW+FD#JbN2TDLI(IA+vB;+!+iCdDBXW>mWC{05u+M6}iQb zEtHF4sBsp>>RXxWg^^Y+t%A3lM_zS|7L?QZAo_pE(n5;ikk9FBD_H<)?jltk#(Y5) zb!dDPO+s`k9Xyd?{`LYdY`B|@IaOETaz!Y{nXQZcI{c?+>*FLbLvb?+n%l=*Y!$aU z?6!UcGFfoKT7HV0002*%;|Fg literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/person.webp b/backend/services/main/static/icons/person.webp new file mode 100644 index 0000000000000000000000000000000000000000..4229a5fe8d947a0ed2de640c76f614b91ad7b4f8 GIT binary patch literal 1018 zcmV=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AP55h0I(GRodGI<0Du5K zF&2tLA|W9biY!nd0|ch-3;+gOYoqpUHU1O$-T8sf1Fapq;V|O=3;#d%)4&1x4gQDi zr-TRWf9oH-ub>Y=59gofeOA3dJpeqazhiy^KWG1GeWHK==KvSQo-FnN{vG_={igu8 z>|wIfRwcb%(T9eDL&WaUa3+NIP(?)@FZ&+ZEH6CvuK*WbIGEPIm5q0Qyc^E{Dft)_&54%lyvhNrf4`>c$n%KssN{?tle< z*3Y_s_9{>OkpBV2m#1)XWF!oX9WWVyJSVT9b-R46`SBP1ue@+nGigaJoxU@NOK%U) z+-)^jZq|s{8Y1mD&GWSs788G@uY+OBPw<-G(zL&C3S{fcQEL0mMtl8&ykNqtvHv8? zedF>n#r*^6X8exzFWllb;x`Yhr|sG}K04k=7I^rXV1}h-cblkGc>-X6g{0*xiymvT z@2MWMh5K{_*P5q~bDMt>1LPT6xa|Ci_T2pt{+3(E|N6yFPP|7Yc#Ji5y!@;G{;^Y& zuMx>xb*OyXd@Mnsmq2Q)#-Lw_$L{)vOu5I9pRkt&+WrhG&-OTE$uIuVYM6Kxc*y_6 z`Jd-iEM_QtwHjUfq0eBQ#z+1q%>O#6V=YnQq5<-Ppx^dV&Z@U|AoH!%Gj^Ndpmh#57lEJv zy`T`j3Of2@lf8|zgs<5TR0`!9BIqnQ?AM$4^PHMSf=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`ASMF<05BE+odGI<0Du5K zF&2tLA|W9biQG^i0|ch-3;+gJ6ZiJ-%B_d4Pxz($1NG;i--|Fci|pI{F_58D4pKi6-7kJz8vZ-C$CKiog{6!&~V ze6j2S`iZ`BYqXV#Z&vM*?Svlma~3Gqnz>qqgfM*cUp~T2cndwWRxu@}F`EJ}hMrM) z32mmzISz8X9iaFTlT_Rhj>$(to(*&;Bll_URR2ek&jLM&(J%oz-}IKCv%{32Ky$&W`qb=Y{IbjvW&DRe#4?+8BRi@4ezJixXCQr#zfoqm`#xlynOGqj1v@6m8 z0@gsowiSf!gh^_4?(S6-CgY=DqF{okK||-7%0sv>V$((b&+uXHxhMbog_htbz3PRu z8JkB9Gf9lR|NnUU?*r_E?R?3RB43hbFh$LQ{fr3%)dRGWyvlk~osnaxT?4`GN>|tX zSQY6N97>caEhaVi|1QZ&nda!fq}(!DPY=y+>K@eswE()Ib=f!<-#4~+NB~LX#hA3W z4hgBePr9Am^zPj3TNmFGo8+0yL3PszE4~whubMJKPZ$kiWwUn@FmF@;GvC2QTax)f z3PCZiPDu|3Z2vAZJFxRQZ)!(NNj1LIhdMosDn3Nk&Fg1ONb6MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AZh~u08keIodGI<0Du5K zF&2nJA|W9c3A{KU0|c=*a9{v3+fZMleo$;~@DIXY#=fR)*X=Li&ScL8|8M-U{^h^{ z`VIZR`UmYl01wii_aEea<-er=X6pd{h5RSgi~Lux2cQS;2czHp-|!y+->;vw{{dgj zf2RN2{QuDaFNb{r>;PddM{+xVX364-Z$f>aPKj+Ci8bK+n)uJGvAbN?^J^pJ#o6>9 z4pk4zf34TgZ|NC&SX)D=uS2WBqSO`oqy~n-0RHjDfJ^(x8LQf?(fQF`HbF~(UzI~V z*JVhnyr++PbIS`zGg!49Kvsc#O+)6~g2^=xzhJ7w`yOC)7L}?qyuh)3zg2jOaOuYa zcCm)X~oQw#RlwqwYk|jzhHZQ#k)idZD#@?6` z-@)Ha$F_FEr!WEl09{y{r)V>DnP9riRj@xq2^$qcqY&qYlvpMNQMYeh~ek&i$)I_b|iTYF!Zf-UrJlUNzi|q9u8KmCGY?HaK zZ&O*haQ6(H&t3i}@$UzR|8J=Tq-*1#J{v9!3=OGF-}}=503`l2;-XkL zFEH>Z&WOa|Fzd|yp1K4p99QMi?aB+pDK=R@1i|@y^b`l4=V1yn<)=ZZ{RD&e?m_qZ z85%y*kkoAV_BdY(`D(vqTL94o#yd5ZVqSxFetfElF$J%-yiO~j>3Sr;lPXvH@wl`N zQF*)LCy@Fw&ZvJz==ic%nq{2mzE i354pRgvl?HepOHo!GZ7pf47c~abP1m_z{Qz0000Pk0&+& literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/privacy.webp b/backend/services/main/static/icons/privacy.webp new file mode 100644 index 0000000000000000000000000000000000000000..65eff06f5202604c362a7e50ef962da184d95ac3 GIT binary patch literal 980 zcmV;_11tPeNk&G@0{{S5MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AmRc50FV>_odGI<0Du5K zF&2qKA|W9c3IsSH0|ch-3;+gMq^tPtP7Y!C$H>pc-O|Hr{TJOQXb&(C(BJi4Ks>;H z$$rLr)p`*00RDmgtJTlc1JDEYyVAqfp>Mu~Wsh&za>|Y&&A&#!UPl~TG;Pm$oFwp=>juz?XjTC|LelE&upOFVpk+ z$?oYW$YFF*KX0(Q3(sjUp0UelWLb=cWyQJbc1Q~5v;Ow%e6&%UfJO~9aTksf&E>cL z@z!m=J{dXAT4|5ljmP|U&EHROoJ8YkYfr9mYOwosiEQzFt>^YpjYm-1Ra?eP0j5G{ zv>MMkteeL#?z8{)W!Jzt;pL9ETR`3J;F{eKAwngw2lpz36h41bU35?24xw4B!?nGW z)&Kt44=4R!c0F15GAyS4@(>^Y2>gJr#>E`j_d__Wt*=>}L%bzo<7D?;h@J6T=1XL+ z<5B0?+qm;P%(%`W@mk&dSB@l?RB}#iGRIxdVjn=7{GYgHsC@Z%0-68>Ko9ZH&%r;R zAL+r9KRO_;I^kfRk?YOL`H|1kO1gCk=CFN;PM?GCq%Z+@Y@fFO^}Z_P{vjZPN~-aA z{^0?P1Q@}1{^0?P0mX8@4uie~ktA=xde3Dtc C=jAy7 literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/shield.webp b/backend/services/main/static/icons/shield.webp new file mode 100644 index 0000000000000000000000000000000000000000..21e0ad7aa7f83dc5ce258d4555bbd93dec0471cf GIT binary patch literal 944 zcmV;h15f-?Nk&Gf0{{S5MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`Aie?s0FV;^odGI<0Du5K zF&2qKA|W9c3IsSH0|ch-3;+gK-dp+&Mb1U}JIW!$J>|>#t~RB-7%Ey`$UU@gwUlj0maZ$jW6w4urD4#IC1? zSyQiGFw<_hQ=>fTQA8Hnd;!h5&a)?2j%N$LUDP?}32z=MYR6>aP>yQ%)>}eww7hDoXjg z!%#i(7IzHkT7GLODE!0e$Mi?vn>>4DT!yn6*Z`hKd%^JPU>7ucuevh8$B5*W`@STO zusSUeAwki>OJQg}lvxz@(LKZ+v%yfUZG(0_G^hR>_0sHz+74j=OjrN@keveN-fwXi zqB7SHTxJe7(S3gFV?~izD$~Mr{%Cg7iGFMM9)0MJ+g3n4cSE0(^w8DUW$>Dm)nQxM z*D8GE?P&dE^<9|lYqs3+03uy?A&ud^lUZxjYEOEBoj$b$3pdrxnH&W|DqD;6ZA4CGKPg1nNcce!@nlmDxfLWBM{2*Kmc!PvFR(s9l(9ix~205ABc^U zfsC7V#Z~#*rv`VJO!Od9Q!38_#%dqF_IgKXI#jNF*Rdz>d||DI-Z)L9=vt+9ul30U S;n6{Fl7Coq0_x@f0000K!o^1b literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/storage.webp b/backend/services/main/static/icons/storage.webp new file mode 100644 index 0000000000000000000000000000000000000000..0743d32d7d32e07b8df56b63fbb10ac253b83ec2 GIT binary patch literal 838 zcmV-M1G)TCNk&FK0{{S5MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AXEYX05A{$odGI<0Du5K zF&KzLA|as{3Y0h?0|c_Sa9{v3TTRce{Uw+UFdvk6fO&xP!}KG(8@!kHfrtQK82BsL z1NevYpZ6RB-?4=#^pB))c%K>ke=b#CRMbkg$d+Vfv(g%&ZdY`s*(m(cQCNM*a`53v zzbeu_Mww>?7Z^A<>O^9$qO1t~QM3R6{^~721D9@V8Co#>4$kL#na&A{;Di$Ct5PdU zgZRFl0r@T3)8i+_b~*E`pQCyTYbM(Ps9VMLMKp;hhvs^2|4F3oYg*=!4e_IpERT+h z!ey_y009kH)pYLP&#Qh`tS^CwtcO{xlk-LC@B469|0H|Bd4Jx#9TRSl_RDw~=~BEC zjjsQtmq-!+vN|>`HkH@=E5P^E!|uH}mKw>ys@i>hCb0<5dI{;kf5Xd>;=cW~$TnZ^ zj!GTG)4yM@W9mI5|B_}9GO`EiKbTmcxT31N?-C@iM~f_1#u<@3y&5@HFoholyyfCrZ+TI;9jmormE7f{-yZ26Gw`FNj zoD^_7ulyNkKf7EzW-^@on3ZtLgzqwsK zwf?lm!F#*D#iv8r0n7|qbUl(3O_x->v(Ci5KI?*OqrNx%qPT++qTi^spPv3`$b(w^ Q-J)35E=k~`n=6n20Mg}<$p8QV literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/terms.webp b/backend/services/main/static/icons/terms.webp new file mode 100644 index 0000000000000000000000000000000000000000..100b2347e6d9d523717db2622997c2172406f3a2 GIT binary patch literal 856 zcmV-e1E>5_Nk&Fc0{{S5MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AZ7vp0B{okodGI<0Du5K zF&K$MA|as`$`n)}0|ch-3;+gK-uL*eQZBRj@6S)cz3JfOr2ohLx^{r^0RJo01I-uf zm+W`Emq-Wew^?7H2bK5jSHRcokM2*j!7>NwN3aL+U+CND#`|OGk*Nzbq8rA{x^BG0 z7{u{Ni0!1WjQqdG<1eLUmx>ddX^n;?e?ys)yAxxoVDs$Q{QS%wy><6Ylj^gv0092* z6MzbT6OKXkVys)BtI|gn@hk=4u?-{iW2kdxn6hoU{k-&1=OKmbJ70n|1c65NAal;i z(ONwm>V>?87kupRaK5FA5e&>kvukN6G-L~8Pyhu;#YlC}l9_D&5BQy-8e1;S@r_R( z$CY>g7eo6YCbo8=8WZ$*Wp8iA3Qgb1>WZm*55+6Tz>hOv`yKUI*8aEeYQAIirzBgZ zZzB8Y)e{l%sizh~zfDqM6(xM#;iw+?i#vvNtv@xClzw6KMW~?SOS+@z?fR}C=x>yO2?2bbbYNzr zhf{-xKsm8eboW2)1D$vVV7s--f1f?1t{P_A2enJt)c3)5ucpLa1K~$xue{UnN2f#i iGlG8U0#@N)R96b?lZ$%83H=lR%uj;tK_GTO0001z*q=fG literal 0 HcmV?d00001 diff --git a/backend/services/main/static/icons/visibility_off.webp b/backend/services/main/static/icons/visibility_off.webp new file mode 100644 index 0000000000000000000000000000000000000000..8416535242acf13de56e71edc9e2a013cf40365c GIT binary patch literal 516 zcmV+f0{i_^Nk&He0RRA3MM6+kP&il$0000G0001g004gg06|PpNW=gD009@EAZ-Jt z{=3=ta?6!Ww*oV`ig5vT7aLN1|SCj){JHP z$=FYaOKKA?2`ASFrk$`0+E0f|TBe^2o7R*Dz)}|AC&wBrDfl`YG`Sf^Rz082D(53C zbqtc6(UDU`AQAxp0I&-HodGI<0Du5K zF&2qJq9Gv{1RNj(1hltsU;qICm&KRI-oPKk|C;ZY{>KqwRYP0Hz*Y7-`l9a8m;p;T zUsv}jkmr2${#2TfD6?n!3zX#qc&<6@Wl7vhmEYCX*R|CWZu|fM{^~7G4u*gL3XkPi zL;sz0f3&_n6CbCusE!rECq?c0@WZ97;0KI8H$3#;B8;4h-c4C+>OWvUax&WeG{|N; zyXqz#M54cl@-L0zLV@$M-OYV_>Cbc(e 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 diff --git a/backend/services/main/websocket/handlers.py b/backend/services/main/websocket/handlers.py index a8e94cd..6ae018b 100644 --- a/backend/services/main/websocket/handlers.py +++ b/backend/services/main/websocket/handlers.py @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6dd9d08..f82dbba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,8 @@ import { delay } from "./utils/utils"; const HomePage = lazy(() => import("./pages/home/HomePage")); const AuthPage = lazy(() => import("./pages/auth/AuthPage")); const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage")); +const PrivacyPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.PrivacyPage }))); +const TermsPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.TermsPage }))); const routeConfig: RouteObject[] = [ { path: "/", element: }, @@ -22,6 +24,8 @@ const routeConfig: RouteObject[] = [ { path: "/login", element: }, { path: "/register", element: }, { path: "/download-app", element: }, + { path: "/privacy", element: }, + { path: "/terms", element: }, { path: "/chat", element: ( diff --git a/frontend/src/core/DeletedUserAvatar.tsx b/frontend/src/core/DeletedUserAvatar.tsx new file mode 100644 index 0000000..b3df54e --- /dev/null +++ b/frontend/src/core/DeletedUserAvatar.tsx @@ -0,0 +1,23 @@ +import { MaterialIcon } from "@/utils/material"; +import { avatarGradientFromUserId } from "@/core/avatarGradient"; +import styles from "@/pages/chat/css/deleted-user-avatar.module.scss"; + +interface DeletedUserAvatarProps { + userId: number; + className?: string; + iconClassName?: string; +} + +export function DeletedUserAvatar({ userId, className, iconClassName }: DeletedUserAvatarProps) { + return ( +
+ +
+ ); +} diff --git a/frontend/src/core/api/account/profile.ts b/frontend/src/core/api/account/profile.ts index ddb1fbc..eb6b8d2 100644 --- a/frontend/src/core/api/account/profile.ts +++ b/frontend/src/core/api/account/profile.ts @@ -170,45 +170,6 @@ export async function verifyUser(userId: number, token: string): Promise<{verifi return null; } } - -/** - * In-memory cache for user similarity results - * Key: userId, Value: similarity result - */ -const similarityCache = new Map(); - -/** - * Checks if a user is similar to any verified user - * Results are cached in memory to avoid redundant API calls - */ -export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> { - // Check cache first - if (similarityCache.has(userId)) { - return similarityCache.get(userId) ?? null; - } - - try { - const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, { - headers: getAuthHeaders(token, true) - }); - - let result: {isSimilar: boolean, similarTo?: string} | null = null; - if (response.ok) { - result = await response.json(); - } - - // Cache the result (even if null/error) - similarityCache.set(userId, result); - return result; - } catch (error) { - console.error('Error checking user similarity:', error); - const result: null = null; - // Cache null result to avoid retrying on errors - similarityCache.set(userId, result); - return result; - } -} - /** * Suspends a user account (admin only) */ diff --git a/frontend/src/core/api/profileApi.ts b/frontend/src/core/api/profileApi.ts index b161756..e63e6bd 100644 --- a/frontend/src/core/api/profileApi.ts +++ b/frontend/src/core/api/profileApi.ts @@ -170,45 +170,6 @@ export async function verifyUser(userId: number, token: string): Promise<{verifi return null; } } - -/** - * In-memory cache for user similarity results - * Key: userId, Value: similarity result - */ -const similarityCache = new Map(); - -/** - * Checks if a user is similar to any verified user - * Results are cached in memory to avoid redundant API calls - */ -export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> { - // Check cache first - if (similarityCache.has(userId)) { - return similarityCache.get(userId) ?? null; - } - - try { - const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, { - headers: getAuthHeaders(token) - }); - - let result: {isSimilar: boolean, similarTo?: string} | null = null; - if (response.ok) { - result = await response.json(); - } - - // Cache the result (even if null/error) - similarityCache.set(userId, result); - return result; - } catch (error) { - console.error('Error checking user similarity:', error); - const result: null = null; - // Cache null result to avoid retrying on errors - similarityCache.set(userId, result); - return result; - } -} - /** * Suspends a user account (admin only) */ diff --git a/frontend/src/core/api/user/profile.ts b/frontend/src/core/api/user/profile.ts index ce3be53..ec3dd45 100644 --- a/frontend/src/core/api/user/profile.ts +++ b/frontend/src/core/api/user/profile.ts @@ -150,42 +150,3 @@ export async function fetchById(token: string, userId: number): Promise(); - -/** - * Checks if a user is similar to any verified user - * Results are cached in memory to avoid redundant API calls - */ -export async function checkSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> { - // Check cache first - if (similarityCache.has(userId)) { - return similarityCache.get(userId) ?? null; - } - - try { - const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, { - headers: getAuthHeaders(token, true) - }); - - let result: {isSimilar: boolean, similarTo?: string} | null = null; - if (response.ok) { - result = await response.json(); - } - - // Cache the result (even if null/error) - similarityCache.set(userId, result); - return result; - } catch (error) { - console.error('Error checking user similarity:', error); - const result: null = null; - // Cache null result to avoid retrying on errors - similarityCache.set(userId, result); - return result; - } -} - - diff --git a/frontend/src/core/avatarGradient.ts b/frontend/src/core/avatarGradient.ts new file mode 100644 index 0000000..f053ed9 --- /dev/null +++ b/frontend/src/core/avatarGradient.ts @@ -0,0 +1,22 @@ +/** Java [String.hashCode] for cross-platform parity with Android avatar gradients. */ +function javaStringHashCode(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0; + } + return hash; +} + +function rgbFromHash(hash: number, offset: number): string { + const r = Math.abs(hash % 256); + const g = Math.abs(Math.floor(hash / 256) % 256); + const b = Math.abs(Math.floor(hash / 65536) % 256); + const clamp = (channel: number) => Math.min(255, Math.max(0, channel)); + return `rgb(${clamp(r + offset)}, ${clamp(g + offset)}, ${clamp(b + offset)})`; +} + +/** CSS linear-gradient matching [generateGradientFromName] on Android for a user id seed. */ +export function avatarGradientFromUserId(userId: number): string { + const hash = javaStringHashCode(String(userId)); + return `linear-gradient(135deg, ${rgbFromHash(hash, 100)}, ${rgbFromHash(hash, 50)})`; +} diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index 9ec282d..3934d2f 100644 --- a/frontend/src/core/components/StatusBadge.tsx +++ b/frontend/src/core/components/StatusBadge.tsx @@ -1,37 +1,32 @@ -import { useState, useEffect } from "react"; -import api from "@/core/api"; -import { useUserStore } from "@/state/user"; import { MaterialIcon } from "@/utils/material"; +export type VerificationStatus = "verified" | "warning" | "blocked" | "none"; + interface StatusBadgeProps { - verified: boolean; - userId?: number; + verificationStatus?: VerificationStatus | null; + /** @deprecated Use verificationStatus instead */ + verified?: boolean; size?: "small" | "medium" | "large"; } -export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) { - const [isSimilarToVerified, setIsSimilarToVerified] = useState(false); - const { user } = useUserStore(); - +function resolveVerificationStatus( + verificationStatus?: VerificationStatus | null, + verified?: boolean, +): VerificationStatus { + if (verificationStatus) { + return verificationStatus; + } + if (verified) { + return "verified"; + } + return "none"; +} + +export function StatusBadge({ verificationStatus, verified, size = "small" }: StatusBadgeProps) { + const status = resolveVerificationStatus(verificationStatus, verified); const className = `status-badge ${size}`; - // Check similarity for unverified users - useEffect(() => { - if (!verified && userId && user.authToken) { - api.user.profile.checkSimilarity(userId, user.authToken) - .then(result => { - setIsSimilarToVerified(result?.isSimilar || false); - }) - .catch(error => { - console.error('Error checking similarity:', error); - setIsSimilarToVerified(false); - }); - } else { - setIsSimilarToVerified(false); - } - }, [verified, userId, user.authToken]); - - if (verified) { + if (status === "verified") { return ( @@ -39,7 +34,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro ); } - if (isSimilarToVerified) { + if (status === "warning") { return ( @@ -47,6 +42,13 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro ); } - // Don't show anything if not verified and not similar + if (status === "blocked") { + return ( + + + + ); + } + return null; -} \ No newline at end of file +} diff --git a/frontend/src/core/legal/LegalInlineLinks.tsx b/frontend/src/core/legal/LegalInlineLinks.tsx new file mode 100644 index 0000000..6a5ea99 --- /dev/null +++ b/frontend/src/core/legal/LegalInlineLinks.tsx @@ -0,0 +1,13 @@ +import { Link } from "react-router-dom"; +import legalStyles from "@/core/legal/legal.module.scss"; + +export function LegalInlineLinks() { + return ( +

+ Регистрируясь, вы соглашаетесь с{" "} + пользовательским соглашением + · + политикой конфиденциальности +

+ ); +} diff --git a/frontend/src/core/legal/LegalMarkdownPage.tsx b/frontend/src/core/legal/LegalMarkdownPage.tsx new file mode 100644 index 0000000..8de39ad --- /dev/null +++ b/frontend/src/core/legal/LegalMarkdownPage.tsx @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { parse } from "marked"; +import { escape as escapeHtml } from "he"; +import { MaterialButton, MaterialIcon } from "@/utils/material"; +import { fitPathToUnitSquare, getMaterialShapePath } from "./materialShapes"; +import { legalMaterialIconName, parseLegalMarkdown, type LegalSection } from "./fcDirective"; +import { rewriteLegalDocumentHref, rewriteLegalLinksInHtml } from "./legalLinks"; +import { + loadLegalDocument, + type LegalDocumentKind, +} from "./legalDocumentLoader"; +import { LegalPageShell } from "./LegalPageShell"; +import styles from "./legal.module.scss"; + +const CACHED_BANNER_TEXT = + "Показана сохранённая копия документа. Содержимое может быть устаревшим."; + +function wrapMarkdownTables(html: string): string { + return html.replace( + /]*>[\s\S]*?<\/table>/gi, + (table) => `
${table}
`, + ); +} + +function renderMarkdownBody(markdown: string): string { + const html = parse(markdown, { breaks: true, gfm: true }) as string; + return wrapMarkdownTables(rewriteLegalLinksInHtml(html)); +} + +function ExpressiveSectionHeader({ + section, +}: { + section: LegalSection; +}) { + const shapePath = useMemo( + () => getMaterialShapePath(section.directive.shape), + [section.directive.shape], + ); + const shapeFit = useMemo( + () => fitPathToUnitSquare(shapePath), + [shapePath], + ); + const iconName = legalMaterialIconName(section.directive.icon); + + return ( +
+
+ + +
+

{section.title}

+
+ ); +} + +interface LegalMarkdownPageProps { + kind: LegalDocumentKind; +} + +export function LegalMarkdownPage({ kind }: LegalMarkdownPageProps) { + const navigate = useNavigate(); + const [loadAttempt, setLoadAttempt] = useState(0); + const [markdown, setMarkdown] = useState(null); + const [isCached, setIsCached] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const handleContentClick = useCallback((event: MouseEvent) => { + const anchor = (event.target as HTMLElement).closest("a"); + if (!anchor) return; + + const href = anchor.getAttribute("href"); + if (!href) return; + + const clientRoute = rewriteLegalDocumentHref(href) ?? ( + href === "/terms" || href === "/privacy" ? href : null + ); + if (!clientRoute) return; + + event.preventDefault(); + navigate(clientRoute); + }, [navigate]); + + const retry = useCallback(() => { + setLoadAttempt((attempt) => attempt + 1); + }, []); + + useEffect(() => { + let cancelled = false; + const abortController = new AbortController(); + + setMarkdown(null); + setError(null); + setIsCached(false); + setLoading(true); + + loadLegalDocument(kind, abortController.signal) + .then((result) => { + if (cancelled) return; + + if (result.status === "error") { + setError(result.message); + return; + } + + setMarkdown(result.markdown); + setIsCached(result.fromCache); + }) + .catch((e: unknown) => { + if (cancelled || (e instanceof DOMException && e.name === "AbortError")) { + return; + } + setError("Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова."); + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + abortController.abort(); + }; + }, [kind, loadAttempt]); + + const content = (() => { + if (loading) { + return ( +
+

Загрузка…

+
+ ); + } + + if (error) { + return ( +
+
+

{escapeHtml(error)}

+ Повторить +
+
+ ); + } + + if (!markdown) { + return ( +
+

Загрузка…

+
+ ); + } + + const { preamble, sections } = parseLegalMarkdown(markdown); + + return ( +
+ {isCached ? ( +
+ {CACHED_BANNER_TEXT} +
+ ) : null} + + {preamble ? ( +
+ ) : null} + + {sections.map((section, index) => ( +
+ +
+
+ ))} +
+ ); + })(); + + return {content}; +} + +export type { LegalDocumentKind }; diff --git a/frontend/src/core/legal/LegalPageShell.tsx b/frontend/src/core/legal/LegalPageShell.tsx new file mode 100644 index 0000000..6017d4d --- /dev/null +++ b/frontend/src/core/legal/LegalPageShell.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { HomeHeader } from "@/pages/home/HomeHeader"; +import { HomeFooter } from "@/pages/home/HomeFooter"; +import homeStyles from "@/pages/home/home.module.scss"; +import styles from "./legal.module.scss"; + +interface LegalPageShellProps { + children: ReactNode; +} + +export function LegalPageShell({ children }: LegalPageShellProps) { + const navigate = useNavigate(); + + const scrollToDownload = () => { + navigate("/"); + }; + + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/frontend/src/core/legal/fcDirective.ts b/frontend/src/core/legal/fcDirective.ts new file mode 100644 index 0000000..8a708c6 --- /dev/null +++ b/frontend/src/core/legal/fcDirective.ts @@ -0,0 +1,84 @@ +/** + * Parses `` directives before section headers. + */ + +export interface FcSectionDirective { + shape: string; + icon: string; +} + +const FC_DIRECTIVE_RE = //i; + +function parseDirectiveBody(body: string): FcSectionDirective | null { + const shapeMatch = body.match(/shape=([A-Za-z0-9_]+)/); + const iconMatch = body.match(/icon=([A-Za-z0-9_-]+)/); + if (!shapeMatch || !iconMatch) return null; + return { shape: shapeMatch[1], icon: iconMatch[1] }; +} + +export function parseFcDirective(line: string): FcSectionDirective | null { + const match = line.match(FC_DIRECTIVE_RE); + if (!match) return null; + return parseDirectiveBody(match[1]); +} + +export interface LegalSection { + directive: FcSectionDirective; + title: string; + bodyMarkdown: string; +} + +/** + * Split markdown into sections keyed by fc directives + `##` headings. + */ +export function parseLegalMarkdown(markdown: string): { preamble: string; sections: LegalSection[] } { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const preambleLines: string[] = []; + const sections: LegalSection[] = []; + + let i = 0; + while (i < lines.length) { + const directive = parseFcDirective(lines[i]); + if (directive && i + 1 < lines.length && lines[i + 1].startsWith("## ")) { + const title = lines[i + 1].slice(3).trim(); + i += 2; + const bodyLines: string[] = []; + while (i < lines.length) { + if (parseFcDirective(lines[i]) && i + 1 < lines.length && lines[i + 1].startsWith("## ")) { + break; + } + bodyLines.push(lines[i]); + i += 1; + } + sections.push({ + directive, + title, + bodyMarkdown: bodyLines.join("\n").trim(), + }); + } else if (sections.length === 0) { + preambleLines.push(lines[i]); + i += 1; + } else { + i += 1; + } + } + + return { + preamble: preambleLines.join("\n").trim(), + sections, + }; +} + +export function staticIconUrl(icon: string): string { + return `/api/static/icons/${encodeURIComponent(icon)}.webp`; +} + +/** Maps legal-doc icon keys to Material Symbols names (Google Fonts). */ +const LEGAL_MATERIAL_ICON: Record = { + privacy: "privacy_tip", + terms: "contract", +}; + +export function legalMaterialIconName(icon: string): string { + return LEGAL_MATERIAL_ICON[icon] ?? icon; +} diff --git a/frontend/src/core/legal/legal.module.scss b/frontend/src/core/legal/legal.module.scss new file mode 100644 index 0000000..f83b0fd --- /dev/null +++ b/frontend/src/core/legal/legal.module.scss @@ -0,0 +1,236 @@ +@use "../../css/material" as *; + +.legalMain { + flex: 1; + width: 100%; +} + +.legalPage { + max-width: 720px; + margin: 0 auto; + padding: 32px 20px 64px; + color: $color-dark-on-surface; +} + +.loading, +.error { + font-size: 1rem; + color: $color-dark-on-surface-variant; + text-align: center; +} + +.error { + color: $color-dark-error; +} + +.errorState { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.cachedBanner { + margin-bottom: 24px; + padding: 12px 16px; + border-radius: 12px; + background: $color-dark-secondary-container; + color: $color-dark-on-secondary-container; + font-size: 0.875rem; + line-height: 1.45; + text-align: center; +} + +.preamble { + margin-bottom: 36px; + font-size: 0.95rem; + line-height: 1.55; + color: $color-dark-on-surface-variant; + text-align: center; + + :global(blockquote) { + margin: 0; + padding: 0; + border: none; + } + + :global(p) { + margin: 0 0 0.75em; + } + + :global(.legalTableScroll) { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin: 0 0 0.75em; + max-width: 100%; + text-align: left; + } + + :global(table) { + width: max-content; + min-width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + } + + :global(th), + :global(td) { + padding: 8px 12px; + text-align: left; + vertical-align: top; + border: 1px solid $color-dark-outline-variant; + } + + :global(th) { + font-weight: 600; + background: $color-dark-surface-container-low; + } +} + +.section { + margin-bottom: 40px; +} + +.sectionHeader { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; + margin-bottom: 16px; +} + +$expressive-hero-shape-size: 110px; +$expressive-hero-icon-size: 50px; + +.sectionIconFrame { + width: $expressive-hero-shape-size; + height: $expressive-hero-shape-size; + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.sectionIconShape { + position: absolute; + top: 50%; + left: 50%; + width: $expressive-hero-shape-size; + height: $expressive-hero-shape-size; + transform: translate(-50%, -50%); + display: block; +} + +.sectionShapeFill { + fill: $color-dark-primary-container; +} + +.sectionIconGlyph { + font-size: $expressive-hero-icon-size !important; + width: $expressive-hero-icon-size !important; + height: $expressive-hero-icon-size !important; + position: relative; + z-index: 1; + color: $color-dark-on-primary-container; +} + +.sectionTitle { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + line-height: 1.3; +} + +.sectionBody { + font-size: 0.95rem; + line-height: 1.55; + + :global(h3) { + font-size: calc((0.95rem + 1.25rem) / 2); + font-weight: 600; + line-height: 1.4; + margin: 1.25em 0 0.5em; + + &:first-child { + margin-top: 0; + } + } + + :global(h2) { + font-size: 1.125rem; + font-weight: 600; + line-height: 1.35; + margin: 1.5em 0 0.5em; + + &:first-child { + margin-top: 0; + } + } + + :global(p) { + margin: 0 0 0.75em; + } + + :global(ul), + :global(ol) { + margin: 0 0 0.75em; + padding-left: 1.25em; + } + + :global(li) { + margin-bottom: 0.35em; + } + + :global(a) { + color: $color-dark-primary; + } + + :global(.legalTableScroll) { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin: 0 0 0.75em; + max-width: 100%; + } + + :global(table) { + width: max-content; + min-width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + } + + :global(th), + :global(td) { + padding: 8px 12px; + text-align: left; + vertical-align: top; + border: 1px solid $color-dark-outline-variant; + } + + :global(th) { + font-weight: 600; + background: $color-dark-surface-container-low; + } +} + +.legalInlineLinks { + font-size: 0.875rem; + color: $color-dark-on-surface-variant; + margin-top: 12px; + + a { + color: $color-dark-primary; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.legalInlineLinksSep { + margin: 0 6px; + opacity: 0.5; +} diff --git a/frontend/src/core/legal/legalDocumentLoader.ts b/frontend/src/core/legal/legalDocumentLoader.ts new file mode 100644 index 0000000..185001b --- /dev/null +++ b/frontend/src/core/legal/legalDocumentLoader.ts @@ -0,0 +1,81 @@ +import { delay } from "@/utils/utils"; + +export type LegalDocumentKind = "privacy" | "terms"; + +export const LEGAL_DOCUMENT_PATH: Record = { + privacy: "/api/static/PRIVACY.md", + terms: "/api/static/TERMS.md", +}; + +const RETRY_WINDOW_MS = 5000; +const RETRY_DELAY_MS = 1000; + +const CACHE_KEY: Record = { + privacy: "fromchat:legal:privacy", + terms: "fromchat:legal:terms", +}; + +export type LegalDocumentLoadResult = + | { status: "success"; markdown: string; fromCache: false } + | { status: "cached"; markdown: string; fromCache: true } + | { status: "error"; message: string }; + +function readCache(kind: LegalDocumentKind): string | null { + try { + return localStorage.getItem(CACHE_KEY[kind]); + } catch { + return null; + } +} + +function writeCache(kind: LegalDocumentKind, markdown: string): void { + try { + localStorage.setItem(CACHE_KEY[kind], markdown); + } catch { + // best-effort + } +} + +async function fetchOnce(path: string): Promise { + const response = await fetch(path); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.text(); +} + +export async function loadLegalDocument( + kind: LegalDocumentKind, + signal?: AbortSignal, +): Promise { + const path = LEGAL_DOCUMENT_PATH[kind]; + const start = Date.now(); + + while (true) { + if (signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + + try { + const markdown = await fetchOnce(path); + writeCache(kind, markdown); + return { status: "success", markdown, fromCache: false }; + } catch { + const elapsed = Date.now() - start; + if (elapsed >= RETRY_WINDOW_MS) { + break; + } + await delay(RETRY_DELAY_MS); + } + } + + const cached = readCache(kind); + if (cached != null && cached.length > 0) { + return { status: "cached", markdown: cached, fromCache: true }; + } + + return { + status: "error", + message: "Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.", + }; +} diff --git a/frontend/src/core/legal/legalLinks.ts b/frontend/src/core/legal/legalLinks.ts new file mode 100644 index 0000000..95cc5ed --- /dev/null +++ b/frontend/src/core/legal/legalLinks.ts @@ -0,0 +1,19 @@ +const LEGAL_STATIC_LINK_RE = /(?:^|\/)?(?:api\/)?static\/(TERMS|PRIVACY)\.md$/i; + +/** + * Maps static legal markdown API paths to client routes. + * Returns null when the href is not a legal document link. + */ +export function rewriteLegalDocumentHref(href: string): string | null { + const path = href.replace(/\\/g, "/").split("?")[0].split("#")[0].replace(/\/+$/, ""); + const match = path.match(LEGAL_STATIC_LINK_RE); + if (!match) return null; + return match[1].toUpperCase() === "TERMS" ? "/terms" : "/privacy"; +} + +export function rewriteLegalLinksInHtml(html: string): string { + return html.replace(/href="([^"]+)"/g, (full, href: string) => { + const rewritten = rewriteLegalDocumentHref(href); + return rewritten ? `href="${rewritten}"` : full; + }); +} diff --git a/frontend/src/core/legal/materialShapes.generated.ts b/frontend/src/core/legal/materialShapes.generated.ts new file mode 100644 index 0000000..737711a --- /dev/null +++ b/frontend/src/core/legal/materialShapes.generated.ts @@ -0,0 +1,39 @@ +/** Auto-generated from MaterialShapes via Robolectric — do not edit. */ + +export const MATERIAL_SHAPE_PATHS: Record = { + "Arch": "M 0.146 0.146 L 0.181 0.114 L 0.22 0.085 L 0.261 0.06 L 0.305 0.039 L 0.351 0.022 L 0.399 0.01 L 0.448 0.002 L 0.5 0 L 0.551 0.002 L 0.6 0.01 L 0.648 0.022 L 0.694 0.039 L 0.738 0.06 L 0.779 0.085 L 0.818 0.114 L 0.853 0.146 L 0.885 0.181 L 0.914 0.22 L 0.939 0.261 L 0.96 0.305 L 0.977 0.351 L 0.989 0.399 L 0.997 0.448 L 0.999 0.5 L 1 0.858 L 0.997 0.887 L 0.988 0.913 L 0.975 0.937 L 0.958 0.958 L 0.937 0.975 L 0.913 0.988 L 0.887 0.997 L 0.858 1 L 0.141 0.999 L 0.112 0.997 L 0.086 0.988 L 0.062 0.975 L 0.041 0.958 L 0.024 0.937 L 0.011 0.913 L 0.002 0.887 L 0 0.858 L 0 0.5 L 0.002 0.448 L 0.01 0.399 L 0.022 0.351 L 0.039 0.305 L 0.06 0.261 L 0.085 0.22 L 0.114 0.181 L 0.146 0.146 L 0.146 0.146 Z", + "Arrow": "M 0.499 0.836 L 0.468 0.838 L 0.438 0.843 L 0.277 0.878 L 0.249 0.882 L 0.221 0.882 L 0.194 0.878 L 0.169 0.87 L 0.146 0.858 L 0.125 0.844 L 0.106 0.827 L 0.09 0.807 L 0.077 0.785 L 0.066 0.762 L 0.059 0.738 L 0.055 0.712 L 0.055 0.686 L 0.059 0.659 L 0.068 0.633 L 0.081 0.607 L 0.172 0.452 L 0.269 0.291 L 0.311 0.227 L 0.349 0.175 L 0.386 0.135 L 0.422 0.106 L 0.459 0.089 L 0.498 0.083 L 0.537 0.089 L 0.575 0.106 L 0.611 0.135 L 0.648 0.175 L 0.686 0.227 L 0.728 0.29 L 0.825 0.451 L 0.916 0.602 L 0.929 0.629 L 0.938 0.656 L 0.942 0.683 L 0.942 0.71 L 0.938 0.737 L 0.931 0.762 L 0.92 0.785 L 0.906 0.808 L 0.889 0.827 L 0.87 0.845 L 0.848 0.86 L 0.824 0.871 L 0.799 0.879 L 0.772 0.884 L 0.743 0.884 L 0.713 0.879 L 0.56 0.843 L 0.529 0.838 L 0.499 0.836 L 0.499 0.836 Z", + "Boom": "M 0.454 0.287 L 0.459 0.281 L 0.493 0.01 L 0.495 0.006 L 0.5 0.004 L 0.504 0.006 L 0.506 0.01 L 0.541 0.281 L 0.546 0.287 L 0.553 0.284 L 0.694 0.05 L 0.698 0.047 L 0.703 0.048 L 0.706 0.051 L 0.707 0.056 L 0.628 0.317 L 0.63 0.325 L 0.638 0.325 L 0.862 0.169 L 0.867 0.167 L 0.871 0.17 L 0.873 0.174 L 0.871 0.179 L 0.693 0.385 L 0.692 0.394 L 0.699 0.397 L 0.967 0.345 L 0.972 0.346 L 0.975 0.35 L 0.975 0.355 L 0.971 0.358 L 0.725 0.474 L 0.721 0.481 L 0.726 0.488 L 0.991 0.549 L 0.996 0.552 L 0.997 0.557 L 0.995 0.561 L 0.99 0.563 L 0.717 0.569 L 0.711 0.573 L 0.713 0.581 L 0.931 0.745 L 0.933 0.75 L 0.933 0.754 L 0.929 0.758 L 0.924 0.757 L 0.672 0.652 L 0.664 0.653 L 0.664 0.661 L 0.795 0.9 L 0.796 0.905 L 0.793 0.909 L 0.789 0.91 L 0.784 0.908 L 0.598 0.709 L 0.59 0.708 L 0.585 0.714 L 0.609 0.986 L 0.607 0.991 L 0.603 0.994 L 0.599 0.993 L 0.595 0.989 L 0.506 0.731 L 0.499 0.727 L 0.493 0.731 L 0.404 0.989 L 0.4 0.993 L 0.395 0.994 L 0.391 0.991 L 0.39 0.986 L 0.413 0.714 L 0.409 0.707 L 0.401 0.709 L 0.215 0.908 L 0.21 0.91 L 0.206 0.909 L 0.203 0.905 L 0.204 0.9 L 0.335 0.661 L 0.334 0.653 L 0.326 0.651 L 0.075 0.757 L 0.07 0.757 L 0.066 0.754 L 0.065 0.75 L 0.068 0.745 L 0.286 0.58 L 0.288 0.573 L 0.282 0.568 L 0.009 0.563 L 0.004 0.561 L 0.002 0.557 L 0.003 0.552 L 0.008 0.549 L 0.273 0.487 L 0.279 0.481 L 0.275 0.474 L 0.028 0.358 L 0.024 0.355 L 0.024 0.35 L 0.027 0.346 L 0.032 0.345 L 0.3 0.396 L 0.307 0.393 L 0.306 0.385 L 0.128 0.179 L 0.126 0.174 L 0.128 0.17 L 0.132 0.167 L 0.137 0.169 L 0.361 0.324 L 0.369 0.324 L 0.372 0.317 L 0.292 0.056 L 0.293 0.051 L 0.296 0.047 L 0.301 0.047 L 0.305 0.05 L 0.446 0.284 L 0.454 0.287 L 0.454 0.287 Z", + "Bun": "M 0.796 0.5 L 0.806 0.503 L 0.85 0.522 L 0.89 0.548 L 0.912 0.569 L 0.932 0.592 L 0.949 0.617 L 0.962 0.643 L 0.973 0.671 L 0.98 0.7 L 0.983 0.731 L 0.983 0.761 L 0.983 0.762 L 0.975 0.81 L 0.958 0.855 L 0.934 0.896 L 0.903 0.931 L 0.866 0.96 L 0.824 0.981 L 0.778 0.995 L 0.729 1 L 0.27 1 L 0.221 0.995 L 0.175 0.981 L 0.133 0.96 L 0.096 0.931 L 0.065 0.896 L 0.041 0.855 L 0.024 0.81 L 0.016 0.762 L 0.016 0.761 L 0.016 0.731 L 0.019 0.7 L 0.026 0.671 L 0.037 0.643 L 0.05 0.617 L 0.067 0.592 L 0.087 0.569 L 0.109 0.548 L 0.149 0.522 L 0.193 0.503 L 0.203 0.5 L 0.193 0.496 L 0.149 0.477 L 0.109 0.451 L 0.087 0.43 L 0.067 0.407 L 0.05 0.382 L 0.037 0.356 L 0.026 0.328 L 0.019 0.299 L 0.016 0.268 L 0.016 0.238 L 0.016 0.237 L 0.024 0.189 L 0.041 0.144 L 0.065 0.103 L 0.096 0.068 L 0.133 0.039 L 0.175 0.018 L 0.221 0.004 L 0.27 0 L 0.729 0 L 0.778 0.004 L 0.824 0.018 L 0.866 0.039 L 0.903 0.068 L 0.934 0.103 L 0.958 0.144 L 0.975 0.189 L 0.983 0.237 L 0.983 0.238 L 0.983 0.268 L 0.98 0.299 L 0.973 0.328 L 0.962 0.356 L 0.949 0.382 L 0.932 0.407 L 0.912 0.43 L 0.89 0.451 L 0.85 0.477 L 0.806 0.496 L 0.796 0.5 L 0.796 0.5 Z", + "Burst": "M 0.5 0 L 0.505 0.003 L 0.588 0.152 L 0.592 0.155 L 0.597 0.154 L 0.743 0.067 L 0.749 0.067 L 0.752 0.072 L 0.75 0.243 L 0.752 0.247 L 0.756 0.249 L 0.926 0.247 L 0.932 0.25 L 0.932 0.256 L 0.844 0.403 L 0.844 0.407 L 0.847 0.411 L 0.995 0.494 L 0.998 0.499 L 0.995 0.504 L 0.846 0.588 L 0.844 0.592 L 0.844 0.596 L 0.932 0.742 L 0.932 0.748 L 0.926 0.751 L 0.755 0.749 L 0.751 0.751 L 0.749 0.755 L 0.752 0.926 L 0.749 0.931 L 0.743 0.931 L 0.596 0.844 L 0.591 0.843 L 0.588 0.846 L 0.505 0.995 L 0.499 0.998 L 0.494 0.995 L 0.411 0.846 L 0.407 0.843 L 0.402 0.844 L 0.256 0.931 L 0.25 0.931 L 0.247 0.926 L 0.249 0.755 L 0.247 0.751 L 0.243 0.749 L 0.073 0.751 L 0.067 0.748 L 0.067 0.742 L 0.155 0.595 L 0.155 0.591 L 0.152 0.587 L 0.004 0.504 L 0.001 0.499 L 0.004 0.494 L 0.153 0.41 L 0.155 0.406 L 0.155 0.402 L 0.067 0.256 L 0.067 0.249 L 0.073 0.246 L 0.244 0.249 L 0.248 0.247 L 0.25 0.243 L 0.247 0.072 L 0.25 0.067 L 0.256 0.067 L 0.403 0.154 L 0.408 0.155 L 0.411 0.152 L 0.494 0.003 L 0.5 0 L 0.5 0 Z", + "Circle": "M 1 0.5 L 0.998 0.538 L 0.993 0.577 L 0.986 0.615 L 0.975 0.653 L 0.962 0.689 L 0.945 0.725 L 0.926 0.759 L 0.905 0.791 L 0.881 0.821 L 0.854 0.85 L 0.826 0.876 L 0.795 0.901 L 0.763 0.922 L 0.728 0.942 L 0.693 0.958 L 0.657 0.971 L 0.619 0.982 L 0.581 0.989 L 0.543 0.994 L 0.504 0.995 L 0.464 0.994 L 0.426 0.989 L 0.388 0.982 L 0.35 0.971 L 0.314 0.958 L 0.279 0.942 L 0.245 0.922 L 0.212 0.901 L 0.181 0.876 L 0.153 0.85 L 0.126 0.821 L 0.102 0.791 L 0.081 0.759 L 0.062 0.725 L 0.045 0.689 L 0.032 0.653 L 0.021 0.615 L 0.014 0.577 L 0.009 0.538 L 0.008 0.499 L 0.009 0.461 L 0.014 0.422 L 0.021 0.384 L 0.032 0.346 L 0.045 0.31 L 0.062 0.274 L 0.081 0.24 L 0.102 0.208 L 0.126 0.178 L 0.153 0.149 L 0.181 0.123 L 0.212 0.098 L 0.245 0.077 L 0.279 0.057 L 0.314 0.041 L 0.35 0.028 L 0.388 0.017 L 0.426 0.01 L 0.464 0.005 L 0.504 0.004 L 0.543 0.005 L 0.581 0.01 L 0.619 0.017 L 0.657 0.028 L 0.693 0.041 L 0.728 0.057 L 0.763 0.077 L 0.795 0.098 L 0.826 0.123 L 0.854 0.149 L 0.881 0.178 L 0.905 0.208 L 0.926 0.24 L 0.945 0.274 L 0.962 0.31 L 0.975 0.346 L 0.986 0.384 L 0.993 0.422 L 0.998 0.461 L 1 0.5 L 1 0.5 Z", + "ClamShell": "M 0.187 0.815 L 0.154 0.79 L 0.129 0.756 L 0.023 0.567 L 0.01 0.534 L 0.005 0.499 L 0.01 0.465 L 0.023 0.432 L 0.128 0.243 L 0.153 0.209 L 0.186 0.184 L 0.224 0.168 L 0.266 0.162 L 0.733 0.162 L 0.774 0.168 L 0.812 0.184 L 0.845 0.209 L 0.87 0.243 L 0.976 0.432 L 0.989 0.465 L 0.994 0.5 L 0.989 0.534 L 0.976 0.567 L 0.871 0.756 L 0.846 0.79 L 0.813 0.815 L 0.775 0.831 L 0.733 0.837 L 0.266 0.837 L 0.225 0.831 L 0.187 0.815 L 0.187 0.815 Z", + "Clover4Leaf": "M 0.5 0.098 L 0.514 0.086 L 0.558 0.058 L 0.606 0.039 L 0.655 0.029 L 0.706 0.028 L 0.755 0.036 L 0.803 0.052 L 0.848 0.077 L 0.888 0.111 L 0.922 0.151 L 0.947 0.196 L 0.963 0.244 L 0.971 0.293 L 0.97 0.344 L 0.96 0.393 L 0.941 0.441 L 0.913 0.485 L 0.901 0.5 L 0.913 0.514 L 0.941 0.558 L 0.96 0.606 L 0.97 0.655 L 0.971 0.706 L 0.963 0.755 L 0.947 0.803 L 0.922 0.848 L 0.888 0.888 L 0.848 0.922 L 0.803 0.947 L 0.755 0.963 L 0.706 0.971 L 0.655 0.97 L 0.606 0.96 L 0.558 0.941 L 0.514 0.913 L 0.5 0.901 L 0.485 0.913 L 0.441 0.941 L 0.393 0.96 L 0.344 0.97 L 0.293 0.971 L 0.244 0.963 L 0.196 0.947 L 0.151 0.922 L 0.111 0.888 L 0.077 0.848 L 0.052 0.803 L 0.036 0.755 L 0.028 0.706 L 0.029 0.655 L 0.039 0.606 L 0.058 0.558 L 0.086 0.514 L 0.098 0.5 L 0.086 0.485 L 0.058 0.441 L 0.039 0.393 L 0.029 0.344 L 0.028 0.293 L 0.036 0.244 L 0.052 0.196 L 0.077 0.151 L 0.111 0.111 L 0.151 0.077 L 0.196 0.052 L 0.244 0.036 L 0.293 0.028 L 0.344 0.029 L 0.393 0.039 L 0.441 0.058 L 0.485 0.086 L 0.5 0.098 L 0.5 0.098 Z", + "Clover8Leaf": "M 0.499 0.071 L 0.521 0.059 L 0.564 0.043 L 0.607 0.037 L 0.649 0.04 L 0.69 0.053 L 0.726 0.074 L 0.758 0.103 L 0.783 0.139 L 0.799 0.182 L 0.803 0.196 L 0.826 0.204 L 0.868 0.222 L 0.903 0.248 L 0.93 0.281 L 0.95 0.318 L 0.961 0.359 L 0.962 0.402 L 0.954 0.445 L 0.936 0.487 L 0.928 0.499 L 0.94 0.521 L 0.956 0.564 L 0.962 0.607 L 0.959 0.649 L 0.946 0.69 L 0.925 0.726 L 0.896 0.758 L 0.86 0.783 L 0.817 0.799 L 0.803 0.803 L 0.795 0.826 L 0.777 0.868 L 0.751 0.903 L 0.718 0.93 L 0.681 0.95 L 0.64 0.961 L 0.597 0.962 L 0.554 0.954 L 0.512 0.936 L 0.499 0.928 L 0.478 0.94 L 0.435 0.956 L 0.392 0.962 L 0.35 0.959 L 0.309 0.946 L 0.273 0.925 L 0.241 0.896 L 0.216 0.86 L 0.2 0.817 L 0.196 0.803 L 0.173 0.795 L 0.131 0.777 L 0.096 0.751 L 0.069 0.718 L 0.049 0.681 L 0.038 0.64 L 0.037 0.597 L 0.045 0.554 L 0.063 0.512 L 0.071 0.499 L 0.059 0.478 L 0.043 0.435 L 0.037 0.392 L 0.04 0.35 L 0.053 0.309 L 0.074 0.273 L 0.103 0.241 L 0.139 0.216 L 0.182 0.2 L 0.196 0.196 L 0.204 0.173 L 0.222 0.131 L 0.248 0.096 L 0.281 0.069 L 0.318 0.049 L 0.359 0.038 L 0.402 0.037 L 0.445 0.045 L 0.487 0.063 L 0.499 0.071 L 0.499 0.071 Z", + "Cookie12Sided": "M 0.5 0.005 L 0.519 0.007 L 0.537 0.012 L 0.554 0.022 L 0.57 0.036 L 0.59 0.053 L 0.615 0.063 L 0.641 0.066 L 0.668 0.062 L 0.688 0.058 L 0.708 0.058 L 0.727 0.062 L 0.744 0.07 L 0.76 0.081 L 0.773 0.096 L 0.783 0.113 L 0.79 0.132 L 0.799 0.157 L 0.815 0.179 L 0.836 0.195 L 0.862 0.204 L 0.881 0.211 L 0.898 0.221 L 0.912 0.234 L 0.924 0.25 L 0.931 0.267 L 0.936 0.286 L 0.936 0.306 L 0.932 0.326 L 0.927 0.352 L 0.931 0.379 L 0.941 0.403 L 0.958 0.424 L 0.972 0.44 L 0.981 0.457 L 0.987 0.475 L 0.989 0.494 L 0.987 0.513 L 0.981 0.532 L 0.972 0.549 L 0.958 0.564 L 0.941 0.585 L 0.931 0.61 L 0.927 0.636 L 0.932 0.663 L 0.936 0.683 L 0.936 0.703 L 0.931 0.722 L 0.924 0.739 L 0.912 0.755 L 0.898 0.768 L 0.881 0.778 L 0.862 0.784 L 0.836 0.794 L 0.815 0.81 L 0.799 0.831 L 0.79 0.857 L 0.783 0.876 L 0.773 0.893 L 0.76 0.907 L 0.744 0.918 L 0.727 0.926 L 0.708 0.931 L 0.688 0.931 L 0.668 0.927 L 0.641 0.922 L 0.615 0.925 L 0.59 0.936 L 0.57 0.953 L 0.554 0.967 L 0.537 0.976 L 0.519 0.982 L 0.499 0.984 L 0.48 0.982 L 0.462 0.976 L 0.445 0.967 L 0.429 0.953 L 0.409 0.936 L 0.384 0.925 L 0.358 0.922 L 0.331 0.927 L 0.311 0.931 L 0.291 0.931 L 0.272 0.926 L 0.255 0.918 L 0.239 0.907 L 0.226 0.893 L 0.216 0.876 L 0.209 0.857 L 0.2 0.831 L 0.184 0.81 L 0.163 0.794 L 0.137 0.784 L 0.118 0.778 L 0.101 0.768 L 0.087 0.755 L 0.075 0.739 L 0.068 0.722 L 0.063 0.703 L 0.063 0.683 L 0.067 0.663 L 0.072 0.636 L 0.068 0.61 L 0.058 0.585 L 0.041 0.564 L 0.027 0.549 L 0.018 0.532 L 0.012 0.513 L 0.01 0.494 L 0.012 0.475 L 0.018 0.457 L 0.027 0.44 L 0.041 0.424 L 0.058 0.403 L 0.068 0.379 L 0.072 0.352 L 0.067 0.326 L 0.063 0.306 L 0.063 0.286 L 0.068 0.267 L 0.075 0.25 L 0.087 0.234 L 0.101 0.221 L 0.118 0.211 L 0.137 0.204 L 0.163 0.195 L 0.184 0.179 L 0.2 0.157 L 0.209 0.132 L 0.216 0.113 L 0.226 0.096 L 0.239 0.081 L 0.255 0.07 L 0.272 0.062 L 0.291 0.058 L 0.311 0.058 L 0.331 0.062 L 0.358 0.066 L 0.384 0.063 L 0.409 0.053 L 0.429 0.036 L 0.445 0.022 L 0.462 0.012 L 0.48 0.007 L 0.5 0.005 L 0.5 0.005 Z", + "Cookie4Sided": "M 0.871 0.87 L 0.847 0.892 L 0.819 0.909 L 0.79 0.923 L 0.759 0.932 L 0.726 0.937 L 0.692 0.936 L 0.657 0.93 L 0.621 0.918 L 0.581 0.9 L 0.541 0.888 L 0.5 0.884 L 0.459 0.888 L 0.419 0.901 L 0.378 0.918 L 0.343 0.93 L 0.308 0.936 L 0.274 0.937 L 0.241 0.932 L 0.21 0.923 L 0.18 0.91 L 0.153 0.892 L 0.129 0.871 L 0.108 0.846 L 0.09 0.819 L 0.076 0.79 L 0.067 0.758 L 0.062 0.725 L 0.063 0.691 L 0.069 0.657 L 0.081 0.621 L 0.099 0.581 L 0.111 0.541 L 0.115 0.5 L 0.111 0.458 L 0.099 0.419 L 0.081 0.378 L 0.069 0.343 L 0.063 0.308 L 0.062 0.274 L 0.067 0.241 L 0.076 0.21 L 0.09 0.18 L 0.107 0.153 L 0.128 0.129 L 0.153 0.107 L 0.18 0.09 L 0.209 0.076 L 0.241 0.067 L 0.274 0.062 L 0.308 0.063 L 0.343 0.069 L 0.378 0.081 L 0.419 0.099 L 0.459 0.111 L 0.5 0.115 L 0.541 0.111 L 0.581 0.098 L 0.621 0.081 L 0.656 0.069 L 0.691 0.063 L 0.725 0.062 L 0.758 0.067 L 0.789 0.076 L 0.819 0.089 L 0.846 0.107 L 0.87 0.128 L 0.892 0.153 L 0.909 0.18 L 0.923 0.209 L 0.932 0.241 L 0.937 0.274 L 0.936 0.308 L 0.93 0.342 L 0.918 0.378 L 0.901 0.418 L 0.888 0.458 L 0.884 0.499 L 0.888 0.541 L 0.901 0.58 L 0.918 0.621 L 0.93 0.656 L 0.937 0.691 L 0.937 0.725 L 0.933 0.758 L 0.923 0.789 L 0.91 0.819 L 0.892 0.846 L 0.871 0.87 L 0.871 0.87 Z", + "Cookie6Sided": "M 0.716 0.872 L 0.692 0.889 L 0.669 0.908 L 0.668 0.909 L 0.63 0.939 L 0.589 0.96 L 0.545 0.973 L 0.5 0.977 L 0.454 0.972 L 0.41 0.96 L 0.369 0.938 L 0.331 0.909 L 0.309 0.89 L 0.285 0.873 L 0.259 0.86 L 0.231 0.851 L 0.229 0.85 L 0.185 0.832 L 0.145 0.807 L 0.112 0.775 L 0.086 0.738 L 0.067 0.697 L 0.056 0.652 L 0.054 0.606 L 0.061 0.559 L 0.066 0.53 L 0.068 0.501 L 0.067 0.471 L 0.061 0.443 L 0.061 0.441 L 0.054 0.393 L 0.056 0.347 L 0.067 0.302 L 0.086 0.261 L 0.112 0.224 L 0.146 0.192 L 0.185 0.167 L 0.229 0.149 L 0.257 0.14 L 0.283 0.127 L 0.307 0.11 L 0.33 0.091 L 0.331 0.09 L 0.369 0.06 L 0.41 0.039 L 0.454 0.026 L 0.499 0.022 L 0.545 0.027 L 0.589 0.039 L 0.63 0.061 L 0.668 0.09 L 0.69 0.109 L 0.714 0.126 L 0.74 0.139 L 0.768 0.148 L 0.77 0.149 L 0.814 0.167 L 0.854 0.192 L 0.887 0.224 L 0.913 0.261 L 0.932 0.302 L 0.943 0.347 L 0.945 0.393 L 0.938 0.44 L 0.933 0.469 L 0.931 0.498 L 0.932 0.528 L 0.938 0.556 L 0.938 0.558 L 0.945 0.606 L 0.943 0.652 L 0.932 0.697 L 0.913 0.738 L 0.887 0.775 L 0.853 0.807 L 0.814 0.832 L 0.77 0.85 L 0.742 0.859 L 0.716 0.872 L 0.716 0.872 Z", + "Cookie7Sided": "M 0.5 0.021 L 0.536 0.025 L 0.571 0.035 L 0.604 0.053 L 0.634 0.077 L 0.659 0.098 L 0.686 0.114 L 0.716 0.125 L 0.748 0.132 L 0.785 0.14 L 0.82 0.155 L 0.85 0.176 L 0.875 0.202 L 0.895 0.233 L 0.909 0.267 L 0.916 0.304 L 0.916 0.342 L 0.915 0.374 L 0.919 0.406 L 0.929 0.436 L 0.944 0.465 L 0.961 0.499 L 0.97 0.536 L 0.973 0.572 L 0.968 0.609 L 0.956 0.643 L 0.938 0.676 L 0.914 0.704 L 0.884 0.728 L 0.858 0.747 L 0.836 0.77 L 0.818 0.797 L 0.805 0.826 L 0.789 0.861 L 0.767 0.891 L 0.739 0.916 L 0.708 0.935 L 0.674 0.947 L 0.637 0.953 L 0.6 0.952 L 0.562 0.943 L 0.531 0.935 L 0.5 0.932 L 0.468 0.935 L 0.437 0.943 L 0.399 0.952 L 0.362 0.953 L 0.325 0.947 L 0.291 0.935 L 0.26 0.916 L 0.232 0.891 L 0.21 0.861 L 0.194 0.826 L 0.181 0.797 L 0.163 0.77 L 0.141 0.747 L 0.115 0.728 L 0.085 0.704 L 0.061 0.676 L 0.043 0.643 L 0.031 0.609 L 0.026 0.572 L 0.029 0.536 L 0.038 0.499 L 0.055 0.465 L 0.07 0.436 L 0.08 0.406 L 0.084 0.374 L 0.083 0.342 L 0.083 0.304 L 0.09 0.267 L 0.104 0.233 L 0.124 0.202 L 0.149 0.176 L 0.179 0.155 L 0.214 0.14 L 0.251 0.132 L 0.283 0.125 L 0.313 0.114 L 0.34 0.098 L 0.365 0.077 L 0.395 0.053 L 0.428 0.035 L 0.463 0.025 L 0.5 0.021 L 0.5 0.021 Z", + "Cookie9Sided": "M 0.5 0.014 L 0.527 0.016 L 0.553 0.023 L 0.578 0.036 L 0.601 0.053 L 0.625 0.071 L 0.651 0.083 L 0.68 0.09 L 0.709 0.092 L 0.738 0.094 L 0.765 0.101 L 0.79 0.112 L 0.812 0.128 L 0.832 0.147 L 0.847 0.17 L 0.859 0.195 L 0.865 0.223 L 0.872 0.252 L 0.884 0.278 L 0.901 0.302 L 0.923 0.322 L 0.943 0.342 L 0.96 0.365 L 0.972 0.39 L 0.979 0.416 L 0.981 0.443 L 0.979 0.471 L 0.971 0.497 L 0.958 0.523 L 0.945 0.549 L 0.937 0.578 L 0.935 0.607 L 0.938 0.636 L 0.941 0.665 L 0.939 0.692 L 0.933 0.719 L 0.921 0.744 L 0.905 0.766 L 0.886 0.786 L 0.863 0.801 L 0.836 0.812 L 0.809 0.824 L 0.785 0.841 L 0.764 0.862 L 0.748 0.886 L 0.733 0.91 L 0.713 0.93 L 0.691 0.946 L 0.666 0.958 L 0.64 0.965 L 0.612 0.967 L 0.584 0.964 L 0.557 0.956 L 0.529 0.947 L 0.499 0.945 L 0.47 0.947 L 0.442 0.956 L 0.415 0.964 L 0.387 0.967 L 0.359 0.965 L 0.333 0.958 L 0.308 0.946 L 0.286 0.93 L 0.266 0.91 L 0.251 0.886 L 0.235 0.862 L 0.214 0.841 L 0.19 0.824 L 0.163 0.812 L 0.136 0.801 L 0.113 0.786 L 0.094 0.766 L 0.078 0.744 L 0.066 0.719 L 0.06 0.692 L 0.058 0.665 L 0.061 0.636 L 0.064 0.607 L 0.062 0.578 L 0.054 0.549 L 0.041 0.523 L 0.028 0.497 L 0.02 0.471 L 0.018 0.443 L 0.02 0.416 L 0.027 0.39 L 0.039 0.365 L 0.056 0.342 L 0.076 0.322 L 0.098 0.302 L 0.115 0.278 L 0.127 0.252 L 0.134 0.223 L 0.14 0.195 L 0.152 0.17 L 0.167 0.147 L 0.187 0.128 L 0.209 0.112 L 0.234 0.101 L 0.261 0.094 L 0.29 0.092 L 0.319 0.09 L 0.348 0.083 L 0.374 0.071 L 0.398 0.053 L 0.421 0.036 L 0.446 0.023 L 0.472 0.016 L 0.5 0.014 L 0.5 0.014 Z", + "Diamond": "M 0.499 1 L 0.459 0.994 L 0.421 0.977 L 0.402 0.962 L 0.381 0.939 L 0.319 0.861 L 0.117 0.6 L 0.103 0.577 L 0.093 0.554 L 0.086 0.529 L 0.084 0.503 L 0.086 0.478 L 0.093 0.453 L 0.103 0.429 L 0.117 0.407 L 0.319 0.146 L 0.381 0.067 L 0.402 0.044 L 0.421 0.029 L 0.459 0.013 L 0.5 0.007 L 0.54 0.013 L 0.578 0.029 L 0.597 0.044 L 0.618 0.067 L 0.68 0.146 L 0.882 0.407 L 0.896 0.429 L 0.906 0.453 L 0.913 0.478 L 0.915 0.503 L 0.913 0.529 L 0.906 0.554 L 0.896 0.577 L 0.882 0.6 L 0.68 0.861 L 0.618 0.939 L 0.597 0.962 L 0.578 0.977 L 0.54 0.994 L 0.499 1 L 0.499 1 Z", + "Fan": "M 0.957 0.955 L 0.926 0.979 L 0.889 0.995 L 0.852 0.999 L 0.788 1 L 0.151 1 L 0.12 0.996 L 0.092 0.988 L 0.066 0.974 L 0.044 0.955 L 0.026 0.933 L 0.012 0.907 L 0.003 0.879 L 0 0.849 L 0 0.149 L 0.003 0.119 L 0.012 0.091 L 0.026 0.065 L 0.044 0.043 L 0.067 0.025 L 0.093 0.012 L 0.121 0.004 L 0.151 0.001 L 0.214 0.003 L 0.293 0.009 L 0.37 0.022 L 0.444 0.042 L 0.515 0.069 L 0.583 0.102 L 0.646 0.142 L 0.706 0.187 L 0.761 0.237 L 0.812 0.292 L 0.857 0.351 L 0.896 0.415 L 0.93 0.483 L 0.957 0.554 L 0.977 0.628 L 0.991 0.704 L 0.997 0.783 L 0.997 0.785 L 0.998 0.849 L 0.995 0.886 L 0.98 0.923 L 0.957 0.955 L 0.957 0.955 Z", + "Flower": "M 0.369 0.186 L 0.396 0.107 L 0.407 0.079 L 0.423 0.053 L 0.442 0.03 L 0.465 0.01 L 0.479 0.002 L 0.495 0 L 0.503 0 L 0.519 0.002 L 0.533 0.01 L 0.556 0.03 L 0.575 0.053 L 0.591 0.079 L 0.603 0.107 L 0.629 0.186 L 0.704 0.148 L 0.732 0.137 L 0.761 0.13 L 0.791 0.127 L 0.821 0.129 L 0.837 0.134 L 0.85 0.143 L 0.855 0.148 L 0.865 0.161 L 0.87 0.177 L 0.871 0.207 L 0.869 0.237 L 0.862 0.267 L 0.85 0.295 L 0.813 0.369 L 0.892 0.396 L 0.92 0.407 L 0.946 0.423 L 0.969 0.442 L 0.989 0.465 L 0.997 0.479 L 0.999 0.495 L 0.999 0.503 L 0.997 0.519 L 0.989 0.533 L 0.969 0.556 L 0.946 0.575 L 0.92 0.591 L 0.892 0.603 L 0.813 0.629 L 0.851 0.704 L 0.862 0.732 L 0.869 0.761 L 0.872 0.791 L 0.87 0.821 L 0.865 0.837 L 0.856 0.85 L 0.851 0.855 L 0.838 0.865 L 0.822 0.87 L 0.792 0.871 L 0.762 0.869 L 0.732 0.862 L 0.704 0.85 L 0.63 0.813 L 0.603 0.892 L 0.592 0.92 L 0.576 0.946 L 0.557 0.969 L 0.534 0.989 L 0.52 0.997 L 0.504 0.999 L 0.496 0.999 L 0.48 0.997 L 0.466 0.989 L 0.443 0.969 L 0.424 0.946 L 0.408 0.92 L 0.396 0.892 L 0.37 0.813 L 0.295 0.851 L 0.267 0.862 L 0.238 0.869 L 0.208 0.872 L 0.178 0.87 L 0.162 0.865 L 0.149 0.856 L 0.144 0.851 L 0.134 0.838 L 0.129 0.822 L 0.128 0.792 L 0.13 0.762 L 0.137 0.732 L 0.149 0.704 L 0.186 0.63 L 0.107 0.603 L 0.079 0.592 L 0.053 0.576 L 0.03 0.557 L 0.01 0.534 L 0.002 0.52 L 0 0.504 L 0 0.496 L 0.002 0.48 L 0.01 0.466 L 0.03 0.443 L 0.053 0.424 L 0.079 0.408 L 0.107 0.396 L 0.186 0.37 L 0.148 0.295 L 0.137 0.267 L 0.13 0.238 L 0.127 0.208 L 0.129 0.178 L 0.134 0.162 L 0.143 0.149 L 0.148 0.144 L 0.161 0.134 L 0.177 0.129 L 0.207 0.128 L 0.237 0.13 L 0.267 0.137 L 0.295 0.149 L 0.369 0.186 L 0.369 0.186 Z", + "Gem": "M 0.499 0.999 L 0.475 0.998 L 0.445 0.993 L 0.412 0.982 L 0.321 0.942 L 0.136 0.857 L 0.106 0.84 L 0.08 0.82 L 0.058 0.795 L 0.04 0.767 L 0.027 0.737 L 0.018 0.705 L 0.015 0.672 L 0.017 0.638 L 0.059 0.354 L 0.07 0.309 L 0.089 0.268 L 0.117 0.232 L 0.151 0.201 L 0.378 0.039 L 0.406 0.022 L 0.436 0.01 L 0.468 0.002 L 0.501 0 L 0.534 0.002 L 0.566 0.01 L 0.596 0.022 L 0.624 0.04 L 0.85 0.203 L 0.884 0.233 L 0.911 0.27 L 0.931 0.311 L 0.942 0.355 L 0.982 0.64 L 0.984 0.674 L 0.981 0.707 L 0.972 0.739 L 0.959 0.769 L 0.941 0.797 L 0.918 0.821 L 0.892 0.842 L 0.862 0.859 L 0.677 0.943 L 0.586 0.982 L 0.553 0.993 L 0.523 0.998 L 0.499 0.999 L 0.499 0.999 Z", + "Ghostish": "M 0.5 0 L 0.548 0.002 L 0.596 0.009 L 0.641 0.021 L 0.685 0.037 L 0.727 0.057 L 0.766 0.081 L 0.803 0.108 L 0.837 0.139 L 0.867 0.173 L 0.895 0.21 L 0.919 0.249 L 0.939 0.291 L 0.955 0.334 L 0.966 0.38 L 0.974 0.427 L 0.976 0.476 L 0.976 0.76 L 0.974 0.786 L 0.969 0.812 L 0.961 0.836 L 0.95 0.858 L 0.936 0.878 L 0.92 0.896 L 0.881 0.926 L 0.837 0.945 L 0.813 0.95 L 0.789 0.953 L 0.764 0.952 L 0.739 0.948 L 0.714 0.94 L 0.69 0.929 L 0.624 0.892 L 0.597 0.88 L 0.569 0.871 L 0.54 0.865 L 0.51 0.863 L 0.489 0.863 L 0.459 0.865 L 0.43 0.871 L 0.402 0.88 L 0.375 0.892 L 0.309 0.929 L 0.285 0.94 L 0.26 0.948 L 0.235 0.952 L 0.21 0.953 L 0.186 0.95 L 0.162 0.945 L 0.118 0.926 L 0.079 0.896 L 0.063 0.878 L 0.049 0.858 L 0.038 0.836 L 0.03 0.812 L 0.025 0.786 L 0.023 0.76 L 0.023 0.476 L 0.025 0.427 L 0.033 0.38 L 0.044 0.334 L 0.06 0.291 L 0.08 0.249 L 0.104 0.21 L 0.132 0.173 L 0.162 0.139 L 0.196 0.108 L 0.233 0.081 L 0.272 0.057 L 0.314 0.037 L 0.358 0.021 L 0.403 0.009 L 0.451 0.002 L 0.5 0 L 0.5 0 Z", + "Heart": "M 0.5 0.285 L 0.504 0.283 L 0.619 0.151 L 0.654 0.12 L 0.693 0.097 L 0.736 0.084 L 0.779 0.081 L 0.823 0.087 L 0.865 0.101 L 0.903 0.125 L 0.936 0.159 L 0.957 0.19 L 0.971 0.224 L 0.98 0.259 L 0.983 0.295 L 0.979 0.331 L 0.969 0.367 L 0.954 0.4 L 0.932 0.431 L 0.501 0.944 L 0.5 0.945 L 0.498 0.944 L 0.067 0.431 L 0.045 0.4 L 0.03 0.367 L 0.02 0.331 L 0.016 0.295 L 0.019 0.259 L 0.028 0.224 L 0.042 0.19 L 0.063 0.159 L 0.096 0.125 L 0.134 0.101 L 0.176 0.087 L 0.22 0.081 L 0.263 0.084 L 0.306 0.097 L 0.345 0.12 L 0.38 0.151 L 0.495 0.283 L 0.5 0.285 L 0.5 0.285 Z", + "Oval": "M 0.908 0.091 L 0.931 0.118 L 0.951 0.15 L 0.966 0.184 L 0.977 0.222 L 0.983 0.263 L 0.984 0.306 L 0.981 0.35 L 0.973 0.396 L 0.961 0.442 L 0.944 0.489 L 0.923 0.537 L 0.897 0.585 L 0.868 0.631 L 0.835 0.677 L 0.799 0.72 L 0.761 0.761 L 0.72 0.799 L 0.677 0.835 L 0.631 0.868 L 0.585 0.897 L 0.537 0.923 L 0.489 0.944 L 0.442 0.961 L 0.396 0.973 L 0.35 0.981 L 0.306 0.984 L 0.263 0.983 L 0.222 0.977 L 0.184 0.966 L 0.15 0.951 L 0.118 0.931 L 0.091 0.908 L 0.068 0.881 L 0.048 0.849 L 0.033 0.815 L 0.022 0.777 L 0.016 0.736 L 0.015 0.693 L 0.018 0.649 L 0.026 0.603 L 0.038 0.557 L 0.055 0.51 L 0.076 0.462 L 0.102 0.414 L 0.131 0.368 L 0.164 0.322 L 0.2 0.279 L 0.238 0.238 L 0.279 0.2 L 0.322 0.164 L 0.368 0.131 L 0.414 0.102 L 0.462 0.076 L 0.51 0.055 L 0.557 0.038 L 0.603 0.026 L 0.649 0.018 L 0.693 0.015 L 0.736 0.016 L 0.777 0.022 L 0.815 0.033 L 0.849 0.048 L 0.881 0.068 L 0.908 0.091 L 0.908 0.091 Z", + "Pentagon": "M 0.499 0.042 L 0.525 0.044 L 0.55 0.05 L 0.573 0.06 L 0.596 0.073 L 0.918 0.3 L 0.938 0.317 L 0.955 0.336 L 0.968 0.358 L 0.977 0.381 L 0.983 0.405 L 0.985 0.43 L 0.983 0.456 L 0.977 0.481 L 0.856 0.844 L 0.846 0.868 L 0.832 0.89 L 0.815 0.909 L 0.796 0.926 L 0.774 0.939 L 0.751 0.949 L 0.726 0.955 L 0.7 0.957 L 0.299 0.957 L 0.273 0.955 L 0.248 0.949 L 0.225 0.939 L 0.203 0.926 L 0.184 0.909 L 0.167 0.89 L 0.153 0.868 L 0.143 0.844 L 0.022 0.481 L 0.016 0.456 L 0.014 0.43 L 0.016 0.405 L 0.022 0.381 L 0.031 0.358 L 0.044 0.336 L 0.061 0.317 L 0.081 0.3 L 0.403 0.073 L 0.426 0.06 L 0.449 0.05 L 0.474 0.044 L 0.499 0.042 L 0.499 0.042 Z", + "Pill": "M 0.873 0.126 L 0.919 0.181 L 0.938 0.211 L 0.955 0.243 L 0.969 0.276 L 0.981 0.31 L 0.99 0.346 L 0.995 0.383 L 1 0.428 L 0.997 0.471 L 0.991 0.513 L 0.98 0.554 L 0.966 0.595 L 0.947 0.633 L 0.925 0.67 L 0.9 0.704 L 0.871 0.736 L 0.736 0.871 L 0.704 0.9 L 0.67 0.925 L 0.633 0.947 L 0.595 0.966 L 0.554 0.98 L 0.513 0.991 L 0.471 0.997 L 0.428 1 L 0.383 0.995 L 0.346 0.99 L 0.31 0.981 L 0.276 0.969 L 0.243 0.955 L 0.211 0.938 L 0.181 0.919 L 0.126 0.873 L 0.08 0.818 L 0.061 0.788 L 0.044 0.756 L 0.03 0.723 L 0.018 0.689 L 0.009 0.653 L 0.004 0.616 L 0 0.571 L 0.002 0.528 L 0.008 0.486 L 0.019 0.445 L 0.033 0.404 L 0.052 0.366 L 0.074 0.329 L 0.099 0.295 L 0.128 0.263 L 0.263 0.128 L 0.295 0.099 L 0.329 0.074 L 0.366 0.052 L 0.404 0.033 L 0.445 0.019 L 0.486 0.008 L 0.528 0.002 L 0.571 0 L 0.616 0.004 L 0.653 0.009 L 0.689 0.018 L 0.723 0.03 L 0.756 0.044 L 0.788 0.061 L 0.818 0.08 L 0.873 0.126 L 0.873 0.126 Z", + "PixelCircle": "M 0.499 0 L 0.704 0 L 0.704 0.065 L 0.843 0.065 L 0.843 0.148 L 0.926 0.148 L 0.926 0.296 L 1 0.296 L 1 0.704 L 0.926 0.704 L 0.926 0.852 L 0.843 0.852 L 0.843 0.935 L 0.704 0.934 L 0.704 1 L 0.499 1 L 0.295 0.999 L 0.295 0.934 L 0.157 0.935 L 0.156 0.851 L 0.073 0.851 L 0.074 0.704 L 0 0.704 L 0 0.295 L 0.074 0.295 L 0.074 0.148 L 0.157 0.147 L 0.157 0.064 L 0.296 0.065 L 0.295 0 L 0.499 0 L 0.499 0 Z", + "PixelTriangle": "M 0.111 0.499 L 0.114 0 L 0.288 0 L 0.288 0.087 L 0.422 0.087 L 0.422 0.17 L 0.561 0.17 L 0.561 0.265 L 0.674 0.265 L 0.676 0.343 L 0.789 0.343 L 0.789 0.438 L 0.888 0.438 L 0.888 0.561 L 0.789 0.561 L 0.789 0.655 L 0.675 0.656 L 0.674 0.735 L 0.561 0.734 L 0.56 0.829 L 0.422 0.829 L 0.422 0.912 L 0.288 0.912 L 0.288 1 L 0.114 1 L 0.111 0.499 L 0.111 0.499 Z", + "Puffy": "M 0.5 0.17 L 0.517 0.143 L 0.533 0.126 L 0.554 0.113 L 0.579 0.105 L 0.607 0.103 L 0.634 0.107 L 0.659 0.116 L 0.679 0.129 L 0.694 0.146 L 0.702 0.158 L 0.713 0.18 L 0.718 0.203 L 0.72 0.225 L 0.732 0.21 L 0.748 0.199 L 0.767 0.191 L 0.787 0.186 L 0.809 0.185 L 0.83 0.188 L 0.85 0.195 L 0.868 0.206 L 0.871 0.209 L 0.889 0.225 L 0.902 0.244 L 0.911 0.263 L 0.916 0.284 L 0.917 0.291 L 0.916 0.316 L 0.91 0.341 L 0.897 0.364 L 0.878 0.386 L 0.884 0.386 L 0.908 0.387 L 0.931 0.393 L 0.95 0.403 L 0.966 0.417 L 0.981 0.435 L 0.991 0.455 L 0.997 0.476 L 1 0.497 L 1 0.502 L 0.997 0.523 L 0.991 0.544 L 0.981 0.564 L 0.966 0.582 L 0.95 0.596 L 0.931 0.606 L 0.908 0.612 L 0.884 0.613 L 0.878 0.613 L 0.897 0.635 L 0.91 0.658 L 0.916 0.683 L 0.917 0.708 L 0.916 0.715 L 0.911 0.736 L 0.902 0.755 L 0.889 0.774 L 0.871 0.79 L 0.868 0.793 L 0.85 0.804 L 0.83 0.811 L 0.809 0.814 L 0.787 0.813 L 0.767 0.808 L 0.748 0.8 L 0.732 0.789 L 0.72 0.774 L 0.718 0.796 L 0.713 0.819 L 0.702 0.841 L 0.694 0.853 L 0.679 0.87 L 0.659 0.883 L 0.634 0.892 L 0.607 0.896 L 0.579 0.894 L 0.554 0.886 L 0.533 0.873 L 0.517 0.856 L 0.5 0.829 L 0.482 0.856 L 0.466 0.873 L 0.445 0.886 L 0.42 0.894 L 0.392 0.896 L 0.365 0.892 L 0.34 0.883 L 0.32 0.87 L 0.305 0.853 L 0.297 0.841 L 0.286 0.819 L 0.281 0.796 L 0.279 0.774 L 0.267 0.789 L 0.251 0.8 L 0.232 0.808 L 0.212 0.813 L 0.19 0.814 L 0.169 0.811 L 0.149 0.804 L 0.131 0.793 L 0.128 0.79 L 0.11 0.774 L 0.097 0.755 L 0.088 0.736 L 0.083 0.715 L 0.082 0.708 L 0.083 0.683 L 0.089 0.658 L 0.102 0.635 L 0.121 0.613 L 0.115 0.613 L 0.091 0.612 L 0.068 0.606 L 0.049 0.596 L 0.033 0.582 L 0.018 0.564 L 0.008 0.544 L 0.002 0.523 L 0 0.502 L 0 0.497 L 0.002 0.476 L 0.008 0.455 L 0.018 0.435 L 0.033 0.417 L 0.049 0.403 L 0.068 0.393 L 0.091 0.387 L 0.115 0.386 L 0.121 0.386 L 0.102 0.364 L 0.089 0.341 L 0.083 0.316 L 0.082 0.291 L 0.083 0.284 L 0.088 0.263 L 0.097 0.244 L 0.11 0.225 L 0.128 0.209 L 0.131 0.206 L 0.149 0.195 L 0.169 0.188 L 0.19 0.185 L 0.212 0.186 L 0.232 0.191 L 0.251 0.199 L 0.267 0.21 L 0.279 0.225 L 0.281 0.203 L 0.286 0.18 L 0.297 0.158 L 0.305 0.146 L 0.32 0.129 L 0.34 0.116 L 0.365 0.107 L 0.392 0.103 L 0.42 0.105 L 0.445 0.113 L 0.466 0.126 L 0.482 0.143 L 0.5 0.17 L 0.5 0.17 Z", + "PuffyDiamond": "M 0.778 0.221 L 0.8 0.249 L 0.815 0.281 L 0.821 0.318 L 0.818 0.356 L 0.818 0.356 L 0.833 0.354 L 0.865 0.353 L 0.896 0.359 L 0.924 0.372 L 0.949 0.389 L 0.97 0.411 L 0.986 0.438 L 0.996 0.467 L 1 0.499 L 0.996 0.532 L 0.986 0.561 L 0.97 0.588 L 0.949 0.61 L 0.924 0.627 L 0.896 0.64 L 0.865 0.646 L 0.833 0.645 L 0.818 0.643 L 0.818 0.643 L 0.821 0.681 L 0.815 0.718 L 0.8 0.75 L 0.778 0.778 L 0.75 0.8 L 0.718 0.815 L 0.681 0.821 L 0.643 0.818 L 0.643 0.818 L 0.645 0.833 L 0.646 0.865 L 0.64 0.896 L 0.627 0.924 L 0.61 0.949 L 0.588 0.97 L 0.561 0.986 L 0.532 0.996 L 0.499 1 L 0.467 0.996 L 0.438 0.986 L 0.411 0.97 L 0.389 0.949 L 0.372 0.924 L 0.359 0.896 L 0.353 0.865 L 0.354 0.833 L 0.356 0.818 L 0.356 0.818 L 0.318 0.821 L 0.281 0.815 L 0.249 0.8 L 0.221 0.778 L 0.199 0.75 L 0.184 0.718 L 0.178 0.681 L 0.181 0.643 L 0.181 0.642 L 0.166 0.645 L 0.134 0.646 L 0.103 0.64 L 0.075 0.627 L 0.05 0.61 L 0.029 0.588 L 0.013 0.561 L 0.003 0.532 L 0 0.499 L 0.003 0.467 L 0.013 0.438 L 0.029 0.411 L 0.05 0.389 L 0.075 0.372 L 0.103 0.359 L 0.134 0.353 L 0.166 0.354 L 0.181 0.356 L 0.181 0.356 L 0.178 0.318 L 0.184 0.281 L 0.199 0.249 L 0.221 0.221 L 0.249 0.199 L 0.281 0.184 L 0.318 0.178 L 0.356 0.181 L 0.357 0.181 L 0.354 0.166 L 0.353 0.134 L 0.359 0.103 L 0.372 0.075 L 0.389 0.05 L 0.411 0.029 L 0.438 0.013 L 0.467 0.003 L 0.5 0 L 0.532 0.003 L 0.561 0.013 L 0.588 0.029 L 0.61 0.05 L 0.627 0.075 L 0.64 0.103 L 0.646 0.134 L 0.645 0.166 L 0.643 0.181 L 0.643 0.181 L 0.681 0.178 L 0.718 0.184 L 0.75 0.199 L 0.778 0.221 L 0.778 0.221 Z", + "SemiCircle": "M 0.969 0.781 L 0.954 0.794 L 0.936 0.804 L 0.916 0.81 L 0.895 0.812 L 0.104 0.812 L 0.083 0.81 L 0.063 0.804 L 0.045 0.794 L 0.03 0.781 L 0.017 0.766 L 0.008 0.748 L 0.002 0.729 L 0 0.708 L 0 0.687 L 0.002 0.636 L 0.01 0.586 L 0.022 0.538 L 0.039 0.492 L 0.06 0.449 L 0.085 0.407 L 0.114 0.369 L 0.146 0.333 L 0.181 0.301 L 0.22 0.272 L 0.261 0.247 L 0.305 0.226 L 0.351 0.209 L 0.399 0.197 L 0.448 0.19 L 0.5 0.187 L 0.551 0.19 L 0.6 0.197 L 0.648 0.209 L 0.694 0.226 L 0.738 0.247 L 0.779 0.272 L 0.818 0.301 L 0.853 0.333 L 0.885 0.369 L 0.914 0.407 L 0.939 0.449 L 0.96 0.492 L 0.977 0.538 L 0.989 0.586 L 0.997 0.636 L 1 0.687 L 1 0.708 L 0.997 0.729 L 0.991 0.748 L 0.982 0.766 L 0.969 0.781 L 0.969 0.781 Z", + "Slanted": "M 0.875 0.914 L 0.85 0.933 L 0.832 0.942 L 0.812 0.949 L 0.762 0.958 L 0.698 0.961 L 0.613 0.961 L 0.201 0.96 L 0.185 0.959 L 0.147 0.954 L 0.112 0.942 L 0.08 0.923 L 0.054 0.899 L 0.032 0.87 L 0.017 0.837 L 0.008 0.801 L 0.007 0.762 L 0.009 0.746 L 0.05 0.341 L 0.059 0.257 L 0.068 0.193 L 0.082 0.145 L 0.091 0.125 L 0.102 0.108 L 0.124 0.085 L 0.149 0.066 L 0.167 0.057 L 0.187 0.05 L 0.237 0.041 L 0.301 0.038 L 0.386 0.038 L 0.798 0.039 L 0.814 0.04 L 0.852 0.045 L 0.887 0.057 L 0.919 0.076 L 0.945 0.1 L 0.967 0.129 L 0.982 0.162 L 0.991 0.198 L 0.992 0.237 L 0.99 0.253 L 0.949 0.658 L 0.94 0.742 L 0.931 0.806 L 0.917 0.854 L 0.908 0.874 L 0.897 0.891 L 0.875 0.914 L 0.875 0.914 Z", + "SoftBoom": "M 0.733 0.453 L 0.793 0.444 L 0.84 0.439 L 0.887 0.441 L 0.923 0.445 L 0.949 0.451 L 0.974 0.463 L 0.98 0.466 L 0.994 0.48 L 0.999 0.5 L 0.994 0.52 L 0.98 0.535 L 0.974 0.538 L 0.949 0.549 L 0.922 0.555 L 0.887 0.559 L 0.84 0.561 L 0.793 0.556 L 0.733 0.546 L 0.792 0.56 L 0.837 0.574 L 0.88 0.594 L 0.911 0.611 L 0.934 0.627 L 0.952 0.647 L 0.956 0.652 L 0.964 0.671 L 0.961 0.691 L 0.949 0.708 L 0.93 0.716 L 0.923 0.717 L 0.896 0.717 L 0.869 0.713 L 0.835 0.703 L 0.791 0.686 L 0.749 0.664 L 0.698 0.632 L 0.746 0.667 L 0.783 0.698 L 0.815 0.733 L 0.837 0.76 L 0.852 0.783 L 0.861 0.809 L 0.863 0.815 L 0.863 0.836 L 0.853 0.854 L 0.835 0.864 L 0.814 0.864 L 0.808 0.862 L 0.782 0.853 L 0.759 0.838 L 0.732 0.816 L 0.697 0.783 L 0.667 0.747 L 0.632 0.698 L 0.663 0.749 L 0.685 0.791 L 0.702 0.836 L 0.712 0.87 L 0.716 0.897 L 0.715 0.924 L 0.714 0.93 L 0.706 0.949 L 0.69 0.962 L 0.67 0.964 L 0.651 0.957 L 0.646 0.953 L 0.626 0.934 L 0.61 0.911 L 0.593 0.88 L 0.573 0.837 L 0.559 0.792 L 0.546 0.733 L 0.555 0.793 L 0.56 0.84 L 0.558 0.887 L 0.554 0.923 L 0.548 0.949 L 0.536 0.974 L 0.533 0.98 L 0.519 0.994 L 0.499 0.999 L 0.479 0.994 L 0.464 0.98 L 0.461 0.974 L 0.45 0.949 L 0.444 0.922 L 0.44 0.887 L 0.438 0.84 L 0.443 0.793 L 0.453 0.733 L 0.439 0.792 L 0.425 0.837 L 0.405 0.88 L 0.388 0.911 L 0.372 0.934 L 0.352 0.952 L 0.347 0.956 L 0.328 0.964 L 0.308 0.961 L 0.291 0.949 L 0.283 0.93 L 0.282 0.923 L 0.282 0.896 L 0.286 0.869 L 0.296 0.835 L 0.313 0.791 L 0.335 0.749 L 0.367 0.698 L 0.332 0.746 L 0.301 0.783 L 0.266 0.815 L 0.239 0.837 L 0.216 0.852 L 0.19 0.861 L 0.184 0.863 L 0.163 0.863 L 0.145 0.853 L 0.135 0.835 L 0.135 0.814 L 0.137 0.808 L 0.146 0.782 L 0.161 0.759 L 0.183 0.732 L 0.216 0.697 L 0.252 0.667 L 0.301 0.632 L 0.25 0.663 L 0.208 0.685 L 0.163 0.702 L 0.129 0.712 L 0.102 0.716 L 0.075 0.715 L 0.069 0.714 L 0.05 0.706 L 0.037 0.69 L 0.035 0.67 L 0.042 0.651 L 0.046 0.646 L 0.065 0.626 L 0.088 0.61 L 0.119 0.593 L 0.162 0.573 L 0.207 0.559 L 0.266 0.546 L 0.206 0.555 L 0.159 0.56 L 0.112 0.558 L 0.076 0.554 L 0.05 0.548 L 0.025 0.536 L 0.019 0.533 L 0.005 0.519 L 0 0.499 L 0.005 0.479 L 0.019 0.464 L 0.025 0.461 L 0.05 0.45 L 0.077 0.444 L 0.112 0.44 L 0.159 0.438 L 0.206 0.443 L 0.266 0.453 L 0.207 0.439 L 0.162 0.425 L 0.119 0.405 L 0.088 0.388 L 0.065 0.372 L 0.047 0.352 L 0.043 0.347 L 0.035 0.328 L 0.038 0.308 L 0.05 0.291 L 0.069 0.283 L 0.076 0.282 L 0.103 0.282 L 0.13 0.286 L 0.164 0.296 L 0.208 0.313 L 0.25 0.335 L 0.301 0.367 L 0.253 0.332 L 0.216 0.301 L 0.184 0.266 L 0.162 0.239 L 0.147 0.216 L 0.138 0.19 L 0.136 0.184 L 0.136 0.163 L 0.146 0.145 L 0.164 0.135 L 0.185 0.135 L 0.191 0.137 L 0.217 0.146 L 0.24 0.161 L 0.267 0.183 L 0.302 0.216 L 0.332 0.252 L 0.367 0.301 L 0.336 0.25 L 0.314 0.208 L 0.297 0.163 L 0.287 0.129 L 0.283 0.102 L 0.284 0.075 L 0.285 0.069 L 0.293 0.05 L 0.309 0.037 L 0.329 0.035 L 0.348 0.042 L 0.353 0.046 L 0.373 0.065 L 0.389 0.088 L 0.406 0.119 L 0.426 0.162 L 0.44 0.207 L 0.453 0.266 L 0.444 0.206 L 0.439 0.159 L 0.441 0.112 L 0.445 0.076 L 0.451 0.05 L 0.463 0.025 L 0.466 0.019 L 0.48 0.005 L 0.5 0 L 0.52 0.005 L 0.535 0.019 L 0.538 0.025 L 0.549 0.05 L 0.555 0.077 L 0.559 0.112 L 0.561 0.159 L 0.556 0.206 L 0.546 0.266 L 0.56 0.207 L 0.574 0.162 L 0.594 0.119 L 0.611 0.088 L 0.627 0.065 L 0.647 0.047 L 0.652 0.043 L 0.671 0.035 L 0.691 0.038 L 0.708 0.05 L 0.716 0.069 L 0.717 0.076 L 0.717 0.103 L 0.713 0.13 L 0.703 0.164 L 0.686 0.208 L 0.664 0.25 L 0.632 0.301 L 0.667 0.253 L 0.698 0.216 L 0.733 0.184 L 0.76 0.162 L 0.783 0.147 L 0.809 0.138 L 0.815 0.136 L 0.836 0.136 L 0.854 0.146 L 0.864 0.164 L 0.864 0.185 L 0.862 0.191 L 0.853 0.217 L 0.838 0.24 L 0.816 0.267 L 0.783 0.302 L 0.747 0.332 L 0.698 0.367 L 0.749 0.336 L 0.791 0.314 L 0.836 0.297 L 0.87 0.287 L 0.897 0.283 L 0.924 0.284 L 0.93 0.285 L 0.949 0.293 L 0.962 0.309 L 0.964 0.329 L 0.957 0.348 L 0.953 0.353 L 0.934 0.373 L 0.911 0.389 L 0.88 0.406 L 0.837 0.426 L 0.792 0.44 L 0.733 0.453 L 0.733 0.453 Z", + "SoftBurst": "M 0.186 0.272 L 0.194 0.256 L 0.196 0.238 L 0.189 0.148 L 0.19 0.134 L 0.194 0.121 L 0.201 0.111 L 0.21 0.102 L 0.221 0.096 L 0.234 0.092 L 0.247 0.092 L 0.26 0.096 L 0.344 0.13 L 0.362 0.134 L 0.38 0.131 L 0.396 0.123 L 0.408 0.109 L 0.455 0.032 L 0.464 0.022 L 0.474 0.014 L 0.486 0.009 L 0.499 0.008 L 0.512 0.009 L 0.524 0.014 L 0.534 0.021 L 0.543 0.032 L 0.591 0.109 L 0.603 0.123 L 0.619 0.131 L 0.637 0.134 L 0.655 0.13 L 0.738 0.095 L 0.751 0.092 L 0.765 0.092 L 0.777 0.095 L 0.788 0.101 L 0.798 0.11 L 0.805 0.121 L 0.809 0.133 L 0.81 0.147 L 0.803 0.237 L 0.805 0.256 L 0.813 0.272 L 0.826 0.284 L 0.842 0.292 L 0.93 0.313 L 0.943 0.318 L 0.954 0.326 L 0.962 0.336 L 0.967 0.347 L 0.97 0.36 L 0.969 0.372 L 0.965 0.385 L 0.957 0.397 L 0.899 0.466 L 0.89 0.482 L 0.887 0.499 L 0.89 0.517 L 0.899 0.533 L 0.957 0.602 L 0.965 0.613 L 0.969 0.626 L 0.97 0.639 L 0.967 0.651 L 0.962 0.663 L 0.954 0.673 L 0.943 0.68 L 0.93 0.685 L 0.842 0.707 L 0.826 0.715 L 0.813 0.727 L 0.805 0.743 L 0.803 0.761 L 0.81 0.851 L 0.809 0.865 L 0.805 0.878 L 0.798 0.888 L 0.789 0.897 L 0.778 0.903 L 0.765 0.907 L 0.752 0.907 L 0.739 0.903 L 0.655 0.869 L 0.637 0.865 L 0.619 0.868 L 0.603 0.876 L 0.591 0.89 L 0.544 0.967 L 0.535 0.977 L 0.525 0.985 L 0.513 0.99 L 0.5 0.991 L 0.487 0.99 L 0.475 0.985 L 0.465 0.978 L 0.456 0.967 L 0.408 0.89 L 0.396 0.876 L 0.38 0.868 L 0.362 0.865 L 0.344 0.869 L 0.261 0.904 L 0.248 0.907 L 0.234 0.907 L 0.222 0.904 L 0.211 0.898 L 0.201 0.889 L 0.194 0.878 L 0.19 0.866 L 0.189 0.852 L 0.196 0.762 L 0.194 0.743 L 0.186 0.727 L 0.173 0.715 L 0.157 0.707 L 0.069 0.686 L 0.056 0.681 L 0.045 0.673 L 0.037 0.663 L 0.032 0.652 L 0.029 0.639 L 0.03 0.627 L 0.034 0.614 L 0.042 0.602 L 0.1 0.533 L 0.109 0.517 L 0.112 0.5 L 0.109 0.482 L 0.1 0.466 L 0.042 0.397 L 0.034 0.386 L 0.03 0.373 L 0.029 0.36 L 0.032 0.348 L 0.037 0.336 L 0.045 0.326 L 0.056 0.319 L 0.069 0.314 L 0.157 0.292 L 0.173 0.284 L 0.186 0.272 L 0.186 0.272 Z", + "Square": "M 0.912 0.912 L 0.867 0.948 L 0.816 0.976 L 0.76 0.993 L 0.73 0.998 L 0.7 1 L 0.3 1 L 0.269 0.998 L 0.239 0.993 L 0.183 0.976 L 0.132 0.948 L 0.087 0.912 L 0.051 0.867 L 0.023 0.816 L 0.006 0.76 L 0.001 0.73 L 0 0.7 L 0 0.3 L 0.001 0.269 L 0.006 0.239 L 0.023 0.183 L 0.051 0.132 L 0.087 0.087 L 0.132 0.051 L 0.183 0.023 L 0.239 0.006 L 0.269 0.001 L 0.3 0 L 0.7 0 L 0.73 0.001 L 0.76 0.006 L 0.816 0.023 L 0.867 0.051 L 0.912 0.087 L 0.948 0.132 L 0.976 0.183 L 0.993 0.239 L 0.998 0.269 L 1 0.3 L 1 0.7 L 0.998 0.73 L 0.993 0.76 L 0.976 0.816 L 0.948 0.867 L 0.912 0.912 L 0.912 0.912 Z", + "Sunny": "M 0.996 0.5 L 0.992 0.526 L 0.978 0.55 L 0.902 0.639 L 0.889 0.66 L 0.884 0.683 L 0.874 0.8 L 0.867 0.827 L 0.852 0.849 L 0.83 0.864 L 0.803 0.871 L 0.686 0.881 L 0.663 0.886 L 0.642 0.899 L 0.553 0.975 L 0.529 0.989 L 0.503 0.993 L 0.476 0.989 L 0.452 0.975 L 0.363 0.899 L 0.342 0.886 L 0.319 0.881 L 0.202 0.871 L 0.175 0.864 L 0.153 0.849 L 0.138 0.827 L 0.131 0.8 L 0.122 0.683 L 0.116 0.66 L 0.103 0.639 L 0.027 0.55 L 0.013 0.526 L 0.009 0.499 L 0.013 0.473 L 0.027 0.449 L 0.103 0.36 L 0.116 0.339 L 0.122 0.316 L 0.131 0.199 L 0.138 0.172 L 0.153 0.15 L 0.175 0.135 L 0.202 0.128 L 0.319 0.118 L 0.342 0.113 L 0.363 0.1 L 0.452 0.024 L 0.476 0.01 L 0.503 0.006 L 0.529 0.01 L 0.553 0.024 L 0.642 0.1 L 0.663 0.113 L 0.686 0.118 L 0.803 0.128 L 0.83 0.135 L 0.852 0.15 L 0.867 0.172 L 0.874 0.199 L 0.884 0.316 L 0.889 0.339 L 0.902 0.36 L 0.978 0.449 L 0.992 0.473 L 0.996 0.5 L 0.996 0.5 Z", + "Triangle": "M 0.5 0.077 L 0.532 0.081 L 0.563 0.094 L 0.59 0.114 L 0.612 0.142 L 0.95 0.727 L 0.963 0.76 L 0.967 0.794 L 0.962 0.827 L 0.95 0.857 L 0.93 0.883 L 0.904 0.903 L 0.873 0.917 L 0.837 0.922 L 0.162 0.922 L 0.126 0.917 L 0.095 0.903 L 0.069 0.883 L 0.049 0.857 L 0.037 0.827 L 0.032 0.794 L 0.036 0.76 L 0.049 0.727 L 0.387 0.142 L 0.409 0.114 L 0.436 0.094 L 0.467 0.081 L 0.5 0.077 L 0.5 0.077 Z", + "VerySunny": "M 0.5 0.993 L 0.479 0.99 L 0.46 0.983 L 0.443 0.97 L 0.429 0.953 L 0.393 0.893 L 0.376 0.873 L 0.353 0.859 L 0.328 0.853 L 0.302 0.855 L 0.234 0.872 L 0.212 0.875 L 0.191 0.871 L 0.172 0.863 L 0.155 0.85 L 0.143 0.834 L 0.134 0.815 L 0.131 0.794 L 0.134 0.772 L 0.151 0.704 L 0.153 0.678 L 0.147 0.652 L 0.133 0.63 L 0.113 0.613 L 0.053 0.577 L 0.036 0.563 L 0.023 0.546 L 0.015 0.527 L 0.013 0.506 L 0.015 0.486 L 0.023 0.466 L 0.036 0.449 L 0.053 0.435 L 0.113 0.399 L 0.133 0.382 L 0.147 0.36 L 0.153 0.335 L 0.151 0.308 L 0.134 0.241 L 0.131 0.218 L 0.134 0.197 L 0.143 0.178 L 0.155 0.162 L 0.172 0.149 L 0.191 0.141 L 0.212 0.138 L 0.234 0.14 L 0.302 0.157 L 0.328 0.16 L 0.353 0.153 L 0.375 0.14 L 0.393 0.12 L 0.428 0.06 L 0.442 0.042 L 0.46 0.03 L 0.479 0.022 L 0.499 0.02 L 0.52 0.022 L 0.539 0.03 L 0.556 0.042 L 0.57 0.06 L 0.606 0.12 L 0.623 0.14 L 0.646 0.153 L 0.671 0.16 L 0.697 0.157 L 0.765 0.14 L 0.787 0.138 L 0.808 0.141 L 0.827 0.149 L 0.844 0.162 L 0.856 0.178 L 0.865 0.197 L 0.868 0.218 L 0.865 0.241 L 0.848 0.308 L 0.846 0.335 L 0.852 0.36 L 0.866 0.382 L 0.886 0.399 L 0.946 0.435 L 0.963 0.449 L 0.976 0.466 L 0.984 0.486 L 0.986 0.506 L 0.984 0.527 L 0.976 0.546 L 0.963 0.563 L 0.946 0.577 L 0.886 0.613 L 0.866 0.63 L 0.852 0.652 L 0.846 0.678 L 0.848 0.704 L 0.865 0.772 L 0.868 0.794 L 0.865 0.815 L 0.856 0.834 L 0.844 0.85 L 0.827 0.863 L 0.808 0.871 L 0.787 0.875 L 0.765 0.872 L 0.697 0.855 L 0.671 0.853 L 0.646 0.859 L 0.624 0.872 L 0.606 0.893 L 0.571 0.953 L 0.557 0.97 L 0.539 0.983 L 0.52 0.99 L 0.5 0.993 L 0.5 0.993 Z", +}; diff --git a/frontend/src/core/legal/materialShapes.ts b/frontend/src/core/legal/materialShapes.ts new file mode 100644 index 0000000..a2607da --- /dev/null +++ b/frontend/src/core/legal/materialShapes.ts @@ -0,0 +1,130 @@ +/** Material expressive shape SVG paths — generated from Compose MaterialShapes. */ + +export { MATERIAL_SHAPE_PATHS } from "./materialShapes.generated"; + +import { MATERIAL_SHAPE_PATHS } from "./materialShapes.generated"; + +export type MaterialShapeName = keyof typeof MATERIAL_SHAPE_PATHS; + +export function getMaterialShapePath(name: string): string { + return MATERIAL_SHAPE_PATHS[name] ?? MATERIAL_SHAPE_PATHS.Circle; +} + +interface PathBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export interface PathUnitSquareFit { + transform: string; +} + +const pathFitCache = new Map(); + +/** + * Parses M/L/Z path commands and returns axis-aligned bounds of all vertices. + * Generated Material shape paths use only these commands. + */ +function computePathBounds(pathD: string): PathBounds { + const tokens = pathD.trim().match(/[MLZmlz]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?/g); + if (!tokens?.length) { + return { minX: 0, minY: 0, maxX: 1, maxY: 1 }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let curX = 0; + let curY = 0; + let startX = 0; + let startY = 0; + let cmd = ""; + let i = 0; + + const extend = (x: number, y: number) => { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + curX = x; + curY = y; + }; + + while (i < tokens.length) { + const token = tokens[i]; + if (/^[A-Za-z]$/.test(token)) { + cmd = token; + i++; + if (cmd === "Z" || cmd === "z") { + extend(startX, startY); + } + continue; + } + + const x = Number(tokens[i++]); + const y = Number(tokens[i++]); + + let absX = x; + let absY = y; + switch (cmd) { + case "M": + absX = x; + absY = y; + startX = absX; + startY = absY; + cmd = "L"; + break; + case "m": + absX = curX + x; + absY = curY + y; + startX = absX; + startY = absY; + cmd = "l"; + break; + case "L": + absX = x; + absY = y; + break; + case "l": + absX = curX + x; + absY = curY + y; + break; + default: + continue; + } + extend(absX, absY); + } + + return { minX, minY, maxX, maxY }; +} + +/** + * Maps a normalized Material shape path to fill viewBox `0 0 1 1` edge-to-edge. + */ +export function fitPathToUnitSquare(pathD: string): PathUnitSquareFit { + const cached = pathFitCache.get(pathD); + if (cached) { + return cached; + } + + const { minX, minY, maxX, maxY } = computePathBounds(pathD); + const width = maxX - minX; + const height = maxY - minY; + + if (width <= 0 || height <= 0) { + const fallback = { transform: "" }; + pathFitCache.set(pathD, fallback); + return fallback; + } + + const sx = 1 / width; + const sy = 1 / height; + const fit: PathUnitSquareFit = { + transform: `scale(${sx}, ${sy}) translate(${-minX}, ${-minY})`, + }; + pathFitCache.set(pathD, fit); + return fit; +} diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 4021300..0c8128c 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -39,6 +39,8 @@ export interface Rect extends Size2D { // App types +export type VerificationStatus = "verified" | "warning" | "blocked" | "none"; + /** * Chat message structure * @interface Message @@ -70,6 +72,7 @@ export interface Message { timestamp: string; profile_picture?: string; verified?: boolean; + verification_status?: VerificationStatus; reply_to?: Message; files?: Attachment[]; reactions?: Reaction[]; @@ -118,6 +121,7 @@ export interface User { bio?: string; profile_picture: string; verified?: boolean; + verification_status?: VerificationStatus; suspended?: boolean; suspension_reason?: string | null; deleted?: boolean; @@ -144,6 +148,9 @@ export interface UserProfile { last_seen: string; created_at: string; verified?: boolean; + verification_status?: VerificationStatus; + deleted?: boolean; + suspended?: boolean; } // ---------- diff --git a/frontend/src/core/userDisplay.ts b/frontend/src/core/userDisplay.ts new file mode 100644 index 0000000..c1babe8 --- /dev/null +++ b/frontend/src/core/userDisplay.ts @@ -0,0 +1,51 @@ +import { parseApiTimestamp } from "@/utils/utils"; + +const DELETED_USERNAME_PREFIX = "#deleted"; + +export function isDeletedUser(user: { deleted?: boolean }): boolean { + return Boolean(user.deleted); +} + +export function isSuspendedUser(user: { suspended?: boolean; deleted?: boolean }): boolean { + return Boolean(user.suspended) && !user.deleted; +} + +export function isDeletedAccountUsername(username: string | undefined | null): boolean { + return Boolean(username?.startsWith(DELETED_USERNAME_PREFIX)); +} + +export function isDeletedPeer(user: { + id?: number; + deleted?: boolean; + username?: string | null; +}): boolean { + return isDeletedUser(user) || isDeletedAccountUsername(user.username); +} + +export const DELETED_ACCOUNT_LABEL = "Deleted account"; + +export function deletedUserLabel(): string { + return DELETED_ACCOUNT_LABEL; +} + +export function displayNameForUser(user: { + id?: number; + display_name?: string | null; + username?: string | null; + deleted?: boolean; +}): string { + if (isDeletedPeer(user)) { + return deletedUserLabel(); + } + return user.display_name?.trim() || user.username?.trim() || ""; +} + +export function isEpochLastSeen(lastSeen: string | undefined | null): boolean { + if (!lastSeen) return false; + const time = parseApiTimestamp(lastSeen).getTime(); + return !Number.isNaN(time) && time <= 0; +} + +export function formatDeletedUserLastSeen(): string { + return "был(а) давно"; +} diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index cdab23f..382d420 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -9,6 +9,7 @@ import api from "@/core/api"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import type { Alert, AlertType } from "./Auth"; import { AuthHeader, AlertsContainer } from "./Auth"; +import { LegalInlineLinks } from "@/core/legal/LegalInlineLinks"; import styles from "./auth.module.scss"; const registerFieldVariants: Variants = { @@ -235,6 +236,8 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
+ + diff --git a/frontend/src/pages/chat/css/Message.module.scss b/frontend/src/pages/chat/css/Message.module.scss index c49fafd..bb1f520 100644 --- a/frontend/src/pages/chat/css/Message.module.scss +++ b/frontend/src/pages/chat/css/Message.module.scss @@ -177,6 +177,21 @@ object-fit: cover; border: 2px solid $color-dark-outline; } + + .deletedUserAvatar { + width: 100%; + height: 100%; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid $color-dark-outline; + + .deletedUserAvatarIcon { + font-size: 24px; + color: white; + } + } } .messageInner { diff --git a/frontend/src/pages/chat/css/deleted-user-avatar.module.scss b/frontend/src/pages/chat/css/deleted-user-avatar.module.scss new file mode 100644 index 0000000..e68db96 --- /dev/null +++ b/frontend/src/pages/chat/css/deleted-user-avatar.module.scss @@ -0,0 +1,15 @@ +@use "@/css/material" as *; + +.deletedUserAvatar { + width: 100%; + height: 100%; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid $color-dark-outline; +} + +.deletedUserAvatarIcon { + color: white; +} diff --git a/frontend/src/pages/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss index 932a64f..927c94e 100644 --- a/frontend/src/pages/chat/css/left-panel.module.scss +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -178,3 +178,17 @@ line-clamp: 2; -webkit-box-orient: vertical; } + +.deletedUserAvatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + .deletedUserAvatarIcon { + font-size: 24px; + color: white; + } +} diff --git a/frontend/src/pages/chat/css/profile-dialog.module.scss b/frontend/src/pages/chat/css/profile-dialog.module.scss index 3a1d9a8..7046364 100644 --- a/frontend/src/pages/chat/css/profile-dialog.module.scss +++ b/frontend/src/pages/chat/css/profile-dialog.module.scss @@ -28,6 +28,23 @@ border: 3px solid $color-dark-outline; } +.deletedAvatar { + width: 120px; + height: 120px; + border-radius: 60px; + display: flex; + align-items: center; + justify-content: center; + border: 3px solid $color-dark-outline; +} + +.deletedAvatarIcon { + width: 64px; + height: 64px; + font-size: 64px; + color: white; +} + .profilePictureEditOverlay { position: absolute; top: 0; diff --git a/frontend/src/pages/chat/css/right-panel.module.scss b/frontend/src/pages/chat/css/right-panel.module.scss index ffbd68f..629e67b 100644 --- a/frontend/src/pages/chat/css/right-panel.module.scss +++ b/frontend/src/pages/chat/css/right-panel.module.scss @@ -138,3 +138,9 @@ } } +.deleteChatBar { + display: flex; + justify-content: center; + padding: 12px 16px 16px; +} + diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 0a52dab..f527115 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -14,6 +14,9 @@ import { OnlineStatus } from "./right/OnlineStatus"; import { Input } from "@/core/components/Input"; import { StyledDialog } from "@/core/components/StyledDialog"; import { MaterialButton, MaterialFab, MaterialIcon } from "@/utils/material"; +import { displayNameForUser, isDeletedPeer } from "@/core/userDisplay"; +import { DeletedUserAvatar } from "@/core/DeletedUserAvatar"; +import { parseApiTimestamp } from "@/utils/utils"; import styles from "@/pages/chat/css/profile-dialog.module.scss"; interface SectionProps { @@ -102,9 +105,12 @@ export function ProfileDialog() { if (userProfile) { freshData = { ...userProfile, - userId: userProfile.id, // Preserve the userId field + userId: userProfile.id, memberSince: userProfile.created_at, - isOwnProfile: profileData.isOwnProfile + isOwnProfile: profileData.isOwnProfile, + deleted: userProfile.deleted, + verification_status: userProfile.verification_status, + suspended: userProfile.suspended, }; } } @@ -329,7 +335,7 @@ export function ProfileDialog() { } function formatDate(dateString: string) { - return new Date(dateString).toLocaleDateString("ru-RU", { + return parseApiTimestamp(dateString).toLocaleDateString("ru-RU", { year: "numeric", month: "long", day: "numeric" @@ -410,6 +416,8 @@ export function ProfileDialog() { if (!currentData) return null; + const isDeletedProfile = isDeletedPeer(currentData); + return (
- Profile Picture { - e.target.src = defaultAvatar; - }} - /> + {isDeletedProfile ? ( + + ) : ( + Profile Picture { + e.target.src = defaultAvatar; + }} + /> + )} - {currentData.isOwnProfile && ( + {currentData.isOwnProfile && !isDeletedProfile && (
- + {!isDeletedProfile && ( + + )}
{errors.display_name && (
{errors.display_name}
)}
- {(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && ( + {(currentData?.userId || currentData?.isOwnProfile) && !isDeletedProfile && (
)} {/* Admin Actions Section - Hide for deleted users */} - {!currentData.isOwnProfile && user.currentUser?.id === 1 && !currentData.deleted && ( + {!currentData.isOwnProfile && user.currentUser?.id === 1 && !isDeletedProfile && (

Admin Actions

@@ -510,7 +528,7 @@ export function ProfileDialog() { )} {/* Verify button for non-admin owner */} - {!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && ( + {!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && !isDeletedProfile && (
({ ...user, userId: user.id, + display_name: displayNameForUser({ ...user, id: user.id }), type: "dm" as const })), { @@ -180,6 +184,19 @@ export function UnifiedChatsList() { return ; } + if (user.isSuspended) { + return ( + + + + + + ); + } + return ( {allChats.map((chat) => { @@ -212,40 +229,53 @@ export function UnifiedChatsList() { ); } + const isDeletedDm = isDeletedPeer(chat); + const displayName = displayNameForUser({ ...chat, id: chat.id }); + return ( handleDMClick(chat)} style={{ cursor: "pointer" }} >
- {chat.display_name} - + {displayName} + {!isDeletedDm && ( + + )}
{chat.lastMessage || "Нет сообщений"}
- {chat.display_name} { - e.target.src = defaultAvatar; - }} - /> - + {isDeletedDm ? ( + + ) : ( + {displayName} { + e.target.src = defaultAvatar; + }} + /> + )} + {!isDeletedDm && }
{chat.unreadCount > 0 && ( diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index 7184e46..64fedae 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -215,9 +215,9 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use
{searchUser.username} -
diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index 855d0fc..3b8a174 100644 --- a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx @@ -17,10 +17,10 @@ export function AccountPanel({ onClose }: AccountPanelProps) { try { await confirm({ - headline: "Delete Account?", - description: "This will permanently delete your account and all your data. This action cannot be undone.", - confirmText: "Delete", - cancelText: "Cancel" + headline: "Удалить аккаунт?", + description: "Профиль будет удалён без возможности восстановления, логин освободится. Отправленные сообщения могут остаться в чатах.", + confirmText: "Удалить", + cancelText: "Отмена" }); await api.user.auth.deleteAccount(authToken); diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx index 810611c..c6e50fc 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -5,6 +5,7 @@ import { useUserStore } from "@/state/user"; import api from "@/core/api"; import type { DeviceInfo } from "@/core/api/user/devices"; import { confirm } from "mdui/functions/confirm"; +import { parseApiTimestamp } from "@/utils/utils"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function DevicesPanel() { @@ -92,7 +93,7 @@ export function DevicesPanel() { function formatLastSeen(dateStr: string | undefined): string { if (!dateStr) return "Never"; - const date = new Date(dateStr); + const date = parseApiTimestamp(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 92685b8..ba916ec 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -14,6 +14,8 @@ import { useImmer } from "use-immer"; import { createPortal } from "react-dom"; import { parseProfileLink } from "@/core/profileLinks"; import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material"; +import { displayNameForUser, isDeletedPeer } from "@/core/userDisplay"; +import { DeletedUserAvatar } from "@/core/DeletedUserAvatar"; import styles from "@/pages/chat/css/Message.module.scss"; import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss"; @@ -517,6 +519,8 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD return messageText.length > 0 && emojiRegex.test(messageText) && messageText.length <= 4; // Most emojis are 1-4 characters }, [messageText]); + const isDeletedSender = isDeletedPeer({ id: message.user_id, username: message.username }); + return ( <>
{!isAuthor && !isDm && (
- {message.username} { - e.target.src = defaultAvatar; - }} - /> + {isDeletedSender ? ( + + ) : ( + {message.username} { + e.target.src = defaultAvatar; + }} + /> + )}
)} @@ -541,12 +553,14 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
- {message.username} - + {displayNameForUser({ id: message.user_id, username: message.username })} + {!isDeletedSender && ( + + )}
)} diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index 84bc661..eff7dcc 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -17,7 +17,7 @@ import { TypingIndicator } from "./TypingIndicator"; import { OnlineStatus } from "./OnlineStatus"; import { typingManager } from "@/core/typingManager"; import { PublicChatPanel } from "./panels/PublicChatPanel"; -import { MaterialIcon, MaterialIconButton } from "@/utils/material"; +import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material"; import styles from "@/pages/chat/css/layout.module.scss"; import rightPanelStyles from "@/pages/chat/css/right-panel.module.scss"; @@ -52,7 +52,7 @@ function ChatHeaderText({ panel }: { panel: MessagePanel | null }) { } export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { - const { applyPendingPanel, isSwitching, pendingPanel, activePanel, setIsSwitching } = useChatStore(); + const { applyPendingPanel, isSwitching, pendingPanel, activePanel, setIsSwitching, setActivePanel } = useChatStore(); const { setProfileDialog } = useProfileStore(); const messagePanelRef = useRef(null); const [panelState, setPanelState] = useState(null); @@ -71,8 +71,38 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { // Drag & drop const [isDragging, setIsDragging] = useState(false); const dragCounterRef = useRef(0); + const [peerDeleted, setPeerDeleted] = useState(false); const addFilesRef = useRef void)>(null); + useEffect(() => { + let cancelled = false; + setPeerDeleted(false); + if (!panel?.isDm()) return; + + const dmPanel = panel as DMPanel; + dmPanel.getProfile().then((profile) => { + if (!cancelled) { + setPeerDeleted(Boolean(profile?.deleted)); + } + }); + + return () => { + cancelled = true; + }; + }, [panel]); + + async function handleDeleteDeletedPeerChat() { + if (!panel?.isDm()) return; + + const dmPanel = panel as DMPanel; + const messages = [...dmPanel.getMessages()].filter((message) => message.id > 0); + for (const message of messages) { + await dmPanel.handleDeleteMessage(message.id); + } + dmPanel.clearMessages(); + setActivePanel(null); + } + useEffect(() => { if (!panel || !panelState) return; @@ -307,7 +337,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {

{panelState?.title || "Выбор чата"}

- {panel?.isDm() && ( + {panel?.isDm() && !peerDeleted && ( )}
@@ -376,7 +406,17 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
)} - {panel && ( + {panel && (peerDeleted && panel.isDm() ? ( +
+ + Удалить чат + +
+ ) : ( { panel.handleSendMessage(text, replyTo?.id, files); @@ -433,7 +473,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { } }} /> - )} + ))}
{panel && ( diff --git a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx index 6aa6615..bbad6a8 100644 --- a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx +++ b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx @@ -7,6 +7,8 @@ import { usePresenceStore } from "@/state/presence"; import { useUserStore } from "@/state/user"; +import { formatDeletedUserLastSeen, isEpochLastSeen } from "@/core/userDisplay"; +import { parseApiTimestamp } from "@/utils/utils"; import styles from "@/pages/chat/css/TypingIndicators.module.scss"; interface OnlineStatusProps { @@ -20,7 +22,10 @@ export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : onlineStatuses.get(userId); function formatLastSeen(lastSeen: string): string { - const date = new Date(lastSeen); + if (isEpochLastSeen(lastSeen)) { + return formatDeletedUserLastSeen(); + } + const date = parseApiTimestamp(lastSeen); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / (1000 * 60)); diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 6a1cf32..ed57b47 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -400,6 +400,7 @@ export class DMPanel extends MessagePanel { bio: userProfile.bio, memberSince: userProfile.created_at, online: userProfile.online, + deleted: userProfile.deleted, isOwnProfile: false }; } catch (error) { diff --git a/frontend/src/pages/download-app/DownloadAppPage.tsx b/frontend/src/pages/download-app/DownloadAppPage.tsx index 67fd693..30a1649 100644 --- a/frontend/src/pages/download-app/DownloadAppPage.tsx +++ b/frontend/src/pages/download-app/DownloadAppPage.tsx @@ -1,4 +1,5 @@ import { MaterialIcon } from "@/utils/material"; +import { Link } from "react-router-dom"; import styles from "./download-app.module.scss"; export default function DownloadAppPage() { @@ -20,6 +21,11 @@ export default function DownloadAppPage() { iOS
+

+ Политика конфиденциальности + {" · "} + Пользовательское соглашение +

Написать в поддержку

diff --git a/frontend/src/pages/home/HomeFooter.tsx b/frontend/src/pages/home/HomeFooter.tsx index 95d487d..b32671d 100644 --- a/frontend/src/pages/home/HomeFooter.tsx +++ b/frontend/src/pages/home/HomeFooter.tsx @@ -65,6 +65,16 @@ export function HomeFooter({ onScrollToDownload }: HomeFooterProps) { Лицензия +
+ + + Политика конфиденциальности + + + + Пользовательское соглашение + +
; +const TermsPage = () => ; + +export { PrivacyPage, TermsPage }; diff --git a/frontend/src/state/types.ts b/frontend/src/state/types.ts index eb251f2..49091d9 100644 --- a/frontend/src/state/types.ts +++ b/frontend/src/state/types.ts @@ -1,4 +1,4 @@ -import type { Message, User } from "@/core/types"; +import type { Message, User, VerificationStatus } from "@/core/types"; import { MessagePanel } from "@/pages/chat/ui/right/panels/MessagePanel"; import { PublicChatPanel } from "@/pages/chat/ui/right/panels/PublicChatPanel"; import { DMPanel } from "@/pages/chat/ui/right/panels/DMPanel"; @@ -17,6 +17,7 @@ export interface ProfileDialogData { online?: boolean; isOwnProfile: boolean; verified?: boolean; + verification_status?: VerificationStatus; suspended?: boolean; suspension_reason?: string | null; deleted?: boolean; diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts index fc2fd09..c927f08 100644 --- a/frontend/src/state/user.ts +++ b/frontend/src/state/user.ts @@ -11,7 +11,7 @@ import type { UserState } from "./types"; interface UserStore { user: UserState; setUser: (token: string, user: User) => void; - logout: () => void; + logout: () => Promise; restoreFromStorage: () => Promise; setSuspended: (reason: string) => void; } @@ -46,12 +46,21 @@ export const useUserStore = create((set) => ({ // Ping will be sent automatically on WebSocket reconnect // No need to send here to avoid duplicate pings }, - logout: () => { + logout: async () => { + const token = useUserStore.getState().user.authToken; + if (token) { + try { + await api.user.auth.logout(token); + } catch (error) { + console.error("Server logout failed:", error); + } + } + try { - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + localStorage.removeItem("authToken"); + localStorage.removeItem("currentUser"); } catch (error) { - console.error('Failed to clear localStorage:', error); + console.error("Failed to clear localStorage:", error); } onlineStatusManager.setAuthToken(null); diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 69aaf37..bed42f9 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -6,19 +6,35 @@ */ /** - * Formats a timestamp string to HH:MM format + * Parses API timestamps into a Date in the user's local timezone. + * Zone-less ISO strings from the server are treated as UTC (append Z), + * matching Android's parseMessageInstant behavior. + */ +export function parseApiTimestamp(dateString: string): Date { + const raw = dateString.trim(); + if (!raw) return new Date(NaN); + const normalized = raw.includes(" ") ? raw.replace(" ", "T") : raw; + const hasOffset = + /[zZ]$/.test(normalized) || + /[+-]\d{2}:?\d{2}$/.test(normalized); + return new Date(hasOffset ? normalized : `${normalized}Z`); +} + +/** + * Formats a timestamp string to HH:MM in the user's local timezone. * @param {string} dateString - ISO timestamp string to format * @returns {string} Formatted time string in HH:MM format * @example - * formatTime('2024-01-15T14:30:00Z'); // Returns "14:30" + * formatTime('2024-01-15T14:30:00Z'); // Returns local "17:30" in UTC+3 */ export function formatTime(dateString: string): string { - const date = new Date(dateString); - let hours = date.getHours(); - let minutes = date.getMinutes(); - const hoursString = hours < 10 ? '0' + hours : hours; - const minutesString = minutes < 10 ? '0' + minutes : minutes; - return hoursString + ':' + minutesString; + const date = parseApiTimestamp(dateString); + if (Number.isNaN(date.getTime())) return ""; + const hours = date.getHours(); + const minutes = date.getMinutes(); + const hoursString = hours < 10 ? "0" + hours : String(hours); + const minutesString = minutes < 10 ? "0" + minutes : String(minutes); + return hoursString + ":" + minutesString; } /**