Implement legal terms, Android client compatibility and more
@@ -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
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Shared constants and helpers for deleted / suspended user API surface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
from .verification_service import VerificationStatus
|
||||||
|
|
||||||
|
DELETED_LAST_SEEN = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def deleted_username_for(user_id: int) -> str:
|
||||||
|
"""Placeholder username with an illegal character so it cannot be claimed."""
|
||||||
|
return f"#deleted{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_deleted_user(user: User) -> bool:
|
||||||
|
return bool(user.deleted)
|
||||||
|
|
||||||
|
|
||||||
|
def is_suspended_user(user: User) -> bool:
|
||||||
|
return bool(user.suspended) and not user.deleted
|
||||||
|
|
||||||
|
|
||||||
|
def is_deleted_or_suspended(user: User) -> bool:
|
||||||
|
return is_deleted_user(user) or is_suspended_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_deleted_user_db_fields(user: User) -> None:
|
||||||
|
user.deleted = True
|
||||||
|
user.username = deleted_username_for(user.id)
|
||||||
|
user.display_name = ""
|
||||||
|
user.bio = None
|
||||||
|
user.password_hash = ""
|
||||||
|
user.profile_picture = None
|
||||||
|
user.last_seen = DELETED_LAST_SEEN
|
||||||
|
user.created_at = None
|
||||||
|
user.online = False
|
||||||
|
|
||||||
|
|
||||||
|
def deleted_user_api_fields(user_id: int) -> dict:
|
||||||
|
"""Static API fields for deleted users. Ignores all DB columns except id."""
|
||||||
|
return {
|
||||||
|
"username": deleted_username_for(user_id),
|
||||||
|
"display_name": "",
|
||||||
|
"profile_picture": None,
|
||||||
|
"bio": None,
|
||||||
|
"online": False,
|
||||||
|
"last_seen": DELETED_LAST_SEEN.isoformat(),
|
||||||
|
"created_at": None,
|
||||||
|
"verified": False,
|
||||||
|
"verification_status": VerificationStatus.NONE.value,
|
||||||
|
"suspended": False,
|
||||||
|
"suspension_reason": None,
|
||||||
|
"deleted": True,
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import logging
|
|||||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""In-memory user presence derived from WebSocket connections only."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
|
||||||
|
class PresenceService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._connections: dict[int, set[WebSocket]] = {}
|
||||||
|
self._last_seen: dict[int, datetime] = {}
|
||||||
|
|
||||||
|
def register_connection(self, user_id: int, websocket: WebSocket) -> bool:
|
||||||
|
"""Track a live connection. Returns True if the user became online."""
|
||||||
|
connections = self._connections.setdefault(user_id, set())
|
||||||
|
was_online = bool(connections)
|
||||||
|
connections.add(websocket)
|
||||||
|
return not was_online
|
||||||
|
|
||||||
|
def unregister_connection(self, user_id: int, websocket: WebSocket) -> tuple[bool, datetime | None]:
|
||||||
|
"""Remove a connection. Returns (became_offline, last_seen) when the last conn drops."""
|
||||||
|
connections = self._connections.get(user_id)
|
||||||
|
if not connections:
|
||||||
|
return False, self._last_seen.get(user_id)
|
||||||
|
|
||||||
|
connections.discard(websocket)
|
||||||
|
if connections:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
del self._connections[user_id]
|
||||||
|
last_seen = datetime.now()
|
||||||
|
self._last_seen[user_id] = last_seen
|
||||||
|
return True, last_seen
|
||||||
|
|
||||||
|
def touch(self, user_id: int) -> None:
|
||||||
|
"""Refresh activity timestamp while online."""
|
||||||
|
if self.is_online(user_id):
|
||||||
|
self._last_seen[user_id] = datetime.now()
|
||||||
|
|
||||||
|
def is_online(self, user_id: int) -> bool:
|
||||||
|
connections = self._connections.get(user_id)
|
||||||
|
return bool(connections)
|
||||||
|
|
||||||
|
def get_last_seen(self, user_id: int) -> datetime | None:
|
||||||
|
if self.is_online(user_id):
|
||||||
|
return self._last_seen.get(user_id) or datetime.now()
|
||||||
|
return self._last_seen.get(user_id)
|
||||||
|
|
||||||
|
def get_presence(self, user_id: int) -> tuple[bool, datetime | None]:
|
||||||
|
online = self.is_online(user_id)
|
||||||
|
if online:
|
||||||
|
return True, self.get_last_seen(user_id)
|
||||||
|
last_seen = self._last_seen.get(user_id)
|
||||||
|
return False, last_seen
|
||||||
|
|
||||||
|
def remove_user(self, user_id: int) -> None:
|
||||||
|
"""Drop all presence state for a deleted user."""
|
||||||
|
self._connections.pop(user_id, None)
|
||||||
|
self._last_seen.pop(user_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
presence_service = PresenceService()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Server-side metadata for the instance public chat (title, bio).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
_STATIC_PROFILE_PATH = Path(__file__).resolve().parent / "static" / "public_chat_profile.json"
|
||||||
|
|
||||||
|
|
||||||
|
class PublicChatStaticProfile(TypedDict):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
bio: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_public_chat_static_profile() -> PublicChatStaticProfile:
|
||||||
|
if not _STATIC_PROFILE_PATH.is_file():
|
||||||
|
raise FileNotFoundError(f"public chat profile config missing: {_STATIC_PROFILE_PATH}")
|
||||||
|
with _STATIC_PROFILE_PATH.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
chat_id = str(data.get("id", "")).strip()
|
||||||
|
title = str(data.get("title", "")).strip()
|
||||||
|
bio = str(data.get("bio", "")).strip()
|
||||||
|
if not chat_id or not title:
|
||||||
|
raise ValueError("public chat profile config must include non-empty id and title")
|
||||||
|
return PublicChatStaticProfile(id=chat_id, title=title, bio=bio)
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Header-only image dimension reads for very large public-chat attachments."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
_HEADER_READ_BYTES = 4 * 1024 * 1024
|
||||||
|
_HEADER_READ_MAX_BYTES = 16 * 1024 * 1024
|
||||||
|
_JPEG_SOF_MARKERS = frozenset(
|
||||||
|
{0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_placeholder_dimensions(width: int, height: int) -> bool:
|
||||||
|
return width <= 1 and height <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def read_image_dimensions_from_path(path: Path) -> list[int] | None:
|
||||||
|
"""Read pixel width/height without decoding multi-hundred-MP images."""
|
||||||
|
try:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
header = handle.read(_HEADER_READ_BYTES)
|
||||||
|
result = read_image_dimensions_from_bytes(header, path.suffix)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
while len(header) < _HEADER_READ_MAX_BYTES:
|
||||||
|
extra = handle.read(_HEADER_READ_BYTES)
|
||||||
|
if not extra:
|
||||||
|
break
|
||||||
|
header += extra
|
||||||
|
result = read_image_dimensions_from_bytes(header, path.suffix)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning("PUBLIC THUMB: header read failed for %s: %s", path, error)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_image_dimensions_from_bytes(data: bytes, suffix: str = "") -> list[int] | None:
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
ext = suffix.lower()
|
||||||
|
wh: tuple[int, int] | None = None
|
||||||
|
if data.startswith(b"\xff\xd8"):
|
||||||
|
wh = _jpeg_dimensions(data)
|
||||||
|
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
wh = _png_dimensions(data)
|
||||||
|
elif data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
|
||||||
|
wh = _gif_dimensions(data)
|
||||||
|
elif data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP":
|
||||||
|
wh = _webp_dimensions(data)
|
||||||
|
elif ext in {".jpg", ".jpeg"}:
|
||||||
|
wh = _jpeg_dimensions(data)
|
||||||
|
elif ext == ".png":
|
||||||
|
wh = _png_dimensions(data)
|
||||||
|
elif ext == ".gif":
|
||||||
|
wh = _gif_dimensions(data)
|
||||||
|
elif ext == ".webp":
|
||||||
|
wh = _webp_dimensions(data)
|
||||||
|
if wh is None:
|
||||||
|
wh = _pil_dimensions_fallback(data)
|
||||||
|
if wh is None:
|
||||||
|
return None
|
||||||
|
width, height = wh
|
||||||
|
if is_placeholder_dimensions(width, height):
|
||||||
|
return None
|
||||||
|
return [width, height]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_exif_orientation(width: int, height: int, orientation: int) -> tuple[int, int]:
|
||||||
|
if orientation in {5, 6, 7, 8}:
|
||||||
|
return height, width
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exif_orientation(exif_bytes: bytes) -> int | None:
|
||||||
|
try:
|
||||||
|
if len(exif_bytes) < 8:
|
||||||
|
return None
|
||||||
|
endian = exif_bytes[0:2]
|
||||||
|
if endian == b"II":
|
||||||
|
endianness = "<"
|
||||||
|
elif endian == b"MM":
|
||||||
|
endianness = ">"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
ifd_offset = struct.unpack(endianness + "I", exif_bytes[4:8])[0]
|
||||||
|
if ifd_offset + 2 > len(exif_bytes):
|
||||||
|
return None
|
||||||
|
count = struct.unpack(endianness + "H", exif_bytes[ifd_offset : ifd_offset + 2])[0]
|
||||||
|
cursor = ifd_offset + 2
|
||||||
|
for _ in range(count):
|
||||||
|
if cursor + 12 > len(exif_bytes):
|
||||||
|
break
|
||||||
|
tag, field_type, value_count = struct.unpack(endianness + "HHI", exif_bytes[cursor : cursor + 8])
|
||||||
|
value_offset = struct.unpack(endianness + "I", exif_bytes[cursor + 8 : cursor + 12])[0]
|
||||||
|
if tag == 0x0112:
|
||||||
|
if field_type == 3 and value_count == 1:
|
||||||
|
if value_offset <= 0xFFFF:
|
||||||
|
return value_offset & 0xFFFF
|
||||||
|
if value_offset + 2 <= len(exif_bytes):
|
||||||
|
return struct.unpack(endianness + "H", exif_bytes[value_offset : value_offset + 2])[0]
|
||||||
|
cursor += 12
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _jpeg_dimensions(data: bytes) -> tuple[int, int] | None:
|
||||||
|
"""Read JPEG SOF dimensions and apply EXIF orientation when present.
|
||||||
|
|
||||||
|
EXIF APP1 may appear after the SOF segment; scan the full header before returning.
|
||||||
|
"""
|
||||||
|
if len(data) < 4 or data[0:2] != b"\xff\xd8":
|
||||||
|
return None
|
||||||
|
orientation = 1
|
||||||
|
sof_width: int | None = None
|
||||||
|
sof_height: int | None = None
|
||||||
|
index = 2
|
||||||
|
while index + 4 < len(data):
|
||||||
|
if data[index] != 0xFF:
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
while index < len(data) and data[index] == 0xFF:
|
||||||
|
index += 1
|
||||||
|
if index >= len(data):
|
||||||
|
break
|
||||||
|
marker = data[index]
|
||||||
|
index += 1
|
||||||
|
if marker in {0xD8, 0xD9}:
|
||||||
|
continue
|
||||||
|
if index + 2 > len(data):
|
||||||
|
break
|
||||||
|
segment_length = struct.unpack(">H", data[index : index + 2])[0]
|
||||||
|
if segment_length < 2:
|
||||||
|
break
|
||||||
|
segment_start = index + 2
|
||||||
|
segment_end = index + segment_length
|
||||||
|
if segment_end > len(data):
|
||||||
|
break
|
||||||
|
if marker == 0xE1 and segment_end - segment_start > 8:
|
||||||
|
exif = data[segment_start:segment_end]
|
||||||
|
if exif[:6] == b"Exif\x00\x00":
|
||||||
|
parsed = _parse_exif_orientation(exif[6:])
|
||||||
|
if parsed is not None:
|
||||||
|
orientation = parsed
|
||||||
|
if (
|
||||||
|
sof_width is None
|
||||||
|
and marker in _JPEG_SOF_MARKERS
|
||||||
|
and segment_end - segment_start >= 7
|
||||||
|
):
|
||||||
|
sof_height = struct.unpack(">H", data[segment_start + 3 : segment_start + 5])[0]
|
||||||
|
sof_width = struct.unpack(">H", data[segment_start + 5 : segment_start + 7])[0]
|
||||||
|
index = segment_end
|
||||||
|
if sof_width is None or sof_height is None:
|
||||||
|
return None
|
||||||
|
return _apply_exif_orientation(sof_width, sof_height, orientation)
|
||||||
|
|
||||||
|
|
||||||
|
def _png_dimensions(data: bytes) -> tuple[int, int] | None:
|
||||||
|
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||||
|
return None
|
||||||
|
width = struct.unpack(">I", data[16:20])[0]
|
||||||
|
height = struct.unpack(">I", data[20:24])[0]
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return None
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def _gif_dimensions(data: bytes) -> tuple[int, int] | None:
|
||||||
|
if len(data) < 10:
|
||||||
|
return None
|
||||||
|
width = struct.unpack("<H", data[6:8])[0]
|
||||||
|
height = struct.unpack("<H", data[8:10])[0]
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return None
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def _webp_dimensions(data: bytes) -> tuple[int, int] | None:
|
||||||
|
if len(data) < 30 or data[8:12] != b"WEBP":
|
||||||
|
return None
|
||||||
|
chunk = data[12:16]
|
||||||
|
if chunk == b"VP8 " and len(data) >= 30:
|
||||||
|
width = struct.unpack("<H", data[26:28])[0] & 0x3FFF
|
||||||
|
height = struct.unpack("<H", data[28:30])[0] & 0x3FFF
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
return width, height
|
||||||
|
if chunk == b"VP8L" and len(data) >= 25:
|
||||||
|
bits = struct.unpack("<I", data[21:25])[0]
|
||||||
|
width = (bits & 0x3FFF) + 1
|
||||||
|
height = ((bits >> 14) & 0x3FFF) + 1
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
return width, height
|
||||||
|
if chunk == b"VP8X" and len(data) >= 30:
|
||||||
|
width = 1 + (data[24] | (data[25] << 8) | (data[26] << 16))
|
||||||
|
height = 1 + (data[27] | (data[28] << 8) | (data[29] << 16))
|
||||||
|
if width > 1 and height > 1:
|
||||||
|
return width, height
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pil_dimensions_fallback(data: bytes) -> tuple[int, int] | None:
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
with Image.open(__import__("io").BytesIO(data)) as image:
|
||||||
|
image = ImageOps.exif_transpose(image)
|
||||||
|
width, height = image.size
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return None
|
||||||
|
return width, height
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning("PUBLIC THUMB: PIL fallback failed: %s", error)
|
||||||
|
return None
|
||||||
@@ -9,13 +9,28 @@ from sqlalchemy import inspect, text
|
|||||||
import uuid
|
import 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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Static legal documents and expressive icons served from the instance deploy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["static"])
|
||||||
|
|
||||||
|
_STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||||
|
_ICONS_DIR = _STATIC_DIR / "icons"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/static/PRIVACY.md")
|
||||||
|
async def privacy_markdown() -> FileResponse:
|
||||||
|
path = _STATIC_DIR / "PRIVACY.md"
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="PRIVACY.md not found")
|
||||||
|
return FileResponse(path, media_type="text/markdown; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/static/TERMS.md")
|
||||||
|
async def terms_markdown() -> FileResponse:
|
||||||
|
path = _STATIC_DIR / "TERMS.md"
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="TERMS.md not found")
|
||||||
|
return FileResponse(path, media_type="text/markdown; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/static/icons/{name}.webp")
|
||||||
|
async def static_icon(name: str) -> FileResponse:
|
||||||
|
safe = Path(name).name
|
||||||
|
if safe != name or ".." in name:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid icon name")
|
||||||
|
path = _ICONS_DIR / f"{safe}.webp"
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Icon not found")
|
||||||
|
return FileResponse(path, media_type="image/webp")
|
||||||
@@ -9,6 +9,7 @@ from typing import Optional, Dict, Any
|
|||||||
import os
|
import 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,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
<!-- fc:shape=Circle icon=privacy -->
|
||||||
|
## Общее
|
||||||
|
|
||||||
|
Здесь политика конфиденциальности FromChat. Я знаю, что 99% ее даже читать не будут, сделал только для того, чтобы ко мне не было вопросов и чтобы те, кому реально интерессно знали, что происходит с данными.
|
||||||
|
|
||||||
|
Эта политика действует только на официальном сервере [fromchat.ru](https://fromchat.ru). На других серверах политика ставится их админами.
|
||||||
|
|
||||||
|
Вы можете свободно использовать этот текст в любых целях без указания авторства.
|
||||||
|
|
||||||
|
Текст может меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если что-то изменится, я напишу об этом в Telegram-канале.
|
||||||
|
|
||||||
|
<!-- fc:shape=Cookie4Sided icon=storage -->
|
||||||
|
## Ваши данные
|
||||||
|
|
||||||
|
### Какие данные собираются?
|
||||||
|
|
||||||
|
- Логин, имя и прочие данные профиля — без них мессенджер не может существовать. Эти данные видны всем, кто общается с вами.
|
||||||
|
- Пароль — на сервере хранится только односторонний хеш, который используется для проверки. Сервер никогда не видит пароль открытым текстом.
|
||||||
|
- Сообщения в общем чате — они публичны. Любой пользователь на сервере может их увидеть. Они хранятся открытым текстом в базе данных.
|
||||||
|
- Личные сообщения — вкратце: они хранятся в зашифрованном виде, но сервер во время обработки кратко видит открытый текст сообщения. Они могут быть переданы по официальному запросу уполномоченных органов. Если интересно, как именно шифруются сообщения — читайте ниже.
|
||||||
|
- Статус «в сети» и время последней активности — чтобы собеседник видел, когда вы были в сети. К сожалению, скрыть его пока нельзя.
|
||||||
|
- Информация об устройствах (тип, ОС, браузер) — видна только вам, нужно для того, чтобы вы легко распознали взлом и его нейтрализовали.
|
||||||
|
- Звонки — идут в зашифрованном виде через WebRTC-сервер, могут быть записаны в целях соблюдения законодательства и предоставлены уполномоченным органам по запросу.
|
||||||
|
|
||||||
|
<!-- fc:shape=Cookie7Sided icon=chat -->
|
||||||
|
## Больше про личные сообщения
|
||||||
|
|
||||||
|
Если вы очень беспокоетесь за безопасность ваших сообщений, сразу говорю — защита несовершенна и любую защиту можно взломать. Но я постарался сделать доступ к вашим перепискам максимально сложным для хакеров.
|
||||||
|
|
||||||
|
### Весь путь сообщения от вас к собеседнику
|
||||||
|
|
||||||
|
Ваше устройство:
|
||||||
|
1. Вы отправляете сообщение.
|
||||||
|
2. Приложение (клиент) запрашивает открытый ключ у сервера обработки сообщений.
|
||||||
|
3. Приложение скачивает ваш открытый ключ и открытый ключ вашего собеседника.
|
||||||
|
3. Сообщение шифруется этим открытым ключем и отсылается на сервер вместе с открытыми ключами, полученными в предыдущем шаге.
|
||||||
|
|
||||||
|
Сервер:
|
||||||
|
1. Сервер получает ваш запрос на отправку сообщения и пересылает его в изолированный контейнер для обработки сообщений.
|
||||||
|
2. Контейнер расшифровывает ваше сообщение своим закрытым ключем и хранит его в оперативной памяти.
|
||||||
|
3. Создается строка из случайных чисел (MEK).
|
||||||
|
4. Текст вашего сообщения шифруется алгоритмом AES-256, MEK используется как ключ.
|
||||||
|
5. MEK шифруется три раза с помощью вашего открытого ключа и открытых ключей собеседника и официальных запросов.
|
||||||
|
6. Открытый текст вашего сообщения полностью удаляется из оперативной памяти.
|
||||||
|
7. Контейнер возвращает главному серверу зашифрованное сообщение вместе с тремя экземплярами MEK.
|
||||||
|
8. Сообщение записывается в базу данных.
|
||||||
|
|
||||||
|
Устройство собеседника:
|
||||||
|
1. Оно получает ваше сообщение и расшифровывает MEK закрытым ключем, сохраненном в аккаунте собеседника в зашифрованном виде, где пароль от аккаунта используется как ключ.
|
||||||
|
2. Зашифрованный текст сообщения расшифровывается с MEK как ключ.
|
||||||
|
3. Собеседник прочитал ваше сообщение.
|
||||||
|
|
||||||
|
<!-- fc:shape=Cookie9Sided icon=shield -->
|
||||||
|
## Реклама и продажа данных
|
||||||
|
|
||||||
|
Никакой рекламы с моей стороны и продажи ваших данных нет и никогда не будет. Мне нет смысла злить вас ради собственной выгоды.
|
||||||
|
|
||||||
|
На данный момент приложение не собирает никакой аналитики.
|
||||||
|
|
||||||
|
В каналах теоритически может быть реклама от их админов. Я в ней не виноват и контролировать не могу.
|
||||||
|
|
||||||
|
<!-- fc:shape=Cookie4Sided icon=delete -->
|
||||||
|
## Удаление данных
|
||||||
|
|
||||||
|
Если вы хотите удалить сообщение, удерживайте и нажмите "Удалить". Тогда сообщение пропадет из публичного доступа. Зашифрованная копия сообщения останется в целях соблюдения законодательства на 6 месяцев.
|
||||||
|
|
||||||
|
Если вам нужно удалить ваши данные профиля из публичного доступа, вы можете удалить аккаунт в настройках приложения.
|
||||||
|
|
||||||
|
В таком случае все сообщения, которые вы отправили будут анонимизированы, но не удалены.
|
||||||
|
|
||||||
|
Если вам нужно удалить ВСЕ, что связано с вашим профилем из публичного доступа, напишите в Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
|
||||||
|
|
||||||
|
<!-- fc:shape=Circle icon=terms -->
|
||||||
|
## Общее
|
||||||
|
|
||||||
|
**FromChat** — 100% бесплатный и открытый мессенджер. Я создал эти правила, чтобы вы точно знали, что можно, а что нельзя.
|
||||||
|
|
||||||
|
Эти правила действуют только на официальном сервере [fromchat.ru](https://fromchat.ru). Админы других серверов устанавливают свои правила.
|
||||||
|
|
||||||
|
Вы можете свободно использовать этот текст в любых целях без указания авторства.
|
||||||
|
|
||||||
|
Сервис предоставляется как есть, перебои и сбои будут гарантированно из-за слабенькой малинки.
|
||||||
|
|
||||||
|
Правила могут меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если правила изменятся, я напишу об этом в Telegram-канале.
|
||||||
|
|
||||||
|
<!-- fc:shape=Cookie4Sided icon=person -->
|
||||||
|
## Ваш аккаунт
|
||||||
|
|
||||||
|
Условия вступают в силу, когда вы создаете аккаунт. Также советую прочитать [политику конфиденциальности](/api/static/PRIVACY.md), поверьте, это очень важно.
|
||||||
|
|
||||||
|
Вы полностью отвечаете за все, что происходит в вашем аккаунте. Если поставите пароль `12345` — вас точно взломают :)
|
||||||
|
|
||||||
|
Если вы нарушите правила, я вас заблокирую. В таком случае вы сможете только читать сообщения, а отправка и реакции будут заблокированы. Если считаете, что я не прав — пишите в Telegram: [@denis0001_dev](https://t.me/denis0001_dev).
|
||||||
|
|
||||||
|
<!-- fc:shape=Cookie7Sided icon=terms -->
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
### Для общего чата
|
||||||
|
Общий чат — это площадка для общения между всеми пользователями на этом сервере. По очевидным причинам, тут запрещено:
|
||||||
|
- Материться, использовать 18+ и другие неприличные слова;
|
||||||
|
- Разговаривать на тему политики, религии, нелегальных действий и неприличия;
|
||||||
|
- Оскорблять других;
|
||||||
|
- Сливать персональные данные (адрес, номер, ФИО и прочее);
|
||||||
|
- Рекламировать любые продукты, сервисы и прочее без моего согласия;
|
||||||
|
- Популяризировать VPN и другие способы обхода блокировок (это закон, не мое личное правило);
|
||||||
|
- Угрожать в любом виде;
|
||||||
|
- Спамить или засорять чат.
|
||||||
|
|
||||||
|
В целях защиты от спама количество сообщений в минуту ограничено и нельзя отправлять слишком много сообщений с одинаковым текстом. При нарушении вы будете автоматически заблокированы. Алгоритм очень примитивный, поэтому ошибки будут. Если это была ошибка, я вас разблокирую.
|
||||||
|
|
||||||
|
### Для личных сообщений
|
||||||
|
|
||||||
|
За личными сообщениями я не шпионю, но могу предоставить по официальному запросу. Поэтому я пока не могу выявлять там нарушения. Я скоро сделаю механизм жалоб.
|
||||||
|
|
||||||
|
В личке правил гораздо меньше. Мне лень писать снова длинный список, поэтому просто прошу вас, не занимайтесь нелегальными вещами и не спамьте. В личке можно обсуждать все остальное и материться.
|
||||||
|
|
||||||
|
### Глобальные правила
|
||||||
|
|
||||||
|
Пожалуйста, не используйте мессенджер для спама и не устраивайте DDoS или любые другие атаки.
|
||||||
|
|
||||||
|
<!-- fc:shape=Cookie9Sided icon=phone -->
|
||||||
|
## Контакты
|
||||||
|
|
||||||
|
### Если у вас возникли любые вопросы, пишите сюда:
|
||||||
|
|
||||||
|
Почта: [support@fromchat.ru](mailto:support@fromchat.ru)
|
||||||
|
|
||||||
|
Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true)
|
||||||
|
|
||||||
|
FromChat: [@denis0001-dev](https://fromchat.ru/@denis0001-dev)
|
||||||
|
|
||||||
|
### Вопросы по безопасности, сообщения об узвимостях
|
||||||
|
|
||||||
|
Если вдруг вы найдете уязвимость или есть вопрос про безопасность, срочно пишите сюда:
|
||||||
|
|
||||||
|
[security@fromchat.ru](mailto:security@fromchat.ru)
|
||||||
|
|
||||||
|
О шифровании договоримся, если надо.
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 794 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1018 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 980 B |
|
After Width: | Height: | Size: 944 B |
|
After Width: | Height: | Size: 838 B |
|
After Width: | Height: | Size: 856 B |
|
After Width: | Height: | Size: 516 B |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"id": "general",
|
||||||
|
"title": "Общий чат",
|
||||||
|
"bio": "Общаемся со всеми пользователями FromChat!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Server-side verification status computation."""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
from .similarity import is_user_similar_to_verified
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationStatus(str, Enum):
|
||||||
|
VERIFIED = "verified"
|
||||||
|
WARNING = "warning"
|
||||||
|
BLOCKED = "blocked"
|
||||||
|
NONE = "none"
|
||||||
|
|
||||||
|
|
||||||
|
def get_verified_users_data(db: Session) -> list[dict[str, str]]:
|
||||||
|
verified_users = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(
|
||||||
|
User.verified.is_(True),
|
||||||
|
User.deleted.is_(False),
|
||||||
|
User.suspended.is_(False),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"username": user.username, "display_name": user.display_name}
|
||||||
|
for user in verified_users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def compute_verification_status(
|
||||||
|
user: User,
|
||||||
|
verified_users_data: list[dict[str, str]],
|
||||||
|
) -> VerificationStatus:
|
||||||
|
if user.deleted:
|
||||||
|
return VerificationStatus.NONE
|
||||||
|
if user.suspended:
|
||||||
|
return VerificationStatus.BLOCKED
|
||||||
|
if user.verified:
|
||||||
|
return VerificationStatus.VERIFIED
|
||||||
|
|
||||||
|
is_similar, _ = is_user_similar_to_verified(
|
||||||
|
user.username,
|
||||||
|
user.display_name,
|
||||||
|
verified_users_data,
|
||||||
|
)
|
||||||
|
return VerificationStatus.WARNING if is_similar else VerificationStatus.NONE
|
||||||
@@ -11,6 +11,7 @@ from ..routes.messaging import (
|
|||||||
MessaggingSocketManager,
|
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:
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)})`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: "Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------
|
// ----------
|
||||||
|
|||||||
@@ -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 "был(а) давно";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { LegalMarkdownPage } from "@/core/legal/LegalMarkdownPage";
|
||||||
|
|
||||||
|
const PrivacyPage = () => <LegalMarkdownPage kind="privacy" />;
|
||||||
|
const TermsPage = () => <LegalMarkdownPage kind="terms" />;
|
||||||
|
|
||||||
|
export { PrivacyPage, TermsPage };
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||