Implement legal terms, Android client compatibility and more

This commit is contained in:
2026-07-13 13:13:05 +03:00
Unverified
parent 54cf37df6c
commit 6de18d0ddc
73 changed files with 3922 additions and 635 deletions
+41 -117
View File
@@ -1,90 +1,51 @@
# Security Audit Command # 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 ## 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 - Kotlin Multiplatform (KMP) shared code
- Python FastAPI backend - Jetpack Compose UI framework
- Caddy reverse proxy with security headers - End-to-End Encryption (NaCl, AES-GCM)
- WebSocket support for real-time features - 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) ## Important Design Decisions (NOT Vulnerabilities)
When auditing, remember these are **intentional design choices**: When auditing, remember these are **intentional design choices**:
1. **Public messages endpoint** - Open forum accessible without authentication (by design) 1. **Local message caching** - Messages downloaded and stored locally (by design)
- The public chat is meant to be an open forum - Messages are end-to-end encrypted at rest in local DB
- Private DMs are properly E2E encrypted and require authentication - Public DMs are not encrypted (messages are public)
2. **Public user list** - All users visible in DMs tab (by design) - Private DMs use NaCl encryption
- Users can see all registered accounts - Cache persists across app restarts for offline access
- This is intentional for a community-based chat app 2. **Local key storage** - Encryption keys stored on device (by design)
3. **XSS protection** - Multi-layer defense already implemented: - Keys protected by Android Keystore (hardware-backed when available)
- React auto-escaping - Encrypted with device-specific secrets
- DOMPurify for sanitization - User data never leaves device in plaintext
- Caddy CSP headers - Do NOT flag key storage as critical (Keystore is production-ready)
- 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
## Output Format ## Output Format
@@ -94,8 +55,8 @@ Provide a **clean, concise report** with:
2. **Security Status** - Critical issues (if any) and recommendations 2. **Security Status** - Critical issues (if any) and recommendations
3. **Security Strengths** - What's done well 3. **Security Strengths** - What's done well
4. **Component Ratings** - Table format for quick reference 4. **Component Ratings** - Table format for quick reference
5. **Design Decisions** - Clarify what's intentional vs vulnerable 5. **Architecture Review** - Data flow, encryption boundaries
6. **Threat Analysis** - Current realistic threats only 6. **Threat Analysis** - Current realistic threats (e.g., rooted device, malicious APK)
7. **Recommendations** - Prioritized with time estimates 7. **Recommendations** - Prioritized with time estimates
8. **Conclusion** - Clear production readiness statement 8. **Conclusion** - Clear production readiness statement
@@ -105,44 +66,7 @@ Provide a **clean, concise report** with:
**DO NOT FLAG THESE AS ISSUES:** **DO NOT FLAG THESE AS ISSUES:**
- Public messages endpoint (intentional) - Local message caching (intentional for offline access)
- Username enumeration (users list is public by design) - Public message viewing without auth (intentional design)
- Keys in localStorage (XSS is well-protected) - Debuggable APK (only relevant if signed/released)
- 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
+219 -1
View File
@@ -666,7 +666,161 @@ async def get_file(filename: str, request: Request):
return FileResponse(str(path), media_type="application/octet-stream", filename=filename) 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) # 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): async def get_file_normal_internal(filename: str):
"""Internal: serve normal (unencrypted) files. Used by proxy when in-process.""" """Internal: serve normal (unencrypted) files. Used by proxy when in-process."""
safe_name = Path(filename).name safe_name = Path(filename).name
@@ -675,7 +829,31 @@ async def get_file_normal_internal(filename: str):
path = FILES_NORMAL_DIR / safe_name path = FILES_NORMAL_DIR / safe_name
if not path.exists(): if not path.exists():
raise HTTPException(status_code=404, detail="File not found") 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) @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) 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): async def get_file_encrypted_internal(filename: str, user_id: int):
"""Internal: serve encrypted files with permission checking. Used by proxy when in-process.""" """Internal: serve encrypted files with permission checking. Used by proxy when in-process."""
safe_name = Path(filename).name safe_name = Path(filename).name
+57
View File
@@ -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,
}
+3 -1
View File
@@ -10,7 +10,7 @@ import logging
from sqlalchemy.orm.exc import DetachedInstanceError from sqlalchemy.orm.exc import DetachedInstanceError
# Import from same directory # 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 .routes.account import get_server_instance_id
from .models import User from .models import User
from .constants import OWNER_USERNAME from .constants import OWNER_USERNAME
@@ -325,6 +325,7 @@ app.add_middleware(
app.include_router(account.router) app.include_router(account.router)
app.include_router(envelope_messaging.router) app.include_router(envelope_messaging.router)
app.include_router(messaging.router) app.include_router(messaging.router)
app.include_router(public_chat.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(push.router, prefix="/push") app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc") 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(moderation.router)
app.include_router(download.router) app.include_router(download.router)
app.include_router(keys.router) app.include_router(keys.router)
app.include_router(static_routes.router)
@app.get("/health") @app.get("/health")
+35
View File
@@ -160,6 +160,22 @@ class DMReaction(Base):
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),) __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 # Tracks authenticated device sessions per user
class DeviceSession(Base): class DeviceSession(Base):
__tablename__ = "device_session" __tablename__ = "device_session"
@@ -202,6 +218,7 @@ class RegisterRequest(BaseModel):
display_name: str display_name: str
password: str password: str
confirm_password: str confirm_password: str
bio: str | None = None
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
@@ -210,9 +227,19 @@ class ChangePasswordRequest(BaseModel):
logoutAllExceptCurrent: bool = False logoutAllExceptCurrent: bool = False
class VerifyPasswordRequest(BaseModel):
passwordDerived: str
class DeleteAccountRequest(BaseModel):
passwordDerived: str
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
content: str content: str
reply_to_id: int | None = None reply_to_id: int | None = None
client_message_id: str | None = None
uploaded_file_ids: list[str] | None = None
class EditMessageRequest(BaseModel): class EditMessageRequest(BaseModel):
@@ -270,6 +297,7 @@ class UserProfileResponse(BaseModel):
last_seen: datetime | None last_seen: datetime | None
created_at: datetime | None created_at: datetime | None
verified: bool verified: bool
verification_status: str
suspended: bool suspended: bool
suspension_reason: str | None suspension_reason: str | None
deleted: bool deleted: bool
@@ -278,6 +306,13 @@ class UserProfileResponse(BaseModel):
from_attributes = True from_attributes = True
class PublicChatProfileResponse(BaseModel):
id: str
title: str
bio: str | None
member_count: int
class MessageResponse(BaseModel): class MessageResponse(BaseModel):
id: int id: int
content: str content: str
+63
View File
@@ -0,0 +1,63 @@
"""In-memory user presence derived from WebSocket connections only."""
from __future__ import annotations
from datetime import datetime
from fastapi import WebSocket
class PresenceService:
def __init__(self) -> None:
self._connections: dict[int, set[WebSocket]] = {}
self._last_seen: dict[int, datetime] = {}
def register_connection(self, user_id: int, websocket: WebSocket) -> bool:
"""Track a live connection. Returns True if the user became online."""
connections = self._connections.setdefault(user_id, set())
was_online = bool(connections)
connections.add(websocket)
return not was_online
def unregister_connection(self, user_id: int, websocket: WebSocket) -> tuple[bool, datetime | None]:
"""Remove a connection. Returns (became_offline, last_seen) when the last conn drops."""
connections = self._connections.get(user_id)
if not connections:
return False, self._last_seen.get(user_id)
connections.discard(websocket)
if connections:
return False, None
del self._connections[user_id]
last_seen = datetime.now()
self._last_seen[user_id] = last_seen
return True, last_seen
def touch(self, user_id: int) -> None:
"""Refresh activity timestamp while online."""
if self.is_online(user_id):
self._last_seen[user_id] = datetime.now()
def is_online(self, user_id: int) -> bool:
connections = self._connections.get(user_id)
return bool(connections)
def get_last_seen(self, user_id: int) -> datetime | None:
if self.is_online(user_id):
return self._last_seen.get(user_id) or datetime.now()
return self._last_seen.get(user_id)
def get_presence(self, user_id: int) -> tuple[bool, datetime | None]:
online = self.is_online(user_id)
if online:
return True, self.get_last_seen(user_id)
last_seen = self._last_seen.get(user_id)
return False, last_seen
def remove_user(self, user_id: int) -> None:
"""Drop all presence state for a deleted user."""
self._connections.pop(user_id, None)
self._last_seen.pop(user_id, None)
presence_service = PresenceService()
@@ -0,0 +1,30 @@
"""
Server-side metadata for the instance public chat (title, bio).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import TypedDict
_STATIC_PROFILE_PATH = Path(__file__).resolve().parent / "static" / "public_chat_profile.json"
class PublicChatStaticProfile(TypedDict):
id: str
title: str
bio: str
def load_public_chat_static_profile() -> PublicChatStaticProfile:
if not _STATIC_PROFILE_PATH.is_file():
raise FileNotFoundError(f"public chat profile config missing: {_STATIC_PROFILE_PATH}")
with _STATIC_PROFILE_PATH.open("r", encoding="utf-8") as f:
data = json.load(f)
chat_id = str(data.get("id", "")).strip()
title = str(data.get("title", "")).strip()
bio = str(data.get("bio", "")).strip()
if not chat_id or not title:
raise ValueError("public chat profile config must include non-empty id and title")
return PublicChatStaticProfile(id=chat_id, title=title, bio=bio)
@@ -0,0 +1,220 @@
"""Header-only image dimension reads for very large public-chat attachments."""
from __future__ import annotations
import logging
import struct
from pathlib import Path
logger = logging.getLogger("uvicorn.error")
_HEADER_READ_BYTES = 4 * 1024 * 1024
_HEADER_READ_MAX_BYTES = 16 * 1024 * 1024
_JPEG_SOF_MARKERS = frozenset(
{0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
)
def is_placeholder_dimensions(width: int, height: int) -> bool:
return width <= 1 and height <= 1
def read_image_dimensions_from_path(path: Path) -> list[int] | None:
"""Read pixel width/height without decoding multi-hundred-MP images."""
try:
with path.open("rb") as handle:
header = handle.read(_HEADER_READ_BYTES)
result = read_image_dimensions_from_bytes(header, path.suffix)
if result is not None:
return result
while len(header) < _HEADER_READ_MAX_BYTES:
extra = handle.read(_HEADER_READ_BYTES)
if not extra:
break
header += extra
result = read_image_dimensions_from_bytes(header, path.suffix)
if result is not None:
return result
return None
except Exception as error:
logger.warning("PUBLIC THUMB: header read failed for %s: %s", path, error)
return None
def read_image_dimensions_from_bytes(data: bytes, suffix: str = "") -> list[int] | None:
if not data:
return None
ext = suffix.lower()
wh: tuple[int, int] | None = None
if data.startswith(b"\xff\xd8"):
wh = _jpeg_dimensions(data)
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
wh = _png_dimensions(data)
elif data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
wh = _gif_dimensions(data)
elif data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP":
wh = _webp_dimensions(data)
elif ext in {".jpg", ".jpeg"}:
wh = _jpeg_dimensions(data)
elif ext == ".png":
wh = _png_dimensions(data)
elif ext == ".gif":
wh = _gif_dimensions(data)
elif ext == ".webp":
wh = _webp_dimensions(data)
if wh is None:
wh = _pil_dimensions_fallback(data)
if wh is None:
return None
width, height = wh
if is_placeholder_dimensions(width, height):
return None
return [width, height]
def _apply_exif_orientation(width: int, height: int, orientation: int) -> tuple[int, int]:
if orientation in {5, 6, 7, 8}:
return height, width
return width, height
def _parse_exif_orientation(exif_bytes: bytes) -> int | None:
try:
if len(exif_bytes) < 8:
return None
endian = exif_bytes[0:2]
if endian == b"II":
endianness = "<"
elif endian == b"MM":
endianness = ">"
else:
return None
ifd_offset = struct.unpack(endianness + "I", exif_bytes[4:8])[0]
if ifd_offset + 2 > len(exif_bytes):
return None
count = struct.unpack(endianness + "H", exif_bytes[ifd_offset : ifd_offset + 2])[0]
cursor = ifd_offset + 2
for _ in range(count):
if cursor + 12 > len(exif_bytes):
break
tag, field_type, value_count = struct.unpack(endianness + "HHI", exif_bytes[cursor : cursor + 8])
value_offset = struct.unpack(endianness + "I", exif_bytes[cursor + 8 : cursor + 12])[0]
if tag == 0x0112:
if field_type == 3 and value_count == 1:
if value_offset <= 0xFFFF:
return value_offset & 0xFFFF
if value_offset + 2 <= len(exif_bytes):
return struct.unpack(endianness + "H", exif_bytes[value_offset : value_offset + 2])[0]
cursor += 12
except Exception:
return None
return None
def _jpeg_dimensions(data: bytes) -> tuple[int, int] | None:
"""Read JPEG SOF dimensions and apply EXIF orientation when present.
EXIF APP1 may appear after the SOF segment; scan the full header before returning.
"""
if len(data) < 4 or data[0:2] != b"\xff\xd8":
return None
orientation = 1
sof_width: int | None = None
sof_height: int | None = None
index = 2
while index + 4 < len(data):
if data[index] != 0xFF:
index += 1
continue
while index < len(data) and data[index] == 0xFF:
index += 1
if index >= len(data):
break
marker = data[index]
index += 1
if marker in {0xD8, 0xD9}:
continue
if index + 2 > len(data):
break
segment_length = struct.unpack(">H", data[index : index + 2])[0]
if segment_length < 2:
break
segment_start = index + 2
segment_end = index + segment_length
if segment_end > len(data):
break
if marker == 0xE1 and segment_end - segment_start > 8:
exif = data[segment_start:segment_end]
if exif[:6] == b"Exif\x00\x00":
parsed = _parse_exif_orientation(exif[6:])
if parsed is not None:
orientation = parsed
if (
sof_width is None
and marker in _JPEG_SOF_MARKERS
and segment_end - segment_start >= 7
):
sof_height = struct.unpack(">H", data[segment_start + 3 : segment_start + 5])[0]
sof_width = struct.unpack(">H", data[segment_start + 5 : segment_start + 7])[0]
index = segment_end
if sof_width is None or sof_height is None:
return None
return _apply_exif_orientation(sof_width, sof_height, orientation)
def _png_dimensions(data: bytes) -> tuple[int, int] | None:
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
return None
width = struct.unpack(">I", data[16:20])[0]
height = struct.unpack(">I", data[20:24])[0]
if width <= 0 or height <= 0:
return None
return width, height
def _gif_dimensions(data: bytes) -> tuple[int, int] | None:
if len(data) < 10:
return None
width = struct.unpack("<H", data[6:8])[0]
height = struct.unpack("<H", data[8:10])[0]
if width <= 0 or height <= 0:
return None
return width, height
def _webp_dimensions(data: bytes) -> tuple[int, int] | None:
if len(data) < 30 or data[8:12] != b"WEBP":
return None
chunk = data[12:16]
if chunk == b"VP8 " and len(data) >= 30:
width = struct.unpack("<H", data[26:28])[0] & 0x3FFF
height = struct.unpack("<H", data[28:30])[0] & 0x3FFF
if width > 0 and height > 0:
return width, height
if chunk == b"VP8L" and len(data) >= 25:
bits = struct.unpack("<I", data[21:25])[0]
width = (bits & 0x3FFF) + 1
height = ((bits >> 14) & 0x3FFF) + 1
if width > 0 and height > 0:
return width, height
if chunk == b"VP8X" and len(data) >= 30:
width = 1 + (data[24] | (data[25] << 8) | (data[26] << 16))
height = 1 + (data[27] | (data[28] << 8) | (data[29] << 16))
if width > 1 and height > 1:
return width, height
return None
def _pil_dimensions_fallback(data: bytes) -> tuple[int, int] | None:
try:
from PIL import Image, ImageOps
with Image.open(__import__("io").BytesIO(data)) as image:
image = ImageOps.exif_transpose(image)
width, height = image.size
if width <= 0 or height <= 0:
return None
return width, height
except Exception as error:
logger.warning("PUBLIC THUMB: PIL fallback failed: %s", error)
return None
+196 -57
View File
@@ -9,13 +9,28 @@ from sqlalchemy import inspect, text
import uuid import uuid
import secrets import secrets
from user_agents import parse as parse_ua from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from ..constants import OWNER_USERNAME from ..constants import OWNER_USERNAME
from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db
from ..models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession from ..models import (
LoginRequest,
RegisterRequest,
ChangePasswordRequest,
VerifyPasswordRequest,
DeleteAccountRequest,
User,
CryptoPublicKey,
CryptoBackup,
DeviceSession,
)
from ..utils import create_token, get_password_hash, verify_password, get_client_ip from ..utils import create_token, get_password_hash, verify_password, get_client_ip
from ..validation import is_valid_password, is_valid_username, is_valid_display_name from ..validation import is_valid_password, is_valid_username, is_valid_display_name
from ..deleted_user import (
apply_deleted_user_db_fields,
deleted_user_api_fields,
is_deleted_user,
is_suspended_user,
)
import os import os
from ..security.audit import log_security from ..security.audit import log_security
@@ -99,23 +114,70 @@ def _reset_failed_logins(identifier: str) -> None:
def _is_admin(user: User) -> bool: def _is_admin(user: User) -> bool:
return user.id == 1 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 { return {
"id": user.id, "id": user.id,
"created_at": user.created_at.isoformat(), "created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(), "last_seen": effective_last_seen.isoformat(),
"online": user.online, "online": online,
"username": user.username, "username": user.username,
"display_name": user.display_name, "display_name": user.display_name,
"profile_picture": user.profile_picture, "profile_picture": user.profile_picture,
"bio": user.bio, "bio": user.bio,
"admin": _is_admin(user), "admin": _is_admin(user),
"verified": user.verified, "verified": user.verified,
"verification_status": verification_status.value,
"suspended": user.suspended or False, "suspended": user.suspended or False,
"suspension_reason": user.suspension_reason, "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") @router.get("/instance_id")
def get_instance_id_public(): def get_instance_id_public():
"""Public deploy fingerprint (used when the client changes server host/port).""" """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") @router.post("/login")
@rate_limit_per_ip("5/minute") @rate_limit_per_ip("5/minute")
def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)): 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, failures=total_failures,
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS, 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( raise HTTPException(
status_code=401, status_code=401,
detail="Неверное имя пользователя или пароль" detail="Неверное имя пользователя или пароль"
@@ -201,9 +280,6 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
revoked=False, revoked=False,
) )
db.add(device) db.add(device)
user.online = True
user.last_seen = datetime.now()
db.commit() db.commit()
logging.getLogger("uvicorn.error").info("Login DB commit complete for user_id=%s", user.id) 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", "status": "success",
"message": "Login successful", "message": "Login successful",
"token": token, "token": token,
"user": convert_user(user) "user": convert_user(user, db)
} }
@@ -294,6 +370,18 @@ def register(
detail="Это имя пользователя уже занято" 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) hashed_password = get_password_hash(password)
# Set verified=True for the owner (first user to register) # Set verified=True for the owner (first user to register)
@@ -304,8 +392,7 @@ def register(
username=username, username=username,
display_name=display_name, display_name=display_name,
password_hash=hashed_password, password_hash=hashed_password,
online=True, bio=bio_text,
last_seen=datetime.now(),
verified=is_owner verified=is_owner
) )
@@ -363,7 +450,7 @@ def register(
"status": "success", "status": "success",
"message": "Регистрация прошла успешно", "message": "Регистрация прошла успешно",
"token": token, "token": token,
"user": convert_user(new_user) "user": convert_user(new_user, db)
} }
@router.get("/crypto/public-key") @router.get("/crypto/public-key")
@@ -448,38 +535,49 @@ def delete_user_as_owner(
return {"status": "success", "deleted_user_id": user_id} 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") @router.get("/logout")
def logout( def logout(
http: Request, request: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db),
): ):
# Revoke current session session_id = getattr(request.state, "session_id", None)
from utils import verify_token as _verify_token if session_id:
payload = _verify_token(credentials.credentials) updated = _revoke_device_session(db, current_user.id, session_id)
if payload and payload.get("session_id"): db.commit()
db.query(DeviceSession).filter( if updated == 0:
DeviceSession.user_id == current_user.id, _logger.warning(
DeviceSession.session_id == payload["session_id"], "logout: session_id=%s not found for user_id=%s",
).update({DeviceSession.revoked: True}) session_id,
current_user.id,
)
else:
_logger.warning("logout: missing session_id for user_id=%s", current_user.id)
current_user.online = False client_ip = get_client_ip(request)
current_user.last_seen = datetime.now()
db.commit()
client_ip = get_client_ip(http)
log_security( log_security(
"logout", "logout",
username=current_user.username, username=current_user.username,
user_id=current_user.id, user_id=current_user.id,
ip=client_ip, ip=client_ip,
session_id=payload.get("session_id") if payload else None, session_id=session_id,
) )
return { return {
"status": "success", "status": "success",
"message": "Logged out successfully" "message": "Logged out successfully",
} }
@@ -488,9 +586,8 @@ def logout(
def change_password( def change_password(
request: Request, request: Request,
password_request: ChangePasswordRequest, password_request: ChangePasswordRequest,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db),
): ):
# Verify current derived password against stored hash # Verify current derived password against stored hash
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_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 # Optionally revoke all other sessions, keeping the current one
if password_request.logoutAllExceptCurrent: if password_request.logoutAllExceptCurrent:
from utils import verify_token as _verify_token current_session_id = getattr(request.state, "session_id", None)
payload = _verify_token(credentials.credentials) if not current_session_id:
if not payload: raise HTTPException(status_code=401, detail="Invalid session")
raise HTTPException(status_code=401, detail="Invalid token")
current_session_id = payload.get("session_id")
db.query(DeviceSession).filter( db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id, DeviceSession.user_id == current_user.id,
DeviceSession.session_id != current_session_id, DeviceSession.session_id != current_session_id,
).update({DeviceSession.revoked: True}) ).update({DeviceSession.revoked: True}, synchronize_session=False)
db.commit() db.commit()
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
@@ -526,13 +621,37 @@ def change_password(
return {"status": "success"} 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") @router.get("/users")
@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse
def list_users(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): def list_users(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
users = db.query(User).order_by(User.username.asc()).all() users = db.query(User).order_by(User.username.asc()).all()
return { return {
"users": [ "users": [
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() ).order_by(User.username.asc()).limit(20).all()
return { 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 user_id = user.id
# Mark user as deleted and clear sensitive data from ..presence_service import presence_service
user.deleted = True
user.display_name = f"Deleted User #{user_id}" apply_deleted_user_db_fields(user)
user.bio = None presence_service.remove_user(user_id)
user.password_hash = ""
user.username = f"deleted_{user_id}"
user.profile_picture = None
user.last_seen = None # Clear last seen timestamp
user.created_at = None # Clear member since timestamp
# Delete profile picture file if exists # Delete profile picture file if exists
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"): 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 # Log error but don't fail the request
pass pass
try:
from .profile import broadcast_profile_update
await broadcast_profile_update(user, db)
except Exception:
pass
try: try:
from .messaging import messagingManager from .messaging import messagingManager
await messagingManager.broadcast_registered_user_count(db) await messagingManager.broadcast_registered_user_count(db)
@@ -636,18 +756,19 @@ async def _delete_user_data(user: User, db: Session):
pass pass
@router.post("/delete") async def _delete_account_impl(
async def delete_account( body: DeleteAccountRequest,
current_user: User = Depends(get_current_user), current_user: User,
db: Session = Depends(get_db) db: Session,
): ) -> dict:
""" """
Delete the current user's own account - preserves messages/DMs/reactions/files Delete the current user's own account - preserves messages/DMs/reactions/files
""" """
# Prevent admin/owner account self-deletion
if _is_admin(current_user): if _is_admin(current_user):
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account") 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) await _delete_user_data(current_user, db)
log_security( log_security(
@@ -659,5 +780,23 @@ async def delete_account(
return { return {
"status": "success", "status": "success",
"message": "Account deleted successfully" "message": "Account deleted successfully",
} }
@router.post("/delete")
async def delete_account(
body: DeleteAccountRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return await _delete_account_impl(body, current_user, db)
@router.post("/account/delete")
async def delete_account_alias(
body: DeleteAccountRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return await _delete_account_impl(body, current_user, db)
File diff suppressed because it is too large Load Diff
+125 -65
View File
@@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
import logging
import re import re
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -10,49 +11,75 @@ import io
from fastapi import Request from fastapi import Request
from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db 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 ..models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel from pydantic import BaseModel
from ..validation import is_valid_username, is_valid_display_name from ..validation import is_valid_username, is_valid_display_name
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 .messaging import messagingManager
from ..security.audit import log_security from ..security.audit import log_security
from ..security.profanity import contains_profanity from ..security.profanity import contains_profanity
from ..security.rate_limit import rate_limit_per_ip from ..security.rate_limit import rate_limit_per_ip
from ..deleted_user import DELETED_LAST_SEEN, deleted_user_api_fields, is_deleted_user
logger = logging.getLogger("uvicorn.error")
router = APIRouter() router = APIRouter()
def _build_user_profile_response(user: User, is_owner_request: bool = False) -> UserProfileResponse: def _build_user_profile_response(
should_hide_profile = (not is_owner_request) and (user.deleted or user.suspended) 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: 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( return UserProfileResponse(
id=user.id, id=user.id,
username=user.username, username=user.username,
display_name=user.display_name, display_name=user.display_name or user.username,
profile_picture=user.profile_picture, profile_picture=user.profile_picture,
bio=user.bio, bio=user.bio,
online=user.online, online=online,
last_seen=user.last_seen, last_seen=last_seen,
created_at=user.created_at, created_at=user.created_at,
verified=user.verified, verified=bool(user.verified),
suspended=user.suspended or False, verification_status=verification_status.value,
suspended=bool(user.suspended),
suspension_reason=user.suspension_reason, suspension_reason=user.suspension_reason,
deleted=user.deleted or False, deleted=bool(user.deleted),
) )
hidden = deleted_user_api_fields(user.id)
return UserProfileResponse( return UserProfileResponse(
id=user.id, id=user.id,
username="deleted", username=hidden["username"],
display_name="Deleted User", display_name=hidden["display_name"],
profile_picture=None, profile_picture=hidden["profile_picture"],
bio=None, bio=hidden["bio"],
online=False, online=hidden["online"],
last_seen=None, last_seen=DELETED_LAST_SEEN,
created_at=None, created_at=hidden["created_at"],
verified=False, verified=hidden["verified"],
suspended=False, verification_status=hidden["verification_status"],
suspension_reason=None, suspended=hidden["suspended"],
deleted=True, suspension_reason=hidden["suspension_reason"],
deleted=hidden["deleted"],
) )
@@ -63,6 +90,40 @@ def _ensure_owner_unsuspended(user: User | None, db: Session):
db.commit() db.commit()
db.refresh(user) 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 # Request models
class UpdateProfileRequest(BaseModel): class UpdateProfileRequest(BaseModel):
username: str | None = None username: str | None = None
@@ -118,6 +179,9 @@ async def upload_profile_picture(
profile_picture_url = f"/api/profile-picture/{filename}" profile_picture_url = f"/api/profile-picture/{filename}"
current_user.profile_picture = profile_picture_url current_user.profile_picture = profile_picture_url
db.commit() db.commit()
db.refresh(current_user)
await broadcast_profile_update(current_user, db)
return { return {
"message": "Profile picture uploaded successfully", "message": "Profile picture uploaded successfully",
@@ -154,16 +218,20 @@ async def get_user_profile(
try: try:
_ensure_owner_unsuspended(current_user, db) _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( return UserProfileResponse(
id=current_user.id, id=current_user.id,
username=current_user.username, username=current_user.username,
display_name=current_user.display_name, display_name=current_user.display_name,
profile_picture=current_user.profile_picture, profile_picture=current_user.profile_picture,
bio=current_user.bio, bio=current_user.bio,
online=current_user.online, online=online,
last_seen=current_user.last_seen, last_seen=last_seen,
created_at=current_user.created_at, created_at=current_user.created_at,
verified=current_user.verified, verified=current_user.verified,
verification_status=verification_status.value,
suspended=current_user.suspended or False, suspended=current_user.suspended or False,
suspension_reason=current_user.suspension_reason, suspension_reason=current_user.suspension_reason,
deleted=current_user.deleted or False, deleted=current_user.deleted or False,
@@ -189,25 +257,29 @@ async def list_users(
_ensure_owner_unsuspended(current_user, db) _ensure_owner_unsuspended(current_user, db)
users = db.query(User).order_by(User.username.asc()).all() users = db.query(User).order_by(User.username.asc()).all()
return { verified_users_data = get_verified_users_data(db)
"users": [ 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( UserProfileResponse(
id=user.id, id=user.id,
username=user.username, username=user.username,
display_name=user.display_name, display_name=user.display_name,
profile_picture=user.profile_picture, profile_picture=user.profile_picture,
bio=user.bio, bio=user.bio,
online=user.online, online=online,
last_seen=user.last_seen, last_seen=last_seen,
created_at=user.created_at, created_at=user.created_at,
verified=user.verified, verified=user.verified,
verification_status=verification_status.value,
suspended=user.suspended or False, suspended=user.suspended or False,
suspension_reason=user.suspension_reason, suspension_reason=user.suspension_reason,
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted deleted=user.deleted or False,
).model_dump() ).model_dump()
for user in users )
] return {"users": profile_items}
}
@router.put("/user/profile") @router.put("/user/profile")
@rate_limit_per_ip("10/minute") @rate_limit_per_ip("10/minute")
@@ -272,6 +344,8 @@ async def update_user_profile(
if updated: if updated:
db.commit() db.commit()
db.refresh(current_user)
await broadcast_profile_update(current_user, db)
return { return {
"message": "Profile updated successfully", "message": "Profile updated successfully",
"username": current_user.username, "username": current_user.username,
@@ -303,6 +377,9 @@ async def update_user_bio(
current_user.bio = bio_request.bio.strip() current_user.bio = bio_request.bio.strip()
db.commit() db.commit()
db.refresh(current_user)
await broadcast_profile_update(current_user, db)
return { return {
"message": "Bio updated successfully", "message": "Bio updated successfully",
@@ -340,7 +417,12 @@ async def get_user_by_username(
_ensure_owner_unsuspended(user, db) _ensure_owner_unsuspended(user, db)
is_owner_request = current_user.id == user.id or current_user.id == 1 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}") @router.get("/user/id/{user_id}")
async def get_user_by_id( async def get_user_by_id(
@@ -362,7 +444,12 @@ async def get_user_by_id(
_ensure_owner_unsuspended(user, db) _ensure_owner_unsuspended(user, db)
is_owner_request = current_user.id == user.id or current_user.id == 1 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") @router.post("/user/{user_id}/verify")
@@ -386,6 +473,9 @@ async def verify_user(
target_user.verified = not target_user.verified target_user.verified = not target_user.verified
db.commit() db.commit()
verified_users_data = get_verified_users_data(db)
verification_status = compute_verification_status(target_user, verified_users_data)
log_security( log_security(
"admin_verify_toggle", "admin_verify_toggle",
actor=current_user.username, actor=current_user.username,
@@ -395,45 +485,15 @@ async def verify_user(
verified=target_user.verified, verified=target_user.verified,
) )
await broadcast_profile_update(target_user, db)
return { return {
"verified": target_user.verified, "verified": target_user.verified,
"verification_status": verification_status.value,
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}" "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 # Admin endpoints for user management
class SuspendUserRequest(BaseModel): class SuspendUserRequest(BaseModel):
reason: str reason: str
@@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..dependencies import get_current_user_allow_suspended, get_db
from ..models import PublicChatProfileResponse, User
from ..public_chat_config import load_public_chat_static_profile
router = APIRouter()
@router.get("/public-chat/profile", response_model=PublicChatProfileResponse)
def get_public_chat_profile(
current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db),
):
"""Metadata for the instance public chat (title, bio, member count)."""
del current_user
try:
static_profile = load_public_chat_static_profile()
except (FileNotFoundError, ValueError, OSError) as exc:
raise HTTPException(status_code=500, detail="Public chat profile is not configured") from exc
member_count = db.query(User).filter(User.deleted.is_(False)).count()
bio = static_profile["bio"].strip() or None
return PublicChatProfileResponse(
id=static_profile["id"],
title=static_profile["title"],
bio=bio,
member_count=member_count,
)
+40
View File
@@ -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")
+167
View File
@@ -9,6 +9,7 @@ from typing import Optional, Dict, Any
import os import os
import logging import logging
import json import json
from pathlib import Path
import httpx import httpx
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -638,6 +639,172 @@ async def get_resumable_upload_data_in_storage(
return r.json() 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( async def delete_resumable_upload_in_storage(
upload_id: str, upload_id: str,
user_id: int, user_id: int,
+72
View File
@@ -0,0 +1,72 @@
<!-- fc:shape=Circle icon=privacy -->
## Общее
Здесь политика конфиденциальности FromChat. Я знаю, что 99% ее даже читать не будут, сделал только для того, чтобы ко мне не было вопросов и чтобы те, кому реально интерессно знали, что происходит с данными.
Эта политика действует только на официальном сервере [fromchat.ru](https://fromchat.ru). На других серверах политика ставится их админами.
Вы можете свободно использовать этот текст в любых целях без указания авторства.
Текст может меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если что-то изменится, я напишу об этом в Telegram-канале.
<!-- fc:shape=Cookie4Sided icon=storage -->
## Ваши данные
### Какие данные собираются?
- Логин, имя и прочие данные профиля — без них мессенджер не может существовать. Эти данные видны всем, кто общается с вами.
- Пароль — на сервере хранится только односторонний хеш, который используется для проверки. Сервер никогда не видит пароль открытым текстом.
- Сообщения в общем чате — они публичны. Любой пользователь на сервере может их увидеть. Они хранятся открытым текстом в базе данных.
- Личные сообщения — вкратце: они хранятся в зашифрованном виде, но сервер во время обработки кратко видит открытый текст сообщения. Они могут быть переданы по официальному запросу уполномоченных органов. Если интересно, как именно шифруются сообщения — читайте ниже.
- Статус «в сети» и время последней активности — чтобы собеседник видел, когда вы были в сети. К сожалению, скрыть его пока нельзя.
- Информация об устройствах (тип, ОС, браузер) — видна только вам, нужно для того, чтобы вы легко распознали взлом и его нейтрализовали.
- Звонки — идут в зашифрованном виде через WebRTC-сервер, могут быть записаны в целях соблюдения законодательства и предоставлены уполномоченным органам по запросу.
<!-- fc:shape=Cookie7Sided icon=chat -->
## Больше про личные сообщения
Если вы очень беспокоетесь за безопасность ваших сообщений, сразу говорю — защита несовершенна и любую защиту можно взломать. Но я постарался сделать доступ к вашим перепискам максимально сложным для хакеров.
### Весь путь сообщения от вас к собеседнику
Ваше устройство:
1. Вы отправляете сообщение.
2. Приложение (клиент) запрашивает открытый ключ у сервера обработки сообщений.
3. Приложение скачивает ваш открытый ключ и открытый ключ вашего собеседника.
3. Сообщение шифруется этим открытым ключем и отсылается на сервер вместе с открытыми ключами, полученными в предыдущем шаге.
Сервер:
1. Сервер получает ваш запрос на отправку сообщения и пересылает его в изолированный контейнер для обработки сообщений.
2. Контейнер расшифровывает ваше сообщение своим закрытым ключем и хранит его в оперативной памяти.
3. Создается строка из случайных чисел (MEK).
4. Текст вашего сообщения шифруется алгоритмом AES-256, MEK используется как ключ.
5. MEK шифруется три раза с помощью вашего открытого ключа и открытых ключей собеседника и официальных запросов.
6. Открытый текст вашего сообщения полностью удаляется из оперативной памяти.
7. Контейнер возвращает главному серверу зашифрованное сообщение вместе с тремя экземплярами MEK.
8. Сообщение записывается в базу данных.
Устройство собеседника:
1. Оно получает ваше сообщение и расшифровывает MEK закрытым ключем, сохраненном в аккаунте собеседника в зашифрованном виде, где пароль от аккаунта используется как ключ.
2. Зашифрованный текст сообщения расшифровывается с MEK как ключ.
3. Собеседник прочитал ваше сообщение.
<!-- fc:shape=Cookie9Sided icon=shield -->
## Реклама и продажа данных
Никакой рекламы с моей стороны и продажи ваших данных нет и никогда не будет. Мне нет смысла злить вас ради собственной выгоды.
На данный момент приложение не собирает никакой аналитики.
В каналах теоритически может быть реклама от их админов. Я в ней не виноват и контролировать не могу.
<!-- fc:shape=Cookie4Sided icon=delete -->
## Удаление данных
Если вы хотите удалить сообщение, удерживайте и нажмите "Удалить". Тогда сообщение пропадет из публичного доступа. Зашифрованная копия сообщения останется в целях соблюдения законодательства на 6 месяцев.
Если вам нужно удалить ваши данные профиля из публичного доступа, вы можете удалить аккаунт в настройках приложения.
В таком случае все сообщения, которые вы отправили будут анонимизированы, но не удалены.
Если вам нужно удалить ВСЕ, что связано с вашим профилем из публичного доступа, напишите в Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true)
+68
View File
@@ -0,0 +1,68 @@
<!-- fc:shape=Circle icon=terms -->
## Общее
**FromChat** — 100% бесплатный и открытый мессенджер. Я создал эти правила, чтобы вы точно знали, что можно, а что нельзя.
Эти правила действуют только на официальном сервере [fromchat.ru](https://fromchat.ru). Админы других серверов устанавливают свои правила.
Вы можете свободно использовать этот текст в любых целях без указания авторства.
Сервис предоставляется как есть, перебои и сбои будут гарантированно из-за слабенькой малинки.
Правила могут меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если правила изменятся, я напишу об этом в Telegram-канале.
<!-- fc:shape=Cookie4Sided icon=person -->
## Ваш аккаунт
Условия вступают в силу, когда вы создаете аккаунт. Также советую прочитать [политику конфиденциальности](/api/static/PRIVACY.md), поверьте, это очень важно.
Вы полностью отвечаете за все, что происходит в вашем аккаунте. Если поставите пароль `12345` — вас точно взломают :)
Если вы нарушите правила, я вас заблокирую. В таком случае вы сможете только читать сообщения, а отправка и реакции будут заблокированы. Если считаете, что я не прав — пишите в Telegram: [@denis0001_dev](https://t.me/denis0001_dev).
<!-- fc:shape=Cookie7Sided icon=terms -->
## Правила
### Для общего чата
Общий чат — это площадка для общения между всеми пользователями на этом сервере. По очевидным причинам, тут запрещено:
- Материться, использовать 18+ и другие неприличные слова;
- Разговаривать на тему политики, религии, нелегальных действий и неприличия;
- Оскорблять других;
- Сливать персональные данные (адрес, номер, ФИО и прочее);
- Рекламировать любые продукты, сервисы и прочее без моего согласия;
- Популяризировать VPN и другие способы обхода блокировок (это закон, не мое личное правило);
- Угрожать в любом виде;
- Спамить или засорять чат.
В целях защиты от спама количество сообщений в минуту ограничено и нельзя отправлять слишком много сообщений с одинаковым текстом. При нарушении вы будете автоматически заблокированы. Алгоритм очень примитивный, поэтому ошибки будут. Если это была ошибка, я вас разблокирую.
### Для личных сообщений
За личными сообщениями я не шпионю, но могу предоставить по официальному запросу. Поэтому я пока не могу выявлять там нарушения. Я скоро сделаю механизм жалоб.
В личке правил гораздо меньше. Мне лень писать снова длинный список, поэтому просто прошу вас, не занимайтесь нелегальными вещами и не спамьте. В личке можно обсуждать все остальное и материться.
### Глобальные правила
Пожалуйста, не используйте мессенджер для спама и не устраивайте DDoS или любые другие атаки.
<!-- fc:shape=Cookie9Sided icon=phone -->
## Контакты
### Если у вас возникли любые вопросы, пишите сюда:
Почта: [support@fromchat.ru](mailto:support@fromchat.ru)
Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true)
FromChat: [@denis0001-dev](https://fromchat.ru/@denis0001-dev)
### Вопросы по безопасности, сообщения об узвимостях
Если вдруг вы найдете уязвимость или есть вопрос про безопасность, срочно пишите сюда:
[security@fromchat.ru](mailto:security@fromchat.ru)
О шифровании договоримся, если надо.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

@@ -0,0 +1,5 @@
{
"id": "general",
"title": "Общий чат",
"bio": "Общаемся со всеми пользователями FromChat!"
}
@@ -0,0 +1,50 @@
"""Server-side verification status computation."""
from enum import Enum
from sqlalchemy.orm import Session
from .models import User
from .similarity import is_user_similar_to_verified
class VerificationStatus(str, Enum):
VERIFIED = "verified"
WARNING = "warning"
BLOCKED = "blocked"
NONE = "none"
def get_verified_users_data(db: Session) -> list[dict[str, str]]:
verified_users = (
db.query(User)
.filter(
User.verified.is_(True),
User.deleted.is_(False),
User.suspended.is_(False),
)
.all()
)
return [
{"username": user.username, "display_name": user.display_name}
for user in verified_users
]
def compute_verification_status(
user: User,
verified_users_data: list[dict[str, str]],
) -> VerificationStatus:
if user.deleted:
return VerificationStatus.NONE
if user.suspended:
return VerificationStatus.BLOCKED
if user.verified:
return VerificationStatus.VERIFIED
is_similar, _ = is_user_similar_to_verified(
user.username,
user.display_name,
verified_users_data,
)
return VerificationStatus.WARNING if is_similar else VerificationStatus.NONE
+62 -27
View File
@@ -11,6 +11,7 @@ from ..routes.messaging import (
MessaggingSocketManager, MessaggingSocketManager,
_send_message_internal, _send_message_internal,
_edit_message_internal, _edit_message_internal,
_mark_dm_conversation_read,
get_messages, get_messages,
edit_message, edit_message,
delete_message, delete_message,
@@ -26,6 +27,7 @@ from ..models import (
DMReactionRequest, DMReactionRequest,
UpdateLog, UpdateLog,
) )
from ..routes.profile import build_profile_update_payload
from ..security.audit import log_access, log_dm from ..security.audit import log_access, log_dm
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -110,14 +112,12 @@ async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db:
@websocket_handler("ping", authRequired=True) @websocket_handler("ping", authRequired=True)
async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Handle ping - authenticate and set user online.""" """Handle ping - authenticate and set user online."""
# Set user online in DB became_online = presence_service.register_connection(user.id, websocket)
user.online = True presence_service.touch(user.id)
user.last_seen = datetime.now() if became_online:
db.commit() _, last_seen = presence_service.get_presence(user.id)
# Add to online users last_seen_iso = last_seen.isoformat() if last_seen else datetime.now().isoformat()
manager.online_users.add(user.id) await manager.broadcast_status_change(user.id, True, last_seen_iso, db)
# Broadcast status change
await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db)
log(manager, websocket, user, "ping") log(manager, websocket, user, "ping")
return {"status": "success"} 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) # Call internal function directly (rate limiting is handled at infrastructure level via Caddy)
response = await _send_message_internal(message_request, user, db, []) 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"]) log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"])
return response return response
@@ -344,6 +340,32 @@ async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: S
return {"status": "ok", "id": env_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}
@websocket_handler("deleteMessage", authRequired=True) @websocket_handler("deleteMessage", authRequired=True)
async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Delete a public chat message.""" """Delete a public chat message."""
@@ -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: async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Subscribe to status updates for a user.""" """Subscribe to status updates for a user."""
user_id_to_subscribe = int(data["userId"]) user_id_to_subscribe = int(data["userId"])
manager.ws_subscriptions[websocket].add(user_id_to_subscribe) manager.ws_subscriptions.setdefault(websocket, set()).add(user_id_to_subscribe)
# Get current status of the user
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first() target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
if target_user: if not 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:
log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found") 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") 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) @websocket_handler("unsubscribeStatus", authRequired=True)
async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
+4
View File
@@ -15,6 +15,8 @@ import { delay } from "./utils/utils";
const HomePage = lazy(() => import("./pages/home/HomePage")); const HomePage = lazy(() => import("./pages/home/HomePage"));
const AuthPage = lazy(() => import("./pages/auth/AuthPage")); const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage")); 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[] = [ const routeConfig: RouteObject[] = [
{ path: "/", element: <HomePage /> }, { path: "/", element: <HomePage /> },
@@ -22,6 +24,8 @@ const routeConfig: RouteObject[] = [
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> }, { path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> }, { path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
{ path: "/download-app", element: <DownloadAppPage /> }, { path: "/download-app", element: <DownloadAppPage /> },
{ path: "/privacy", element: <PrivacyPage /> },
{ path: "/terms", element: <TermsPage /> },
{ {
path: "/chat", path: "/chat",
element: ( element: (
+23
View File
@@ -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 (
<div
className={className ?? styles.deletedUserAvatar}
style={{ background: avatarGradientFromUserId(userId) }}
>
<MaterialIcon
name="account_circle_off--outlined"
className={iconClassName ?? styles.deletedUserAvatarIcon}
/>
</div>
);
}
-39
View File
@@ -170,45 +170,6 @@ export async function verifyUser(userId: number, token: string): Promise<{verifi
return null; return null;
} }
} }
/**
* In-memory cache for user similarity results
* Key: userId, Value: similarity result
*/
const similarityCache = new Map<number, {isSimilar: boolean, similarTo?: string} | null>();
/**
* 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) * Suspends a user account (admin only)
*/ */
-39
View File
@@ -170,45 +170,6 @@ export async function verifyUser(userId: number, token: string): Promise<{verifi
return null; return null;
} }
} }
/**
* In-memory cache for user similarity results
* Key: userId, Value: similarity result
*/
const similarityCache = new Map<number, {isSimilar: boolean, similarTo?: string} | null>();
/**
* 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) * Suspends a user account (admin only)
*/ */
-39
View File
@@ -150,42 +150,3 @@ export async function fetchById(token: string, userId: number): Promise<UserProf
} }
} }
/**
* In-memory cache for user similarity results
* Key: userId, Value: similarity result
*/
const similarityCache = new Map<number, {isSimilar: boolean, similarTo?: string} | null>();
/**
* 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;
}
}
+22
View File
@@ -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)})`;
}
+29 -27
View File
@@ -1,37 +1,32 @@
import { useState, useEffect } from "react";
import api from "@/core/api";
import { useUserStore } from "@/state/user";
import { MaterialIcon } from "@/utils/material"; import { MaterialIcon } from "@/utils/material";
export type VerificationStatus = "verified" | "warning" | "blocked" | "none";
interface StatusBadgeProps { interface StatusBadgeProps {
verified: boolean; verificationStatus?: VerificationStatus | null;
userId?: number; /** @deprecated Use verificationStatus instead */
verified?: boolean;
size?: "small" | "medium" | "large"; size?: "small" | "medium" | "large";
} }
export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) { function resolveVerificationStatus(
const [isSimilarToVerified, setIsSimilarToVerified] = useState(false); verificationStatus?: VerificationStatus | null,
const { user } = useUserStore(); 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}`; const className = `status-badge ${size}`;
// Check similarity for unverified users if (status === "verified") {
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) {
return ( return (
<span className={`${className} verified`} title="Подтверждённый аккаунт"> <span className={`${className} verified`} title="Подтверждённый аккаунт">
<MaterialIcon name="verified--filled" /> <MaterialIcon name="verified--filled" />
@@ -39,7 +34,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro
); );
} }
if (isSimilarToVerified) { if (status === "warning") {
return ( return (
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт"> <span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
<MaterialIcon name="warning--filled" /> <MaterialIcon name="warning--filled" />
@@ -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 (
<span className={`${className} blocked`} title="Аккаунт заблокирован">
<MaterialIcon name="block--filled" />
</span>
);
}
return null; return null;
} }
@@ -0,0 +1,13 @@
import { Link } from "react-router-dom";
import legalStyles from "@/core/legal/legal.module.scss";
export function LegalInlineLinks() {
return (
<p className={legalStyles.legalInlineLinks}>
Регистрируясь, вы соглашаетесь с{" "}
<Link to="/terms">пользовательским соглашением</Link>
<span className={legalStyles.legalInlineLinksSep}>·</span>
<Link to="/privacy">политикой конфиденциальности</Link>
</p>
);
}
@@ -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(
/<table\b[^>]*>[\s\S]*?<\/table>/gi,
(table) => `<div class="legalTableScroll">${table}</div>`,
);
}
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 (
<div className={styles.sectionHeader}>
<div className={styles.sectionIconFrame}>
<svg
viewBox="0 0 1 1"
className={styles.sectionIconShape}
aria-hidden="true"
>
<g transform={shapeFit.transform}>
<path d={shapePath} className={styles.sectionShapeFill} />
</g>
</svg>
<MaterialIcon name={iconName} className={styles.sectionIconGlyph} />
</div>
<h2 className={styles.sectionTitle}>{section.title}</h2>
</div>
);
}
interface LegalMarkdownPageProps {
kind: LegalDocumentKind;
}
export function LegalMarkdownPage({ kind }: LegalMarkdownPageProps) {
const navigate = useNavigate();
const [loadAttempt, setLoadAttempt] = useState(0);
const [markdown, setMarkdown] = useState<string | null>(null);
const [isCached, setIsCached] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const handleContentClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
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 (
<div className={styles.legalPage}>
<p className={styles.loading}>Загрузка</p>
</div>
);
}
if (error) {
return (
<div className={styles.legalPage}>
<div className={styles.errorState}>
<p className={styles.error}>{escapeHtml(error)}</p>
<MaterialButton onClick={retry}>Повторить</MaterialButton>
</div>
</div>
);
}
if (!markdown) {
return (
<div className={styles.legalPage}>
<p className={styles.loading}>Загрузка</p>
</div>
);
}
const { preamble, sections } = parseLegalMarkdown(markdown);
return (
<div className={styles.legalPage} onClick={handleContentClick}>
{isCached ? (
<div className={styles.cachedBanner} role="status">
{CACHED_BANNER_TEXT}
</div>
) : null}
{preamble ? (
<div
className={styles.preamble}
dangerouslySetInnerHTML={{ __html: renderMarkdownBody(preamble) }}
/>
) : null}
{sections.map((section, index) => (
<section key={`${section.title}-${index}`} className={styles.section}>
<ExpressiveSectionHeader section={section} />
<div
className={styles.sectionBody}
dangerouslySetInnerHTML={{ __html: renderMarkdownBody(section.bodyMarkdown) }}
/>
</section>
))}
</div>
);
})();
return <LegalPageShell>{content}</LegalPageShell>;
}
export type { LegalDocumentKind };
@@ -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 (
<div className={homeStyles.homepage}>
<HomeHeader onScrollToDownload={scrollToDownload} />
<main className={styles.legalMain}>{children}</main>
<HomeFooter onScrollToDownload={scrollToDownload} />
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Parses `<!-- fc:shape=Cookie4Sided icon=shield -->` directives before section headers.
*/
export interface FcSectionDirective {
shape: string;
icon: string;
}
const FC_DIRECTIVE_RE = /<!--\s*fc:([^>]+?)\s*-->/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<string, string> = {
privacy: "privacy_tip",
terms: "contract",
};
export function legalMaterialIconName(icon: string): string {
return LEGAL_MATERIAL_ICON[icon] ?? icon;
}
+236
View File
@@ -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;
}
@@ -0,0 +1,81 @@
import { delay } from "@/utils/utils";
export type LegalDocumentKind = "privacy" | "terms";
export const LEGAL_DOCUMENT_PATH: Record<LegalDocumentKind, string> = {
privacy: "/api/static/PRIVACY.md",
terms: "/api/static/TERMS.md",
};
const RETRY_WINDOW_MS = 5000;
const RETRY_DELAY_MS = 1000;
const CACHE_KEY: Record<LegalDocumentKind, string> = {
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<string> {
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<LegalDocumentLoadResult> {
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: "Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.",
};
}
+19
View File
@@ -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;
});
}
@@ -0,0 +1,39 @@
/** Auto-generated from MaterialShapes via Robolectric — do not edit. */
export const MATERIAL_SHAPE_PATHS: Record<string, string> = {
"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",
};
+130
View File
@@ -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<string, PathUnitSquareFit>();
/**
* 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;
}
+7
View File
@@ -39,6 +39,8 @@ export interface Rect extends Size2D {
// App types // App types
export type VerificationStatus = "verified" | "warning" | "blocked" | "none";
/** /**
* Chat message structure * Chat message structure
* @interface Message * @interface Message
@@ -70,6 +72,7 @@ export interface Message {
timestamp: string; timestamp: string;
profile_picture?: string; profile_picture?: string;
verified?: boolean; verified?: boolean;
verification_status?: VerificationStatus;
reply_to?: Message; reply_to?: Message;
files?: Attachment[]; files?: Attachment[];
reactions?: Reaction[]; reactions?: Reaction[];
@@ -118,6 +121,7 @@ export interface User {
bio?: string; bio?: string;
profile_picture: string; profile_picture: string;
verified?: boolean; verified?: boolean;
verification_status?: VerificationStatus;
suspended?: boolean; suspended?: boolean;
suspension_reason?: string | null; suspension_reason?: string | null;
deleted?: boolean; deleted?: boolean;
@@ -144,6 +148,9 @@ export interface UserProfile {
last_seen: string; last_seen: string;
created_at: string; created_at: string;
verified?: boolean; verified?: boolean;
verification_status?: VerificationStatus;
deleted?: boolean;
suspended?: boolean;
} }
// ---------- // ----------
+51
View File
@@ -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 "был(а) давно";
}
+3
View File
@@ -9,6 +9,7 @@ import api from "@/core/api";
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
import type { Alert, AlertType } from "./Auth"; import type { Alert, AlertType } from "./Auth";
import { AuthHeader, AlertsContainer } from "./Auth"; import { AuthHeader, AlertsContainer } from "./Auth";
import { LegalInlineLinks } from "@/core/legal/LegalInlineLinks";
import styles from "./auth.module.scss"; import styles from "./auth.module.scss";
const registerFieldVariants: Variants = { const registerFieldVariants: Variants = {
@@ -236,6 +237,8 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
</motion.div> </motion.div>
</div> </div>
<LegalInlineLinks />
</motion.form> </motion.form>
</div> </div>
</> </>
@@ -177,6 +177,21 @@
object-fit: cover; object-fit: cover;
border: 2px solid $color-dark-outline; 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 { .messageInner {
@@ -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;
}
@@ -178,3 +178,17 @@
line-clamp: 2; line-clamp: 2;
-webkit-box-orient: vertical; -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;
}
}
@@ -28,6 +28,23 @@
border: 3px solid $color-dark-outline; 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 { .profilePictureEditOverlay {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -138,3 +138,9 @@
} }
} }
.deleteChatBar {
display: flex;
justify-content: center;
padding: 12px 16px 16px;
}
+39 -21
View File
@@ -14,6 +14,9 @@ import { OnlineStatus } from "./right/OnlineStatus";
import { Input } from "@/core/components/Input"; import { Input } from "@/core/components/Input";
import { StyledDialog } from "@/core/components/StyledDialog"; import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialButton, MaterialFab, MaterialIcon } from "@/utils/material"; 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"; import styles from "@/pages/chat/css/profile-dialog.module.scss";
interface SectionProps { interface SectionProps {
@@ -102,9 +105,12 @@ export function ProfileDialog() {
if (userProfile) { if (userProfile) {
freshData = { freshData = {
...userProfile, ...userProfile,
userId: userProfile.id, // Preserve the userId field userId: userProfile.id,
memberSince: userProfile.created_at, 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) { function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString("ru-RU", { return parseApiTimestamp(dateString).toLocaleDateString("ru-RU", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric" day: "numeric"
@@ -410,6 +416,8 @@ export function ProfileDialog() {
if (!currentData) return null; if (!currentData) return null;
const isDeletedProfile = isDeletedPeer(currentData);
return ( return (
<StyledDialog <StyledDialog
open={isOpen} open={isOpen}
@@ -431,16 +439,24 @@ export function ProfileDialog() {
} }
> >
<div className={styles.profilePictureSection}> <div className={styles.profilePictureSection}>
<img {isDeletedProfile ? (
className={styles.profilePicture} <DeletedUserAvatar
src={currentData.profilePicture || defaultAvatar} userId={currentData.userId!}
alt="Profile Picture" className={styles.deletedAvatar}
onError={(e) => { iconClassName={styles.deletedAvatarIcon}
e.target.src = defaultAvatar; />
}} ) : (
/> <img
className={styles.profilePicture}
src={currentData.profilePicture || defaultAvatar}
alt="Profile Picture"
onError={(e) => {
e.target.src = defaultAvatar;
}}
/>
)}
{currentData.isOwnProfile && ( {currentData.isOwnProfile && !isDeletedProfile && (
<div <div
className={styles.profilePictureEditOverlay} className={styles.profilePictureEditOverlay}
onClick={handleProfilePictureClick} onClick={handleProfilePictureClick}
@@ -456,29 +472,31 @@ export function ProfileDialog() {
autoresizing={true} autoresizing={true}
className={styles.usernameInput} className={styles.usernameInput}
type="text" type="text"
value={currentData.display_name} value={isDeletedProfile ? displayNameForUser(currentData) : currentData.display_name}
onChange={handleDisplayNameChange} onChange={handleDisplayNameChange}
readOnly={!currentData.isOwnProfile} readOnly={!currentData.isOwnProfile}
placeholder="Имя" /> placeholder="Имя" />
<StatusBadge {!isDeletedProfile && (
verified={currentData.verified || false} <StatusBadge
userId={currentData.userId} verificationStatus={currentData.verification_status}
size="large" /> verified={currentData.verified || false}
size="large" />
)}
</div> </div>
{errors.display_name && ( {errors.display_name && (
<div className={styles.errorMessage}>{errors.display_name}</div> <div className={styles.errorMessage}>{errors.display_name}</div>
)} )}
</div> </div>
{(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && ( {(currentData?.userId || currentData?.isOwnProfile) && !isDeletedProfile && (
<div className={styles.onlineStatusSection}> <div className={styles.onlineStatusSection}>
<OnlineStatus userId={currentData.userId || user.currentUser!.id} /> <OnlineStatus userId={currentData.userId || user.currentUser!.id} />
</div> </div>
)} )}
{/* Admin Actions Section - Hide for deleted users */} {/* Admin Actions Section - Hide for deleted users */}
{!currentData.isOwnProfile && user.currentUser?.id === 1 && !currentData.deleted && ( {!currentData.isOwnProfile && user.currentUser?.id === 1 && !isDeletedProfile && (
<div className={styles.adminActionsSection}> <div className={styles.adminActionsSection}>
<h3 className={styles.adminActionsHeader}>Admin Actions</h3> <h3 className={styles.adminActionsHeader}>Admin Actions</h3>
<div className={styles.adminButtons}> <div className={styles.adminButtons}>
@@ -510,7 +528,7 @@ export function ProfileDialog() {
)} )}
{/* Verify button for non-admin owner */} {/* Verify button for non-admin owner */}
{!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && ( {!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && !isDeletedProfile && (
<div className={styles.verifySection}> <div className={styles.verifySection}>
<VerifyButton <VerifyButton
userId={currentData.userId} userId={currentData.userId}
@@ -523,7 +541,7 @@ export function ProfileDialog() {
)} )}
{/* Hide profile sections for deleted users */} {/* Hide profile sections for deleted users */}
{!currentData.deleted && ( {!isDeletedProfile && (
<div className={styles.profileSections}> <div className={styles.profileSections}>
<Section <Section
type="username" type="username"
@@ -4,12 +4,14 @@ import { useChatStore } from "@/state/chat";
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import api from "@/core/api"; import api from "@/core/api";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import type { Message } from "@/core/types"; import type { Message, VerificationStatus } from "@/core/types";
import { websocket } from "@/core/websocket"; import { websocket } from "@/core/websocket";
import { onlineStatusManager } from "@/core/onlineStatusManager"; import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator"; import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { MaterialBadge, MaterialCircularProgress, MaterialList, MaterialListItem } from "@/utils/material"; import { MaterialBadge, MaterialCircularProgress, MaterialIcon, MaterialList, MaterialListItem } from "@/utils/material";
import { displayNameForUser, isDeletedPeer } from "@/core/userDisplay";
import { DeletedUserAvatar } from "@/core/DeletedUserAvatar";
import styles from "@/pages/chat/css/left-panel.module.scss"; import styles from "@/pages/chat/css/left-panel.module.scss";
interface PublicChat { interface PublicChat {
@@ -31,6 +33,7 @@ interface DMConversation {
unreadCount: number; unreadCount: number;
publicKey?: string | null; publicKey?: string | null;
verified?: boolean; verified?: boolean;
verification_status?: VerificationStatus;
} }
type ChatItem = PublicChat | DMConversation; type ChatItem = PublicChat | DMConversation;
@@ -73,6 +76,7 @@ export function UnifiedChatsList() {
...dmUsers.map((user: DMUser) => ({ ...dmUsers.map((user: DMUser) => ({
...user, ...user,
userId: user.id, userId: user.id,
display_name: displayNameForUser({ ...user, id: user.id }),
type: "dm" as const type: "dm" as const
})), })),
{ {
@@ -180,6 +184,19 @@ export function UnifiedChatsList() {
return <MaterialCircularProgress />; return <MaterialCircularProgress />;
} }
if (user.isSuspended) {
return (
<MaterialList className={styles.unifiedChatsList}>
<MaterialListItem
headline="Аккаунт заблокирован"
style={{ cursor: "pointer" }}
>
<MaterialIcon name="block--filled" slot="icon" />
</MaterialListItem>
</MaterialList>
);
}
return ( return (
<MaterialList className={styles.unifiedChatsList}> <MaterialList className={styles.unifiedChatsList}>
{allChats.map((chat) => { {allChats.map((chat) => {
@@ -212,40 +229,53 @@ export function UnifiedChatsList() {
); );
} }
const isDeletedDm = isDeletedPeer(chat);
const displayName = displayNameForUser({ ...chat, id: chat.id });
return ( return (
<MaterialListItem <MaterialListItem
key={`dm-${chat.id}`} key={`dm-${chat.id}`}
headline={chat.display_name} headline={displayName}
onClick={() => handleDMClick(chat)} onClick={() => handleDMClick(chat)}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
<div slot="headline" className="dm-list-headline"> <div slot="headline" className="dm-list-headline">
{chat.display_name} {displayName}
<StatusBadge {!isDeletedDm && (
verified={chat.verified || false} <StatusBadge
userId={chat.userId} verificationStatus={chat.verification_status}
size="small" verified={chat.verified || false}
/> size="small"
/>
)}
</div> </div>
<span slot="description" className={styles.listDescription}> <span slot="description" className={styles.listDescription}>
{chat.lastMessage || "Нет сообщений"} {chat.lastMessage || "Нет сообщений"}
</span> </span>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}> <div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
<img {isDeletedDm ? (
src={chat.profile_picture || defaultAvatar} <DeletedUserAvatar
alt={chat.display_name} userId={chat.id}
style={{ className={styles.deletedUserAvatar}
width: "40px", iconClassName={styles.deletedUserAvatarIcon}
height: "40px", />
borderRadius: "50%", ) : (
objectFit: "cover", <img
display: "block" src={chat.profile_picture || defaultAvatar}
}} alt={displayName}
onError={(e) => { style={{
e.target.src = defaultAvatar; width: "40px",
}} height: "40px",
/> borderRadius: "50%",
<OnlineIndicator userId={chat.id} /> objectFit: "cover",
display: "block"
}}
onError={(e) => {
e.target.src = defaultAvatar;
}}
/>
)}
{!isDeletedDm && <OnlineIndicator userId={chat.id} />}
</div> </div>
{chat.unreadCount > 0 && ( {chat.unreadCount > 0 && (
<MaterialBadge slot="end-icon"> <MaterialBadge slot="end-icon">
@@ -216,8 +216,8 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use
<div className={styles.searchResultHeadline}> <div className={styles.searchResultHeadline}>
{searchUser.username} {searchUser.username}
<StatusBadge <StatusBadge
verificationStatus={searchUser.verification_status}
verified={searchUser.verified || false} verified={searchUser.verified || false}
userId={searchUser.id}
size="small" size="small"
/> />
</div> </div>
@@ -17,10 +17,10 @@ export function AccountPanel({ onClose }: AccountPanelProps) {
try { try {
await confirm({ await confirm({
headline: "Delete Account?", headline: "Удалить аккаунт?",
description: "This will permanently delete your account and all your data. This action cannot be undone.", description: "Профиль будет удалён без возможности восстановления, логин освободится. Отправленные сообщения могут остаться в чатах.",
confirmText: "Delete", confirmText: "Удалить",
cancelText: "Cancel" cancelText: "Отмена"
}); });
await api.user.auth.deleteAccount(authToken); await api.user.auth.deleteAccount(authToken);
@@ -5,6 +5,7 @@ import { useUserStore } from "@/state/user";
import api from "@/core/api"; import api from "@/core/api";
import type { DeviceInfo } from "@/core/api/user/devices"; import type { DeviceInfo } from "@/core/api/user/devices";
import { confirm } from "mdui/functions/confirm"; import { confirm } from "mdui/functions/confirm";
import { parseApiTimestamp } from "@/utils/utils";
import styles from "@/pages/chat/css/settings-dialog.module.scss"; import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function DevicesPanel() { export function DevicesPanel() {
@@ -92,7 +93,7 @@ export function DevicesPanel() {
function formatLastSeen(dateStr: string | undefined): string { function formatLastSeen(dateStr: string | undefined): string {
if (!dateStr) return "Never"; if (!dateStr) return "Never";
const date = new Date(dateStr); const date = parseApiTimestamp(dateStr);
const now = new Date(); const now = new Date();
const diffMs = now.getTime() - date.getTime(); const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000); const diffMins = Math.floor(diffMs / 60000);
+27 -13
View File
@@ -14,6 +14,8 @@ import { useImmer } from "use-immer";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { parseProfileLink } from "@/core/profileLinks"; import { parseProfileLink } from "@/core/profileLinks";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material"; 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 styles from "@/pages/chat/css/Message.module.scss";
import replyPreviewStyles from "@/pages/chat/css/reply-preview.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 return messageText.length > 0 && emojiRegex.test(messageText) && messageText.length <= 4; // Most emojis are 1-4 characters
}, [messageText]); }, [messageText]);
const isDeletedSender = isDeletedPeer({ id: message.user_id, username: message.username });
return ( return (
<> <>
<div <div
@@ -526,13 +530,21 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
> >
{!isAuthor && !isDm && ( {!isAuthor && !isDm && (
<div className={styles.messageProfilePic} onClick={handleProfileClick}> <div className={styles.messageProfilePic} onClick={handleProfileClick}>
<img {isDeletedSender ? (
src={message.profile_picture || defaultAvatar} <DeletedUserAvatar
alt={message.username} userId={message.user_id}
onError={(e) => { className={styles.deletedUserAvatar}
e.target.src = defaultAvatar; iconClassName={styles.deletedUserAvatarIcon}
}} />
/> ) : (
<img
src={message.profile_picture || defaultAvatar}
alt={message.username}
onError={(e) => {
e.target.src = defaultAvatar;
}}
/>
)}
</div> </div>
)} )}
@@ -541,12 +553,14 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
<div <div
className={styles.messageUsername} className={styles.messageUsername}
onClick={handleProfileClick}> onClick={handleProfileClick}>
{message.username} {displayNameForUser({ id: message.user_id, username: message.username })}
<StatusBadge {!isDeletedSender && (
verified={message.verified || false} <StatusBadge
userId={message.user_id} verificationStatus={message.verification_status}
size="small" verified={message.verified || false}
/> size="small"
/>
)}
</div> </div>
)} )}
@@ -17,7 +17,7 @@ import { TypingIndicator } from "./TypingIndicator";
import { OnlineStatus } from "./OnlineStatus"; import { OnlineStatus } from "./OnlineStatus";
import { typingManager } from "@/core/typingManager"; import { typingManager } from "@/core/typingManager";
import { PublicChatPanel } from "./panels/PublicChatPanel"; 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 styles from "@/pages/chat/css/layout.module.scss";
import rightPanelStyles from "@/pages/chat/css/right-panel.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) { export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const { applyPendingPanel, isSwitching, pendingPanel, activePanel, setIsSwitching } = useChatStore(); const { applyPendingPanel, isSwitching, pendingPanel, activePanel, setIsSwitching, setActivePanel } = useChatStore();
const { setProfileDialog } = useProfileStore(); const { setProfileDialog } = useProfileStore();
const messagePanelRef = useRef<HTMLDivElement>(null); const messagePanelRef = useRef<HTMLDivElement>(null);
const [panelState, setPanelState] = useState<MessagePanelState | null>(null); const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
@@ -71,8 +71,38 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
// Drag & drop // Drag & drop
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const [peerDeleted, setPeerDeleted] = useState(false);
const addFilesRef = useRef<null | ((files: File[]) => void)>(null); const addFilesRef = useRef<null | ((files: File[]) => 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(() => { useEffect(() => {
if (!panel || !panelState) return; if (!panel || !panelState) return;
@@ -307,7 +337,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4> <h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<ChatHeaderText panel={panel} /> <ChatHeaderText panel={panel} />
</div> </div>
{panel?.isDm() && ( {panel?.isDm() && !peerDeleted && (
<MaterialIconButton onClick={handleCallClick} icon="call--filled" /> <MaterialIconButton onClick={handleCallClick} icon="call--filled" />
)} )}
</div> </div>
@@ -376,7 +406,17 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</div> </div>
)} )}
{panel && ( {panel && (peerDeleted && panel.isDm() ? (
<div className={rightPanelStyles.deleteChatBar}>
<MaterialButton
variant="filled"
color="error"
onClick={handleDeleteDeletedPeerChat}
>
Удалить чат
</MaterialButton>
</div>
) : (
<ChatInputWrapper <ChatInputWrapper
onSendMessage={(text, files) => { onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files); panel.handleSendMessage(text, replyTo?.id, files);
@@ -433,7 +473,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
} }
}} }}
/> />
)} ))}
</div> </div>
{panel && ( {panel && (
@@ -7,6 +7,8 @@
import { usePresenceStore } from "@/state/presence"; import { usePresenceStore } from "@/state/presence";
import { useUserStore } from "@/state/user"; 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"; import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface OnlineStatusProps { 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); const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : onlineStatuses.get(userId);
function formatLastSeen(lastSeen: string): string { function formatLastSeen(lastSeen: string): string {
const date = new Date(lastSeen); if (isEpochLastSeen(lastSeen)) {
return formatDeletedUserLastSeen();
}
const date = parseApiTimestamp(lastSeen);
const now = new Date(); const now = new Date();
const diffMs = now.getTime() - date.getTime(); const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60)); const diffMins = Math.floor(diffMs / (1000 * 60));
@@ -400,6 +400,7 @@ export class DMPanel extends MessagePanel {
bio: userProfile.bio, bio: userProfile.bio,
memberSince: userProfile.created_at, memberSince: userProfile.created_at,
online: userProfile.online, online: userProfile.online,
deleted: userProfile.deleted,
isOwnProfile: false isOwnProfile: false
}; };
} catch (error) { } catch (error) {
@@ -1,4 +1,5 @@
import { MaterialIcon } from "@/utils/material"; import { MaterialIcon } from "@/utils/material";
import { Link } from "react-router-dom";
import styles from "./download-app.module.scss"; import styles from "./download-app.module.scss";
export default function DownloadAppPage() { export default function DownloadAppPage() {
@@ -20,6 +21,11 @@ export default function DownloadAppPage() {
iOS iOS
</a> </a>
</div> </div>
<p>
<Link to="/privacy">Политика конфиденциальности</Link>
{" · "}
<Link to="/terms">Пользовательское соглашение</Link>
</p>
<p> <p>
<a href="https://t.me/denis0001-dev">Написать в поддержку</a> <a href="https://t.me/denis0001-dev">Написать в поддержку</a>
</p> </p>
+10
View File
@@ -65,6 +65,16 @@ export function HomeFooter({ onScrollToDownload }: HomeFooterProps) {
Лицензия Лицензия
</a> </a>
</div> </div>
<div className={styles.footerSection}>
<Link to="/privacy" className={styles.footerLink}>
<MaterialIcon name="shield" className={styles.footerLinkIcon} />
Политика конфиденциальности
</Link>
<Link to="/terms" className={styles.footerLink}>
<MaterialIcon name="description" className={styles.footerLinkIcon} />
Пользовательское соглашение
</Link>
</div>
<div className={styles.footerSection}> <div className={styles.footerSection}>
<a <a
href="https://t.me/fromchat_ch" href="https://t.me/fromchat_ch"
+6
View File
@@ -0,0 +1,6 @@
import { LegalMarkdownPage } from "@/core/legal/LegalMarkdownPage";
const PrivacyPage = () => <LegalMarkdownPage kind="privacy" />;
const TermsPage = () => <LegalMarkdownPage kind="terms" />;
export { PrivacyPage, TermsPage };
+2 -1
View File
@@ -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 { MessagePanel } from "@/pages/chat/ui/right/panels/MessagePanel";
import { PublicChatPanel } from "@/pages/chat/ui/right/panels/PublicChatPanel"; import { PublicChatPanel } from "@/pages/chat/ui/right/panels/PublicChatPanel";
import { DMPanel } from "@/pages/chat/ui/right/panels/DMPanel"; import { DMPanel } from "@/pages/chat/ui/right/panels/DMPanel";
@@ -17,6 +17,7 @@ export interface ProfileDialogData {
online?: boolean; online?: boolean;
isOwnProfile: boolean; isOwnProfile: boolean;
verified?: boolean; verified?: boolean;
verification_status?: VerificationStatus;
suspended?: boolean; suspended?: boolean;
suspension_reason?: string | null; suspension_reason?: string | null;
deleted?: boolean; deleted?: boolean;
+14 -5
View File
@@ -11,7 +11,7 @@ import type { UserState } from "./types";
interface UserStore { interface UserStore {
user: UserState; user: UserState;
setUser: (token: string, user: User) => void; setUser: (token: string, user: User) => void;
logout: () => void; logout: () => Promise<void>;
restoreFromStorage: () => Promise<void>; restoreFromStorage: () => Promise<void>;
setSuspended: (reason: string) => void; setSuspended: (reason: string) => void;
} }
@@ -46,12 +46,21 @@ export const useUserStore = create<UserStore>((set) => ({
// Ping will be sent automatically on WebSocket reconnect // Ping will be sent automatically on WebSocket reconnect
// No need to send here to avoid duplicate pings // 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 { try {
localStorage.removeItem('authToken'); localStorage.removeItem("authToken");
localStorage.removeItem('currentUser'); localStorage.removeItem("currentUser");
} catch (error) { } catch (error) {
console.error('Failed to clear localStorage:', error); console.error("Failed to clear localStorage:", error);
} }
onlineStatusManager.setAuthToken(null); onlineStatusManager.setAuthToken(null);
+24 -8
View File
@@ -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 * @param {string} dateString - ISO timestamp string to format
* @returns {string} Formatted time string in HH:MM format * @returns {string} Formatted time string in HH:MM format
* @example * @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 { export function formatTime(dateString: string): string {
const date = new Date(dateString); const date = parseApiTimestamp(dateString);
let hours = date.getHours(); if (Number.isNaN(date.getTime())) return "";
let minutes = date.getMinutes(); const hours = date.getHours();
const hoursString = hours < 10 ? '0' + hours : hours; const minutes = date.getMinutes();
const minutesString = minutes < 10 ? '0' + minutes : minutes; const hoursString = hours < 10 ? "0" + hours : String(hours);
return hoursString + ':' + minutesString; const minutesString = minutes < 10 ? "0" + minutes : String(minutes);
return hoursString + ":" + minutesString;
} }
/** /**