mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Compare commits
36 Commits
@@ -1,179 +1,51 @@
|
||||
# Security Audit Command
|
||||
|
||||
Perform a comprehensive security audit of the FromChat application codebase.
|
||||
Perform a comprehensive security audit of the FromChat **Android application** only.
|
||||
|
||||
## Project Context
|
||||
|
||||
**FromChat** is a 100% open source secure messaging application with:
|
||||
- React/TypeScript frontend
|
||||
- Python FastAPI backend
|
||||
- End-to-end encryption for DMs and calls
|
||||
- Caddy reverse proxy with security headers
|
||||
**FromChat Android** is a 100% open source secure messaging mobile application built with:
|
||||
|
||||
- Kotlin Multiplatform (KMP) shared code
|
||||
- Jetpack Compose UI framework
|
||||
- End-to-End Encryption (NaCl, AES-GCM)
|
||||
- WebSocket support for real-time features
|
||||
- Electron support for desktop app
|
||||
- LiveKit integration for calls
|
||||
- Local database storage (SQLite)
|
||||
|
||||
## Scope: Android Only
|
||||
|
||||
**OUT OF SCOPE:**
|
||||
|
||||
- Web backend (Python FastAPI, Caddy infrastructure)
|
||||
- React/TypeScript frontend
|
||||
|
||||
**IN SCOPE:**
|
||||
|
||||
- Android app code (`app/android`, `app/shared/src/androidMain`)
|
||||
- Shared cross-platform code (`app/shared/src/commonMain`)
|
||||
- Local encryption implementation (NaCl, AES-GCM)
|
||||
- Secure storage (Android Keystore, encrypted SharedPreferences)
|
||||
- WebSocket client security
|
||||
- Permission usage and handling
|
||||
- Call security (LiveKit integration)
|
||||
- Memory safety and injection attacks
|
||||
- Backend
|
||||
|
||||
## Important Design Decisions (NOT Vulnerabilities)
|
||||
|
||||
When auditing, remember these are **intentional design choices**:
|
||||
|
||||
1. **Public messages endpoint** - Open forum accessible without authentication (by design)
|
||||
- The public chat is meant to be an open forum
|
||||
- Private DMs are properly E2E encrypted and require authentication
|
||||
|
||||
2. **Public user list** - All users visible in DMs tab (by design)
|
||||
- Users can see all registered accounts
|
||||
- This is intentional for a community-based chat app
|
||||
|
||||
3. **XSS protection** - Multi-layer defense already implemented:
|
||||
- React auto-escaping
|
||||
- DOMPurify for sanitization
|
||||
- Caddy CSP headers
|
||||
- Do NOT flag localStorage key storage as critical (already well-protected)
|
||||
|
||||
4. **File upload security** - Docker isolation in place:
|
||||
- Server runs in Docker without executable flags
|
||||
- Files cannot execute on server
|
||||
- PIL re-encodes images
|
||||
- Do NOT flag Content-Type validation as critical
|
||||
|
||||
5. **CSRF protection** - Not needed:
|
||||
- No cookies used
|
||||
- JWT tokens in Authorization headers only
|
||||
- CSRF attacks don't apply to this auth model
|
||||
|
||||
6. **Beta domain CSP** - 'unsafe-inline' is required:
|
||||
- Beta domain (beta.fromchat.ru) points to development machine
|
||||
- Vite dev server requires 'unsafe-inline' to function
|
||||
- Production domain has strict CSP
|
||||
|
||||
7. **Security logging** - Already implemented:
|
||||
- All events are logged including security-related activity
|
||||
- Do NOT flag as missing
|
||||
|
||||
8. **100% Open Source** - This is a security strength:
|
||||
- Full transparency
|
||||
- Community review capability
|
||||
- No hidden backdoors
|
||||
|
||||
## Android App
|
||||
|
||||
**EXCLUDE from all audits** - Android app is not production-ready and out of scope.
|
||||
|
||||
## Infrastructure (Caddy)
|
||||
|
||||
The application runs behind Caddy reverse proxy with comprehensive security controls:
|
||||
|
||||
### Caddyfile Configuration
|
||||
|
||||
```caddyfile
|
||||
fromchat.ru {
|
||||
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 {
|
||||
lb_policy first
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||
X-Frame-Options "DENY" # Prevent clickjacking
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||
}
|
||||
|
||||
rate_limit {
|
||||
zone global {
|
||||
key {remote_ip}
|
||||
window 1m
|
||||
burst 20
|
||||
events 500
|
||||
}
|
||||
}
|
||||
|
||||
handle_errors {
|
||||
@errors {
|
||||
expression {err.status_code} >= 400
|
||||
}
|
||||
|
||||
handle @errors {
|
||||
rewrite * /{err.status_code}
|
||||
reverse_proxy https://http.cat {
|
||||
header_up Host {upstream_hostport}
|
||||
replace_status {err.status_code}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beta.fromchat.ru {
|
||||
reverse_proxy 95.165.0.162:8301
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||
X-Frame-Options "DENY" # Prevent clickjacking
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||
}
|
||||
|
||||
rate_limit {
|
||||
zone global {
|
||||
key {remote_ip}
|
||||
window 1m
|
||||
burst 20
|
||||
events 1000
|
||||
}
|
||||
}
|
||||
|
||||
handle_errors {
|
||||
@errors {
|
||||
expression {err.status_code} >= 400
|
||||
}
|
||||
|
||||
handle @errors {
|
||||
rewrite * /{err.status_code}
|
||||
reverse_proxy https://http.cat {
|
||||
header_up Host {upstream_hostport}
|
||||
replace_status {err.status_code}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Infrastructure Protections
|
||||
|
||||
- ✅ **HTTPS enforcement** - Automatic SSL/TLS with Caddy
|
||||
- ✅ **HSTS** - Strict-Transport-Security with preload
|
||||
- ✅ **CSP** - Content Security Policy (strict on production, 'unsafe-inline' for scripts on beta for Vite)
|
||||
- ✅ **Rate limiting** - 500 events/min (production), 1000 events/min (beta)
|
||||
- ✅ **X-Frame-Options: DENY** - Prevents clickjacking
|
||||
- ✅ **X-Content-Type-Options: nosniff** - Prevents MIME sniffing
|
||||
- ✅ **X-XSS-Protection: 1; mode=block** - XSS protection
|
||||
- ✅ **Permissions-Policy** - Restricts geolocation, allows camera/mic for calls
|
||||
|
||||
**Important:** These protections are already in place at the infrastructure level. Don't flag missing security headers or rate limiting in the application code.
|
||||
|
||||
## Audit Process
|
||||
|
||||
1. **Read the Caddyfile first** to understand infrastructure protections
|
||||
2. **Check backend code** for authentication, authorization, input validation
|
||||
3. **Review frontend code** for XSS protections, crypto implementation
|
||||
4. **Verify E2E encryption** implementation (NaCl for DMs, AES-GCM for calls)
|
||||
5. **Test CORS configuration** in backend/app.py
|
||||
6. **Review password policies** in backend/validation.py
|
||||
7. **Check file upload handling** in backend/routes/messaging.py and profile.py
|
||||
|
||||
## Rating Guidelines
|
||||
|
||||
- **Infrastructure (Caddy):** Should be 9/10 or higher (excellent security headers)
|
||||
- **Cryptography:** Should be 8-9/10 (uses industry-standard libraries)
|
||||
- **Frontend Security:** Should be 7-8/10 (multi-layer XSS protection)
|
||||
- **Backend API:** Focus on CORS, password policies, rate limiting
|
||||
1. **Local message caching** - Messages downloaded and stored locally (by design)
|
||||
- Messages are end-to-end encrypted at rest in local DB
|
||||
- Public DMs are not encrypted (messages are public)
|
||||
- Private DMs use NaCl encryption
|
||||
- Cache persists across app restarts for offline access
|
||||
2. **Local key storage** - Encryption keys stored on device (by design)
|
||||
- Keys protected by Android Keystore (hardware-backed when available)
|
||||
- Encrypted with device-specific secrets
|
||||
- User data never leaves device in plaintext
|
||||
- Do NOT flag key storage as critical (Keystore is production-ready)
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -183,8 +55,8 @@ Provide a **clean, concise report** with:
|
||||
2. **Security Status** - Critical issues (if any) and recommendations
|
||||
3. **Security Strengths** - What's done well
|
||||
4. **Component Ratings** - Table format for quick reference
|
||||
5. **Design Decisions** - Clarify what's intentional vs vulnerable
|
||||
6. **Threat Analysis** - Current realistic threats only
|
||||
5. **Architecture Review** - Data flow, encryption boundaries
|
||||
6. **Threat Analysis** - Current realistic threats (e.g., rooted device, malicious APK)
|
||||
7. **Recommendations** - Prioritized with time estimates
|
||||
8. **Conclusion** - Clear production readiness statement
|
||||
|
||||
@@ -193,43 +65,8 @@ Provide a **clean, concise report** with:
|
||||
## Common False Positives to Avoid
|
||||
|
||||
❌ **DO NOT FLAG THESE AS ISSUES:**
|
||||
- Public messages endpoint (intentional)
|
||||
- Username enumeration (users list is public by design)
|
||||
- Keys in localStorage (XSS is well-protected)
|
||||
- Content-Type validation (Docker isolation prevents execution)
|
||||
- CSRF protection (not applicable - no cookies)
|
||||
- Beta CSP 'unsafe-inline' (required for Vite)
|
||||
- Security logging (already implemented)
|
||||
- Android app security (out of scope)
|
||||
|
||||
## Key Security Features to Verify
|
||||
|
||||
✅ **MUST CHECK:**
|
||||
- CORS configuration in backend/app.py
|
||||
- Password validation in backend/validation.py
|
||||
- JWT token generation and validation
|
||||
- E2E encryption implementation (NaCl, AES-GCM)
|
||||
- File upload sanitization
|
||||
- Authorization checks on sensitive endpoints
|
||||
- Rate limiting configuration
|
||||
- Security headers in Caddyfile
|
||||
|
||||
## Example Good Finding Format
|
||||
|
||||
```markdown
|
||||
### Password Policy (HIGH PRIORITY - Non-blocking)
|
||||
**Current:** 5 character minimum
|
||||
**Recommended:** 12+ characters with complexity requirements
|
||||
**Risk:** Brute force attacks (mitigated by rate limiting)
|
||||
**Estimated Fix:** 4-6 hours
|
||||
**Code Location:** backend/validation.py:11-16
|
||||
```
|
||||
|
||||
## Notes from Developer
|
||||
|
||||
- Application is production-ready after CORS fix
|
||||
- Focus on practical, actionable improvements
|
||||
- Don't overthink things that are already well-protected
|
||||
- Open source is a feature, not a concern
|
||||
- Community can audit the code themselves
|
||||
- Local message caching (intentional for offline access)
|
||||
- Public message viewing without auth (intentional design)
|
||||
- Debuggable APK (only relevant if signed/released)
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ When working with this project, follow these rules:
|
||||
## File Operations
|
||||
- If possible, try to update files in a single edit when making multiple changes.
|
||||
- Do NOT "cd" to the project directory.
|
||||
- NEVER edit/delete/regenerate .env files without explicit permission.
|
||||
- Don't use rg, it doesn't work anyway.
|
||||
|
||||
## Testing & Validation
|
||||
- Do NOT "test the implementation" when you are done. The only exception is when you
|
||||
@@ -31,8 +33,7 @@ When working with this project, follow these rules:
|
||||
- If the typecheck passed, there's no need for checking the linter errors.
|
||||
|
||||
## Async Operations
|
||||
- When you need a delay, use `await delay(millis);` from `@/utils/utils` in an async function. If the current function is not async,
|
||||
make it async.
|
||||
- When you need a delay, use `await delay(millis);` from `@/utils/utils` in an async function. If the current function is not async, make it async.
|
||||
|
||||
## Database
|
||||
- NEVER create database migrations, they are auto-generated.
|
||||
@@ -58,4 +59,9 @@ When working with this project, follow these rules:
|
||||
- Put SCSS into one folder per page
|
||||
|
||||
## Animations with Framer Motion
|
||||
- Don't use variants if they are used only once
|
||||
- Don't use variants if they are used only once
|
||||
|
||||
## Debug Mode
|
||||
- When in debug mode and the issue is not yet fixed, ALWAYS end responses with `<reproduction_steps>` containing the steps to reproduce the issue and trigger logging
|
||||
- When NOT in debug mode or when the issue IS fixed, escape the tag as `<reproduction_steps>` to avoid triggering it
|
||||
- Never use other `<re>` tags, only `<reproduction_steps>`
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Do not filter cache, API, or DB fields by matching fixed UI/placeholder English strings
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# No magic-string “sanitization” of user or message data
|
||||
|
||||
- **Never** strip, null out, or rewrite stored or displayed values by comparing them to hard-coded UI strings (e.g. `"Direct messages"`, `"Direct message"`, `"User 123"`, etc.). Those strings can be legitimate **usernames, display names, or message text**.
|
||||
- **Prefer**: fix the source (don’t persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
|
||||
- Applies especially to: local storage/cache layers, list previews, and any code that “cleans” strings before show or read.
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: decrypt
|
||||
description: Decrypts a DM message on localhost by granting temporary compliance extract access, running extract and decrypt CLI commands, then reverting all temporary changes. Use when the user asks to decrypt a message or run the compliance decryption workflow on localhost.
|
||||
---
|
||||
|
||||
# Decrypt (local debug)
|
||||
|
||||
End-to-end workflow for extracting and decrypting one message on **localhost**. All temporary backend access must be removed when finished.
|
||||
|
||||
**After decrypt:** what to do with the plaintext and files (compare, inspect, report, etc.) comes from the **current conversation** — not from this skill.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Web dev server running at `http://localhost:8301` (browser rules default).
|
||||
- `compliance_keypair.txt` at the Web repo root (`Web/compliance_keypair.txt`).
|
||||
- Python deps for `scripts/compliance/decryption/` (`cryptography`, etc.).
|
||||
|
||||
## Step 0 — Read the compliance keypair
|
||||
|
||||
Read `Web/compliance_keypair.txt` before any decrypt step. Confirm the **private** key line exists (line after `PRIVATE KEY` header, base64).
|
||||
|
||||
The decrypt CLI loads `compliance_keypair.txt` from the **current working directory**. Always run extract/decrypt with `cd` to the Web repo root:
|
||||
|
||||
```bash
|
||||
cd /path/to/Web
|
||||
```
|
||||
|
||||
If `load_compliance_private_key` fails, the file format may need a `PRIVATE_KEY=<base64>` line (the loader expects that or a 43-char base64 line; 32-byte keys are often 44 chars with padding).
|
||||
|
||||
## Step 1 — Create a temporary user and capture token
|
||||
|
||||
Generate random credentials (username 3–20 chars: letters, digits, `-`, `_`; password 5–50 chars, no spaces):
|
||||
|
||||
```bash
|
||||
DEBUG_USER="decryptdbg$(openssl rand -hex 3)"
|
||||
DEBUG_PASS="$(openssl rand -base64 12 | tr -d '/+=' | head -c 16)"
|
||||
echo "user=$DEBUG_USER pass=$DEBUG_PASS"
|
||||
```
|
||||
|
||||
Register and save **`token`** from the JSON response (not the derived login secret):
|
||||
|
||||
```bash
|
||||
REGISTER_JSON=$(curl -sS -X POST "http://localhost:8301/api/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"${DEBUG_USER}\",\"display_name\":\"DecryptDebug\",\"password\":\"${DEBUG_PASS}\",\"confirm_password\":\"${DEBUG_PASS}\"}")
|
||||
echo "$REGISTER_JSON"
|
||||
DEBUG_TOKEN=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' <<< "$REGISTER_JSON")
|
||||
echo "token saved (length ${#DEBUG_TOKEN})"
|
||||
```
|
||||
|
||||
Alternatively export for the CLI: `export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"`
|
||||
|
||||
Save `user.id` from the same JSON if needed for debugging.
|
||||
|
||||
## Step 2 — Temporary compliance extract permission (marker blocks)
|
||||
|
||||
`GET /api/dm/compliance/extract/{message_id}` is restricted to **user id 1** in:
|
||||
|
||||
`backend/services/main/routes/envelope_messaging.py` (function `extract_message_for_compliance`).
|
||||
|
||||
Add a **temporary** bypass between searchable markers (replace `DEBUG_USERNAME` with the user from step 1):
|
||||
|
||||
```python
|
||||
# TEMP_COMPLIANCE_DEBUG_START — remove after decrypt debug run
|
||||
_TEMP_COMPLIANCE_DEBUG_USERNAMES = {"DEBUG_USERNAME"}
|
||||
# TEMP_COMPLIANCE_DEBUG_END
|
||||
|
||||
# Security check: only user ID 1 can access this
|
||||
if current_user.id != 1 and current_user.username not in _TEMP_COMPLIANCE_DEBUG_USERNAMES:
|
||||
```
|
||||
|
||||
Backend will auto-reload after this change, you don't need to do anything.
|
||||
|
||||
To remove later: search the repo for `TEMP_COMPLIANCE_DEBUG` and delete the marker block + revert the `if` to only `current_user.id != 1`.
|
||||
|
||||
## Step 3 — Extract bundle (online)
|
||||
|
||||
From Web repo root, set variables and run (user supplies `MESSAGE_ID`):
|
||||
|
||||
```bash
|
||||
MESSAGE_ID=<message_id>
|
||||
RANDOM_FOLDER="run_$(openssl rand -hex 4)"
|
||||
BUNDLE_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}"
|
||||
DECRYPT_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}_dec"
|
||||
mkdir -p /tmp/debug_decrypt
|
||||
|
||||
cd /path/to/Web
|
||||
python scripts/compliance/decryption/main.py extract \
|
||||
--server localhost:8301 \
|
||||
--http \
|
||||
--token "${DEBUG_TOKEN}" \
|
||||
--message-ids "${MESSAGE_ID}" \
|
||||
--out-dir "${BUNDLE_DIR}"
|
||||
```
|
||||
|
||||
Equivalent using env (no `--token` flag):
|
||||
|
||||
```bash
|
||||
export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"
|
||||
python scripts/compliance/decryption/main.py extract \
|
||||
--server localhost:8301 \
|
||||
--http \
|
||||
--message-ids "${MESSAGE_ID}" \
|
||||
--out-dir "${BUNDLE_DIR}"
|
||||
```
|
||||
|
||||
Confirm `${BUNDLE_DIR}/bundle.json` exists.
|
||||
|
||||
**Auth flags:** use `--token` (Bearer from register/login), or `--username` + `--password` (CLI derives the login secret). Do not pass both.
|
||||
|
||||
## Step 4 — Decrypt bundle (offline)
|
||||
|
||||
Still from Web repo root (`compliance_keypair.txt` must resolve):
|
||||
|
||||
```bash
|
||||
python scripts/compliance/decryption/main.py decrypt \
|
||||
--bundle-dir "${BUNDLE_DIR}" \
|
||||
--output-dir "${DECRYPT_DIR}"
|
||||
```
|
||||
|
||||
Outputs:
|
||||
|
||||
- `${DECRYPT_DIR}/messages/<message_id>/message.decrypted.txt` — message plaintext
|
||||
- `${DECRYPT_DIR}/messages/<message_id>/files/` — decrypted attachments
|
||||
- `${DECRYPT_DIR}/index.html` — HTML report
|
||||
|
||||
## Step 5 — Use decrypted output (conversation-driven)
|
||||
|
||||
Follow the **user’s request in the current chat** for what to do next (e.g. compare hashes, inspect text, verify a specific attachment). This skill stops at producing `${DECRYPT_DIR}`; do not assume a fixed post-decrypt task.
|
||||
|
||||
## Step 6 — Cleanup (required)
|
||||
|
||||
1. **Remove decrypt dirs:**
|
||||
```bash
|
||||
rm -rf "${BUNDLE_DIR}" "${DECRYPT_DIR}"
|
||||
```
|
||||
|
||||
2. **Delete temp user** (local SQLite default DB):
|
||||
```bash
|
||||
sqlite3 backend/data/database.db "DELETE FROM users WHERE username='${DEBUG_USER}';"
|
||||
```
|
||||
If your deployment uses another DB, delete the same username there.
|
||||
|
||||
3. **Remove temporary permission:** search `TEMP_COMPLIANCE_DEBUG` in the repo, delete the marker block, restore the original `if current_user.id != 1:` check, restart backend.
|
||||
|
||||
4. Do **not** commit `compliance_keypair.txt` or any decrypt output.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Read compliance_keypair.txt
|
||||
- [ ] Register DEBUG_USER; save DEBUG_TOKEN from response
|
||||
- [ ] Add TEMP_COMPLIANCE_DEBUG_* bypass; restart backend
|
||||
- [ ] extract --token → bundle.json present
|
||||
- [ ] decrypt → output under DECRYPT_DIR
|
||||
- [ ] Post-decrypt work per conversation context
|
||||
- [ ] rm -rf BUNDLE_DIR and DECRYPT_DIR
|
||||
- [ ] DELETE temp user from DB
|
||||
- [ ] Remove TEMP_COMPLIANCE_DEBUG markers; restart backend
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
- Extract API: `GET /api/dm/compliance/extract/{message_id}` (see `bundle_extract.py`).
|
||||
- Decrypt implementation: `scripts/compliance/decryption/bundle_decrypt.py`, `crypto.py`.
|
||||
- Extract auth: `--token` (Bearer), env `FROMCHAT_API_TOKEN` / `FROMCHAT_TOKEN`, or `--username` + plain `--password` (CLI calls `derive_auth_secret` for login only).
|
||||
|
||||
## Rules
|
||||
- NEVER use sleep in any command.
|
||||
- Set a timeout on EVERY command that may request user input.
|
||||
+44
-9
@@ -1,12 +1,47 @@
|
||||
# Exclude data directory to prevent local database from being copied into production images
|
||||
backend/data/
|
||||
# Shared build context ignore (used by deployment images with context: ..)
|
||||
|
||||
# Exclude logs
|
||||
backend/logs/
|
||||
# VCS / editor
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.DS_Store
|
||||
|
||||
# Exclude development files
|
||||
node_modules/
|
||||
.git/
|
||||
.gitignore
|
||||
README.md
|
||||
# Secrets / local env
|
||||
.env
|
||||
deployment/.env
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.vite
|
||||
dist
|
||||
dist-electron
|
||||
build
|
||||
out
|
||||
coverage
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ipynb_checkpoints
|
||||
.venv
|
||||
venv/
|
||||
|
||||
# App runtime data/logs (mounted, not baked)
|
||||
backend/data
|
||||
backend/files
|
||||
data
|
||||
logs
|
||||
**/logs
|
||||
**/logs/**
|
||||
*.log
|
||||
|
||||
# Deploy cache (hashes)
|
||||
.deploy-cache
|
||||
|
||||
# Firebase cert: bind-mounted at runtime; do not send to docker build context
|
||||
firebase-cert.json
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# HTTP API host.
|
||||
# Example for local backend: http://localhost:8300
|
||||
VITE_API_BASE_URL=http://localhost:8300
|
||||
@@ -1,63 +0,0 @@
|
||||
# Simple workflow for deploying to the self-hosted server
|
||||
name: Deploy to server
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
# push:
|
||||
# branches: ["main"]
|
||||
# paths:
|
||||
# - "backend/**"
|
||||
# - "frontend/**"
|
||||
# - "deployment/**"
|
||||
# - "**/package.json"
|
||||
# - ".nvmrc"
|
||||
# - ".github/workflows/deploy.yml"
|
||||
# - "!frontend/electron/**"
|
||||
# - "!**.d.ts"
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: raspberry-pi
|
||||
env:
|
||||
HOME: "/root"
|
||||
environment:
|
||||
name: production
|
||||
url: https://fromchat.ru
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: |
|
||||
mkdir -p deployment
|
||||
touch deployment/.env
|
||||
cat > deployment/.env << EOF
|
||||
JWT_SECRET=${{ secrets.JWT_SECRET }}
|
||||
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||
TURN_USERNAME=${{ vars.TURN_USERNAME }}
|
||||
TURN_PASSWORD=${{ secrets.TURN_PASSWORD }}
|
||||
EOF
|
||||
- name: Build container
|
||||
run: |
|
||||
cd deployment
|
||||
docker compose build
|
||||
- name: Set up the service
|
||||
run: |
|
||||
cp -f deployment/fromchat.service /etc/systemd/system/fromchat.service
|
||||
systemctl daemon-reload
|
||||
- name: Start the server
|
||||
run: |
|
||||
if ! systemctl restart fromchat && sleep 10 && systemctl status fromchat; then
|
||||
journalctl --no-pager -xeu fromchat
|
||||
exit 1
|
||||
fi
|
||||
+20
-1
@@ -35,6 +35,9 @@ Temporary Items
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### FromChat local tools (downloaded LiveKit server binary) ###
|
||||
.tools/
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
@@ -118,6 +121,12 @@ web_modules/
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# Firebase service account JSON (backend/firebase-cert.json; bind-mounted in docker-compose)
|
||||
firebase-cert.json
|
||||
**/firebase-cert.json
|
||||
firebase-adminsdk.json
|
||||
**/firebase-adminsdk.json
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
@@ -567,7 +576,9 @@ buck-out/
|
||||
|
||||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||
|
||||
backend/services/main/.fromchat_instance_id
|
||||
backend/data
|
||||
backend/files
|
||||
.vite
|
||||
*.db
|
||||
package-lock.json
|
||||
@@ -579,4 +590,12 @@ backend/alembic/**
|
||||
|
||||
.cursor/plans
|
||||
tmp
|
||||
compliance_keypair.txt
|
||||
compliance_keypair.txt
|
||||
backend/files
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
.deploy-cache
|
||||
|
||||
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/
|
||||
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/lib
|
||||
|
||||
Vendored
+9
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"npm.autoDetect": "off",
|
||||
"files.exclude": {
|
||||
".husky": true,
|
||||
"build": true
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/__pycache__": true,
|
||||
@@ -7,5 +15,6 @@
|
||||
"**/.venv": true,
|
||||
"**/node_modules": true
|
||||
},
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"python.terminal.activateEnvironment": false
|
||||
}
|
||||
Vendored
+13
-47
@@ -1,28 +1,10 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Backend",
|
||||
"type": "npm",
|
||||
"script": "backend:run",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build"
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "Frontend (Web)",
|
||||
"type": "npm",
|
||||
"script": "frontend:dev",
|
||||
"type": "shell",
|
||||
"command": "npm run frontend:dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
@@ -39,8 +21,8 @@
|
||||
},
|
||||
{
|
||||
"label": "Frontend (Electron)",
|
||||
"type": "npm",
|
||||
"script": "frontend:electron:dev",
|
||||
"type": "shell",
|
||||
"command": "npm run frontend:electron:dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
@@ -58,25 +40,7 @@
|
||||
|
||||
{
|
||||
"label": "Web",
|
||||
"dependsOn": ["Backend", "Frontend (Web)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Electron",
|
||||
"dependsOn": ["Backend", "Frontend (Electron)"],
|
||||
"dependsOn": ["Frontend (Web)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build"
|
||||
@@ -89,15 +53,17 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Deploy",
|
||||
"type": "shell",
|
||||
"command": "npm run deploy",
|
||||
"label": "Electron",
|
||||
"dependsOn": ["Frontend (Electron)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build"
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": true,
|
||||
"panel": "dedicated",
|
||||
"clear": true
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# 1. Frontend production build
|
||||
FROM node:24 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 1.1. Install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 1.2. Copy sources and configure
|
||||
COPY . .
|
||||
|
||||
WORKDIR /app
|
||||
ARG NODE_ENV=production
|
||||
# Overridable at build time. Must be a real http(s) URL — never a compose ${...} stub.
|
||||
ARG VITE_API_BASE_URL=https://api.fromchat.ru
|
||||
|
||||
# Fail the build if the arg is not a real URL (e.g. unexpanded Compose ${...}).
|
||||
RUN if ! printf "%s" "${VITE_API_BASE_URL}" | grep -Eq '^https?://'; then \
|
||||
echo "ERROR: VITE_API_BASE_URL must be an http(s) URL, got: ${VITE_API_BASE_URL}" >&2; \
|
||||
exit 1; \
|
||||
fi && \
|
||||
export NODE_ENV=production VITE_API_BASE_URL && \
|
||||
npm run frontend:build
|
||||
|
||||
|
||||
# 2. Production web static server
|
||||
FROM joseluisq/static-web-server:latest AS production
|
||||
|
||||
# 2.1. Copy files to server root (/var/public -> /home/sws/public)
|
||||
COPY --from=builder /app/build/normal/dist /var/public
|
||||
|
||||
# 2.2. Configure (defaults serve the image's built-in landing page instead of our app)
|
||||
ENV SERVER_ROOT=/var/public
|
||||
ENV SERVER_FALLBACK_PAGE=/var/public/index.html
|
||||
|
||||
EXPOSE 80
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
# FromChat Web Client — Messaging Web App
|
||||
|
||||
[Читать на других языках: Русский](./README.md)
|
||||
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/fromchat-messenger/android/main/app/android/src/main/ic_launcher-playstore.png" width="120" alt="FromChat Logo" />
|
||||
|
||||
**Web client for FromChat messenger**
|
||||
|
||||
[🌐 Web Client](https://github.com/fromchat-messenger/web) • [🖥️ Backend](https://github.com/fromchat-messenger/backend) • [📱 Android](https://github.com/fromchat-messenger/android) • [🌍 Website](https://github.com/fromchat-messenger/site)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description
|
||||
|
||||
FromChat Web is a React/TypeScript client (browser and Electron) for the FromChat server.
|
||||
|
||||
**Note:** Landing pages and legal documents live in [fromchat-messenger/site](https://github.com/fromchat-messenger/site).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Client Comparison
|
||||
|
||||
| Feature | Android | Web | iOS |
|
||||
|---|---|---|---|
|
||||
| **Messaging & profiles** | ✅ | ✅ | ❌ |
|
||||
| **Voice/video calls** | ✅ | ✅ | ❌ |
|
||||
| **Screen sharing** | ✅ | ✅ | ❌ |
|
||||
| **Message reactions** | ❌ | ✅ | ❌ |
|
||||
| **Rich attachment support** | ✅ | ❌ | ❌ |
|
||||
|
||||
⚠️ **iOS is temporarily not supported.**
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Protected DMs (legal encryption scheme)
|
||||
- Voice/video calls and screen sharing
|
||||
- Message reactions
|
||||
- Public chats and profiles
|
||||
- Device management
|
||||
- WebSocket real-time updates
|
||||
- Dark mode
|
||||
- Optional Electron desktop build
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
| Component | Notes |
|
||||
|---|---|
|
||||
| React 19 | UI |
|
||||
| TypeScript | strict typing |
|
||||
| Vite 7 | dev server & build |
|
||||
| MDUI | Material Design |
|
||||
| Zustand + use-immer | state |
|
||||
| Motion | animations |
|
||||
| TweetNaCl.js | cryptography |
|
||||
| Electron | desktop (optional) |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js 20+ (Docker image uses Node 24)
|
||||
- npm
|
||||
- Backend API on `http://localhost:8300` (proxied as `/api`)
|
||||
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fromchat-messenger/web.git
|
||||
cd web
|
||||
npm install
|
||||
cp .env.example .env # if needed; install may copy it for you
|
||||
npm run frontend:dev
|
||||
```
|
||||
|
||||
Open `http://localhost:8301`.
|
||||
|
||||
`.env`:
|
||||
|
||||
```env
|
||||
# HTTP API host (Vite proxy target for /api)
|
||||
VITE_API_BASE_URL=http://localhost:8300
|
||||
```
|
||||
|
||||
In the browser the client uses same-origin `/api` (HTTP and WebSocket, e.g. `/api/chat/ws`).
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run frontend:dev # Vite on :8301
|
||||
npm run frontend:typecheck # TypeScript
|
||||
npm run frontend:build # typecheck + production build → build/normal
|
||||
npm run frontend:preview # preview built frontend
|
||||
npm run frontend:electron:dev # Electron + Vite
|
||||
npm run frontend:electron:build # Electron package
|
||||
```
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main/ # React app (@/)
|
||||
│ │ ├── pages/ # auth, chat, profile, …
|
||||
│ │ ├── core/ # API, websocket, calls, …
|
||||
│ │ ├── state/ # Zustand stores
|
||||
│ │ ├── utils/
|
||||
│ │ └── css/ # SCSS (Material Design)
|
||||
│ ├── electron/ # Electron main/preload
|
||||
│ └── protocol/ # shared protocol (@fromchat/protocol)
|
||||
├── plugins/ # Vite plugins
|
||||
├── vite.config.ts
|
||||
├── compose.yml # production web image (:8301→80)
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
docker build -t fromchat-web:latest .
|
||||
# or via compose:
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
Container listens on port **8301** (static server on 80 inside).
|
||||
|
||||
Production edge (Caddy/HAProxy) is configured via the deployment repo / backend `compose.prod.yml`.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Branch for your change
|
||||
2. Open a PR with a description
|
||||
3. Ensure `npm run frontend:typecheck` passes
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU Affero General Public License v3.0 — see [LICENSE](./LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Repositories
|
||||
|
||||
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||
- [Android Client](https://github.com/fromchat-messenger/android)
|
||||
- [Website](https://github.com/fromchat-messenger/site)
|
||||
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: How do I run locally?**
|
||||
A: Start the backend on `:8300`, then `npm run frontend:dev` and open `http://localhost:8301`.
|
||||
|
||||
**Q: Which browsers?**
|
||||
A: Current Chrome, Firefox, Safari, Edge.
|
||||
|
||||
**Q: Do calls work on web?**
|
||||
A: Yes — voice/video and screen share (server needs LiveKit).
|
||||
|
||||
**Q: How do I report a bug?**
|
||||
A: GitHub Issues with reproduction steps.
|
||||
|
||||
---
|
||||
|
||||
**[⬆ back to top](#fromchat-web-client--messaging-web-app)**
|
||||
@@ -1,30 +1,117 @@
|
||||
# FromChat
|
||||
# FromChat Web Client — веб-приложение для обмена сообщениями
|
||||
|
||||
FromChat - полностью открытый мессенджер.
|
||||
[Read in other languages: English](./README.en.md)
|
||||
|
||||
Его можно попробовать на [сайте](http://fromchat.ru).
|
||||
_Написано ИИ. Могут быть ошибки._
|
||||
|
||||
## Содержание:
|
||||
- [Основные моменты](#highlights)
|
||||
- [Использование](#usage)
|
||||
- [Часто задаваемые вопросы](#faq)
|
||||
- [Внос вклада](#contributing)
|
||||
## 📝 Описание
|
||||
|
||||
## Основные моменты
|
||||
- Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд).
|
||||
- 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя.
|
||||
Веб-клиент FromChat — React/TypeScript приложение (браузер и Electron) для работы с сервером FromChat.
|
||||
|
||||
## Использование
|
||||
_В разработке._
|
||||
## Развернуть в 1 клик
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
<!--
|
||||
Вопрос: __Чему равно 2+2?__
|
||||
Ответ: __4__
|
||||
Вопрос: __Какая цитата Джейсона Стетхема на ваш взгляд является лучшей?__
|
||||
Ответ: __"Одна ошибка, и ты ошибся."__
|
||||
-->
|
||||
_В разработке._
|
||||
```bash
|
||||
docker run -d --restart always -p 8301:80 fromchat/web:latest
|
||||
```
|
||||
|
||||
## Внос вклада
|
||||
Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev).
|
||||
## ✨ Возможности
|
||||
|
||||
- Защищённые личные сообщения (легальная схема шифрования)
|
||||
- Голосовые/видеозвонки и демонстрация экрана
|
||||
- Реакции на сообщения
|
||||
- Публичные чаты и профили
|
||||
- Управление устройствами
|
||||
- WebSocket для реал-тайма
|
||||
- Тёмный режим
|
||||
- Сборка Electron (опционально)
|
||||
|
||||
## 🏗️ Технологический стек
|
||||
|
||||
| Компонент | Примечание |
|
||||
|---|---|
|
||||
| React 19 | UI |
|
||||
| TypeScript | строгая типизация |
|
||||
| Vite 7 | dev-сервер и сборка |
|
||||
| MDUI | Material Design |
|
||||
| Zustand + use-immer | состояние |
|
||||
| Motion | анимации |
|
||||
| TweetNaCl.js | криптография |
|
||||
| Electron | десктоп (опционально) |
|
||||
|
||||
## 🔧 Разработка
|
||||
|
||||
### Требования
|
||||
|
||||
- Node.js 20+ (для Docker-образа используется Node 24)
|
||||
- npm
|
||||
- Backend API на `http://localhost:8300` (проксируется через `/api`)
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fromchat-messenger/web.git
|
||||
cd web
|
||||
npm install
|
||||
cp .env.example .env # при необходимости; install может скопировать сам
|
||||
npm run frontend:dev
|
||||
```
|
||||
|
||||
Откройте `http://localhost:8301`.
|
||||
|
||||
### Команды
|
||||
|
||||
```bash
|
||||
npm run frontend:dev # Vite на :8301
|
||||
npm run frontend:typecheck # TypeScript
|
||||
npm run frontend:build # typecheck + production build → build/normal
|
||||
npm run frontend:preview # preview собранного фронта
|
||||
npm run frontend:electron:dev # Electron + Vite
|
||||
npm run frontend:electron:build # сборка Electron
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main/ # React-приложение (@/)
|
||||
│ │ ├── pages/ # auth, chat, profile, …
|
||||
│ │ ├── core/ # API, websocket, calls, …
|
||||
│ │ ├── state/ # Zustand stores
|
||||
│ │ ├── utils/
|
||||
│ │ └── css/ # SCSS (Material Design)
|
||||
│ ├── electron/ # main/preload Electron
|
||||
│ └── protocol/ # общий протокол (@fromchat/protocol)
|
||||
├── plugins/ # Vite-плагины
|
||||
├── vite.config.ts
|
||||
├── compose.yml # production-образ веба (:8301→80)
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Контейнер слушает порт **8301** (внутри nginx/static-server на 80).
|
||||
|
||||
## 🤝 Внесение вклада
|
||||
|
||||
1. Создайте ветку под изменение
|
||||
2. Отправьте PR с описанием
|
||||
3. Проверьте типы: `npm run frontend:typecheck`
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
GNU Affero General Public License v3.0 — см. [LICENSE](./LICENSE).
|
||||
|
||||
## 🔗 Связанные репозитории
|
||||
|
||||
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||
- [Android Client](https://github.com/fromchat-messenger/android)
|
||||
- [Website](https://github.com/fromchat-messenger/site)
|
||||
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||
@@ -1,417 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from getpass import getpass
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
import readline
|
||||
import httpx
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
class CLIError(Exception):
|
||||
"""Generic CLI error with a human-readable message."""
|
||||
|
||||
|
||||
def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
|
||||
return hmac.new(salt, ikm, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
|
||||
blocks: list[bytes] = []
|
||||
previous = b""
|
||||
counter = 1
|
||||
while len(b"".join(blocks)) < length:
|
||||
previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest()
|
||||
blocks.append(previous)
|
||||
counter += 1
|
||||
return b"".join(blocks)[:length]
|
||||
|
||||
|
||||
def derive_auth_secret(username: str, password: str) -> str:
|
||||
salt = f"fromchat.user:{username}".encode("utf-8")
|
||||
prk = _hkdf_extract(salt, password.encode("utf-8"))
|
||||
okm = _hkdf_expand(prk, b"auth-secret", 32)
|
||||
return base64.b64encode(okm).decode("utf-8")
|
||||
|
||||
|
||||
def _read_single_key() -> str:
|
||||
try: # Windows
|
||||
import msvcrt # type: ignore
|
||||
|
||||
ch = msvcrt.getch()
|
||||
return ch.decode("utf-8", errors="ignore").lower()
|
||||
except ImportError:
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
ch = sys.stdin.read(1)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
return ch.lower()
|
||||
|
||||
|
||||
class AdminCLI:
|
||||
def __init__(self, api_url: str) -> None:
|
||||
self.console = Console()
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.client = httpx.Client(base_url=self.api_url, timeout=30.0)
|
||||
self.username: Optional[str] = None
|
||||
self.token: Optional[str] = None
|
||||
|
||||
# --------------------------- HTTP helpers --------------------------- #
|
||||
def _auth_headers(self) -> dict:
|
||||
headers: dict = {}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def _request(self, method: str, path: str, *, auth: bool = True, **kwargs) -> httpx.Response:
|
||||
rel_path = path.lstrip("/")
|
||||
headers = kwargs.pop("headers", {})
|
||||
if auth:
|
||||
headers.update(self._auth_headers())
|
||||
response = self.client.request(method, rel_path, headers=headers, **kwargs)
|
||||
if response.status_code >= 400:
|
||||
detail = ""
|
||||
try:
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict):
|
||||
detail = payload.get("detail") or payload.get("message") or ""
|
||||
except Exception:
|
||||
detail = response.text
|
||||
message = f"{response.status_code} {response.reason_phrase}"
|
||||
if detail:
|
||||
message = f"{message}: {detail}"
|
||||
raise CLIError(message.strip())
|
||||
return response
|
||||
|
||||
# --------------------------- CLI primitives ------------------------- #
|
||||
def _require_auth(self) -> None:
|
||||
if not self.token:
|
||||
raise CLIError("You must login before running this command.")
|
||||
|
||||
def _resolve_user(self, identifier: str) -> dict:
|
||||
self._require_auth()
|
||||
if identifier.isdigit():
|
||||
response = self._request("GET", f"user/id/{identifier}")
|
||||
else:
|
||||
response = self._request("GET", f"user/{identifier.replace('@', '')}")
|
||||
return response.json()
|
||||
|
||||
def _confirm(self, prompt: str) -> bool:
|
||||
self.console.print(f"[bold yellow]{prompt}[/] [green](y)[/] / [red](n)[/]: ", end="")
|
||||
choice = _read_single_key()
|
||||
self.console.print("") # move to next line
|
||||
return choice == "y"
|
||||
|
||||
def _render_user(self, user: dict) -> None:
|
||||
table = Table(show_header=False)
|
||||
table.add_row("ID", str(user.get("id")))
|
||||
table.add_row("Username", user.get("username", ""))
|
||||
table.add_row("Display name", user.get("display_name", ""))
|
||||
table.add_row("Verified", "✅" if user.get("verified") else "❌")
|
||||
if user.get("suspended"):
|
||||
table.add_row("Suspended", f"🚫 ({user.get('suspension_reason') or 'no reason'})")
|
||||
else:
|
||||
table.add_row("Suspended", "✅ Active")
|
||||
self.console.print(table)
|
||||
|
||||
# --------------------------- Commands ------------------------------- #
|
||||
def cmd_login(self, args: List[str]) -> None:
|
||||
if args:
|
||||
username = args[0]
|
||||
else:
|
||||
username = self.console.input("[bold cyan]Username[/]: ").strip()
|
||||
if not username:
|
||||
raise CLIError("Username is required.")
|
||||
|
||||
password = getpass("Password: ")
|
||||
derived_password = derive_auth_secret(username, password)
|
||||
payload = {"username": username, "password": derived_password}
|
||||
response = self._request("POST", "login", json=payload, auth=False)
|
||||
body = response.json()
|
||||
token = body.get("token")
|
||||
if not token:
|
||||
raise CLIError("Authentication succeeded but token was not returned.")
|
||||
self.token = token
|
||||
self.username = username
|
||||
self.console.print("[bold green]Login successful.[/]")
|
||||
|
||||
def cmd_suspend(self, args: List[str]) -> None:
|
||||
if not args:
|
||||
raise CLIError("Usage: suspend <user_id|username>")
|
||||
identifier = args[0]
|
||||
user = self._resolve_user(identifier)
|
||||
self.console.print(Panel.fit("[bold red]Suspend user[/]", style="red"))
|
||||
self._render_user(user)
|
||||
reason = self.console.input("[bold yellow]Reason (press Enter to leave empty)[/]: ").strip()
|
||||
if not self._confirm(f"Confirm suspension of {user.get('username')}?"):
|
||||
self.console.print("[yellow]Suspension cancelled.[/]")
|
||||
return
|
||||
payload = {"reason": reason}
|
||||
self._request("POST", f"user/{user['id']}/suspend", json=payload)
|
||||
log_reason = reason or "no reason provided"
|
||||
self.console.print(f"[bold red]User {user['username']} suspended ({log_reason}).[/]")
|
||||
|
||||
def cmd_unsuspend(self, args: List[str]) -> None:
|
||||
if not args:
|
||||
raise CLIError("Usage: unsuspend <user_id|username>")
|
||||
identifier = args[0]
|
||||
user = self._resolve_user(identifier)
|
||||
self.console.print(Panel.fit("[bold green]Unsuspend user[/]", style="green"))
|
||||
self._render_user(user)
|
||||
if not self._confirm(f"Unsuspend {user.get('username')}?"):
|
||||
self.console.print("[yellow]Unsuspension cancelled.[/]")
|
||||
return
|
||||
self._request("POST", f"user/{user['id']}/unsuspend")
|
||||
self.console.print(f"[bold green]User {user['username']} unsuspended.[/]")
|
||||
|
||||
def cmd_block_word(self, args: List[str]) -> None:
|
||||
if not args:
|
||||
raise CLIError("Usage: block-word <word or phrase> [additional words...]")
|
||||
self._require_auth()
|
||||
words = args
|
||||
response = self._request("POST", "moderation/blocklist", json={"words": words})
|
||||
data = response.json()
|
||||
added = data.get("added", [])
|
||||
current = data.get("words", [])
|
||||
if added:
|
||||
self.console.print(f"[bold green]Added {len(added)} entr{'y' if len(added)==1 else 'ies'} to blocklist.[/]")
|
||||
else:
|
||||
self.console.print("[yellow]No new words added.[/]")
|
||||
self.console.print(f"Blocklist size: {len(current)}")
|
||||
|
||||
def cmd_list_users(self) -> None:
|
||||
self._require_auth()
|
||||
payload = self._request("GET", "user/list").json()
|
||||
users = payload.get("users", [])
|
||||
table = Table(title="Users", show_lines=False)
|
||||
table.add_column("ID")
|
||||
table.add_column("Username")
|
||||
table.add_column("Display name")
|
||||
table.add_column("Suspended")
|
||||
for user in users:
|
||||
table.add_row(
|
||||
str(user.get("id")),
|
||||
user.get("username", ""),
|
||||
user.get("display_name", ""),
|
||||
"🚫" if user.get("suspended") else "✅",
|
||||
)
|
||||
self.console.print(table)
|
||||
|
||||
def cmd_user(self, args: List[str]) -> None:
|
||||
if not args:
|
||||
raise CLIError("Usage: user <user_id|username>")
|
||||
user = self._resolve_user(args[0])
|
||||
self._render_user(user)
|
||||
|
||||
def cmd_delete(self, args: List[str]) -> None:
|
||||
if not args:
|
||||
raise CLIError("Usage: delete <user_id|username>")
|
||||
user = self._resolve_user(args[0])
|
||||
self.console.print(Panel.fit("[bold red]Delete user[/]", style="red"))
|
||||
self._render_user(user)
|
||||
if not self._confirm(f"Permanently delete {user.get('username')}?"):
|
||||
self.console.print("[yellow]Deletion cancelled.[/]")
|
||||
return
|
||||
self._request("POST", f"user/{user['id']}/delete")
|
||||
self.console.print(f"[bold red]User {user['username']} deleted.[/]")
|
||||
|
||||
def cmd_unblock_word(self, args: List[str]) -> None:
|
||||
if not args:
|
||||
raise CLIError("Usage: unblock-word <word or phrase> [additional words...]")
|
||||
self._require_auth()
|
||||
response = self._request("DELETE", "moderation/blocklist", json={"words": args})
|
||||
data = response.json()
|
||||
removed = data.get("removed", [])
|
||||
current = data.get("words", [])
|
||||
if removed:
|
||||
self.console.print(f"[bold green]Removed {len(removed)} entr{'y' if len(removed)==1 else 'ies'} from blocklist.[/]")
|
||||
else:
|
||||
self.console.print("[yellow]No matching words removed.[/]")
|
||||
self.console.print(f"Blocklist size: {len(current)}")
|
||||
|
||||
def cmd_verify(self, args: List[str]) -> None:
|
||||
if not args:
|
||||
raise CLIError("Usage: verify <user_id|username>")
|
||||
user = self._resolve_user(args[0])
|
||||
if user.get("verified"):
|
||||
self.console.print(f"[yellow]{user['username']} is already verified.[/]")
|
||||
return
|
||||
self._request("POST", f"user/{user['id']}/verify")
|
||||
self.console.print(f"[bold green]{user['username']} marked as verified.[/]")
|
||||
|
||||
def cmd_unverify(self, args: List[str]) -> None:
|
||||
if not args:
|
||||
raise CLIError("Usage: unverify <user_id|username>")
|
||||
user = self._resolve_user(args[0])
|
||||
if not user.get("verified"):
|
||||
self.console.print(f"[yellow]{user['username']} is already unverified.[/]")
|
||||
return
|
||||
self._request("POST", f"user/{user['id']}/verify")
|
||||
self.console.print(f"[bold green]{user['username']} is now unverified.[/]")
|
||||
|
||||
def cmd_list_blocklist(self) -> None:
|
||||
self._require_auth()
|
||||
response = self._request("GET", "moderation/blocklist")
|
||||
words = response.json().get("words", [])
|
||||
if not words:
|
||||
self.console.print("[cyan]Blocklist is empty.[/]")
|
||||
return
|
||||
table = Table(title="Blocked Words", show_lines=True)
|
||||
table.add_column("Word / Phrase")
|
||||
for entry in words:
|
||||
table.add_row(entry)
|
||||
self.console.print(table)
|
||||
|
||||
def cmd_unblock_ip(self, args: List[str]) -> None:
|
||||
if not args:
|
||||
raise CLIError("Usage: unblock-ip <ip_address>")
|
||||
self._require_auth()
|
||||
ip = args[0].strip()
|
||||
if not ip:
|
||||
raise CLIError("IP address cannot be empty")
|
||||
response = self._request("POST", "moderation/unblock-ip", json={"ip": ip})
|
||||
data = response.json()
|
||||
message = data.get("message", "IP unblocked")
|
||||
self.console.print(f"[bold green]{message}[/]")
|
||||
|
||||
def cmd_clear_all_rate_limits(self) -> None:
|
||||
"""Clear all rate limit entries. Use with caution."""
|
||||
self._require_auth()
|
||||
if not self._confirm("Clear ALL rate limit entries? This affects all IPs."):
|
||||
self.console.print("[yellow]Operation cancelled.[/]")
|
||||
return
|
||||
response = self._request("POST", "moderation/clear-all-rate-limits")
|
||||
data = response.json()
|
||||
message = data.get("message", "Rate limits cleared")
|
||||
self.console.print(f"[bold green]{message}[/]")
|
||||
|
||||
def cmd_help(self) -> None:
|
||||
cmds = {
|
||||
"login [username]": "Authenticate as owner/admin.",
|
||||
"suspend <user>": "Suspend account (alias: ban).",
|
||||
"unsuspend <user>": "Unsuspend account (alias: unban).",
|
||||
"delete <user>": "Permanently delete the user account.",
|
||||
"verify <user>": "Mark user as verified.",
|
||||
"unverify <user>": "Remove verification flag.",
|
||||
"block-word <words>": "Add words/phrases to chat filter.",
|
||||
"unblock-word <words>": "Remove words/phrases from filter.",
|
||||
"blocklist": "Show current blocklist.",
|
||||
"unblock-ip <ip>": "Unblock an IP address from rate limiting.",
|
||||
"clear-all-rate-limits": "Clear all rate limit entries (use with caution).",
|
||||
"list": "List all users.",
|
||||
"user <user>": "Show detailed user information.",
|
||||
"whoami": "Display current session context.",
|
||||
"help": "Show this help panel.",
|
||||
"exit": "Quit the CLI.",
|
||||
}
|
||||
table = Table(title="Available Commands")
|
||||
table.add_column("Command", style="cyan")
|
||||
table.add_column("Description", style="white")
|
||||
for cmd, desc in cmds.items():
|
||||
table.add_row(cmd, desc)
|
||||
self.console.print(table)
|
||||
|
||||
def cmd_whoami(self) -> None:
|
||||
if not self.token:
|
||||
self.console.print("[yellow]Not authenticated.[/]")
|
||||
return
|
||||
self.console.print(f"[green]Logged in as[/] [bold]{self.username}[/] ({self.api_url})")
|
||||
|
||||
# --------------------------- Main loop ------------------------------ #
|
||||
def run(self) -> None:
|
||||
self.console.print(Panel.fit("[bold magenta]FromChat Admin CLI[/]", style="magenta"))
|
||||
while True:
|
||||
prompt_identity = self.username or "guest"
|
||||
try:
|
||||
prompt_str = f"\033[36m{prompt_identity}\033[0m \033[1m>\033[0m "
|
||||
raw = input(prompt_str).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
self.console.print("\n[red]Exiting...[/]")
|
||||
break
|
||||
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
try:
|
||||
parts = shlex.split(raw)
|
||||
except ValueError as exc:
|
||||
self.console.print(f"[red]Parse error:[/] {exc}")
|
||||
continue
|
||||
|
||||
command = parts[0].lstrip("/").lower()
|
||||
args = parts[1:]
|
||||
|
||||
if command in {"exit", "quit"}:
|
||||
self.console.print("[red]Goodbye.[/]")
|
||||
break
|
||||
|
||||
try:
|
||||
if command == "login":
|
||||
self.cmd_login(args)
|
||||
elif command in {"suspend", "ban"}:
|
||||
self.cmd_suspend(args)
|
||||
elif command in {"unsuspend", "unban"}:
|
||||
self.cmd_unsuspend(args)
|
||||
elif command == "block-word":
|
||||
self.cmd_block_word(args)
|
||||
elif command == "unblock-word":
|
||||
self.cmd_unblock_word(args)
|
||||
elif command == "blocklist":
|
||||
self.cmd_list_blocklist()
|
||||
elif command == "unblock-ip":
|
||||
self.cmd_unblock_ip(args)
|
||||
elif command == "clear-all-rate-limits":
|
||||
self.cmd_clear_all_rate_limits()
|
||||
elif command == "verify":
|
||||
self.cmd_verify(args)
|
||||
elif command == "unverify":
|
||||
self.cmd_unverify(args)
|
||||
elif command in {"delete", "remove"}:
|
||||
self.cmd_delete(args)
|
||||
elif command == "list":
|
||||
self.cmd_list_users()
|
||||
elif command == "user":
|
||||
self.cmd_user(args)
|
||||
elif command == "help":
|
||||
self.cmd_help()
|
||||
elif command == "whoami":
|
||||
self.cmd_whoami()
|
||||
else:
|
||||
self.console.print("[yellow]Unknown command. Type /help for a list of commands.[/]")
|
||||
except CLIError as err:
|
||||
self.console.print(f"[red]Error:[/] {err}")
|
||||
except httpx.RequestError as err:
|
||||
self.console.print(f"[red]Network error:[/] {err}")
|
||||
|
||||
self.client.close()
|
||||
|
||||
|
||||
def main(argv: Optional[Iterable[str]] = None) -> None:
|
||||
parser = argparse.ArgumentParser(description="FromChat Emergency Admin CLI")
|
||||
parser.add_argument(
|
||||
"--api-url",
|
||||
default=os.getenv("FC_ADMIN_API_URL", "http://127.0.0.1:8300"),
|
||||
help="Base API URL for the FromChat backend (default: %(default)s).",
|
||||
)
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
cli = AdminCLI(args.api_url)
|
||||
cli.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///./data/database.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,78 +0,0 @@
|
||||
from logging.config import fileConfig
|
||||
import logging
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from models import Base
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,28 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
import asyncio
|
||||
import time
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from routes import account, messaging, profile, push, webrtc, devices, moderation, download
|
||||
import logging
|
||||
from models import User
|
||||
from constants import OWNER_USERNAME
|
||||
from utils import get_client_ip
|
||||
|
||||
from db import POOL_CONFIG, SessionLocal
|
||||
from logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||
from security.audit import log_access
|
||||
from security.rate_limit import limiter
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup - run migration in subprocess to avoid logging interference
|
||||
try:
|
||||
logger.info("Starting database migration check...")
|
||||
# Run migration in a separate process
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
||||
],
|
||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run database migrations: {e}")
|
||||
raise
|
||||
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
owner = db.query(User).filter(User.id == 1).first()
|
||||
if owner and not owner.verified:
|
||||
owner.verified = True
|
||||
db.commit()
|
||||
logger.info(f"Owner user '{OWNER_USERNAME}' has been verified")
|
||||
elif owner and owner.verified:
|
||||
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
|
||||
else:
|
||||
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure owner verification: {e}")
|
||||
|
||||
logger.info(
|
||||
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
|
||||
POOL_CONFIG["pool_size"],
|
||||
POOL_CONFIG["max_overflow"],
|
||||
POOL_CONFIG["pool_timeout"],
|
||||
POOL_CONFIG["pool_recycle"],
|
||||
POOL_CONFIG["pool_pre_ping"],
|
||||
)
|
||||
|
||||
# Start the messaging cleanup task
|
||||
try:
|
||||
from routes.messaging import messagingManager
|
||||
messagingManager.start_cleanup_task()
|
||||
logger.info("Messaging cleanup task started")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start messaging cleanup task: {e}")
|
||||
|
||||
# Reset all rate limits on startup to ensure clean state
|
||||
# This prevents rate limits from persisting across restarts
|
||||
try:
|
||||
from security.rate_limit import reset_all_rate_limits
|
||||
cleared = reset_all_rate_limits()
|
||||
if cleared > 0:
|
||||
logger.info(f"Cleared {cleared} rate limit entries on startup")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
||||
|
||||
# Start the rate limit cleanup task
|
||||
try:
|
||||
from security.rate_limit import start_rate_limit_cleanup_task
|
||||
cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task())
|
||||
logger.info("Rate limit cleanup task started")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rate limit cleanup task: {e}")
|
||||
cleanup_task = None
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown - cancel cleanup task if it exists
|
||||
if cleanup_task:
|
||||
cleanup_task.cancel()
|
||||
try:
|
||||
await cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Инициализация FastAPI
|
||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||
|
||||
# Add rate limiting middleware
|
||||
app.state.limiter = limiter
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def access_logging_middleware(request: Request, call_next):
|
||||
# Log incoming request and Authorization header presence for debugging auth issues
|
||||
try:
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header:
|
||||
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
|
||||
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
|
||||
else:
|
||||
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
|
||||
except Exception:
|
||||
pass
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as exc:
|
||||
duration = time.perf_counter() - start
|
||||
user = getattr(getattr(request, "state", None), "current_user", None)
|
||||
log_access(
|
||||
"http_error",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status="error",
|
||||
user=getattr(user, "username", None),
|
||||
ip=get_client_ip(request),
|
||||
duration=f"{duration:.3f}s",
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
else:
|
||||
duration = time.perf_counter() - start
|
||||
user = getattr(getattr(request, "state", None), "current_user", None)
|
||||
log_access(
|
||||
"http_request",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status=response.status_code,
|
||||
user=getattr(user, "username", None),
|
||||
ip=get_client_ip(request),
|
||||
duration=f"{duration:.3f}s",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://fromchat.ru",
|
||||
"https://beta.fromchat.ru",
|
||||
"https://www.fromchat.ru",
|
||||
"http://127.0.0.1:8301",
|
||||
"http://127.0.0.1:8300",
|
||||
"http://localhost:8301",
|
||||
"http://localhost:8300",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Routes
|
||||
app.include_router(account.router)
|
||||
app.include_router(messaging.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
app.include_router(webrtc.router, prefix="/webrtc")
|
||||
app.include_router(devices.router, prefix="/devices")
|
||||
app.include_router(moderation.router)
|
||||
app.include_router(download.router)
|
||||
@@ -1,13 +0,0 @@
|
||||
import os
|
||||
|
||||
DATABASE_URL = "sqlite:///./data/database.db"
|
||||
JWT_ALGORITHM = "HS256"
|
||||
# Token inactivity expiration - token expires if not used for this duration
|
||||
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
||||
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
|
||||
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
|
||||
OWNER_USERNAME = "denis0001-dev"
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET")
|
||||
|
||||
if not JWT_SECRET_KEY:
|
||||
raise ValueError("JWT secret key empty")
|
||||
@@ -1,40 +0,0 @@
|
||||
import os
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
|
||||
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
|
||||
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
||||
|
||||
POOL_CONFIG = {
|
||||
"pool_size": POOL_SIZE,
|
||||
"max_overflow": MAX_OVERFLOW,
|
||||
"pool_recycle": POOL_RECYCLE,
|
||||
"pool_timeout": POOL_TIMEOUT,
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
|
||||
engine_kwargs = {
|
||||
"pool_size": POOL_SIZE,
|
||||
"max_overflow": MAX_OVERFLOW,
|
||||
"pool_recycle": POOL_RECYCLE,
|
||||
"pool_pre_ping": True,
|
||||
"pool_timeout": POOL_TIMEOUT,
|
||||
}
|
||||
|
||||
connect_args = {}
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args=connect_args,
|
||||
**engine_kwargs,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
@@ -1,120 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from utils import verify_token
|
||||
from models import User, DeviceSession
|
||||
from db import SessionLocal
|
||||
import logging
|
||||
|
||||
security = HTTPBearer()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Зависимость для получения сессии БД
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Зависимость для получения текущего пользователя
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = verify_token(token)
|
||||
except Exception as e:
|
||||
logger.warning("get_current_user: token verification error: %s", str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not payload:
|
||||
logger.info("get_current_user: verify_token returned empty payload")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
||||
if not user:
|
||||
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if user.id == 1 and user.suspended:
|
||||
user.suspended = False
|
||||
user.suspension_reason = None
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Validate device session from JWT
|
||||
session_id = payload.get("session_id")
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid session",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
device_session = (
|
||||
db.query(DeviceSession)
|
||||
.filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not device_session or device_session.revoked:
|
||||
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session revoked or not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if session has been inactive for too long (sliding expiration)
|
||||
from constants import TOKEN_INACTIVITY_EXPIRE_HOURS
|
||||
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
|
||||
if device_session.last_seen < inactivity_threshold:
|
||||
# Session expired due to inactivity - revoke it
|
||||
device_session.revoked = True
|
||||
db.commit()
|
||||
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session expired due to inactivity",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Touch last_seen on valid session (sliding expiration - extends token life)
|
||||
device_session.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
# Check if user is suspended
|
||||
if user.suspended:
|
||||
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account suspended",
|
||||
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
|
||||
)
|
||||
|
||||
# Check if user is deleted
|
||||
if user.deleted:
|
||||
logger.info("get_current_user: account deleted for user_id=%s", user.id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account deleted",
|
||||
)
|
||||
|
||||
request.state.current_user = user
|
||||
request.state.session_id = session_id
|
||||
|
||||
return user
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate VAPID keys for push notifications
|
||||
Run this script to generate new VAPID keys for your application
|
||||
"""
|
||||
|
||||
import sys
|
||||
import base64
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
def generate_vapid_keys():
|
||||
"""Generate VAPID keys for push notifications"""
|
||||
try:
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
public_key = private_key.public_key()
|
||||
|
||||
# Convert to base64 for web push
|
||||
private_key_b64 = base64.urlsafe_b64encode(
|
||||
private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
).decode('utf-8').rstrip('=')
|
||||
|
||||
# Get the raw uncompressed public key point (65 bytes: 0x04 + 32 bytes x + 32 bytes y)
|
||||
public_numbers = public_key.public_numbers()
|
||||
x_bytes = public_numbers.x.to_bytes(32, 'big')
|
||||
y_bytes = public_numbers.y.to_bytes(32, 'big')
|
||||
public_key_raw = b'\x04' + x_bytes + y_bytes
|
||||
|
||||
public_key_b64 = base64.urlsafe_b64encode(public_key_raw).decode('utf-8').rstrip('=')
|
||||
|
||||
print(f"VAPID_PRIVATE_KEY=\"{private_key_b64}\"")
|
||||
print(f"VAPID_PUBLIC_KEY=\"{public_key_b64}\"")
|
||||
|
||||
return private_key_b64, public_key_b64
|
||||
except Exception as e:
|
||||
print(f"Error generating VAPID keys: {e}", file=sys.stderr)
|
||||
return None, None
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_vapid_keys()
|
||||
@@ -1,89 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Dict
|
||||
|
||||
LOGS_DIR = Path(__file__).resolve().parent / "logs"
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class HumanReadableFileHandler(RotatingFileHandler):
|
||||
def __init__(self, filename: Path, level: int) -> None:
|
||||
super().__init__(filename, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", delay=True)
|
||||
self.level = level
|
||||
self._lock = RLock()
|
||||
self._last_date: str | None = None
|
||||
self._previous_entry: str | None = None
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
message = record.getMessage().strip()
|
||||
if not message:
|
||||
return
|
||||
|
||||
timestamp = datetime.fromtimestamp(record.created)
|
||||
date_str = timestamp.strftime("%d.%m.%Y")
|
||||
time_str = timestamp.strftime("%H:%M:%S")
|
||||
lines = [line.rstrip() for line in message.splitlines() if line.strip()]
|
||||
|
||||
with self._lock:
|
||||
if self.stream is None:
|
||||
self.stream = self._open()
|
||||
|
||||
if self._last_date != date_str:
|
||||
if self._last_date is not None:
|
||||
self.stream.write("\n")
|
||||
separator = "-" * 11
|
||||
self.stream.write(f"\n\n{separator}\n{date_str}\n{separator}\n\n")
|
||||
self._last_date = date_str
|
||||
|
||||
entry_lines: list[str] = []
|
||||
if lines:
|
||||
entry_lines.append(f"{time_str} {lines[0]}")
|
||||
for line in lines[1:]:
|
||||
if line.startswith("|"):
|
||||
entry_lines.append(f" {line}")
|
||||
else:
|
||||
entry_lines.append(f" ↳ {line}")
|
||||
else:
|
||||
entry_lines.append(time_str)
|
||||
entry_text = "\n".join(entry_lines)
|
||||
if entry_text == self._previous_entry:
|
||||
return
|
||||
self.stream.write(entry_text + "\n")
|
||||
self._previous_entry = entry_text
|
||||
self.flush()
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
_HANDLED_FILES: Dict[str, Path] = {}
|
||||
|
||||
|
||||
def _configure_logger(name: str, filename: str, level: int = logging.INFO) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
target_path = LOGS_DIR / filename
|
||||
|
||||
if _HANDLED_FILES.get(name) == target_path:
|
||||
return logger
|
||||
|
||||
logger.handlers.clear()
|
||||
|
||||
handler = HumanReadableFileHandler(target_path, level)
|
||||
handler.setLevel(level)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(level)
|
||||
logger.propagate = False
|
||||
|
||||
_HANDLED_FILES[name] = target_path
|
||||
return logger
|
||||
|
||||
|
||||
security_logger = _configure_logger("security", "security.log")
|
||||
public_chat_logger = _configure_logger("public_chat", "public-chat.log")
|
||||
dm_logger = _configure_logger("dm", "dm.log")
|
||||
access_logger = _configure_logger("access", "access.log")
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from constants import *
|
||||
from db import *
|
||||
from models import *
|
||||
from validation import *
|
||||
from utils import *
|
||||
from dependencies import *
|
||||
from app import *
|
||||
@@ -1,626 +0,0 @@
|
||||
"""
|
||||
Database migration utility using Alembic.
|
||||
This module handles running database migrations on startup.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def run_migrations():
|
||||
"""
|
||||
Run database migrations using Alembic.
|
||||
This function will upgrade the database to the latest migration.
|
||||
Fully automated - handles all scenarios automatically.
|
||||
"""
|
||||
try:
|
||||
# FIRST: Check if database has any application tables (excluding alembic_version)
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(connection)
|
||||
existing_tables = [table for table in inspector.get_table_names()
|
||||
if not table.startswith('sqlite_') and table != 'alembic_version']
|
||||
|
||||
# If no application tables exist, create them directly from models
|
||||
if not existing_tables:
|
||||
logger.info("No application tables found. Creating all tables directly from models...")
|
||||
from models import Base
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("All tables created successfully from models.")
|
||||
|
||||
# Get the directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Create Alembic configuration
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
|
||||
# Disable Alembic's logging configuration to avoid interfering with FastAPI
|
||||
alembic_cfg.set_main_option("configure_logging", "false")
|
||||
|
||||
# Set the database URL in the config
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Check if any migration files exist
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
|
||||
if not os.path.exists(versions_dir):
|
||||
os.makedirs(versions_dir)
|
||||
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if not migration_files:
|
||||
logger.info("No migration files found. Creating initial migration...")
|
||||
# Check if database exists and has tables
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
|
||||
existing_tables = result.fetchall()
|
||||
|
||||
if existing_tables:
|
||||
logger.info("Found existing database with tables. Creating migration to match current schema...")
|
||||
# Create migration with autogenerate to detect differences
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration from existing database")
|
||||
|
||||
# Check if the generated migration is empty (common with existing databases)
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
# Check if migration is empty
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content:
|
||||
logger.info("Generated migration is empty. Creating complete schema migration...")
|
||||
# Remove the empty migration
|
||||
os.remove(migration_path)
|
||||
# Create a complete migration
|
||||
_create_complete_migration(alembic_cfg)
|
||||
else:
|
||||
logger.info("No existing tables found. Creating fresh migration...")
|
||||
# Create fresh migration
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
|
||||
logger.info("Initial migration created successfully.")
|
||||
else:
|
||||
# Migration files exist, check if we need to create a new migration for schema changes
|
||||
logger.info("Migration files exist. Checking for pending schema changes...")
|
||||
try:
|
||||
# Create a new migration to detect any schema changes
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration for schema changes")
|
||||
|
||||
# Check if the new migration is empty (no changes detected)
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
# Check if migration is empty
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content and 'op.drop_table' not in content and 'op.drop_column' not in content:
|
||||
logger.info("No schema changes detected. Removing empty migration...")
|
||||
# Remove the empty migration
|
||||
os.remove(migration_path)
|
||||
else:
|
||||
logger.info("Schema changes detected. New migration created.")
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"No new migrations needed or error creating migration: {e}")
|
||||
pass
|
||||
|
||||
# Check if database is in an inconsistent state (has alembic_version but no tables)
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text, inspect
|
||||
inspector = inspect(connection)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# Check if we have alembic_version but no actual tables
|
||||
if 'alembic_version' in existing_tables and len(existing_tables) == 1:
|
||||
logger.info("Database has alembic_version but no actual tables - resetting migration state...")
|
||||
# Clear alembic_version and start fresh
|
||||
connection.execute(text("DELETE FROM alembic_version"))
|
||||
connection.commit()
|
||||
logger.info("Reset migration state - will create fresh migration")
|
||||
|
||||
# Run the upgrade command
|
||||
logger.info("Running database migrations...")
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully.")
|
||||
except Exception as upgrade_error:
|
||||
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
||||
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
||||
# Clear the alembic_version table and start fresh
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DELETE FROM alembic_version"))
|
||||
connection.commit()
|
||||
|
||||
# Set the correct revision in alembic_version table
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if migration_files:
|
||||
# Get the latest migration file and extract its revision ID
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
# Extract revision ID from the file
|
||||
import re
|
||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
||||
if revision_match:
|
||||
revision_id = revision_match.group(1)
|
||||
logger.info(f"Setting alembic_version to {revision_id}")
|
||||
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
||||
connection.commit()
|
||||
|
||||
# Try upgrade again
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully after reset.")
|
||||
elif "no such table" in str(upgrade_error).lower():
|
||||
logger.info("Database tables missing - resetting migration state...")
|
||||
# Clear the alembic_version table and start fresh
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DELETE FROM alembic_version"))
|
||||
connection.commit()
|
||||
|
||||
# Try upgrade again
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully after reset.")
|
||||
else:
|
||||
raise upgrade_error
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running database migrations: {e}")
|
||||
# Fully automated recovery - handle ALL error scenarios
|
||||
logger.info("Attempting automated recovery...")
|
||||
try:
|
||||
# Clear the alembic_version table to reset state
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
||||
connection.commit()
|
||||
|
||||
# Check if we have existing migration files
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if migration_files:
|
||||
# We have migration files, just fix the alembic_version table
|
||||
logger.info("Found existing migration files, fixing alembic_version table...")
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
import re
|
||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
||||
if revision_match:
|
||||
revision_id = revision_match.group(1)
|
||||
logger.info(f"Setting alembic_version to {revision_id}")
|
||||
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
||||
connection.commit()
|
||||
|
||||
# Try upgrade again
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Automated recovery completed successfully.")
|
||||
else:
|
||||
# No migration files, create fresh ones
|
||||
logger.info("No migration files found, creating fresh migration...")
|
||||
_create_complete_migration(alembic_cfg)
|
||||
|
||||
# Run the migration
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Automated recovery completed successfully.")
|
||||
|
||||
except Exception as recovery_error:
|
||||
logger.error(f"Automated recovery failed: {recovery_error}")
|
||||
# Last resort: create database using SQLAlchemy directly
|
||||
logger.info("Using fallback: creating database directly...")
|
||||
_create_database_directly()
|
||||
logger.info("Database created successfully using fallback method.")
|
||||
|
||||
|
||||
def _create_complete_migration(alembic_cfg):
|
||||
"""Create a complete migration file with all database schema."""
|
||||
# Create a new migration file
|
||||
command.revision(alembic_cfg, message="Complete schema migration")
|
||||
|
||||
# Get the latest migration file
|
||||
versions_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
latest_migration = max(migration_files) if migration_files else None
|
||||
|
||||
if latest_migration:
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
_populate_migration_file(migration_path)
|
||||
|
||||
|
||||
def _populate_migration_file(migration_path):
|
||||
"""Populate a migration file with the complete database schema from models."""
|
||||
# Generate the migration content dynamically from models
|
||||
migration_content = _generate_migration_from_models()
|
||||
|
||||
# Read the current migration file
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Add datetime import if needed
|
||||
if "datetime.now" in migration_content and "from datetime import datetime" not in content:
|
||||
# Insert the import after the existing imports
|
||||
import re
|
||||
content = re.sub(
|
||||
r'(from alembic import op\nimport sqlalchemy as sa\n)',
|
||||
r'\1from datetime import datetime\n',
|
||||
content
|
||||
)
|
||||
|
||||
# Replace the empty upgrade/downgrade functions
|
||||
import re
|
||||
# More flexible regex to match the actual content
|
||||
content = re.sub(
|
||||
r'def upgrade\(\) -> None:.*?pass.*?(?=\n\ndef downgrade|\n\nif __name__|\Z)',
|
||||
migration_content,
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
# Write the updated content back
|
||||
with open(migration_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _generate_migration_from_models():
|
||||
"""Generate migration content dynamically from SQLAlchemy models."""
|
||||
from models import Base
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime
|
||||
|
||||
# Generate migration content using Alembic's op functions
|
||||
upgrade_statements = []
|
||||
downgrade_statements = []
|
||||
|
||||
# Get all tables from Base metadata
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
if table_name != 'alembic_version': # Skip alembic_version table
|
||||
# Check if table exists and compare schema
|
||||
schema_diff = _detect_schema_differences(table_name, table)
|
||||
|
||||
if schema_diff['table_exists']:
|
||||
if schema_diff['needs_update']:
|
||||
# Generate ALTER TABLE statements for existing table
|
||||
upgrade_statements.append(f" # Update {table_name} table schema")
|
||||
for statement in schema_diff['alter_statements']:
|
||||
upgrade_statements.append(f" {statement}")
|
||||
else:
|
||||
# Table exists and is up to date - skip creating it
|
||||
upgrade_statements.append(f" # Table {table_name} already exists and is up to date")
|
||||
else:
|
||||
# Generate CREATE TABLE for new table
|
||||
table_code = _generate_table_creation_code(table_name, table)
|
||||
upgrade_statements.append(f" # Create {table_name} table")
|
||||
upgrade_statements.append(table_code)
|
||||
|
||||
# Only add to downgrade if table actually exists
|
||||
if schema_diff['table_exists']:
|
||||
downgrade_statements.append(f" # op.drop_table('{table_name}') # Skipped - table exists")
|
||||
else:
|
||||
downgrade_statements.append(f" op.drop_table('{table_name}')")
|
||||
|
||||
# Combine all statements
|
||||
upgrade_content = "def upgrade() -> None:\n \"\"\"Upgrade schema.\"\"\"\n" + "\n".join(upgrade_statements)
|
||||
downgrade_content = "def downgrade() -> None:\n \"\"\"Downgrade schema.\"\"\"\n" + "\n".join(downgrade_statements)
|
||||
|
||||
return upgrade_content + "\n\n" + downgrade_content
|
||||
|
||||
|
||||
def _detect_schema_differences(table_name, expected_table):
|
||||
"""Detect differences between existing table and expected schema."""
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
# Check if table exists
|
||||
inspector = inspect(connection)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return {
|
||||
'table_exists': False,
|
||||
'needs_update': False,
|
||||
'alter_statements': []
|
||||
}
|
||||
|
||||
# Get existing columns
|
||||
existing_columns = inspector.get_columns(table_name)
|
||||
existing_column_names = {col['name'] for col in existing_columns}
|
||||
|
||||
# Get expected columns
|
||||
expected_column_names = {col.name for col in expected_table.columns}
|
||||
|
||||
# Check for missing columns
|
||||
missing_columns = expected_column_names - existing_column_names
|
||||
extra_columns = existing_column_names - expected_column_names
|
||||
|
||||
alter_statements = []
|
||||
|
||||
# Add missing columns
|
||||
for column in expected_table.columns:
|
||||
if column.name in missing_columns:
|
||||
column_def = _generate_column_definition(column)
|
||||
alter_statements.append(f"op.add_column('{table_name}', {column_def})")
|
||||
|
||||
# Add missing indexes
|
||||
for index in expected_table.indexes:
|
||||
if not index.unique:
|
||||
cols = "', '".join([col.name for col in index.columns])
|
||||
alter_statements.append(f"op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
||||
|
||||
return {
|
||||
'table_exists': True,
|
||||
'needs_update': len(alter_statements) > 0,
|
||||
'alter_statements': alter_statements
|
||||
}
|
||||
|
||||
|
||||
def _generate_column_definition(column):
|
||||
"""Generate column definition for ALTER TABLE."""
|
||||
type_def = _get_column_type(column)
|
||||
nullable = "nullable=True" if column.nullable else "nullable=False"
|
||||
|
||||
definition = f"sa.Column('{column.name}', {type_def}, {nullable}"
|
||||
|
||||
# Handle default values properly
|
||||
if column.default is not None:
|
||||
if hasattr(column.default, 'arg'):
|
||||
# Handle callable defaults
|
||||
if callable(column.default.arg):
|
||||
definition += f", default=datetime.now"
|
||||
else:
|
||||
definition += f", default={repr(column.default.arg)}"
|
||||
else:
|
||||
definition += f", default={repr(column.default)}"
|
||||
|
||||
definition += ")"
|
||||
return definition
|
||||
|
||||
|
||||
def _generate_table_creation_code(table_name, table):
|
||||
"""Generate op.create_table code for a SQLAlchemy table."""
|
||||
lines = [f" op.create_table('{table_name}',"]
|
||||
|
||||
# Collect all table items (columns + constraints)
|
||||
all_items = []
|
||||
|
||||
# Add columns
|
||||
for column in table.columns:
|
||||
column_def = f" sa.Column('{column.name}', {_get_column_type(column)}, nullable={column.nullable}"
|
||||
if column.default is not None:
|
||||
# Handle callable defaults properly
|
||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
||||
column_def += f", default=datetime.now"
|
||||
else:
|
||||
column_def += f", default={repr(column.default)}"
|
||||
column_def += ")"
|
||||
all_items.append(column_def)
|
||||
|
||||
# Add constraints
|
||||
for constraint in table.constraints:
|
||||
if hasattr(constraint, 'columns'):
|
||||
if constraint.__class__.__name__ == 'PrimaryKeyConstraint':
|
||||
all_items.append(f" sa.PrimaryKeyConstraint('{constraint.columns.keys()[0]}')")
|
||||
elif constraint.__class__.__name__ == 'UniqueConstraint':
|
||||
cols = "', '".join(constraint.columns.keys())
|
||||
all_items.append(f" sa.UniqueConstraint('{cols}')")
|
||||
|
||||
# Add foreign key constraints
|
||||
for fk in table.foreign_keys:
|
||||
all_items.append(f" sa.ForeignKeyConstraint(['{fk.parent.name}'], ['{fk.column.table.name}.{fk.column.name}'], )")
|
||||
|
||||
# Add all items with commas (except the last one)
|
||||
for i, item in enumerate(all_items):
|
||||
if i < len(all_items) - 1:
|
||||
item += ","
|
||||
lines.append(item)
|
||||
|
||||
lines.append(" )")
|
||||
|
||||
# Add indexes with IF NOT EXISTS equivalent using try/except
|
||||
for index in table.indexes:
|
||||
if not index.unique:
|
||||
cols = "', '".join([col.name for col in index.columns])
|
||||
lines.append(f" # Create index for {table_name}")
|
||||
lines.append(f" try:")
|
||||
lines.append(f" op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
||||
lines.append(f" except Exception:")
|
||||
lines.append(f" pass # Index may already exist")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _get_column_type(column):
|
||||
"""Get SQLAlchemy column type string."""
|
||||
type_name = column.type.__class__.__name__
|
||||
|
||||
if type_name == 'String':
|
||||
return f"sa.String(length={column.type.length})"
|
||||
elif type_name == 'Integer':
|
||||
return "sa.Integer()"
|
||||
elif type_name == 'Text':
|
||||
return "sa.Text()"
|
||||
elif type_name == 'Boolean':
|
||||
return "sa.Boolean()"
|
||||
elif type_name == 'DateTime':
|
||||
return "sa.DateTime()"
|
||||
else:
|
||||
return f"sa.{type_name}()"
|
||||
|
||||
|
||||
def _create_database_directly():
|
||||
"""Fallback method: create database directly using SQLAlchemy."""
|
||||
from models import Base
|
||||
from db import engine
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
# Check existing tables and update schema
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# For each model table, check if it needs updates
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
if table_name != 'alembic_version':
|
||||
if table_name in existing_tables:
|
||||
# Table exists, check for missing columns
|
||||
existing_columns = {col['name'] for col in inspector.get_columns(table_name)}
|
||||
expected_columns = {col.name for col in table.columns}
|
||||
missing_columns = expected_columns - existing_columns
|
||||
|
||||
# Add missing columns
|
||||
for column in table.columns:
|
||||
if column.name in missing_columns:
|
||||
# Convert to raw SQL for direct execution
|
||||
sql_type = _get_sql_type(column)
|
||||
nullable = "NULL" if column.nullable else "NOT NULL"
|
||||
|
||||
# Handle datetime columns without default (SQLite limitation)
|
||||
if column.type.__class__.__name__ == 'DateTime':
|
||||
# Add column without default, then update existing rows
|
||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}"
|
||||
try:
|
||||
connection.execute(text(alter_sql))
|
||||
logger.info(f"Added column {column.name} to {table_name}")
|
||||
|
||||
# Update existing rows with current timestamp
|
||||
update_sql = f"UPDATE {table_name} SET {column.name} = CURRENT_TIMESTAMP WHERE {column.name} IS NULL"
|
||||
connection.execute(text(update_sql))
|
||||
logger.info(f"Updated {column.name} with current timestamp")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not add column {column.name}: {e}")
|
||||
else:
|
||||
# Handle other column types with defaults
|
||||
default_clause = ""
|
||||
if column.default is not None:
|
||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
||||
# Skip callable defaults for SQLite compatibility
|
||||
pass
|
||||
elif hasattr(column.default, 'arg'):
|
||||
default_clause = f" DEFAULT {repr(column.default.arg)}"
|
||||
|
||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}{default_clause}"
|
||||
try:
|
||||
connection.execute(text(alter_sql))
|
||||
logger.info(f"Added column {column.name} to {table_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not add column {column.name}: {e}")
|
||||
else:
|
||||
# Table doesn't exist, create it
|
||||
logger.info(f"Creating table {table_name}")
|
||||
|
||||
# Create alembic_version table manually
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS alembic_version (
|
||||
version_num VARCHAR(32) NOT NULL,
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)
|
||||
"""))
|
||||
|
||||
# Get the correct revision ID from existing migration files
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
import re
|
||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
||||
if revision_match:
|
||||
revision_id = revision_match.group(1)
|
||||
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
||||
else:
|
||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||
else:
|
||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _get_sql_type(column):
|
||||
"""Get SQL type for direct SQL execution."""
|
||||
type_name = column.type.__class__.__name__
|
||||
|
||||
if type_name == 'String':
|
||||
return f"VARCHAR({column.type.length})"
|
||||
elif type_name == 'Integer':
|
||||
return "INTEGER"
|
||||
elif type_name == 'Text':
|
||||
return "TEXT"
|
||||
elif type_name == 'Boolean':
|
||||
return "BOOLEAN"
|
||||
elif type_name == 'DateTime':
|
||||
return "DATETIME"
|
||||
else:
|
||||
return "TEXT" # fallback
|
||||
|
||||
|
||||
def check_migration_status():
|
||||
"""
|
||||
Check if the database needs migrations.
|
||||
Returns True if migrations are needed, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Create engine
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
# Check if alembic_version table exists
|
||||
with engine.connect() as connection:
|
||||
# Check if alembic_version table exists
|
||||
from sqlalchemy import text
|
||||
result = connection.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'")
|
||||
)
|
||||
alembic_table_exists = result.fetchone() is not None
|
||||
|
||||
if not alembic_table_exists:
|
||||
return True
|
||||
|
||||
# Get current migration context
|
||||
context = MigrationContext.configure(connection)
|
||||
current_rev = context.get_current_revision()
|
||||
|
||||
# Get the latest revision from alembic
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
script_dir = command.ScriptDirectory.from_config(alembic_cfg)
|
||||
head_rev = script_dir.get_current_head()
|
||||
|
||||
return current_rev != head_rev
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking migration status: {e}")
|
||||
return True # Assume migrations are needed if we can't check
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# This allows running migrations directly
|
||||
run_migrations()
|
||||
@@ -1,312 +0,0 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# Модели базы данных
|
||||
class User(Base):
|
||||
__tablename__ = "user"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
display_name = Column(String(64), nullable=False)
|
||||
password_hash = Column(String(200), nullable=False)
|
||||
profile_picture = Column(String(255), nullable=True)
|
||||
bio = Column(Text, nullable=True)
|
||||
online = Column(Boolean, default=False)
|
||||
last_seen = Column(DateTime, default=datetime.now)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
verified = Column(Boolean, default=False)
|
||||
suspended = Column(Boolean, default=False)
|
||||
suspension_reason = Column(Text, nullable=True)
|
||||
deleted = Column(Boolean, default=False)
|
||||
messages = relationship("Message", back_populates="author", lazy="select")
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "message"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
content = Column(Text, nullable=False)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
is_read = Column(Boolean, default=False)
|
||||
reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True)
|
||||
is_edited = Column(Boolean, default=False)
|
||||
|
||||
author = relationship("User", back_populates="messages")
|
||||
reply_to = relationship("Message", remote_side=[id])
|
||||
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
|
||||
class MessageFile(Base):
|
||||
__tablename__ = "message_file"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
path = Column(Text, nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("Message", back_populates="files")
|
||||
|
||||
|
||||
class CryptoPublicKey(Base):
|
||||
__tablename__ = "crypto_public_key"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
public_key_b64 = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class CryptoBackup(Base):
|
||||
__tablename__ = "crypto_backup"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
blob_json = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class DMEnvelope(Base):
|
||||
__tablename__ = "dm_envelope"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
iv_b64 = Column(Text, nullable=False)
|
||||
ciphertext_b64 = Column(Text, nullable=False)
|
||||
salt_b64 = Column(Text, nullable=False)
|
||||
iv2_b64 = Column(Text, nullable=False)
|
||||
wrapped_mk_b64 = Column(Text, nullable=False)
|
||||
reply_to_id = Column(Integer, nullable=True)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
|
||||
class DMFile(Base):
|
||||
__tablename__ = "dm_file"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
path = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("DMEnvelope", back_populates="files")
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
__tablename__ = "push_subscription"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
endpoint = Column(Text, nullable=False)
|
||||
p256dh_key = Column(Text, nullable=False)
|
||||
auth_key = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class FcmToken(Base):
|
||||
__tablename__ = "fcm_token"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
token = Column(Text, nullable=False, unique=True)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
|
||||
# Ensure unique combination of message, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
|
||||
|
||||
|
||||
class DMReaction(Base):
|
||||
__tablename__ = "dm_reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
|
||||
|
||||
# Ensure unique combination of dm_envelope, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
||||
|
||||
|
||||
# Tracks authenticated device sessions per user
|
||||
class DeviceSession(Base):
|
||||
__tablename__ = "device_session"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
|
||||
# Raw User-Agent for reference/debugging
|
||||
raw_user_agent = Column(Text, nullable=True)
|
||||
|
||||
# Parsed fields
|
||||
device_name = Column(String(128), nullable=True)
|
||||
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
|
||||
os_name = Column(String(64), nullable=True)
|
||||
os_version = Column(String(64), nullable=True)
|
||||
browser_name = Column(String(64), nullable=True)
|
||||
browser_version = Column(String(64), nullable=True)
|
||||
brand = Column(String(64), nullable=True)
|
||||
model = Column(String(64), nullable=True)
|
||||
|
||||
# Session identity embedded into JWTs
|
||||
session_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
|
||||
# Lifecycle
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
last_seen = Column(DateTime, default=datetime.now)
|
||||
revoked = Column(Boolean, default=False)
|
||||
|
||||
# Relationship back to user (optional lazy to avoid heavy loads)
|
||||
user = relationship("User", lazy="select")
|
||||
|
||||
# Pydantic модели
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
display_name: str
|
||||
password: str
|
||||
confirm_password: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
currentPasswordDerived: str
|
||||
newPasswordDerived: str
|
||||
logoutAllExceptCurrent: bool = False
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
content: str
|
||||
reply_to_id: int | None = None
|
||||
|
||||
|
||||
class EditMessageRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class DeleteMessageRequest(BaseModel):
|
||||
message_id: int
|
||||
|
||||
|
||||
class UpdateBioRequest(BaseModel):
|
||||
bio: str
|
||||
|
||||
|
||||
class PushSubscriptionRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: dict
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
display_name: str
|
||||
profile_picture: str | None
|
||||
bio: str | None
|
||||
online: bool
|
||||
last_seen: datetime | None
|
||||
created_at: datetime | None
|
||||
verified: bool
|
||||
suspended: bool
|
||||
suspension_reason: str | None
|
||||
deleted: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
timestamp: datetime
|
||||
is_author: bool
|
||||
is_read: bool
|
||||
username: str
|
||||
profile_picture: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReactionRequest(BaseModel):
|
||||
message_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class ReactionResponse(BaseModel):
|
||||
id: int
|
||||
message_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DMReactionRequest(BaseModel):
|
||||
dm_envelope_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class DMReactionResponse(BaseModel):
|
||||
id: int
|
||||
dm_envelope_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UpdateLog(Base):
|
||||
"""Stores update sequence numbers and updates for gap detection"""
|
||||
__tablename__ = "update_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
sequence = Column(Integer, nullable=False, index=True)
|
||||
updates = Column(Text, nullable=False) # JSON array of updates
|
||||
timestamp = Column(DateTime, default=datetime.now, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "sequence", name="uq_user_sequence"),
|
||||
)
|
||||
|
||||
|
||||
# Tables are now created through Alembic migrations
|
||||
# Base.metadata.create_all(bind=engine)
|
||||
@@ -1,244 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from pywebpush import webpush, WebPushException
|
||||
from models import PushSubscription, User, Message, DMEnvelope
|
||||
from models import FcmToken
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials as firebase_credentials
|
||||
from firebase_admin import messaging as firebase_messaging
|
||||
import base64
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
class PushNotificationService:
|
||||
def __init__(self):
|
||||
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
|
||||
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
|
||||
# Firebase Admin initialization (modern API). Only FIREBASE_CERT env is supported.
|
||||
self.firebase_initialized = False
|
||||
try:
|
||||
firebase_cert = os.getenv("FIREBASE_CERT")
|
||||
if not firebase_cert:
|
||||
raise RuntimeError("FIREBASE_CERT env variable is required for Firebase Admin SDK initialization")
|
||||
|
||||
# Support raw JSON or base64-encoded JSON in FIREBASE_CERT
|
||||
decoded = base64.b64decode(firebase_cert).decode("utf-8")
|
||||
sa_dict = json.loads(decoded)
|
||||
|
||||
cred = firebase_credentials.Certificate(sa_dict)
|
||||
firebase_admin.initialize_app(cred)
|
||||
self.firebase_initialized = True
|
||||
logger.info("Firebase Admin SDK initialized for push sending (FIREBASE_CERT)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Firebase Admin SDK from FIREBASE_CERT: {e}")
|
||||
raise
|
||||
|
||||
if (not self.vapid_public_key) or (not self.vapid_private_key):
|
||||
raise ValueError("VAPID public or private key is None")
|
||||
|
||||
self.vapid_claims = {
|
||||
"sub": "mailto:support@fromchat.ru",
|
||||
"aud": "https://fcm.googleapis.com"
|
||||
}
|
||||
|
||||
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
|
||||
"""Subscribe a user to push notifications"""
|
||||
try:
|
||||
# Check if user already has a subscription
|
||||
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
existing_sub.endpoint = endpoint
|
||||
existing_sub.p256dh_key = p256dh_key
|
||||
existing_sub.auth_key = auth_key
|
||||
else:
|
||||
# Create new subscription
|
||||
new_sub = PushSubscription(
|
||||
user_id=user_id,
|
||||
endpoint=endpoint,
|
||||
p256dh_key=p256dh_key,
|
||||
auth_key=auth_key
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Push subscription saved for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
|
||||
"""Send push notification for a new public chat message"""
|
||||
try:
|
||||
# Get all users except the sender
|
||||
users = db.query(User).filter(User.id != message.user_id)
|
||||
if exclude_user_id:
|
||||
users = users.filter(User.id != exclude_user_id)
|
||||
|
||||
for user in users:
|
||||
# Check if user has push subscription before trying to send
|
||||
# Try all FCM tokens first (Android). If none or all fail, fall back to web push subscription.
|
||||
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == user.id).all()
|
||||
payload_data = {
|
||||
"type": "public_message",
|
||||
"message_id": message.id,
|
||||
"sender_id": message.user_id,
|
||||
"sender_username": message.author.username
|
||||
}
|
||||
title = f"{message.author.username}"
|
||||
body = message.content[:100] + ("..." if len(message.content) > 100 else "")
|
||||
|
||||
if fcm_rows and self.firebase_initialized:
|
||||
for fcm in fcm_rows:
|
||||
try:
|
||||
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send FCM to user {user.id} token {fcm.token}: {e}")
|
||||
# Check if this is a permanent failure and clean up the token
|
||||
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
||||
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
||||
if subscription:
|
||||
await self._send_notification_to_user(
|
||||
db, user.id, title, body, message.author.profile_picture, payload_data
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send public message notifications: {e}")
|
||||
|
||||
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
||||
"""Send push notification for a new DM"""
|
||||
try:
|
||||
title = f"{sender.username}"
|
||||
body = "New direct message"
|
||||
payload_data = {
|
||||
"type": "dm",
|
||||
"dm_id": dm_envelope.id,
|
||||
"sender_id": sender.id,
|
||||
"sender_username": sender.username
|
||||
}
|
||||
|
||||
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == dm_envelope.recipient_id).all()
|
||||
if fcm_rows and self.firebase_initialized:
|
||||
for fcm in fcm_rows:
|
||||
try:
|
||||
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send FCM to user {dm_envelope.recipient_id} token {fcm.token}: {e}")
|
||||
# Check if this is a permanent failure and clean up the token
|
||||
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
||||
|
||||
await self._send_notification_to_user(
|
||||
db, dm_envelope.recipient_id, title, body, sender.profile_picture, payload_data
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send DM notification: {e}")
|
||||
|
||||
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
|
||||
"""Send a push notification to a specific user"""
|
||||
try:
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"icon": icon or "about:blank",
|
||||
"tag": f"message_{user_id}",
|
||||
"data": data
|
||||
}
|
||||
|
||||
subscription_info = {
|
||||
"endpoint": subscription.endpoint,
|
||||
"keys": {
|
||||
"p256dh": subscription.p256dh_key,
|
||||
"auth": subscription.auth_key
|
||||
}
|
||||
}
|
||||
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=self.vapid_private_key,
|
||||
vapid_claims=self.vapid_claims
|
||||
)
|
||||
|
||||
except WebPushException as e:
|
||||
logger.error(f"WebPush error for user {user_id}: {e}")
|
||||
# If the subscription is invalid, remove it
|
||||
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
||||
|
||||
def _send_fcm_to_token(self, token: str, title: str, body: str, data: dict):
|
||||
"""Send an FCM data-only push to a single device token using Firebase Admin SDK.
|
||||
Notification display is handled by the app, not FCM."""
|
||||
if not self.firebase_initialized:
|
||||
raise RuntimeError("Firebase Admin SDK not initialized (FIREBASE_CERT required)")
|
||||
|
||||
try:
|
||||
# Send only data payload - let the app handle notification display
|
||||
# This prevents FCM from auto-showing notifications
|
||||
msg = firebase_messaging.Message(
|
||||
token=token,
|
||||
data={
|
||||
"title": title,
|
||||
"body": body,
|
||||
**{k: str(v) for k, v in (data or {}).items()}
|
||||
},
|
||||
android=firebase_messaging.AndroidConfig(priority="high"),
|
||||
apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"})
|
||||
)
|
||||
resp = firebase_messaging.send(msg)
|
||||
return resp
|
||||
except Exception as e:
|
||||
logger.error(f"Firebase Admin send failed for token {token}: {e}")
|
||||
raise
|
||||
|
||||
def _cleanup_failed_fcm_token(self, db: Session, fcm_token_entry, error_message: str):
|
||||
"""Clean up FCM tokens that have permanent failures"""
|
||||
try:
|
||||
# Check for permanent failure indicators in the error message
|
||||
permanent_errors = [
|
||||
"unregistered", "invalidregistration", "notregistered",
|
||||
"sender_id_mismatch", "invalid_argument"
|
||||
]
|
||||
|
||||
error_lower = error_message.lower()
|
||||
is_permanent = any(permanent_error in error_lower for permanent_error in permanent_errors)
|
||||
|
||||
if is_permanent:
|
||||
logger.info(f"Removing permanently failed FCM token for user {fcm_token_entry.user_id}: {fcm_token_entry.token}")
|
||||
db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete()
|
||||
db.commit()
|
||||
else:
|
||||
logger.debug(f"Temporary FCM failure for token {fcm_token_entry.token}, keeping token: {error_message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup FCM token {fcm_token_entry.token}: {e}")
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
||||
"""Unsubscribe a user from push notifications"""
|
||||
try:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
logger.info(f"Push subscription removed for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
# Global instance
|
||||
push_service = PushNotificationService()
|
||||
@@ -1,17 +0,0 @@
|
||||
PyJWT>=2.8.0
|
||||
fastapi[standard]>=0.116.1
|
||||
pydantic>=2.11.7
|
||||
sqlalchemy>=2.0.43
|
||||
bcrypt>=4.3.0
|
||||
websockets>=15.0.1
|
||||
Pillow>=10.0.0
|
||||
python-multipart>=0.0.6
|
||||
pywebpush>=1.14.0
|
||||
cryptography>=41.0.0
|
||||
alembic>=1.13.2
|
||||
better-profanity>=0.7.0
|
||||
user-agents>=2.2.0
|
||||
httpx>=0.27.2
|
||||
rich>=13.9.4
|
||||
slowapi>=0.1.9
|
||||
firebase_admin>=7.1.0
|
||||
@@ -1,574 +0,0 @@
|
||||
from datetime import datetime
|
||||
from collections import defaultdict, deque
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import inspect, text
|
||||
import uuid
|
||||
from user_agents import parse as parse_ua
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
||||
from utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||
import os
|
||||
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
router = APIRouter()
|
||||
|
||||
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
||||
_FAILED_ATTEMPT_THRESHOLD = 5
|
||||
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
def _record_failed_login(identifier: str) -> bool:
|
||||
now = time.time()
|
||||
attempts = _failed_login_attempts[identifier]
|
||||
attempts.append(now)
|
||||
|
||||
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
|
||||
attempts.popleft()
|
||||
|
||||
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
|
||||
|
||||
|
||||
def _reset_failed_logins(identifier: str) -> None:
|
||||
_failed_login_attempts.pop(identifier, None)
|
||||
|
||||
def _is_admin(user: User) -> bool:
|
||||
return user.id == 1
|
||||
|
||||
def convert_user(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_seen": user.last_seen.isoformat(),
|
||||
"online": user.online,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"profile_picture": user.profile_picture,
|
||||
"bio": user.bio,
|
||||
"admin": _is_admin(user),
|
||||
"verified": user.verified,
|
||||
"suspended": user.suspended or False,
|
||||
"suspension_reason": user.suspension_reason,
|
||||
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
||||
}
|
||||
|
||||
@router.get("/check_auth")
|
||||
def check_auth(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"authenticated": True,
|
||||
"username": current_user.username,
|
||||
"admin": _is_admin(current_user)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@rate_limit_per_ip("5/minute")
|
||||
def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)):
|
||||
username = login_request.username.strip()
|
||||
client_ip = get_client_ip(request)
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user or not verify_password(login_request.password.strip(), user.password_hash):
|
||||
log_security(
|
||||
"login_failed",
|
||||
severity="warning",
|
||||
username=username,
|
||||
ip=client_ip,
|
||||
reason="invalid_credentials",
|
||||
)
|
||||
identifiers = [f"user:{username}"]
|
||||
if client_ip:
|
||||
identifiers.append(f"ip:{client_ip}")
|
||||
|
||||
suspicious = False
|
||||
for identifier in identifiers:
|
||||
if _record_failed_login(identifier):
|
||||
suspicious = True
|
||||
|
||||
if suspicious:
|
||||
total_failures = {
|
||||
identifier: len(_failed_login_attempts.get(identifier, []))
|
||||
for identifier in identifiers
|
||||
}
|
||||
log_security(
|
||||
"auth_bruteforce_detected",
|
||||
severity="warning",
|
||||
username=username,
|
||||
ip=client_ip,
|
||||
failures=total_failures,
|
||||
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверное имя пользователя или пароль"
|
||||
)
|
||||
|
||||
# Create device session and embed into JWT
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
device_name = request.headers.get("x-device-name")
|
||||
ua = parse_ua(raw_ua or "")
|
||||
session_id = uuid.uuid4().hex
|
||||
|
||||
device = DeviceSession(
|
||||
user_id=user.id,
|
||||
raw_user_agent=raw_ua,
|
||||
device_name=device_name,
|
||||
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
||||
os_name=(ua.os.family or None),
|
||||
os_version=(ua.os.version_string or None),
|
||||
browser_name=(ua.browser.family or None),
|
||||
browser_version=(ua.browser.version_string or None),
|
||||
brand=(ua.device.brand or None),
|
||||
model=(ua.device.model or None),
|
||||
session_id=session_id,
|
||||
created_at=datetime.now(),
|
||||
last_seen=datetime.now(),
|
||||
revoked=False,
|
||||
)
|
||||
db.add(device)
|
||||
|
||||
user.online = True
|
||||
user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
token = create_token(user.id, user.username, session_id)
|
||||
|
||||
identifiers = [f"user:{username}"]
|
||||
if client_ip:
|
||||
identifiers.append(f"ip:{client_ip}")
|
||||
for identifier in identifiers:
|
||||
_reset_failed_logins(identifier)
|
||||
|
||||
log_security(
|
||||
"login_success",
|
||||
username=user.username,
|
||||
user_id=user.id,
|
||||
ip=client_ip,
|
||||
session_id=session_id,
|
||||
device=device.device_type,
|
||||
os=device.os_name,
|
||||
browser=device.browser_name,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Login successful",
|
||||
"token": token,
|
||||
"user": convert_user(user)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
@rate_limit_per_ip("3/hour")
|
||||
def register(request: Request, register_request: RegisterRequest, db: Session = Depends(get_db)):
|
||||
username = register_request.username.strip()
|
||||
display_name = register_request.display_name.strip()
|
||||
password = register_request.password.strip()
|
||||
confirm_password = register_request.confirm_password.strip()
|
||||
client_ip = get_client_ip(request)
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
|
||||
# Determine if owner already exists
|
||||
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
||||
|
||||
# Validate input
|
||||
if not is_valid_username(username):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
||||
)
|
||||
if contains_profanity(username):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Имя пользователя содержит запрещённые слова"
|
||||
)
|
||||
|
||||
if not is_valid_display_name(display_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||
)
|
||||
if contains_profanity(display_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Отображаемое имя содержит запрещённые слова"
|
||||
)
|
||||
|
||||
if not is_valid_password(password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пароль должен быть от 5 до 50 символов и не содержать пробелов"
|
||||
)
|
||||
|
||||
if password != confirm_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пароли не совпадают"
|
||||
)
|
||||
|
||||
existing_user = db.query(User).filter(User.username == username).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Это имя пользователя уже занято"
|
||||
)
|
||||
|
||||
hashed_password = get_password_hash(password)
|
||||
|
||||
# Set verified=True for the owner (first user to register)
|
||||
is_owner = not owner_exists and username == OWNER_USERNAME
|
||||
|
||||
new_user = User(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
password_hash=hashed_password,
|
||||
online=True,
|
||||
last_seen=datetime.now(),
|
||||
verified=is_owner
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# Create initial device session
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
device_name = request.headers.get("x-device-name")
|
||||
ua = parse_ua(raw_ua or "")
|
||||
session_id = uuid.uuid4().hex
|
||||
device = DeviceSession(
|
||||
user_id=new_user.id,
|
||||
raw_user_agent=raw_ua,
|
||||
device_name=device_name,
|
||||
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
||||
os_name=(ua.os.family or None),
|
||||
os_version=(ua.os.version_string or None),
|
||||
browser_name=(ua.browser.family or None),
|
||||
browser_version=(ua.browser.version_string or None),
|
||||
brand=(ua.device.brand or None),
|
||||
model=(ua.device.model or None),
|
||||
session_id=session_id,
|
||||
created_at=datetime.now(),
|
||||
last_seen=datetime.now(),
|
||||
revoked=False,
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
|
||||
token = create_token(new_user.id, new_user.username, session_id)
|
||||
|
||||
os_name = ua.os.family or "Unknown OS"
|
||||
if ua.os.version_string:
|
||||
os_name = f"{os_name} {ua.os.version_string}"
|
||||
browser_name = ua.browser.family or "Unknown browser"
|
||||
if ua.browser.version_string:
|
||||
browser_name = f"{browser_name} {ua.browser.version_string}"
|
||||
user_agent_summary = f"{os_name}, {browser_name}"
|
||||
|
||||
log_security(
|
||||
"registration_success",
|
||||
username=new_user.username,
|
||||
display_name=new_user.display_name,
|
||||
user_id=new_user.id,
|
||||
ip=client_ip,
|
||||
user_agent=user_agent_summary,
|
||||
owner=is_owner,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Регистрация прошла успешно",
|
||||
"token": token,
|
||||
"user": convert_user(new_user)
|
||||
}
|
||||
|
||||
@router.get("/crypto/public-key")
|
||||
def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||
return {"publicKey": row.public_key_b64 if row else None}
|
||||
|
||||
|
||||
@router.post("/crypto/public-key")
|
||||
def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
pk = payload.get("publicKey")
|
||||
if not pk:
|
||||
raise HTTPException(status_code=400, detail="publicKey required")
|
||||
if not isinstance(pk, str) or len(pk) > 10000 or len(pk) < 10:
|
||||
raise HTTPException(status_code=400, detail="Invalid publicKey format")
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.public_key_b64 = pk
|
||||
else:
|
||||
row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/crypto/backup")
|
||||
def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||
return {"blob": row.blob_json if row else None}
|
||||
|
||||
|
||||
@router.post("/crypto/backup")
|
||||
def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
blob = payload.get("blob")
|
||||
if not blob:
|
||||
raise HTTPException(status_code=400, detail="blob required")
|
||||
if not isinstance(blob, str) or len(blob) > 1000000: # 1MB limit
|
||||
raise HTTPException(status_code=400, detail="Invalid blob format or size exceeds 1MB")
|
||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.blob_json = blob
|
||||
else:
|
||||
row = CryptoBackup(user_id=current_user.id, blob_json=blob)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/admin/user/{user_id}")
|
||||
def delete_user_as_owner(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Only owner can delete users
|
||||
if _is_admin(current_user):
|
||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deleting the owner account via API
|
||||
if _is_admin(user):
|
||||
raise HTTPException(status_code=400, detail="Cannot delete owner account")
|
||||
|
||||
# Manually delete user's messages to satisfy FK constraints
|
||||
from models import Message # local import to avoid circular
|
||||
db.query(Message).filter(Message.user_id == user.id).delete()
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
log_security(
|
||||
"admin_delete_user",
|
||||
severity="warning",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=user.username,
|
||||
target_id=user.id,
|
||||
)
|
||||
|
||||
return {"status": "success", "deleted_user_id": user_id}
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(
|
||||
http: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Revoke current session
|
||||
from utils import verify_token as _verify_token
|
||||
payload = _verify_token(credentials.credentials)
|
||||
if payload and payload.get("session_id"):
|
||||
db.query(DeviceSession).filter(
|
||||
DeviceSession.user_id == current_user.id,
|
||||
DeviceSession.session_id == payload["session_id"],
|
||||
).update({DeviceSession.revoked: True})
|
||||
|
||||
current_user.online = False
|
||||
current_user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
client_ip = get_client_ip(http)
|
||||
log_security(
|
||||
"logout",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip=client_ip,
|
||||
session_id=payload.get("session_id") if payload else None,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Logged out successfully"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
@rate_limit_per_ip("5/hour")
|
||||
def change_password(
|
||||
request: Request,
|
||||
password_request: ChangePasswordRequest,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Verify current derived password against stored hash
|
||||
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
|
||||
|
||||
# Update password hash to hash of new derived password
|
||||
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip())
|
||||
db.commit()
|
||||
|
||||
# Optionally revoke all other sessions, keeping the current one
|
||||
if password_request.logoutAllExceptCurrent:
|
||||
from utils import verify_token as _verify_token
|
||||
payload = _verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
current_session_id = payload.get("session_id")
|
||||
db.query(DeviceSession).filter(
|
||||
DeviceSession.user_id == current_user.id,
|
||||
DeviceSession.session_id != current_session_id,
|
||||
).update({DeviceSession.revoked: True})
|
||||
db.commit()
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
log_security(
|
||||
"password_changed",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip=client_ip,
|
||||
logout_others=bool(password_request.logoutAllExceptCurrent),
|
||||
)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse
|
||||
def list_users(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
users = db.query(User).order_by(User.username.asc()).all()
|
||||
return {
|
||||
"users": [
|
||||
convert_user(u) for u in users if u.id != current_user.id
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crypto/public-key/of/{user_id}")
|
||||
@rate_limit_per_ip("100/minute") # Per-IP limit to prevent abuse
|
||||
def get_public_key_of(request: Request, user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
|
||||
return {"publicKey": row.public_key_b64 if row else None}
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
||||
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if len(q.strip()) < 2:
|
||||
return {"users": []}
|
||||
|
||||
# Case-insensitive partial match on username
|
||||
users = db.query(User).filter(
|
||||
User.username.ilike(f"%{q.strip()}%"),
|
||||
User.id != current_user.id # Exclude current user
|
||||
).order_by(User.username.asc()).limit(20).all()
|
||||
|
||||
return {
|
||||
"users": [convert_user(u) for u in users]
|
||||
}
|
||||
|
||||
|
||||
async def _delete_user_data(user: User, db: Session):
|
||||
"""
|
||||
Helper function to delete user data - marks user as deleted, clears sensitive data,
|
||||
deletes profile picture, removes non-whitelist user data, and sends WebSocket message.
|
||||
"""
|
||||
user_id = user.id
|
||||
|
||||
# Mark user as deleted and clear sensitive data
|
||||
user.deleted = True
|
||||
user.display_name = f"Deleted User #{user_id}"
|
||||
user.bio = None
|
||||
user.password_hash = ""
|
||||
user.username = f"deleted_{user_id}"
|
||||
user.profile_picture = None
|
||||
user.last_seen = None # Clear last seen timestamp
|
||||
user.created_at = None # Clear member since timestamp
|
||||
|
||||
# Delete profile picture file if exists
|
||||
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"):
|
||||
try:
|
||||
filename = user.profile_picture.split("/")[-1]
|
||||
filepath = os.path.join("data/uploads/pfp", filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the request
|
||||
pass
|
||||
|
||||
# Dynamic deletion of all non-whitelist data
|
||||
WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"}
|
||||
|
||||
try:
|
||||
inspector = inspect(db.bind)
|
||||
all_tables = inspector.get_table_names()
|
||||
|
||||
for table_name in all_tables:
|
||||
if table_name in WHITELIST_TABLES or table_name == "user":
|
||||
continue
|
||||
|
||||
# Check if table has user_id column
|
||||
columns = inspector.get_columns(table_name)
|
||||
has_user_id = any(col['name'] == 'user_id' for col in columns)
|
||||
|
||||
if has_user_id:
|
||||
# Delete all records for this user
|
||||
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
# Log error and rollback
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail="Failed to delete user data")
|
||||
|
||||
# Send WebSocket deletion message
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.send_deletion_to_user(user_id)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the request
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
async def delete_account(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete the current user's own account - preserves messages/DMs/reactions/files
|
||||
"""
|
||||
# Prevent admin/owner account self-deletion
|
||||
if _is_admin(current_user):
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
|
||||
|
||||
await _delete_user_data(current_user, db)
|
||||
|
||||
log_security(
|
||||
"self_delete_account",
|
||||
severity="warning",
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Account deleted successfully"
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, DeviceSession
|
||||
from utils import verify_token
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def _get_current_session_id(credentials: HTTPAuthorizationCredentials) -> str:
|
||||
token = credentials.credentials
|
||||
payload = verify_token(token)
|
||||
if not payload or "session_id" not in payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid session")
|
||||
return payload["session_id"]
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_devices(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
current_session_id = _get_current_session_id(credentials)
|
||||
sessions = (
|
||||
db.query(DeviceSession)
|
||||
.filter(DeviceSession.user_id == current_user.id, DeviceSession.revoked == False)
|
||||
.order_by(DeviceSession.last_seen.desc())
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"devices": [
|
||||
{
|
||||
"session_id": s.session_id,
|
||||
"device_type": s.device_type,
|
||||
"device_name": s.device_name,
|
||||
"os_name": s.os_name,
|
||||
"os_version": s.os_version,
|
||||
"browser_name": s.browser_name,
|
||||
"browser_version": s.browser_version,
|
||||
"brand": s.brand,
|
||||
"model": s.model,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"last_seen": s.last_seen.isoformat() if s.last_seen else None,
|
||||
"revoked": s.revoked,
|
||||
"current": s.session_id == current_session_id,
|
||||
}
|
||||
for s in sessions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{session_id}")
|
||||
def revoke_device(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not session_id or len(session_id) > 64 or len(session_id) < 1:
|
||||
raise HTTPException(status_code=400, detail="Invalid session ID")
|
||||
|
||||
s = (
|
||||
db.query(DeviceSession)
|
||||
.filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id)
|
||||
.first()
|
||||
)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Device session not found")
|
||||
s.revoked = True
|
||||
db.commit()
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/logout-all")
|
||||
def logout_all_except_current(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
current_session_id = _get_current_session_id(credentials)
|
||||
db.query(DeviceSession).filter(
|
||||
DeviceSession.user_id == current_user.id,
|
||||
DeviceSession.session_id != current_session_id,
|
||||
).update({DeviceSession.revoked: True})
|
||||
db.commit()
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
"""
|
||||
Download routes for FromChat desktop and mobile builds.
|
||||
Fetches from GitHub Actions (PC) and GitHub Releases (mobile), with disk caching.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/download", tags=["download"])
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
WEB_OWNER, WEB_REPO = "fromchat-messenger", "web"
|
||||
APP_OWNER, APP_REPO = "fromchat-messenger", "app"
|
||||
WORKFLOW_FILE = "build.yml"
|
||||
TIMEOUT = 10.0
|
||||
|
||||
ARTIFACT_NAMES = {
|
||||
"windows": "FromChat-windows",
|
||||
"linux": "FromChat-linux",
|
||||
"macos": "FromChat-macOS",
|
||||
}
|
||||
|
||||
CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "downloads"
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
token = os.environ.get("RELEASES_TOKEN")
|
||||
if not token:
|
||||
raise HTTPException(status_code=503, detail="RELEASES_TOKEN not configured")
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
|
||||
def _etag_path(os_name: str) -> Path:
|
||||
return CACHE_DIR / f"{os_name}.etag"
|
||||
|
||||
|
||||
def _cached_file_path(os_name: str) -> Path:
|
||||
ext = ".zip" if os_name in ARTIFACT_NAMES else (".apk" if os_name == "android" else ".ipa")
|
||||
return CACHE_DIR / f"{os_name}{ext}"
|
||||
|
||||
|
||||
async def _fetch_pc_artifact_url(os_name: str) -> tuple[str, int]:
|
||||
"""Fetch workflow runs, get latest run, find artifact. Returns (download_url, artifact_id)."""
|
||||
artifact_name = ARTIFACT_NAMES[os_name]
|
||||
logger.info("[download] Fetching PC artifact for %s: workflow=%s/%s/%s", os_name, WEB_OWNER, WEB_REPO, WORKFLOW_FILE)
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
||||
runs_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/workflows/{WORKFLOW_FILE}/runs"
|
||||
logger.info("[download] GitHub API: GET %s (per_page=1, status=success)", runs_url)
|
||||
runs_resp = await client.get(
|
||||
runs_url,
|
||||
headers=_headers(),
|
||||
params={"per_page": 1, "status": "success"},
|
||||
)
|
||||
logger.info("[download] GitHub workflow runs response: status=%s", runs_resp.status_code)
|
||||
runs_resp.raise_for_status()
|
||||
runs = runs_resp.json()
|
||||
workflow_runs = runs.get("workflow_runs", [])
|
||||
if not workflow_runs:
|
||||
logger.warning("[download] No successful workflow runs for %s", artifact_name)
|
||||
raise HTTPException(status_code=404, detail=f"No successful workflow run for {artifact_name}")
|
||||
|
||||
run_id = workflow_runs[0]["id"]
|
||||
logger.info("[download] Latest run_id=%s, fetching artifacts", run_id)
|
||||
artifacts_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/runs/{run_id}/artifacts"
|
||||
artifacts_resp = await client.get(artifacts_url, headers=_headers())
|
||||
logger.info("[download] GitHub artifacts response: status=%s", artifacts_resp.status_code)
|
||||
artifacts_resp.raise_for_status()
|
||||
data = artifacts_resp.json()
|
||||
for artifact in data.get("artifacts", []):
|
||||
if artifact["name"] == artifact_name:
|
||||
url = artifact["archive_download_url"]
|
||||
aid = artifact["id"]
|
||||
logger.info("[download] Found artifact %s id=%s, download_url=%s", artifact_name, aid, url[:80] + "..." if len(url) > 80 else url)
|
||||
return url, aid
|
||||
logger.warning("[download] Artifact %s not found in run %s", artifact_name, run_id)
|
||||
raise HTTPException(status_code=404, detail=f"Artifact {artifact_name} not found")
|
||||
|
||||
|
||||
async def _fetch_mobile_asset_url(os_name: str) -> str:
|
||||
"""Fetch latest release, find asset by name. Returns browser_download_url."""
|
||||
keyword = "android" if os_name == "android" else "ios"
|
||||
logger.info("[download] Fetching mobile asset for %s: releases %s/%s", os_name, APP_OWNER, APP_REPO)
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
||||
releases_url = f"{GITHUB_API}/repos/{APP_OWNER}/{APP_REPO}/releases"
|
||||
logger.info("[download] GitHub API: GET %s (per_page=10)", releases_url)
|
||||
resp = await client.get(
|
||||
releases_url,
|
||||
headers=_headers(),
|
||||
params={"per_page": 10},
|
||||
)
|
||||
logger.info("[download] GitHub releases response: status=%s", resp.status_code)
|
||||
resp.raise_for_status()
|
||||
releases = resp.json()
|
||||
for release in releases:
|
||||
if release.get("draft"):
|
||||
continue
|
||||
for asset in release.get("assets", []):
|
||||
if keyword.lower() in asset.get("name", "").lower():
|
||||
url = asset["browser_download_url"]
|
||||
logger.info("[download] Found %s asset: %s (release: %s)", os_name, asset.get("name"), release.get("tag_name"))
|
||||
return url
|
||||
logger.warning("[download] No %s asset in releases", os_name)
|
||||
raise HTTPException(status_code=404, detail=f"No {os_name} asset found in releases")
|
||||
|
||||
|
||||
async def _download_and_stream(
|
||||
url: str,
|
||||
os_name: str,
|
||||
stored_etag: str | None,
|
||||
) -> StreamingResponse | FileResponse:
|
||||
"""Stream from GitHub to client and save to disk. If 304, serve from disk."""
|
||||
etag_path = _etag_path(os_name)
|
||||
cache_path = _cached_file_path(os_name)
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
headers = {**_headers(), "Accept": "*/*"}
|
||||
if stored_etag:
|
||||
headers["If-None-Match"] = stored_etag
|
||||
|
||||
logger.info("[download] Mobile %s: GET %s (etag=%s)", os_name, url[:100] + "..." if len(url) > 100 else url, stored_etag or "none")
|
||||
|
||||
async def stream_and_save():
|
||||
total = 0
|
||||
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
|
||||
new_etag: str | None = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
async with client.stream("GET", url, headers=headers) as resp:
|
||||
if resp.status_code == 304 and cache_path.exists():
|
||||
yield None
|
||||
return
|
||||
if resp.status_code != 200:
|
||||
if resp.status_code in (404, 410):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Release asset not found on GitHub",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="GitHub returned an error while downloading asset",
|
||||
)
|
||||
new_etag = resp.headers.get("etag")
|
||||
logger.info("[download] Mobile %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
|
||||
with open(tmp_path, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
total += len(chunk)
|
||||
yield chunk
|
||||
tmp_path.rename(cache_path)
|
||||
if new_etag:
|
||||
etag_path.write_text(new_etag)
|
||||
logger.info("[download] Mobile %s: completed, saved %d bytes", os_name, total)
|
||||
except httpx.StreamClosed:
|
||||
logger.info("[download] Mobile %s: client disconnected after %d bytes", os_name, total)
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except httpx.TimeoutException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
if cache_path.exists():
|
||||
raise _CacheFallback()
|
||||
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
||||
except HTTPException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
class _CacheFallback(Exception):
|
||||
pass
|
||||
|
||||
gen = stream_and_save()
|
||||
try:
|
||||
first = await gen.__anext__()
|
||||
except StopAsyncIteration:
|
||||
first = None
|
||||
except _CacheFallback:
|
||||
await gen.aclose()
|
||||
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
|
||||
if first is None:
|
||||
await gen.aclose()
|
||||
logger.info("[download] Mobile %s: serving from cache (304)", os_name)
|
||||
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
|
||||
|
||||
async def body():
|
||||
yield first
|
||||
async for chunk in gen:
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
body(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_artifact_download_url(url: str) -> str:
|
||||
"""Resolve artifact URL: GitHub 302 redirects to Azure; Azure rejects Authorization. Get Location without following."""
|
||||
headers = {**_headers(), "Accept": "application/vnd.github+json"}
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
if resp.status_code in (404, 410):
|
||||
raise HTTPException(status_code=404, detail="Artifact not found on GitHub")
|
||||
if resp.status_code != 302:
|
||||
raise HTTPException(status_code=503, detail="GitHub returned an error while resolving artifact URL")
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
raise HTTPException(status_code=502, detail="No redirect location from GitHub")
|
||||
return location
|
||||
|
||||
|
||||
async def _download_artifact_and_stream(
|
||||
url: str,
|
||||
os_name: str,
|
||||
artifact_id: int,
|
||||
) -> StreamingResponse | FileResponse:
|
||||
"""Download artifact (zip). GitHub redirects to Azure; Azure must be called WITHOUT Authorization."""
|
||||
etag_path = _etag_path(os_name)
|
||||
cache_path = _cached_file_path(os_name)
|
||||
stored_id = etag_path.read_text().strip() if etag_path.exists() else None
|
||||
if stored_id == str(artifact_id) and cache_path.exists():
|
||||
logger.info("[download] PC %s: serving from cache (artifact_id=%s)", os_name, artifact_id)
|
||||
return FileResponse(
|
||||
str(cache_path),
|
||||
media_type="application/zip",
|
||||
filename=cache_path.name,
|
||||
)
|
||||
|
||||
try:
|
||||
download_url = await _resolve_artifact_download_url(url)
|
||||
except HTTPException:
|
||||
if cache_path.exists():
|
||||
logger.info("[download] PC %s: GitHub error, serving from cache", os_name)
|
||||
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
|
||||
raise
|
||||
|
||||
logger.info("[download] PC %s: streaming from Azure URL (no auth)", os_name)
|
||||
|
||||
async def stream_and_save():
|
||||
total = 0
|
||||
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
async with client.stream("GET", download_url) as resp:
|
||||
if resp.status_code != 200:
|
||||
if resp.status_code in (404, 410):
|
||||
raise HTTPException(status_code=404, detail="Artifact file not found on GitHub")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="GitHub returned an error while downloading artifact file",
|
||||
)
|
||||
logger.info("[download] PC %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
|
||||
with open(tmp_path, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
total += len(chunk)
|
||||
yield chunk
|
||||
tmp_path.rename(cache_path)
|
||||
etag_path.write_text(str(artifact_id))
|
||||
logger.info("[download] PC %s: completed, saved %d bytes", os_name, total)
|
||||
except httpx.StreamClosed:
|
||||
logger.info("[download] PC %s: client disconnected after %d bytes", os_name, total)
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except HTTPException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
gen = stream_and_save()
|
||||
try:
|
||||
first = await gen.__anext__()
|
||||
except StopAsyncIteration:
|
||||
first = None
|
||||
except HTTPException:
|
||||
if cache_path.exists():
|
||||
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
|
||||
raise
|
||||
|
||||
if first is None:
|
||||
await gen.aclose()
|
||||
raise HTTPException(status_code=502, detail="Empty response from download")
|
||||
|
||||
async def body():
|
||||
yield first
|
||||
async for chunk in gen:
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
body(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
|
||||
)
|
||||
|
||||
|
||||
def _head_response(filename: str, content_length: int | None = None) -> Response:
|
||||
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
if content_length is not None:
|
||||
headers["Content-Length"] = str(content_length)
|
||||
return Response(status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.api_route("/{os_name}", methods=["GET", "HEAD"])
|
||||
async def download(request: Request, os_name: str):
|
||||
"""Download app for the given OS: windows, linux, macos, android, ios."""
|
||||
is_head = request.method == "HEAD"
|
||||
os_name = os_name.lower()
|
||||
logger.info("[download] %s /download/%s", request.method, os_name)
|
||||
|
||||
if os_name not in ("windows", "linux", "macos", "android", "ios"):
|
||||
raise HTTPException(status_code=400, detail="Invalid os. Use: windows, linux, macos, android, ios")
|
||||
|
||||
try:
|
||||
if os_name in ARTIFACT_NAMES:
|
||||
try:
|
||||
url, artifact_id = await asyncio.wait_for(
|
||||
_fetch_pc_artifact_url(os_name),
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[download] PC %s: GitHub API timeout", os_name)
|
||||
cache_path = _cached_file_path(os_name)
|
||||
if cache_path.exists():
|
||||
if is_head:
|
||||
return _head_response(cache_path.name, cache_path.stat().st_size)
|
||||
return FileResponse(
|
||||
str(cache_path),
|
||||
media_type="application/zip",
|
||||
filename=cache_path.name,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
||||
cache_path = _cached_file_path(os_name)
|
||||
result = await _download_artifact_and_stream(url, os_name, artifact_id)
|
||||
if is_head:
|
||||
fn = getattr(result, "filename", None) or cache_path.name
|
||||
size = cache_path.stat().st_size if cache_path.exists() else None
|
||||
return _head_response(fn, size)
|
||||
return result
|
||||
else:
|
||||
stored_etag = None
|
||||
etag_path = _etag_path(os_name)
|
||||
cache_path = _cached_file_path(os_name)
|
||||
if etag_path.exists():
|
||||
stored_etag = etag_path.read_text().strip() or None
|
||||
|
||||
try:
|
||||
url = await asyncio.wait_for(
|
||||
_fetch_mobile_asset_url(os_name),
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[download] Mobile %s: GitHub API timeout", os_name)
|
||||
if cache_path.exists():
|
||||
if is_head:
|
||||
return _head_response(cache_path.name, cache_path.stat().st_size)
|
||||
return FileResponse(
|
||||
str(cache_path),
|
||||
media_type="application/octet-stream",
|
||||
filename=cache_path.name,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
||||
|
||||
result = await _download_and_stream(url, os_name, stored_etag)
|
||||
if is_head:
|
||||
fn = getattr(result, "filename", None) or cache_path.name
|
||||
size = cache_path.stat().st_size if cache_path.exists() else None
|
||||
return _head_response(fn, size)
|
||||
return result
|
||||
except HTTPException as exc:
|
||||
if exc.status_code in (404, 410):
|
||||
raise HTTPException(status_code=404, detail=exc.detail)
|
||||
if exc.status_code in (502, 503, 504):
|
||||
raise HTTPException(status_code=503, detail=exc.detail)
|
||||
raise
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user
|
||||
from models import User
|
||||
from security.audit import log_security
|
||||
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||
from security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
||||
|
||||
|
||||
class BlocklistUpdateRequest(BaseModel):
|
||||
words: List[str] = Field(default_factory=list, min_items=1)
|
||||
|
||||
|
||||
class UnblockIPRequest(BaseModel):
|
||||
ip: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/moderation", tags=["moderation"])
|
||||
|
||||
|
||||
def _ensure_owner(user: User) -> None:
|
||||
if user.username != OWNER_USERNAME:
|
||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
||||
|
||||
|
||||
@router.get("/blocklist")
|
||||
def list_blocklist(current_user: User = Depends(get_current_user)):
|
||||
_ensure_owner(current_user)
|
||||
return {"words": get_blocklist()}
|
||||
|
||||
|
||||
@router.post("/blocklist")
|
||||
def append_blocklist(
|
||||
request: BlocklistUpdateRequest,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
_ensure_owner(current_user)
|
||||
added, updated = add_to_blocklist(request.words)
|
||||
log_security(
|
||||
"blocklist_add",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
added=added,
|
||||
)
|
||||
return {"added": added, "words": updated}
|
||||
|
||||
|
||||
@router.delete("/blocklist")
|
||||
def delete_from_blocklist(
|
||||
request: BlocklistUpdateRequest,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
_ensure_owner(current_user)
|
||||
removed, updated = remove_from_blocklist(request.words)
|
||||
log_security(
|
||||
"blocklist_remove",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
removed=removed,
|
||||
)
|
||||
return {"removed": removed, "words": updated}
|
||||
|
||||
|
||||
@router.post("/unblock-ip")
|
||||
def unblock_ip(
|
||||
request: UnblockIPRequest,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Unblock an IP address from rate limiting."""
|
||||
_ensure_owner(current_user)
|
||||
ip = request.ip.strip()
|
||||
|
||||
if not ip:
|
||||
raise HTTPException(status_code=400, detail="IP address is required")
|
||||
|
||||
cleared = reset_rate_limit_for_ip(ip)
|
||||
|
||||
log_security(
|
||||
"rate_limit_unblock",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
ip=ip,
|
||||
success=cleared,
|
||||
)
|
||||
|
||||
if cleared:
|
||||
return {"status": "success", "message": f"Rate limit cleared for IP: {ip}"}
|
||||
else:
|
||||
return {"status": "success", "message": f"No rate limit entries found for IP: {ip}"}
|
||||
|
||||
|
||||
@router.post("/clear-all-rate-limits")
|
||||
def clear_all_rate_limits_endpoint(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Clear all rate limit entries. Use with caution."""
|
||||
_ensure_owner(current_user)
|
||||
|
||||
cleared = clear_all_rate_limits()
|
||||
|
||||
log_security(
|
||||
"rate_limit_clear_all",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
entries_cleared=cleared,
|
||||
)
|
||||
|
||||
return {"status": "success", "message": f"Cleared {cleared} rate limit entries"}
|
||||
|
||||
|
||||
|
||||
@@ -1,570 +0,0 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from PIL import Image
|
||||
import os
|
||||
import uuid
|
||||
import io
|
||||
from fastapi import Request
|
||||
|
||||
from dependencies import get_db, get_current_user
|
||||
from models import User, UpdateBioRequest, UserProfileResponse
|
||||
from pydantic import BaseModel
|
||||
from validation import is_valid_username, is_valid_display_name
|
||||
from similarity import is_user_similar_to_verified
|
||||
from .messaging import messagingManager
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ensure_owner_unsuspended(user: User | None, db: Session):
|
||||
if user and user.id == 1 and user.suspended:
|
||||
user.suspended = False
|
||||
user.suspension_reason = None
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Request models
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
username: str | None = None
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
# Create uploads directory if it doesn't exist
|
||||
PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
|
||||
|
||||
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
|
||||
|
||||
@router.post("/upload-profile-picture")
|
||||
@rate_limit_per_ip("10/minute")
|
||||
async def upload_profile_picture(
|
||||
request: Request,
|
||||
profile_picture: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Upload and process a profile picture
|
||||
"""
|
||||
# Validate file type
|
||||
if not profile_picture.content_type.startswith('image/'):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
# Validate file size (max 5MB)
|
||||
if profile_picture.size > 5 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="File size must be less than 5MB")
|
||||
|
||||
try:
|
||||
# Read and process the image
|
||||
image_data = await profile_picture.read()
|
||||
|
||||
# Open image with PIL
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Convert to RGB if necessary
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# Resize to a reasonable size (200x200)
|
||||
image.thumbnail((200, 200), Image.Resampling.LANCZOS)
|
||||
|
||||
# Generate unique filename
|
||||
filename = f"{current_user.id}_{uuid.uuid4().hex}.jpg"
|
||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
||||
|
||||
# Save the processed image
|
||||
image.save(filepath, 'JPEG', quality=85)
|
||||
|
||||
# Update user's profile picture in database
|
||||
profile_picture_url = f"/api/profile-picture/{filename}"
|
||||
current_user.profile_picture = profile_picture_url
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Profile picture uploaded successfully",
|
||||
"profile_picture_url": profile_picture_url
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
|
||||
|
||||
@router.get("/profile-picture/{filename}")
|
||||
async def get_profile_picture(filename: str):
|
||||
"""
|
||||
Serve profile picture files
|
||||
"""
|
||||
|
||||
if not re.match(r"^\d+_[0-9a-z]+\.jpg$", filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
|
||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="Profile picture not found")
|
||||
|
||||
return FileResponse(filepath, media_type="image/jpeg")
|
||||
|
||||
@router.get("/user/profile")
|
||||
async def get_user_profile(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get current user's profile information
|
||||
"""
|
||||
_ensure_owner_unsuspended(current_user, db)
|
||||
|
||||
return UserProfileResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
display_name=current_user.display_name,
|
||||
profile_picture=current_user.profile_picture,
|
||||
bio=current_user.bio,
|
||||
online=current_user.online,
|
||||
last_seen=current_user.last_seen,
|
||||
created_at=current_user.created_at,
|
||||
verified=current_user.verified,
|
||||
suspended=current_user.suspended or False,
|
||||
suspension_reason=current_user.suspension_reason,
|
||||
deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted
|
||||
)
|
||||
|
||||
|
||||
@router.get("/user/list")
|
||||
async def list_users(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if current_user.id != 1:
|
||||
raise HTTPException(status_code=403, detail="Only admin can list users")
|
||||
|
||||
_ensure_owner_unsuspended(current_user, db)
|
||||
|
||||
users = db.query(User).order_by(User.username.asc()).all()
|
||||
return {
|
||||
"users": [
|
||||
UserProfileResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
profile_picture=user.profile_picture,
|
||||
bio=user.bio,
|
||||
online=user.online,
|
||||
last_seen=user.last_seen,
|
||||
created_at=user.created_at,
|
||||
verified=user.verified,
|
||||
suspended=user.suspended or False,
|
||||
suspension_reason=user.suspension_reason,
|
||||
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
|
||||
).model_dump()
|
||||
for user in users
|
||||
]
|
||||
}
|
||||
|
||||
@router.put("/user/profile")
|
||||
@rate_limit_per_ip("10/minute")
|
||||
async def update_user_profile(
|
||||
request: Request,
|
||||
update_request: UpdateProfileRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update current user's profile information
|
||||
"""
|
||||
updated = False
|
||||
|
||||
# Update username if provided
|
||||
if update_request.username is not None:
|
||||
username = update_request.username.strip()
|
||||
if not is_valid_username(username):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
||||
)
|
||||
if contains_profanity(username):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Имя пользователя содержит запрещённые слова"
|
||||
)
|
||||
|
||||
# Check if username is already taken by another user
|
||||
existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first()
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="Это имя пользователя уже занято")
|
||||
|
||||
current_user.username = username
|
||||
updated = True
|
||||
|
||||
# Update display name if provided
|
||||
if update_request.display_name is not None:
|
||||
display_name = update_request.display_name.strip()
|
||||
if not is_valid_display_name(display_name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||
)
|
||||
if contains_profanity(display_name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Отображаемое имя содержит запрещённые слова"
|
||||
)
|
||||
|
||||
current_user.display_name = display_name
|
||||
updated = True
|
||||
|
||||
# Update bio if provided
|
||||
if update_request.description is not None:
|
||||
bio = update_request.description.strip()
|
||||
if len(bio) > 500:
|
||||
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
|
||||
|
||||
current_user.bio = bio
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
db.commit()
|
||||
return {
|
||||
"message": "Profile updated successfully",
|
||||
"username": current_user.username,
|
||||
"display_name": current_user.display_name,
|
||||
"bio": current_user.bio
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"message": "No changes made",
|
||||
"username": current_user.username,
|
||||
"display_name": current_user.display_name,
|
||||
"bio": current_user.bio
|
||||
}
|
||||
|
||||
|
||||
@router.put("/user/bio")
|
||||
@rate_limit_per_ip("10/minute")
|
||||
async def update_user_bio(
|
||||
request: Request,
|
||||
bio_request: UpdateBioRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update current user's bio
|
||||
"""
|
||||
if len(bio_request.bio) > 500: # Limit bio to 500 characters
|
||||
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
|
||||
|
||||
current_user.bio = bio_request.bio.strip()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Bio updated successfully",
|
||||
"bio": current_user.bio
|
||||
}
|
||||
|
||||
|
||||
@router.get("/user/{username}")
|
||||
async def get_user_by_username(
|
||||
username: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get user profile by username
|
||||
"""
|
||||
if not username or not is_valid_username(username):
|
||||
raise HTTPException(status_code=400, detail="Invalid username format")
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
_ensure_owner_unsuspended(user, db)
|
||||
|
||||
# Handle deleted or suspended users
|
||||
if user.deleted or user.suspended:
|
||||
return UserProfileResponse(
|
||||
id=user.id,
|
||||
username="deleted",
|
||||
display_name="Deleted User",
|
||||
profile_picture=None,
|
||||
bio=None,
|
||||
online=False,
|
||||
last_seen=None, # Clear last seen timestamp
|
||||
created_at=None, # Clear member since timestamp
|
||||
verified=False,
|
||||
suspended=False,
|
||||
suspension_reason=None,
|
||||
deleted=True
|
||||
)
|
||||
|
||||
return UserProfileResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
profile_picture=user.profile_picture,
|
||||
bio=user.bio,
|
||||
online=user.online,
|
||||
last_seen=user.last_seen,
|
||||
created_at=user.created_at,
|
||||
verified=user.verified,
|
||||
suspended=user.suspended or False,
|
||||
suspension_reason=user.suspension_reason,
|
||||
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
|
||||
)
|
||||
|
||||
@router.get("/user/id/{user_id}")
|
||||
async def get_user_by_id(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get user profile by user ID
|
||||
"""
|
||||
if user_id <= 0:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
_ensure_owner_unsuspended(user, db)
|
||||
|
||||
# Handle deleted or suspended users
|
||||
if user.deleted or user.suspended:
|
||||
return UserProfileResponse(
|
||||
id=user.id,
|
||||
username="deleted",
|
||||
display_name="Deleted User",
|
||||
profile_picture=None,
|
||||
bio=None,
|
||||
online=False,
|
||||
last_seen=None, # Clear last seen timestamp
|
||||
created_at=None, # Clear member since timestamp
|
||||
verified=False,
|
||||
suspended=False,
|
||||
suspension_reason=None,
|
||||
deleted=True
|
||||
)
|
||||
|
||||
return UserProfileResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
profile_picture=user.profile_picture,
|
||||
bio=user.bio,
|
||||
online=user.online,
|
||||
last_seen=user.last_seen,
|
||||
created_at=user.created_at,
|
||||
verified=user.verified,
|
||||
suspended=user.suspended or False,
|
||||
suspension_reason=user.suspension_reason,
|
||||
deleted=user.deleted or False
|
||||
)
|
||||
|
||||
|
||||
@router.post("/user/{user_id}/verify")
|
||||
async def verify_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Toggle verification status for a user (owner only)
|
||||
"""
|
||||
# Only user with ID 1 (owner) can verify users
|
||||
if current_user.id != 1:
|
||||
raise HTTPException(status_code=403, detail="Only owner can verify users")
|
||||
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Toggle verification status
|
||||
target_user.verified = not target_user.verified
|
||||
db.commit()
|
||||
|
||||
log_security(
|
||||
"admin_verify_toggle",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=target_user.username,
|
||||
target_id=target_user.id,
|
||||
verified=target_user.verified,
|
||||
)
|
||||
|
||||
return {
|
||||
"verified": target_user.verified,
|
||||
"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),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Check if a user is similar to any verified user
|
||||
"""
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Get all verified users
|
||||
verified_users = db.query(User).filter(User.verified == True).all()
|
||||
verified_users_data = [
|
||||
{"username": user.username, "display_name": user.display_name}
|
||||
for user in verified_users
|
||||
]
|
||||
|
||||
# Check similarity
|
||||
is_similar, similar_to = is_user_similar_to_verified(
|
||||
target_user.username,
|
||||
target_user.display_name,
|
||||
verified_users_data
|
||||
)
|
||||
|
||||
return {
|
||||
"isSimilar": is_similar,
|
||||
"similarTo": similar_to if is_similar else None
|
||||
}
|
||||
|
||||
|
||||
# Admin endpoints for user management
|
||||
class SuspendUserRequest(BaseModel):
|
||||
reason: str
|
||||
|
||||
@router.post("/user/{user_id}/suspend")
|
||||
async def suspend_user(
|
||||
user_id: int,
|
||||
request: SuspendUserRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Suspend a user account (admin only)
|
||||
"""
|
||||
# Only user with ID 1 (admin) can suspend users
|
||||
if current_user.id != 1:
|
||||
raise HTTPException(status_code=403, detail="Only admin can suspend users")
|
||||
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Cannot suspend admin
|
||||
if target_user.id == 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot suspend admin account")
|
||||
|
||||
# Suspend the user
|
||||
target_user.suspended = True
|
||||
target_user.suspension_reason = request.reason
|
||||
db.commit()
|
||||
|
||||
log_security(
|
||||
"admin_suspend_user",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=target_user.username,
|
||||
target_id=target_user.id,
|
||||
reason=request.reason,
|
||||
)
|
||||
|
||||
# Send WebSocket suspension message
|
||||
try:
|
||||
await messagingManager.send_suspension_to_user(user_id, request.reason)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the request
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"User {target_user.username} has been suspended",
|
||||
"reason": request.reason
|
||||
}
|
||||
|
||||
|
||||
@router.post("/user/{user_id}/unsuspend")
|
||||
async def unsuspend_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Unsuspend a user account (admin only)
|
||||
"""
|
||||
# Only user with ID 1 (admin) can unsuspend users
|
||||
if current_user.id != 1:
|
||||
raise HTTPException(status_code=403, detail="Only admin can unsuspend users")
|
||||
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Unsuspend the user
|
||||
target_user.suspended = False
|
||||
target_user.suspension_reason = None
|
||||
db.commit()
|
||||
|
||||
log_security(
|
||||
"admin_unsuspend_user",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=target_user.username,
|
||||
target_id=target_user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"User {target_user.username} has been unsuspended"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/user/{user_id}/delete")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete a user account (admin only) - preserves messages/DMs/reactions/files
|
||||
"""
|
||||
# Only user with ID 1 (admin) can delete users
|
||||
if current_user.id != 1:
|
||||
raise HTTPException(status_code=403, detail="Only admin can delete users")
|
||||
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Cannot delete admin
|
||||
if target_user.id == 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin account")
|
||||
|
||||
snapshot_username = target_user.username
|
||||
snapshot_display_name = target_user.display_name
|
||||
|
||||
from .account import _delete_user_data
|
||||
await _delete_user_data(target_user, db)
|
||||
|
||||
log_security(
|
||||
"admin_delete_user",
|
||||
severity="warning",
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
target_username=snapshot_username,
|
||||
target_display_name=snapshot_display_name,
|
||||
target_id=target_user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"User {target_user.username} has been deleted"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, PushSubscriptionRequest
|
||||
from push_service import push_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_to_push_notifications(
|
||||
request: PushSubscriptionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Subscribe user to push notifications"""
|
||||
try:
|
||||
success = await push_service.subscribe_user(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
endpoint=request.endpoint,
|
||||
p256dh_key=request.keys["p256dh"],
|
||||
auth_key=request.keys["auth"]
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"status": "success", "message": "Push notifications enabled"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to enable push notifications")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/unsubscribe")
|
||||
async def unsubscribe_from_push_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Unsubscribe user from push notifications"""
|
||||
try:
|
||||
success = await push_service.unsubscribe_user(db=db, user_id=current_user.id)
|
||||
|
||||
if success:
|
||||
return {"status": "success", "message": "Push notifications disabled"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1,89 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
from fastapi import APIRouter, Depends
|
||||
from dependencies import get_current_user
|
||||
import traceback
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
def generate_turn_credentials(username: str, secret: str, expiration_minutes: int = 60):
|
||||
"""Generate time-limited TURN credentials using TURN REST API format.
|
||||
|
||||
This creates temporary credentials that expire after the specified time.
|
||||
The username format is: timestamp:username
|
||||
The password is an HMAC hash of the username and secret.
|
||||
"""
|
||||
# Current timestamp (seconds since epoch)
|
||||
timestamp = int(time.time()) + (expiration_minutes * 60)
|
||||
|
||||
# Create temporary username: timestamp:original_username
|
||||
temp_username = f"{timestamp}:{username}"
|
||||
|
||||
# Generate password using HMAC-SHA1
|
||||
temp_password = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
temp_username.encode('utf-8'),
|
||||
hashlib.sha1
|
||||
).hexdigest()
|
||||
|
||||
return temp_username, temp_password
|
||||
|
||||
|
||||
@router.get("/ice")
|
||||
async def get_ice_servers(current_user = Depends(get_current_user)):
|
||||
"""Return ICE server configuration (STUN/TURN) for WebRTC clients.
|
||||
|
||||
Generates time-limited TURN credentials that expire in 1 hour.
|
||||
"""
|
||||
try:
|
||||
# Prefer using your own coturn for both STUN and TURN
|
||||
turn_domain = "fromchat.ru"
|
||||
stun_urls = [
|
||||
f"stun:{turn_domain}:3478",
|
||||
f"stuns:{turn_domain}:5349",
|
||||
]
|
||||
|
||||
turn_urls = [
|
||||
f"turn:{turn_domain}:3478",
|
||||
f"turns:{turn_domain}:5349",
|
||||
]
|
||||
|
||||
# Get TURN configuration from environment
|
||||
turn_username = os.getenv("TURN_USERNAME")
|
||||
turn_secret = os.getenv("TURN_SECRET")
|
||||
|
||||
# Check if required environment variables are set
|
||||
if not turn_username:
|
||||
logger.error("ERROR: TURN_USERNAME environment variable is not set")
|
||||
raise ValueError("TURN_USERNAME environment variable is not set")
|
||||
|
||||
if not turn_secret:
|
||||
logger.error("ERROR: TURN_SECRET environment variable is not set")
|
||||
raise ValueError("TURN_SECRET environment variable is not set")
|
||||
|
||||
ice_servers: list[dict] = [{"urls": url} for url in stun_urls]
|
||||
|
||||
temp_username, temp_password = generate_turn_credentials(
|
||||
turn_username,
|
||||
turn_secret,
|
||||
expiration_minutes=60 # Expires in 1 hour
|
||||
)
|
||||
|
||||
ice_servers.append({
|
||||
"urls": turn_urls,
|
||||
"username": temp_username,
|
||||
"credential": temp_password,
|
||||
})
|
||||
|
||||
return {"iceServers": ice_servers}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ERROR in /api/webrtc/ice: {str(e)}")
|
||||
logger.error(f"ERROR type: {type(e).__name__}")
|
||||
traceback.print_exc()
|
||||
raise
|
||||
@@ -1,2 +0,0 @@
|
||||
# Package marker for security utilities
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from html import unescape
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
||||
|
||||
|
||||
def _clean_username(username: Any) -> str:
|
||||
if not username:
|
||||
return "unknown user"
|
||||
return f"@{username}"
|
||||
|
||||
|
||||
def _format_user(fields: Dict[str, Any], username_key: str = "username", user_id_key: str = "user_id") -> str:
|
||||
username = fields.get(username_key)
|
||||
if username is None and "_" in username_key:
|
||||
base_key = username_key.split("_", 1)[0]
|
||||
username = fields.get(base_key)
|
||||
|
||||
user_id = fields.get(user_id_key)
|
||||
if user_id is None and "_" in user_id_key:
|
||||
base_key = user_id_key.split("_", 1)[0]
|
||||
user_id = fields.get(base_key)
|
||||
|
||||
if username and user_id is not None:
|
||||
return f"{_clean_username(username)} (user id {user_id})"
|
||||
if username:
|
||||
return _clean_username(username)
|
||||
if user_id is not None:
|
||||
return f"user id {user_id}"
|
||||
return "unknown user"
|
||||
|
||||
|
||||
def _format_actor(fields: Dict[str, Any], prefix: str) -> str:
|
||||
return _format_user(fields, f"{prefix}_username", f"{prefix}_id")
|
||||
|
||||
|
||||
def _plural(label: str, count: int) -> str:
|
||||
return f"{count} {label if count == 1 else label + 's'}"
|
||||
|
||||
|
||||
def _yes_no(flag: Any) -> str:
|
||||
return "yes" if flag else "no"
|
||||
|
||||
|
||||
def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
|
||||
if action == "login_success":
|
||||
lines = [f"Login approved for {_format_user(fields)}"]
|
||||
session = fields.get("session_id")
|
||||
if session:
|
||||
lines.append(f"Session: {session}")
|
||||
client_bits: List[str] = []
|
||||
if fields.get("device"):
|
||||
client_bits.append(fields["device"])
|
||||
if fields.get("os"):
|
||||
client_bits.append(fields["os"])
|
||||
if fields.get("browser"):
|
||||
client_bits.append(fields["browser"])
|
||||
if client_bits:
|
||||
lines.append(f"Client: {', '.join(client_bits)}")
|
||||
if fields.get("ip"):
|
||||
lines.append(f"IP address: {fields['ip']}")
|
||||
return lines
|
||||
if action == "login_failed":
|
||||
lines = [f"Login denied for {_format_user(fields)}"]
|
||||
if fields.get("reason"):
|
||||
lines.append(f"Reason: {fields['reason']}")
|
||||
if fields.get("ip"):
|
||||
lines.append(f"IP address: {fields['ip']}")
|
||||
return lines
|
||||
if action == "auth_bruteforce_detected":
|
||||
lines = ["Brute-force login pattern detected"]
|
||||
lines.append(f"Target: {_format_user(fields)}")
|
||||
failures = fields.get("failures")
|
||||
if isinstance(failures, dict):
|
||||
for key, value in failures.items():
|
||||
lines.append(f"{key}: {value}")
|
||||
if fields.get("ip"):
|
||||
lines.append(f"IP address: {fields['ip']}")
|
||||
if fields.get("window_seconds"):
|
||||
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
||||
return lines
|
||||
if action == "registration_success":
|
||||
ip_raw = fields.get("ip")
|
||||
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
|
||||
display_name = fields.get("display_name") or "Unknown"
|
||||
username = fields.get("username")
|
||||
user_id = fields.get("user_id")
|
||||
user_agent = fields.get("user_agent") or "Unknown user agent"
|
||||
lines = ["Account registered"]
|
||||
lines.append(f"Display name: {display_name}")
|
||||
lines.append(f"Username: {_clean_username(username) if username else 'unknown'}")
|
||||
if ip_display:
|
||||
lines.append(f"IP: {ip_display}")
|
||||
if user_agent:
|
||||
lines.append(f"User agent: {user_agent}")
|
||||
if user_id is not None:
|
||||
lines.append(f"User ID: {user_id}")
|
||||
return lines
|
||||
if action == "password_changed":
|
||||
lines = [f"Password changed for {_format_user(fields)}"]
|
||||
lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}")
|
||||
if fields.get("ip"):
|
||||
lines.append(f"IP address: {fields['ip']}")
|
||||
return lines
|
||||
if action == "logout":
|
||||
lines = [f"Logout recorded for {_format_user(fields)}"]
|
||||
if fields.get("session_id"):
|
||||
lines.append(f"Session: {fields['session_id']}")
|
||||
if fields.get("ip"):
|
||||
lines.append(f"IP address: {fields['ip']}")
|
||||
return lines
|
||||
if action == "admin_delete_user":
|
||||
return [
|
||||
"Account removal",
|
||||
f"Actor: {_format_actor(fields, 'actor')}",
|
||||
f"Target: {_format_actor(fields, 'target')}",
|
||||
]
|
||||
if action == "admin_suspend_user":
|
||||
lines = [
|
||||
"User suspension",
|
||||
f"Actor: {_format_actor(fields, 'actor')}",
|
||||
f"Target: {_format_actor(fields, 'target')}",
|
||||
]
|
||||
if fields.get("reason"):
|
||||
lines.append(f"Reason: {fields.get('reason')}")
|
||||
return lines
|
||||
if action == "admin_unsuspend_user":
|
||||
return [
|
||||
"User unsuspension",
|
||||
f"Actor: {_format_actor(fields, 'actor')}",
|
||||
f"Target: {_format_actor(fields, 'target')}",
|
||||
]
|
||||
if action == "admin_verify_toggle":
|
||||
return [
|
||||
"User verification",
|
||||
f"Actor: {_format_actor(fields, 'actor')}",
|
||||
f"Target: {_format_actor(fields, 'target')}",
|
||||
f"Verified: {_yes_no(fields.get('verified'))}",
|
||||
]
|
||||
if action == "self_delete_account":
|
||||
return [f"User {_format_user(fields)} deleted their account"]
|
||||
if action == "auto_suspension_public_spam":
|
||||
lines = [
|
||||
f"Automatic suspension triggered for {_format_user(fields)}",
|
||||
]
|
||||
match_type = fields.get("match_type")
|
||||
if match_type:
|
||||
lines.append(f"Match type: {match_type}")
|
||||
similar = fields.get("similar_messages")
|
||||
occurrences = fields.get("occurrences")
|
||||
if similar:
|
||||
lines.append(f"Similar messages detected: {similar}")
|
||||
if occurrences and not similar:
|
||||
lines.append(f"Occurrences: {occurrences}")
|
||||
if fields.get("window_seconds"):
|
||||
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
||||
if fields.get("reason"):
|
||||
lines.append(f"Reason: {fields['reason']}")
|
||||
return lines
|
||||
if action == "auto_suspension_public_burst":
|
||||
lines = [
|
||||
f"Automatic suspension triggered for {_format_user(fields)}",
|
||||
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
|
||||
]
|
||||
if fields.get("reason"):
|
||||
lines.append(f"Reason: {fields['reason']}")
|
||||
return lines
|
||||
if action == "public_message_burst":
|
||||
return [
|
||||
f"Rapid messaging spike for {_format_user(fields)}",
|
||||
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
|
||||
]
|
||||
if action == "blocklist_add":
|
||||
added = fields.get("added") or []
|
||||
lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"]
|
||||
if added:
|
||||
lines.append(f"Added entries: {', '.join(added)}")
|
||||
total = len(fields.get("words") or [])
|
||||
lines.append(f"Total entries: {total}")
|
||||
return lines
|
||||
if action == "blocklist_remove":
|
||||
removed = fields.get("removed") or []
|
||||
lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"]
|
||||
if removed:
|
||||
lines.append(f"Removed entries: {', '.join(removed)}")
|
||||
total = len(fields.get("words") or [])
|
||||
lines.append(f"Total entries: {total}")
|
||||
return lines
|
||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
||||
for key, value in fields.items()
|
||||
if value is not None
|
||||
]
|
||||
|
||||
|
||||
def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]:
|
||||
if action == "message_created":
|
||||
lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"]
|
||||
if fields.get("reply_to"):
|
||||
lines.append(f"In reply to message #{fields['reply_to']}")
|
||||
attachments = fields.get("attachments")
|
||||
if attachments:
|
||||
lines.append(f"Attachments: {_plural('file', attachments)}")
|
||||
|
||||
# If content was censored, log both raw and censored versions
|
||||
if fields.get("raw_content") is not None:
|
||||
lines.append("Raw content (before censoring):")
|
||||
for line in unescape(fields["raw_content"]).splitlines():
|
||||
lines.append(f"| {line}")
|
||||
lines.append("Censored content (stored):")
|
||||
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
|
||||
lines.append(f"| {line}")
|
||||
elif fields.get("content"):
|
||||
lines.append("Content:")
|
||||
for line in unescape(fields["content"]).splitlines():
|
||||
lines.append(f"| {line}")
|
||||
return lines
|
||||
if action == "message_edited":
|
||||
lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"]
|
||||
if fields.get("reply_to"):
|
||||
lines.append(f"Reply to #{fields['reply_to']}")
|
||||
if fields.get("previous_content"):
|
||||
lines.append("Previous content:")
|
||||
for line in unescape(fields["previous_content"] or "").splitlines() or [""]:
|
||||
lines.append(f"| {line}")
|
||||
|
||||
# If content was censored, log both raw and censored versions
|
||||
if fields.get("raw_content") is not None:
|
||||
lines.append("Raw content (before censoring):")
|
||||
for line in unescape(fields["raw_content"]).splitlines():
|
||||
lines.append(f"| {line}")
|
||||
lines.append("Censored content (stored):")
|
||||
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
|
||||
lines.append(f"| {line}")
|
||||
elif fields.get("content"):
|
||||
lines.append("New content:")
|
||||
for line in unescape(fields["content"] or "").splitlines() or [""]:
|
||||
lines.append(f"| {line}")
|
||||
|
||||
return lines
|
||||
if action == "message_deleted":
|
||||
lines = [
|
||||
f"Message #{fields.get('message_id')} deleted",
|
||||
f"Actor: {_format_actor(fields, 'actor')}",
|
||||
]
|
||||
if fields.get("original_author_id") is not None:
|
||||
lines.append(f"Original author: user #{fields['original_author_id']}")
|
||||
if fields.get("content"):
|
||||
lines.append("Previous content:")
|
||||
for line in unescape(fields["content"]).splitlines():
|
||||
lines.append(f"| {line}")
|
||||
return lines
|
||||
if action == "reaction_update":
|
||||
lines = [
|
||||
f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}",
|
||||
f"User: {_format_user(fields)}",
|
||||
]
|
||||
if fields.get("emoji"):
|
||||
lines.append(f"Emoji: {fields['emoji']}")
|
||||
return lines
|
||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
||||
for key, value in fields.items()
|
||||
if value is not None
|
||||
]
|
||||
|
||||
|
||||
def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]:
|
||||
if action in {"message_sent", "message_sent_ws"}:
|
||||
lines = [
|
||||
f"Direct message #{fields.get('dm_envelope_id')} sent",
|
||||
f"Sender: {_format_actor(fields, 'sender')}",
|
||||
]
|
||||
if fields.get("recipient_id") is not None:
|
||||
lines.append(f"Recipient: user id {fields['recipient_id']}")
|
||||
attachments = fields.get("attachment_count")
|
||||
if attachments:
|
||||
lines.append(f"Attachments: {_plural('file', attachments)}")
|
||||
if fields.get("reply_to"):
|
||||
lines.append(f"In reply to DM #{fields['reply_to']}")
|
||||
return lines
|
||||
if action == "message_edited":
|
||||
return [
|
||||
f"Direct message #{fields.get('dm_envelope_id')} edited",
|
||||
f"Author: {_format_user(fields)}",
|
||||
]
|
||||
if action == "message_deleted":
|
||||
lines = [
|
||||
f"Direct message #{fields.get('dm_envelope_id')} deleted",
|
||||
f"Actor: {_format_user(fields)}",
|
||||
]
|
||||
if fields.get("recipient_id") is not None:
|
||||
lines.append(f"Recipient: user id {fields['recipient_id']}")
|
||||
return lines
|
||||
if action == "reaction_update":
|
||||
lines = [
|
||||
f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}",
|
||||
f"User: {_format_user(fields)}",
|
||||
]
|
||||
if fields.get("emoji"):
|
||||
lines.append(f"Emoji: {fields['emoji']}")
|
||||
return lines
|
||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
||||
for key, value in fields.items()
|
||||
if value is not None
|
||||
]
|
||||
|
||||
|
||||
def _render_access(action: str, fields: Dict[str, Any]) -> List[str]:
|
||||
ip_raw = fields.get("ip")
|
||||
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
|
||||
if action == "http_request":
|
||||
first_line = f"{fields.get('method')} {fields.get('path')}"
|
||||
if ip_display:
|
||||
first_line += f" from {ip_display}"
|
||||
first_line += f" -> {fields.get('status')}"
|
||||
lines = [first_line]
|
||||
if fields.get("user"):
|
||||
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
|
||||
return lines
|
||||
if action == "http_error":
|
||||
first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}"
|
||||
if ip_display:
|
||||
first_line += f" from {ip_display}"
|
||||
lines = [first_line]
|
||||
if fields.get("error"):
|
||||
lines.append(f"Exception: {fields['error']}")
|
||||
if fields.get("user"):
|
||||
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
|
||||
return lines
|
||||
if action == "ws_connect":
|
||||
lines = ["WebSocket connected"]
|
||||
if fields.get("path"):
|
||||
lines.append(f"Endpoint: {fields['path']}")
|
||||
if ip_display:
|
||||
lines.append(f"IP: {ip_display}")
|
||||
return lines
|
||||
if action == "ws_disconnect":
|
||||
lines = ["WebSocket disconnected"]
|
||||
if fields.get("path"):
|
||||
lines.append(f"Endpoint: {fields['path']}")
|
||||
if fields.get("code") is not None:
|
||||
reason = fields.get("reason") or "no reason"
|
||||
lines.append(f"Code {fields['code']} ({reason})")
|
||||
if ip_display:
|
||||
lines.append(f"IP: {ip_display}")
|
||||
return lines
|
||||
if action == "ws_event":
|
||||
event_name = fields.get("event")
|
||||
path = fields.get("path")
|
||||
first_line = "WS"
|
||||
if path:
|
||||
first_line += f" {path}"
|
||||
if ip_display:
|
||||
first_line += f" from {ip_display}"
|
||||
if event_name:
|
||||
first_line += f" -> {event_name}"
|
||||
lines = [first_line]
|
||||
if fields.get("user"):
|
||||
lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}")
|
||||
for key, value in fields.items():
|
||||
if key in {"path", "event", "user", "user_id", "ip"} or value is None:
|
||||
continue
|
||||
lines.append(f"{key.replace('_', ' ').capitalize()}: {value}")
|
||||
return lines
|
||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
||||
for key, value in fields.items()
|
||||
if value is not None
|
||||
]
|
||||
|
||||
|
||||
def _log_event(
|
||||
logger: logging.Logger,
|
||||
renderer: Callable[[str, Dict[str, Any]], List[str]],
|
||||
action: str,
|
||||
severity: str,
|
||||
fields: Dict[str, Any],
|
||||
) -> None:
|
||||
lines = renderer(action, fields)
|
||||
if not lines:
|
||||
return
|
||||
level = getattr(logging, severity.upper(), logging.INFO)
|
||||
logger.log(level, "\n".join(lines))
|
||||
|
||||
|
||||
def log_security(action: str, severity: str = "info", **fields: Any) -> None:
|
||||
_log_event(security_logger, _render_security, action, severity, fields)
|
||||
|
||||
|
||||
def log_public_chat(action: str, severity: str = "info", **fields: Any) -> None:
|
||||
_log_event(public_chat_logger, _render_public_chat, action, severity, fields)
|
||||
|
||||
|
||||
def log_dm(action: str, severity: str = "info", **fields: Any) -> None:
|
||||
sanitized_fields = {key: value for key, value in fields.items() if key != "content"}
|
||||
_log_event(dm_logger, _render_dm, action, severity, sanitized_fields)
|
||||
|
||||
|
||||
def log_access(action: str, severity: str = "info", **fields: Any) -> None:
|
||||
_log_event(access_logger, _render_access, action, severity, fields)
|
||||
|
||||
@@ -1,694 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Iterable, List, Set, Tuple
|
||||
|
||||
from better_profanity import Profanity
|
||||
|
||||
BLOCKLIST_PATH = Path("data/profanity/blocklist.json")
|
||||
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_CUSTOM_RU_TERMS: Set[str] = {
|
||||
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
||||
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
|
||||
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
|
||||
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
||||
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор",
|
||||
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
||||
}
|
||||
|
||||
_ADULT_TERMS: Set[str] = {
|
||||
"порно", "порнуха", "эротика", "эротический", "секс", "сексуальный",
|
||||
"инцест", "порнография", "порностудия", "порновидео", "порносайт",
|
||||
"сексчат", "сексчатик", "секслайв", "сексвидео",
|
||||
}
|
||||
|
||||
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
|
||||
|
||||
# Words that should never be flagged as profanity (whitelist)
|
||||
_WHITELIST: Set[str] = {
|
||||
"говно", # Allow this word
|
||||
}
|
||||
|
||||
# Phrase patterns - these will be applied to normalized text (without special chars)
|
||||
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||
)
|
||||
|
||||
# Patterns to check in original text (before normalization) to catch visual bypasses
|
||||
# These patterns check for special character combinations that visually form letters
|
||||
_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
||||
# Catch "}{" used to visually form "х" followed by "С0С" or similar patterns
|
||||
# This catches "хуесос" written as "}{¥€С0С" or variations
|
||||
# Matches: }{ + any characters (including special chars) + С/с + 0 + С/с
|
||||
# The pattern allows any characters between to catch special chars like ¥€
|
||||
re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
||||
# Also catch "}{" followed by "уесос" with 0 instead of о
|
||||
re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
||||
)
|
||||
|
||||
# Map for normalizing homoglyphs (similar-looking characters)
|
||||
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
|
||||
# Also includes Greek, full-width, and other Unicode variants
|
||||
_LEET_MAP = {
|
||||
# Numbers to letters
|
||||
"0": "о",
|
||||
"1": "и",
|
||||
"3": "е",
|
||||
"4": "а",
|
||||
# Latin to Cyrillic (lowercase)
|
||||
"a": "а",
|
||||
"c": "с",
|
||||
"e": "е",
|
||||
"f": "ф",
|
||||
"g": "г",
|
||||
"i": "и",
|
||||
"m": "м",
|
||||
"n": "н",
|
||||
"o": "о",
|
||||
"p": "п",
|
||||
"s": "с",
|
||||
"t": "т",
|
||||
"u": "у",
|
||||
"v": "в",
|
||||
"x": "х",
|
||||
"y": "у",
|
||||
"z": "з", # English 'z' to Cyrillic 'з'
|
||||
# Latin to Cyrillic (uppercase)
|
||||
"A": "а",
|
||||
"C": "с",
|
||||
"E": "е",
|
||||
"F": "ф",
|
||||
"G": "г",
|
||||
"I": "и",
|
||||
"M": "м",
|
||||
"N": "н",
|
||||
"O": "о",
|
||||
"P": "п",
|
||||
"S": "с",
|
||||
"T": "т",
|
||||
"U": "у",
|
||||
"V": "в",
|
||||
"X": "х",
|
||||
"Y": "у",
|
||||
"Z": "з", # English 'Z' to Cyrillic 'з'
|
||||
# Greek letters that look like Cyrillic/Latin
|
||||
"α": "а", # Greek alpha
|
||||
"Α": "а",
|
||||
"ο": "о", # Greek omicron
|
||||
"Ο": "о",
|
||||
"ρ": "р", # Greek rho (looks like Cyrillic р)
|
||||
"Ρ": "р",
|
||||
"υ": "у", # Greek upsilon
|
||||
"Υ": "у",
|
||||
"χ": "х", # Greek chi
|
||||
"Χ": "х",
|
||||
"ε": "е", # Greek epsilon
|
||||
"Ε": "е",
|
||||
"ι": "и", # Greek iota
|
||||
"Ι": "и",
|
||||
"ν": "н", # Greek nu
|
||||
"Ν": "н",
|
||||
"μ": "м", # Greek mu
|
||||
"Μ": "м",
|
||||
"π": "п", # Greek pi
|
||||
"Π": "п",
|
||||
"τ": "т", # Greek tau
|
||||
"Τ": "т",
|
||||
"γ": "г", # Greek gamma
|
||||
"Γ": "г",
|
||||
"σ": "с", # Greek sigma
|
||||
"Σ": "с",
|
||||
"φ": "ф", # Greek phi
|
||||
"Φ": "ф",
|
||||
# Full-width Latin characters
|
||||
"a": "а",
|
||||
"A": "а",
|
||||
"c": "с",
|
||||
"C": "с",
|
||||
"e": "е",
|
||||
"E": "е",
|
||||
"f": "ф",
|
||||
"F": "ф",
|
||||
"g": "г",
|
||||
"G": "г",
|
||||
"i": "и",
|
||||
"I": "и",
|
||||
"m": "м",
|
||||
"M": "м",
|
||||
"n": "н",
|
||||
"N": "н",
|
||||
"o": "о",
|
||||
"O": "о",
|
||||
"p": "п",
|
||||
"P": "п",
|
||||
"s": "с",
|
||||
"S": "с",
|
||||
"t": "т",
|
||||
"T": "т",
|
||||
"u": "у",
|
||||
"U": "у",
|
||||
"v": "в",
|
||||
"V": "в",
|
||||
"x": "х",
|
||||
"X": "х",
|
||||
"y": "у",
|
||||
"Y": "у",
|
||||
"z": "з", # Full-width 'z' to Cyrillic 'з'
|
||||
"Z": "з",
|
||||
# Cyrillic to canonical Cyrillic (identity mappings)
|
||||
"а": "а",
|
||||
"с": "с",
|
||||
"е": "е",
|
||||
"ё": "е",
|
||||
"ф": "ф",
|
||||
"г": "г",
|
||||
"и": "и",
|
||||
"м": "м",
|
||||
"н": "н",
|
||||
"о": "о",
|
||||
"п": "п",
|
||||
"т": "т",
|
||||
"у": "у",
|
||||
"ү": "у", # Cyrillic capital U (U+04AE)
|
||||
"Ү": "у", # Cyrillic capital U (U+04AE)
|
||||
"в": "в",
|
||||
"х": "х",
|
||||
"р": "р",
|
||||
"з": "з", # Cyrillic 'з'
|
||||
"д": "д", # Cyrillic 'д'
|
||||
"б": "б", # Cyrillic 'б'
|
||||
"л": "л", # Cyrillic 'л'
|
||||
"я": "я", # Cyrillic 'я'
|
||||
"н": "н", # Already mapped, but explicit
|
||||
# Special characters
|
||||
"@": "а",
|
||||
# Multi-character visual bypasses (handled separately in preprocessing)
|
||||
# "}{" visually forms "х" - handled in _preprocess_visual_bypasses
|
||||
}
|
||||
|
||||
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
||||
("generic", ("айфон", "топ")),
|
||||
("generic", ("самсунг", "говно")),
|
||||
)
|
||||
|
||||
_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
|
||||
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
||||
|
||||
|
||||
def _preprocess_visual_bypasses(text: str) -> str:
|
||||
"""
|
||||
Preprocess text to convert multi-character visual bypasses to their intended letters.
|
||||
This handles cases like "}{" visually forming "х".
|
||||
"""
|
||||
result = text
|
||||
# Convert "}{" to "х" (visual bypass for Cyrillic х)
|
||||
# The curly braces visually form the letter х when placed together
|
||||
result = result.replace("}{", "х")
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_char(ch: str) -> str:
|
||||
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
||||
# First try direct mapping (preserves case for non-mapped chars)
|
||||
if ch in _LEET_MAP:
|
||||
return _LEET_MAP[ch]
|
||||
# Then try lowercase mapping
|
||||
lower = ch.lower()
|
||||
if lower in _LEET_MAP:
|
||||
return _LEET_MAP[lower]
|
||||
# If no mapping and character is ASCII letter, return lowercase
|
||||
# This preserves English words like "fromchat" as-is
|
||||
if ch.isascii() and ch.isalpha():
|
||||
return lower
|
||||
# For other characters, return lowercase for consistency
|
||||
return lower
|
||||
|
||||
|
||||
def _normalize_token(token: str) -> str:
|
||||
"""Normalize a token by mapping all homoglyphs."""
|
||||
return "".join(_normalize_char(ch) for ch in token)
|
||||
|
||||
|
||||
def _normalize_text_for_profanity(text: str) -> str:
|
||||
"""
|
||||
Normalize entire text by mapping homoglyphs to canonical forms.
|
||||
This prevents bypasses like using English 'u' instead of Russian 'у'.
|
||||
"""
|
||||
return "".join(_normalize_char(ch) for ch in text)
|
||||
|
||||
|
||||
def _strip_zero_width_chars(text: str) -> str:
|
||||
"""
|
||||
Remove zero-width characters that could be used to bypass filters.
|
||||
"""
|
||||
# Zero-width space, zero-width non-joiner, zero-width joiner, etc.
|
||||
zero_width_chars = [
|
||||
'\u200B', # Zero-width space
|
||||
'\u200C', # Zero-width non-joiner
|
||||
'\u200D', # Zero-width joiner
|
||||
'\uFEFF', # Zero-width no-break space
|
||||
'\u2060', # Word joiner
|
||||
'\u2061', # Function application
|
||||
'\u2062', # Invisible times
|
||||
'\u2063', # Invisible separator
|
||||
'\u2064', # Invisible plus
|
||||
]
|
||||
result = text
|
||||
for zw_char in zero_width_chars:
|
||||
result = result.replace(zw_char, '')
|
||||
return result
|
||||
|
||||
|
||||
def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]:
|
||||
"""
|
||||
Extract only alphanumeric characters from text and create a mapping
|
||||
from normalized positions to original positions.
|
||||
|
||||
Args:
|
||||
preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching)
|
||||
|
||||
Returns:
|
||||
(normalized_text, position_map) where position_map[i] is the original
|
||||
position of the i-th character in normalized_text
|
||||
"""
|
||||
# First preprocess visual bypasses (like "}{" -> "х")
|
||||
text = _preprocess_visual_bypasses(text)
|
||||
|
||||
# Then normalize Unicode (composed vs decomposed)
|
||||
normalized_unicode = unicodedata.normalize('NFKC', text)
|
||||
|
||||
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
||||
if preserve_spaces:
|
||||
zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064']
|
||||
for zw_char in zero_width_chars:
|
||||
normalized_unicode = normalized_unicode.replace(zw_char, ' ')
|
||||
else:
|
||||
# Strip zero-width characters
|
||||
normalized_unicode = _strip_zero_width_chars(normalized_unicode)
|
||||
|
||||
normalized = []
|
||||
position_map = []
|
||||
|
||||
for i, ch in enumerate(normalized_unicode):
|
||||
# Check if character is alphanumeric (including Cyrillic)
|
||||
if ch.isalnum():
|
||||
# For phrase matching, preserve ASCII letters as-is (just lowercase)
|
||||
# to allow English words in patterns to match
|
||||
if preserve_spaces and ch.isascii() and ch.isalpha():
|
||||
normalized.append(ch.lower())
|
||||
else:
|
||||
# Normalize this character (homoglyphs, Cyrillic, etc.)
|
||||
normalized.append(_normalize_char(ch))
|
||||
position_map.append(i)
|
||||
elif preserve_spaces:
|
||||
# For phrase matching, treat any whitespace or non-alphanumeric as word separator
|
||||
if ch.isspace() or not ch.isalnum():
|
||||
# Normalize to single space to allow patterns to match
|
||||
if normalized and normalized[-1] != ' ': # Don't add consecutive spaces
|
||||
normalized.append(' ')
|
||||
position_map.append(i)
|
||||
|
||||
return "".join(normalized), position_map
|
||||
|
||||
|
||||
def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Check for profane words as substrings or subsequences in normalized text.
|
||||
This catches cases like "хуй" in "хууй" (with extra characters).
|
||||
Returns list of (start, end) positions where profanity is found.
|
||||
"""
|
||||
spans = []
|
||||
normalized_lower = normalized_text.lower()
|
||||
|
||||
for word in profane_words:
|
||||
word_lower = word.lower()
|
||||
|
||||
# First try exact substring match
|
||||
start = 0
|
||||
while True:
|
||||
pos = normalized_lower.find(word_lower, start)
|
||||
if pos == -1:
|
||||
break
|
||||
spans.append((pos, pos + len(word_lower)))
|
||||
start = pos + 1
|
||||
|
||||
# Also check if profane word appears as a subsequence (allowing extra chars)
|
||||
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
|
||||
# Now applies to ALL words, not just length >= 4, to prevent bypasses
|
||||
word_chars = list(word_lower)
|
||||
text_chars = list(normalized_lower)
|
||||
|
||||
# Stricter span limits based on word length to prevent false positives
|
||||
# Shorter words get much stricter limits
|
||||
if len(word_lower) <= 3:
|
||||
max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй")
|
||||
elif len(word_lower) == 4:
|
||||
max_span_ratio = 1.4 # Strict for 4-char words
|
||||
elif len(word_lower) <= 5:
|
||||
max_span_ratio = 1.5 # Moderate for 5-char words
|
||||
else:
|
||||
max_span_ratio = 1.8 # Slightly more lenient for longer words
|
||||
|
||||
# Try to find the word as a subsequence
|
||||
i = 0 # position in text
|
||||
j = 0 # position in word
|
||||
seq_start = None
|
||||
|
||||
while i < len(text_chars) and j < len(word_chars):
|
||||
if text_chars[i] == word_chars[j]:
|
||||
if seq_start is None:
|
||||
seq_start = i
|
||||
j += 1
|
||||
if j == len(word_chars):
|
||||
# Found the word as subsequence
|
||||
seq_end = i + 1
|
||||
# Check if the span is reasonable (not too long)
|
||||
span_length = seq_end - seq_start
|
||||
max_allowed_span = int(len(word_lower) * max_span_ratio)
|
||||
if span_length <= max_allowed_span:
|
||||
# Only add if it's not already covered by exact match
|
||||
if (seq_start, seq_end) not in spans:
|
||||
spans.append((seq_start, seq_end))
|
||||
# Reset to find next occurrence - continue from after the end of this match
|
||||
next_start = seq_start + 1
|
||||
seq_start = None
|
||||
j = 0
|
||||
i = next_start
|
||||
continue
|
||||
i += 1
|
||||
|
||||
return spans
|
||||
|
||||
|
||||
def _check_profanity_in_normalized(normalized_text: str) -> bool:
|
||||
"""
|
||||
Check if normalized text contains profanity.
|
||||
Uses both better_profanity library and substring matching for better detection.
|
||||
|
||||
Returns True if profanity is found.
|
||||
"""
|
||||
if not normalized_text:
|
||||
return False
|
||||
|
||||
# Check normalized text for profanity using better_profanity
|
||||
censored = _profanity.censor(normalized_text, censor_char="\\*")
|
||||
|
||||
# Check if better_profanity found anything
|
||||
if "*" in censored:
|
||||
return True
|
||||
|
||||
# Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня")
|
||||
profane_words = _STATIC_TERMS
|
||||
substring_spans = _check_profanity_substrings(normalized_text, profane_words)
|
||||
|
||||
# If we found any substring matches, there's profanity
|
||||
if substring_spans:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
|
||||
tokens: List[Tuple[int, int, str]] = []
|
||||
start: int | None = None
|
||||
buffer: List[str] = []
|
||||
|
||||
for idx, ch in enumerate(text):
|
||||
if ch.isalnum() or ch in {"@", "#", "_"}:
|
||||
if start is None:
|
||||
start = idx
|
||||
buffer.append(ch)
|
||||
else:
|
||||
if buffer and start is not None:
|
||||
token_raw = "".join(buffer)
|
||||
tokens.append((start, idx, _normalize_token(token_raw)))
|
||||
buffer.clear()
|
||||
start = None
|
||||
if buffer and start is not None:
|
||||
token_raw = "".join(buffer)
|
||||
tokens.append((start, len(text), _normalize_token(token_raw)))
|
||||
return tokens
|
||||
|
||||
|
||||
def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool:
|
||||
if a == b:
|
||||
return True
|
||||
if max_distance <= 0:
|
||||
return False
|
||||
if abs(len(a) - len(b)) > max_distance:
|
||||
return False
|
||||
|
||||
previous = list(range(len(b) + 1))
|
||||
for i, ca in enumerate(a, 1):
|
||||
current = [i]
|
||||
best = current[0]
|
||||
for j, cb in enumerate(b, 1):
|
||||
insert_cost = current[j - 1] + 1
|
||||
delete_cost = previous[j] + 1
|
||||
replace_cost = previous[j - 1] + (0 if ca == cb else 1)
|
||||
cost = min(insert_cost, delete_cost, replace_cost)
|
||||
current.append(cost)
|
||||
if cost < best:
|
||||
best = cost
|
||||
if best > max_distance:
|
||||
return False
|
||||
previous = current
|
||||
return previous[-1] <= max_distance
|
||||
|
||||
|
||||
def _load_sensitive_phrases() -> List[Tuple[str, ...]]:
|
||||
if not _SENSITIVE_PHRASE_PATH.exists():
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8"))
|
||||
phrases: List[Tuple[str, ...]] = []
|
||||
if isinstance(payload, list):
|
||||
for entry in payload:
|
||||
if isinstance(entry, list) and entry:
|
||||
normalized = tuple(str(part).strip() for part in entry if str(part).strip())
|
||||
if normalized:
|
||||
phrases.append(normalized)
|
||||
return phrases
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]:
|
||||
if group not in _PHRASE_CACHE:
|
||||
base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group]
|
||||
if group == "sensitive":
|
||||
base.extend(_load_sensitive_phrases())
|
||||
_PHRASE_CACHE[group] = tuple(
|
||||
tuple(_normalize_token(part) for part in phrase)
|
||||
for phrase in base
|
||||
)
|
||||
return _PHRASE_CACHE[group]
|
||||
|
||||
|
||||
def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]:
|
||||
tokens = _tokenize_with_spans(text)
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
spans: List[Tuple[int, int]] = []
|
||||
normalized_phrases = _get_phrases(group)
|
||||
|
||||
for index in range(len(tokens)):
|
||||
for phrase in normalized_phrases:
|
||||
if index + len(phrase) > len(tokens):
|
||||
continue
|
||||
matches = True
|
||||
for offset, target in enumerate(phrase):
|
||||
token = tokens[index + offset][2]
|
||||
if not _edit_distance_limited(token, target):
|
||||
matches = False
|
||||
break
|
||||
if matches:
|
||||
span_start = tokens[index][0]
|
||||
span_end = tokens[index + len(phrase) - 1][1]
|
||||
spans.append((span_start, span_end))
|
||||
return spans
|
||||
|
||||
_dictionary_lock = RLock()
|
||||
_blocklist_signature: Tuple[str, ...] | None = None
|
||||
_profanity = Profanity()
|
||||
|
||||
|
||||
def _normalize_words(words: Iterable[str]) -> Set[str]:
|
||||
normalized: Set[str] = set()
|
||||
for raw in words:
|
||||
if not raw:
|
||||
continue
|
||||
cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower()
|
||||
if cleaned:
|
||||
normalized.add(cleaned)
|
||||
return normalized
|
||||
|
||||
|
||||
def _load_blocklist() -> Set[str]:
|
||||
if not BLOCKLIST_PATH.exists():
|
||||
return set()
|
||||
try:
|
||||
data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return _normalize_words(data)
|
||||
except Exception:
|
||||
pass
|
||||
return set()
|
||||
|
||||
|
||||
def _write_blocklist(words: Iterable[str]) -> None:
|
||||
BLOCKLIST_PATH.write_text(
|
||||
json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _rebuild_dictionary(force: bool = False) -> None:
|
||||
global _profanity, _blocklist_signature
|
||||
with _dictionary_lock:
|
||||
blocklist_list = sorted(_load_blocklist())
|
||||
signature = tuple(blocklist_list)
|
||||
if not force and _blocklist_signature == signature and _blocklist_signature is not None:
|
||||
return
|
||||
|
||||
profanity = Profanity()
|
||||
profanity.load_censor_words()
|
||||
# Remove whitelisted words from the default word list
|
||||
try:
|
||||
for word in _WHITELIST:
|
||||
profanity.remove_censor_words([word])
|
||||
except AttributeError:
|
||||
# If remove_censor_words doesn't exist, we'll handle it in post-processing
|
||||
pass
|
||||
combined = set(_STATIC_TERMS)
|
||||
combined.update(blocklist_list)
|
||||
# Remove whitelisted words from our custom terms
|
||||
combined -= _WHITELIST
|
||||
if combined:
|
||||
profanity.add_censor_words(list(combined))
|
||||
|
||||
_profanity = profanity
|
||||
_blocklist_signature = signature
|
||||
|
||||
|
||||
def _check_phrase_patterns(text: str) -> bool:
|
||||
"""
|
||||
Check if text matches any phrase patterns.
|
||||
Returns True if any pattern matches.
|
||||
"""
|
||||
# Normalize text for phrase matching (remove special chars but preserve spaces)
|
||||
normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True)
|
||||
normalized_lower = normalized_text.lower()
|
||||
|
||||
# Check phrase patterns
|
||||
for pattern in _PHRASE_PATTERNS:
|
||||
if pattern.search(normalized_lower):
|
||||
return True
|
||||
|
||||
# Check fuzzy phrase spans
|
||||
if _find_fuzzy_phrase_spans(normalized_lower, "generic"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def contains_profanity(text: str) -> bool:
|
||||
"""
|
||||
Check if text contains profanity.
|
||||
Returns True if profanity is detected.
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
_rebuild_dictionary()
|
||||
|
||||
# Check original text patterns first (before normalization) to catch visual bypasses
|
||||
# like "}{" used to form "х"
|
||||
for pattern in _ORIGINAL_TEXT_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return True
|
||||
|
||||
# Check phrase patterns
|
||||
if _check_phrase_patterns(text):
|
||||
return True
|
||||
|
||||
# Normalize text for whitelist matching (to handle special characters)
|
||||
normalized_for_whitelist, _ = _extract_alphanumeric_with_mapping(text)
|
||||
normalized_for_whitelist_lower = normalized_for_whitelist.lower()
|
||||
|
||||
# Check if text contains whitelisted words - if the entire text is a whitelisted word, skip profanity check
|
||||
for whitelist_word in _WHITELIST:
|
||||
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
|
||||
normalized_whitelist_lower = normalized_whitelist.lower()
|
||||
|
||||
# Check if the normalized text exactly matches a whitelisted word
|
||||
if normalized_for_whitelist_lower == normalized_whitelist_lower:
|
||||
return False
|
||||
|
||||
# Extract only alphanumeric characters and normalize homoglyphs
|
||||
# This removes special characters, emojis, etc. that could be used to bypass the filter
|
||||
normalized_text, _ = _extract_alphanumeric_with_mapping(text)
|
||||
|
||||
# Check profanity on normalized text (without special characters)
|
||||
return _check_profanity_in_normalized(normalized_text)
|
||||
|
||||
|
||||
def contains_sensitive_phrase(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
if _find_fuzzy_phrase_spans(text, "sensitive"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_blocklist() -> List[str]:
|
||||
with _dictionary_lock:
|
||||
return sorted(_load_blocklist())
|
||||
|
||||
|
||||
def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
||||
normalized = _normalize_words(words)
|
||||
if not normalized:
|
||||
return [], get_blocklist()
|
||||
|
||||
with _dictionary_lock:
|
||||
current = _load_blocklist()
|
||||
added = sorted(normalized - current)
|
||||
if not added:
|
||||
return [], sorted(current)
|
||||
|
||||
updated = sorted(current | normalized)
|
||||
_write_blocklist(updated)
|
||||
_rebuild_dictionary(force=True)
|
||||
return added, updated
|
||||
|
||||
|
||||
def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
||||
normalized = _normalize_words(words)
|
||||
if not normalized:
|
||||
return [], get_blocklist()
|
||||
|
||||
with _dictionary_lock:
|
||||
current = _load_blocklist()
|
||||
removed = sorted(word for word in normalized if word in current)
|
||||
if not removed:
|
||||
return [], sorted(current)
|
||||
|
||||
updated = sorted(current - normalized)
|
||||
_write_blocklist(updated)
|
||||
_rebuild_dictionary(force=True)
|
||||
return removed, updated
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable
|
||||
from fastapi import Request
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from utils import get_client_ip
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
def get_ip_key(request: Request) -> str:
|
||||
"""Get rate limit key based on IP address."""
|
||||
return get_client_ip(request) or get_remote_address(request)
|
||||
|
||||
# Initialize limiter with IP-based key function
|
||||
# Note: We don't set default_limits to avoid affecting all users if one IP is attacked.
|
||||
# Each endpoint should have an explicit rate limit based on its sensitivity.
|
||||
# Rate limits automatically expire after the time window - IPs are not permanently blocked.
|
||||
limiter = Limiter(
|
||||
key_func=get_ip_key,
|
||||
default_limits=[], # No global default - each endpoint must have explicit limits
|
||||
storage_uri="memory://", # In-memory storage (can be changed to Redis later)
|
||||
)
|
||||
|
||||
|
||||
# Rate limit decorator for IP-based limiting
|
||||
def rate_limit_per_ip(limit: str) -> Callable:
|
||||
"""Rate limit based on IP address."""
|
||||
return limiter.limit(limit, key_func=get_ip_key)
|
||||
|
||||
|
||||
def _get_storage_dict(storage) -> dict | None:
|
||||
"""Get the internal storage dictionary from slowapi's memory storage."""
|
||||
if hasattr(storage, "_storage") and isinstance(storage._storage, dict):
|
||||
return storage._storage
|
||||
elif hasattr(storage, "storage") and isinstance(storage.storage, dict):
|
||||
return storage.storage
|
||||
return None
|
||||
|
||||
|
||||
def reset_all_rate_limits() -> int:
|
||||
"""
|
||||
Reset all rate limits by clearing the storage.
|
||||
This should be called on startup to ensure a clean state.
|
||||
Returns the number of entries cleared.
|
||||
"""
|
||||
try:
|
||||
# Access the private _storage attribute
|
||||
storage = limiter._storage
|
||||
storage_dict = _get_storage_dict(storage)
|
||||
|
||||
if storage_dict is None:
|
||||
# Try using the storage's reset method if available
|
||||
if hasattr(storage, "reset"):
|
||||
try:
|
||||
# Try reset() with no args first (clears all)
|
||||
storage.reset()
|
||||
logger.info("Reset all rate limits on startup using storage.reset()")
|
||||
return 1 # Assume it worked
|
||||
except TypeError:
|
||||
# reset() might require arguments, try clearing differently
|
||||
try:
|
||||
# Some storage backends need explicit clearing
|
||||
if hasattr(storage, "clear"):
|
||||
storage.clear()
|
||||
logger.info("Reset all rate limits on startup using storage.clear()")
|
||||
return 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("Could not reset rate limits: storage dict not accessible and no reset method")
|
||||
return 0
|
||||
|
||||
count = len(storage_dict)
|
||||
if count > 0:
|
||||
storage_dict.clear()
|
||||
logger.info(f"Reset all rate limits on startup: cleared {count} entries")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def reset_rate_limit_for_ip(ip: str) -> bool:
|
||||
"""
|
||||
Manually reset rate limit for a specific IP address.
|
||||
This clears all rate limit entries for the given IP.
|
||||
Returns True if any entries were cleared, False otherwise.
|
||||
"""
|
||||
if not ip:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Access the private _storage attribute
|
||||
storage = limiter._storage
|
||||
storage_dict = _get_storage_dict(storage)
|
||||
|
||||
if storage_dict is None:
|
||||
# Try alternative methods
|
||||
if hasattr(storage, "reset"):
|
||||
try:
|
||||
storage.reset(ip)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
cleared = False
|
||||
# slowapi stores entries with keys like "LIMITER:{ip}:{endpoint}"
|
||||
# We need to find all keys that contain this IP
|
||||
# Also handle cases where IP might be in different positions
|
||||
keys_to_remove = []
|
||||
|
||||
for key in list(storage_dict.keys()):
|
||||
if isinstance(key, str):
|
||||
# Check multiple patterns:
|
||||
# - "LIMITER:{ip}:{endpoint}"
|
||||
# - Keys containing the IP anywhere
|
||||
# - Keys starting with the IP
|
||||
if (key.startswith(f"LIMITER:{ip}:") or
|
||||
key.startswith(f"LIMITER:{ip}") or
|
||||
f":{ip}:" in key or
|
||||
key.endswith(f":{ip}") or
|
||||
(ip in key and "LIMITER" in key)):
|
||||
keys_to_remove.append(key)
|
||||
|
||||
for key in keys_to_remove:
|
||||
try:
|
||||
del storage_dict[key]
|
||||
cleared = True
|
||||
logger.info(f"Cleared rate limit key: {key}")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if cleared:
|
||||
logger.info(f"Successfully cleared rate limits for IP: {ip}")
|
||||
else:
|
||||
logger.warning(f"No rate limit entries found for IP: {ip}")
|
||||
|
||||
return cleared
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reset rate limit for IP {ip}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def clear_all_rate_limits() -> int:
|
||||
"""
|
||||
Clear all rate limit entries. Use with caution - this affects all IPs.
|
||||
Returns the number of entries cleared.
|
||||
"""
|
||||
try:
|
||||
# Access the private _storage attribute
|
||||
storage = limiter._storage
|
||||
storage_dict = _get_storage_dict(storage)
|
||||
|
||||
if storage_dict is None:
|
||||
return 0
|
||||
|
||||
count = len(storage_dict)
|
||||
storage_dict.clear()
|
||||
logger.warning(f"Cleared all {count} rate limit entries")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear all rate limits: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_expired_rate_limits() -> int:
|
||||
"""
|
||||
Clean up expired rate limit entries from memory storage.
|
||||
This helps prevent rate limits from being stuck indefinitely.
|
||||
Returns the number of entries cleaned up.
|
||||
"""
|
||||
try:
|
||||
# Access the private _storage attribute
|
||||
storage = limiter._storage
|
||||
storage_dict = _get_storage_dict(storage)
|
||||
|
||||
if storage_dict is None:
|
||||
return 0
|
||||
|
||||
# slowapi's memory storage stores entries as tuples: (count, reset_time)
|
||||
# Entries should expire naturally, but we'll clean up any that are clearly expired
|
||||
now = time.time()
|
||||
cleaned = 0
|
||||
keys_to_remove = []
|
||||
|
||||
for key, value in storage_dict.items():
|
||||
if isinstance(value, (tuple, list)) and len(value) >= 2:
|
||||
# Check if reset_time has passed (with some buffer)
|
||||
reset_time = value[1] if isinstance(value[1], (int, float)) else 0
|
||||
# Add 60 second buffer to ensure we don't remove active entries
|
||||
if reset_time > 0 and now > (reset_time + 60):
|
||||
keys_to_remove.append(key)
|
||||
elif isinstance(value, dict):
|
||||
# Some storage formats use dicts with 'expiry' or 'reset' fields
|
||||
expiry = value.get("expiry") or value.get("reset") or value.get("reset_time")
|
||||
if expiry and isinstance(expiry, (int, float)) and now > (expiry + 60):
|
||||
keys_to_remove.append(key)
|
||||
|
||||
for key in keys_to_remove:
|
||||
try:
|
||||
del storage_dict[key]
|
||||
cleaned += 1
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if cleaned > 0:
|
||||
logger.info(f"Cleaned up {cleaned} expired rate limit entries")
|
||||
|
||||
return cleaned
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup expired rate limits: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def start_rate_limit_cleanup_task() -> None:
|
||||
"""Start a background task to periodically clean up expired rate limit entries."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(300) # Run every 5 minutes
|
||||
cleanup_expired_rate_limits()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in rate limit cleanup task: {e}")
|
||||
await asyncio.sleep(60) # Wait 1 minute before retrying
|
||||
@@ -1,147 +0,0 @@
|
||||
"""
|
||||
Similarity detection utilities for username and display name comparison.
|
||||
Implements both edit distance and visual similarity detection.
|
||||
"""
|
||||
|
||||
def levenshtein_distance(s1: str, s2: str) -> int:
|
||||
"""Calculate Levenshtein distance between two strings."""
|
||||
if len(s1) < len(s2):
|
||||
return levenshtein_distance(s2, s1)
|
||||
|
||||
if len(s2) == 0:
|
||||
return len(s1)
|
||||
|
||||
previous_row = list(range(len(s2) + 1))
|
||||
for i, c1 in enumerate(s1):
|
||||
current_row = [i + 1]
|
||||
for j, c2 in enumerate(s2):
|
||||
insertions = previous_row[j + 1] + 1
|
||||
deletions = current_row[j] + 1
|
||||
substitutions = previous_row[j] + (c1 != c2)
|
||||
current_row.append(min(insertions, deletions, substitutions))
|
||||
previous_row = current_row
|
||||
|
||||
return previous_row[-1]
|
||||
|
||||
|
||||
def check_visual_similarity(s1: str, s2: str) -> bool:
|
||||
"""
|
||||
Check if two strings are visually similar using common homoglyphs.
|
||||
Returns True if strings are visually similar.
|
||||
"""
|
||||
if len(s1) != len(s2):
|
||||
return False
|
||||
|
||||
# Common homoglyph mappings
|
||||
homoglyphs = {
|
||||
'0': ['O', 'o', 'Q'],
|
||||
'O': ['0', 'o', 'Q'],
|
||||
'o': ['0', 'O', 'Q'],
|
||||
'1': ['l', 'I', '|'],
|
||||
'l': ['1', 'I', '|'],
|
||||
'I': ['1', 'l', '|'],
|
||||
'5': ['S', 's'],
|
||||
'S': ['5', 's'],
|
||||
's': ['5', 'S'],
|
||||
'6': ['G', 'g'],
|
||||
'G': ['6', 'g'],
|
||||
'g': ['6', 'G'],
|
||||
'8': ['B', 'b'],
|
||||
'B': ['8', 'b'],
|
||||
'b': ['8', 'B'],
|
||||
'9': ['g', 'q'],
|
||||
'g': ['9', 'q'],
|
||||
'q': ['9', 'g'],
|
||||
'2': ['Z', 'z'],
|
||||
'Z': ['2', 'z'],
|
||||
'z': ['2', 'Z'],
|
||||
'3': ['E'],
|
||||
'E': ['3'],
|
||||
'4': ['A'],
|
||||
'A': ['4'],
|
||||
'7': ['T', 't'],
|
||||
'T': ['7', 't'],
|
||||
't': ['7', 'T'],
|
||||
}
|
||||
|
||||
for i in range(len(s1)):
|
||||
c1, c2 = s1[i], s2[i]
|
||||
if c1 == c2:
|
||||
continue
|
||||
|
||||
# Check if characters are homoglyphs
|
||||
if (c1 in homoglyphs and c2 in homoglyphs[c1]) or \
|
||||
(c2 in homoglyphs and c1 in homoglyphs[c2]):
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_username_similarity(username1: str, username2: str) -> bool:
|
||||
"""
|
||||
Check if two usernames are similar using both edit distance and visual similarity.
|
||||
Returns True if usernames are considered similar.
|
||||
"""
|
||||
if username1 == username2:
|
||||
return False
|
||||
|
||||
# Check edit distance (Levenshtein distance <= 2)
|
||||
edit_distance = levenshtein_distance(username1.lower(), username2.lower())
|
||||
if edit_distance <= 2:
|
||||
return True
|
||||
|
||||
# Check visual similarity
|
||||
if check_visual_similarity(username1, username2):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_display_name_similarity(display_name1: str, display_name2: str) -> bool:
|
||||
"""
|
||||
Check if two display names are similar using both edit distance and visual similarity.
|
||||
Returns True if display names are considered similar.
|
||||
"""
|
||||
if display_name1 == display_name2:
|
||||
return False
|
||||
|
||||
# Check edit distance (Levenshtein distance <= 2)
|
||||
edit_distance = levenshtein_distance(display_name1.lower(), display_name2.lower())
|
||||
if edit_distance <= 2:
|
||||
return True
|
||||
|
||||
# Check visual similarity
|
||||
if check_visual_similarity(display_name1, display_name2):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_user_similar_to_verified(user_username: str, user_display_name: str,
|
||||
verified_users: list[dict]) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if a user is similar to any verified user.
|
||||
|
||||
Args:
|
||||
user_username: Username to check
|
||||
user_display_name: Display name to check
|
||||
verified_users: List of verified user dictionaries with 'username' and 'display_name' keys
|
||||
|
||||
Returns:
|
||||
Tuple of (is_similar, similar_to_username)
|
||||
"""
|
||||
for verified_user in verified_users:
|
||||
verified_username = verified_user.get('username', '')
|
||||
verified_display_name = verified_user.get('display_name', '')
|
||||
|
||||
# Check username similarity
|
||||
if check_username_similarity(user_username, verified_username):
|
||||
return True, verified_username
|
||||
|
||||
# Check display name similarity
|
||||
if check_display_name_similarity(user_display_name, verified_display_name):
|
||||
return True, verified_username
|
||||
|
||||
return False, ""
|
||||
@@ -1,71 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import Request
|
||||
import jwt
|
||||
from typing import Optional, Any
|
||||
import bcrypt
|
||||
|
||||
from constants import MAX_TOKEN_LIFETIME_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
|
||||
|
||||
# JWT Helper Functions
|
||||
def create_token(user_id: int, username: str, session_id: str) -> str:
|
||||
# Set a long expiration as safety net (actual expiration based on inactivity)
|
||||
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int)
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> Optional[str]:
|
||||
if not request:
|
||||
return None
|
||||
|
||||
headers = request.headers
|
||||
|
||||
# First, check x-real-ip header (set by some proxies, or configured in Caddy)
|
||||
real_ip = headers.get("x-real-ip") or headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
candidate = real_ip.strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
# Fall back to x-forwarded-for header (Caddy sets this automatically)
|
||||
forwarded = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
# X-Forwarded-For can contain multiple IPs: "client, proxy1, proxy2"
|
||||
# Take the first one (original client IP)
|
||||
candidate = forwarded.split(",")[0].strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
# Fall back to direct client connection (when not behind a proxy)
|
||||
if request.client and request.client.host:
|
||||
return request.client.host
|
||||
|
||||
# Last resort: check scope
|
||||
if isinstance(request.scope, dict):
|
||||
client_info = request.scope.get("client")
|
||||
if isinstance(client_info, (list, tuple)) and client_info:
|
||||
return client_info[0]
|
||||
|
||||
return None
|
||||
@@ -1,26 +0,0 @@
|
||||
import re
|
||||
|
||||
def is_valid_username(username: str) -> bool:
|
||||
if len(username) < 3 or len(username) > 20:
|
||||
return False
|
||||
# Only allow English letters, numbers, dashes and underscores
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_display_name(display_name: str) -> bool:
|
||||
if len(display_name) < 1 or len(display_name) > 64:
|
||||
return False
|
||||
# Check if not blank (only whitespace)
|
||||
if not display_name.strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_password(password: str) -> bool:
|
||||
if len(password) < 5 or len(password) > 50:
|
||||
return False
|
||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
|
||||
return False
|
||||
return True
|
||||
@@ -1,7 +0,0 @@
|
||||
from websocket.registry import WebSocketHandlerRegistry
|
||||
|
||||
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
|
||||
# Import them directly from websocket.handlers when needed
|
||||
|
||||
__all__ = ["WebSocketHandlerRegistry"]
|
||||
|
||||
@@ -1,570 +0,0 @@
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from fastapi import HTTPException, WebSocket, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from websocket.registry import WebSocketHandlerRegistry
|
||||
from routes.messaging import (
|
||||
MessaggingSocketManager,
|
||||
_send_message_internal,
|
||||
_edit_message_internal,
|
||||
get_messages,
|
||||
edit_message,
|
||||
delete_message,
|
||||
add_reaction,
|
||||
add_dm_reaction,
|
||||
)
|
||||
from models import (
|
||||
User,
|
||||
SendMessageRequest,
|
||||
EditMessageRequest,
|
||||
DMEnvelope,
|
||||
ReactionRequest,
|
||||
DMReactionRequest,
|
||||
UpdateLog,
|
||||
)
|
||||
from security.audit import log_access, log_dm
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Create global registry instance
|
||||
handler_registry = WebSocketHandlerRegistry()
|
||||
|
||||
# Create decorator alias
|
||||
websocket_handler = handler_registry.register
|
||||
|
||||
|
||||
def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None:
|
||||
"""Log WebSocket event."""
|
||||
ws_path = getattr(getattr(websocket, "url", None), "path", None)
|
||||
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
|
||||
ws_path = websocket.scope.get("path")
|
||||
ws_path = ws_path or "unknown"
|
||||
headers = {}
|
||||
if isinstance(getattr(websocket, "scope", None), dict):
|
||||
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
|
||||
xff = headers.get("x-forwarded-for")
|
||||
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
|
||||
|
||||
log_access(
|
||||
"ws_event",
|
||||
path=ws_path,
|
||||
event=event,
|
||||
user=user.username if user else None,
|
||||
user_id=user.id if user else None,
|
||||
ip=client_ip,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
@websocket_handler("getUpdates", authRequired=True)
|
||||
async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Handle gap detection - client requests updates from a specific sequence number."""
|
||||
last_seq = data.get("lastSeq", 0)
|
||||
manager.last_seq_by_ws[websocket] = last_seq
|
||||
current_seq = manager.sequence_numbers.get(user.id, 0)
|
||||
|
||||
# Query database for missed updates
|
||||
missed_updates = []
|
||||
if last_seq > 0 and last_seq < current_seq:
|
||||
try:
|
||||
# Get all updates between last_seq and current_seq
|
||||
update_logs = db.query(UpdateLog).filter(
|
||||
UpdateLog.user_id == user.id,
|
||||
UpdateLog.sequence > last_seq,
|
||||
UpdateLog.sequence <= current_seq
|
||||
).order_by(UpdateLog.sequence.asc()).all()
|
||||
|
||||
# Each log entry contains a batch of updates with the same sequence number
|
||||
for log_entry in update_logs:
|
||||
updates = json.loads(log_entry.updates)
|
||||
missed_updates.append({
|
||||
"seq": log_entry.sequence,
|
||||
"updates": updates
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve missed updates: {e}")
|
||||
|
||||
# Send missed updates directly (not through return value)
|
||||
for batch in missed_updates:
|
||||
await websocket.send_json({
|
||||
"type": "updates",
|
||||
"seq": batch["seq"],
|
||||
"updates": batch["updates"]
|
||||
})
|
||||
|
||||
# Update the websocket's last sequence tracking
|
||||
manager.last_seq_by_ws[websocket] = current_seq
|
||||
log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates))
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"lastSeq": current_seq,
|
||||
"missedCount": len(missed_updates)
|
||||
}
|
||||
|
||||
|
||||
@websocket_handler("ping", authRequired=True)
|
||||
async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Handle ping - authenticate and set user online."""
|
||||
# Set user online in DB
|
||||
user.online = True
|
||||
user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
# Add to online users
|
||||
manager.online_users.add(user.id)
|
||||
# Broadcast status change
|
||||
await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db)
|
||||
|
||||
log(manager, websocket, user, "ping")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@websocket_handler("getMessages", authRequired=True)
|
||||
async def getMessages(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Get all public chat messages."""
|
||||
result = await get_messages(user, db)
|
||||
log(manager, websocket, user, "getMessages")
|
||||
return result
|
||||
|
||||
|
||||
@websocket_handler("sendMessage", authRequired=True)
|
||||
async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Send a public chat message."""
|
||||
message_request: SendMessageRequest = SendMessageRequest.model_validate(data)
|
||||
|
||||
# Call internal function directly (rate limiting is handled at infrastructure level via Caddy)
|
||||
response = await _send_message_internal(message_request, user, db, [])
|
||||
await manager.broadcast({
|
||||
"type": "newMessage",
|
||||
"data": response["message"]
|
||||
}, db)
|
||||
|
||||
log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"])
|
||||
return response
|
||||
|
||||
|
||||
@websocket_handler("dmSend", authRequired=True)
|
||||
async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Send a direct message."""
|
||||
payload = data
|
||||
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
||||
for key in required:
|
||||
if key not in payload:
|
||||
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
||||
|
||||
env = DMEnvelope(
|
||||
sender_id=user.id,
|
||||
recipient_id=int(payload["recipientId"]),
|
||||
iv_b64=payload["iv"],
|
||||
ciphertext_b64=payload["ciphertext"],
|
||||
salt_b64=payload["salt"],
|
||||
iv2_b64=payload["iv2"],
|
||||
wrapped_mk_b64=payload["wrappedMk"],
|
||||
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
|
||||
)
|
||||
db.add(env)
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
|
||||
payload_ws = {
|
||||
"type": "dmNew",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv": env.iv_b64,
|
||||
"ciphertext": env.ciphertext_b64,
|
||||
"salt": env.salt_b64,
|
||||
"iv2": env.iv2_b64,
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
"replyToId": env.reply_to_id,
|
||||
}
|
||||
}
|
||||
|
||||
# Send push notification for DM
|
||||
try:
|
||||
from push_service import push_service
|
||||
await push_service.send_dm_notification(db, env, user)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||
|
||||
await manager.send_update_to_user(env.recipient_id, "dmNew", payload_ws["data"], db)
|
||||
await manager.send_update_to_user(env.sender_id, "dmNew", payload_ws["data"], db)
|
||||
|
||||
log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id)
|
||||
log_dm(
|
||||
"message_sent_ws",
|
||||
dm_envelope_id=env.id,
|
||||
sender_id=user.id,
|
||||
sender_username=user.username,
|
||||
recipient_id=env.recipient_id,
|
||||
reply_to=env.reply_to_id,
|
||||
)
|
||||
|
||||
return {"status": "ok", "id": env.id}
|
||||
|
||||
|
||||
@websocket_handler("editMessage", authRequired=True)
|
||||
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Edit a public chat message."""
|
||||
|
||||
message_id = data["message_id"]
|
||||
edit_request: EditMessageRequest = EditMessageRequest.model_validate(data)
|
||||
|
||||
response = await _edit_message_internal(message_id, edit_request, user, db)
|
||||
await manager.broadcast({
|
||||
"type": "messageEdited",
|
||||
"data": response["message"]
|
||||
}, db)
|
||||
|
||||
log(manager, websocket, user, "editMessage", message_id=message_id)
|
||||
return response
|
||||
|
||||
|
||||
@websocket_handler("dmEdit", authRequired=True)
|
||||
async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Edit a direct message."""
|
||||
payload = data
|
||||
env_id = int(payload["id"])
|
||||
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="DM not found")
|
||||
if env.sender_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
||||
|
||||
# Replace ciphertext and iv
|
||||
env.iv_b64 = payload["iv"]
|
||||
env.ciphertext_b64 = payload["ciphertext"]
|
||||
env.iv2_b64 = payload["iv2"]
|
||||
env.wrapped_mk_b64 = payload["wrappedMk"]
|
||||
env.salt_b64 = payload["salt"]
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
|
||||
payload_ws = {
|
||||
"type": "dmEdited",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv": env.iv_b64,
|
||||
"ciphertext": env.ciphertext_b64,
|
||||
"iv2": env.iv2_b64,
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"salt": env.salt_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
}
|
||||
}
|
||||
await manager.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db)
|
||||
await manager.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db)
|
||||
|
||||
log(manager, websocket, user, "dmEdit", dm_envelope_id=env.id)
|
||||
log_dm(
|
||||
"message_edited",
|
||||
dm_envelope_id=env.id,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
)
|
||||
|
||||
return {"status": "ok", "id": env.id}
|
||||
|
||||
|
||||
@websocket_handler("dmDelete", authRequired=True)
|
||||
async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Delete a direct message."""
|
||||
payload = data
|
||||
env_id = int(payload["id"])
|
||||
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="DM not found")
|
||||
if env.sender_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
||||
|
||||
db.delete(env)
|
||||
db.commit()
|
||||
|
||||
payload_ws = {
|
||||
"type": "dmDeleted",
|
||||
"data": {
|
||||
"id": env_id,
|
||||
"senderId": user.id,
|
||||
"recipientId": payload.get("recipientId")
|
||||
}
|
||||
}
|
||||
await manager.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db)
|
||||
await manager.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db)
|
||||
|
||||
log(manager, websocket, user, "dmDelete", dm_envelope_id=env_id)
|
||||
log_dm(
|
||||
"message_deleted",
|
||||
dm_envelope_id=env_id,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
recipient_id=env.recipient_id,
|
||||
)
|
||||
|
||||
return {"status": "ok", "id": env_id}
|
||||
|
||||
|
||||
@websocket_handler("deleteMessage", authRequired=True)
|
||||
async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Delete a public chat message."""
|
||||
message_id = data["message_id"]
|
||||
response = await delete_message(message_id, user, db)
|
||||
await manager.broadcast({
|
||||
"type": "messageDeleted",
|
||||
"data": {"message_id": message_id}
|
||||
}, db)
|
||||
|
||||
log(manager, websocket, user, "deleteMessage", message_id=message_id)
|
||||
return response
|
||||
|
||||
|
||||
@websocket_handler("addReaction", authRequired=True)
|
||||
async def addReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Add or remove a reaction to a public chat message."""
|
||||
reaction_request = ReactionRequest(
|
||||
message_id=data["message_id"],
|
||||
emoji=data["emoji"]
|
||||
)
|
||||
|
||||
response = await add_reaction(reaction_request, user, db)
|
||||
|
||||
# Broadcast reaction update
|
||||
await manager.broadcast({
|
||||
"type": "reactionUpdate",
|
||||
"data": {
|
||||
"message_id": data["message_id"],
|
||||
"emoji": data["emoji"],
|
||||
"action": response["action"],
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"reactions": response["reactions"]
|
||||
}
|
||||
}, db)
|
||||
|
||||
log(manager, websocket, user, "addReaction", message_id=data["message_id"], emoji=data["emoji"], action=response["action"])
|
||||
return response
|
||||
|
||||
|
||||
@websocket_handler("addDmReaction", authRequired=True)
|
||||
async def addDmReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Add or remove a reaction to a direct message."""
|
||||
reaction_request = DMReactionRequest(
|
||||
dm_envelope_id=data["dm_envelope_id"],
|
||||
emoji=data["emoji"]
|
||||
)
|
||||
|
||||
response = await add_dm_reaction(reaction_request, user, db)
|
||||
|
||||
# Broadcast reaction update
|
||||
await manager.broadcast({
|
||||
"type": "dmReactionUpdate",
|
||||
"data": {
|
||||
"dm_envelope_id": data["dm_envelope_id"],
|
||||
"emoji": data["emoji"],
|
||||
"action": response["action"],
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"reactions": response["reactions"]
|
||||
}
|
||||
}, db)
|
||||
|
||||
log(manager, websocket, user, "addDmReaction", dm_envelope_id=data["dm_envelope_id"], emoji=data["emoji"], action=response["action"])
|
||||
return response
|
||||
|
||||
|
||||
@websocket_handler("call_signaling", authRequired=True)
|
||||
async def call_signaling(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Forward WebRTC signaling between peers."""
|
||||
payload = data or {}
|
||||
to_user_id = int(payload.get("toUserId") or 0)
|
||||
if not to_user_id:
|
||||
raise HTTPException(status_code=400, detail="Missing toUserId")
|
||||
|
||||
# Ensure sender is set by the server
|
||||
payload["fromUserId"] = user.id
|
||||
payload["fromUsername"] = user.username
|
||||
|
||||
await manager.send_to_user(to_user_id, {
|
||||
"type": "call_signaling",
|
||||
"data": payload
|
||||
})
|
||||
|
||||
log(manager, websocket, user, "call_signaling", to_user_id=to_user_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@websocket_handler("call_video_toggle", authRequired=True)
|
||||
async def call_video_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Forward video toggle state between peers."""
|
||||
payload = data or {}
|
||||
to_user_id = int(payload.get("toUserId") or 0)
|
||||
if not to_user_id:
|
||||
raise HTTPException(status_code=400, detail="Missing toUserId")
|
||||
|
||||
await manager.send_update_to_user(to_user_id, "call_signaling", {
|
||||
"type": "call_video_toggle",
|
||||
"fromUserId": user.id,
|
||||
"toUserId": to_user_id,
|
||||
"data": {"enabled": payload.get("enabled", False)}
|
||||
}, db)
|
||||
|
||||
log(manager, websocket, user, "call_video_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@websocket_handler("call_screen_share_toggle", authRequired=True)
|
||||
async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Forward screen share toggle state between peers."""
|
||||
payload = data or {}
|
||||
to_user_id = int(payload.get("toUserId") or 0)
|
||||
if not to_user_id:
|
||||
raise HTTPException(status_code=400, detail="Missing toUserId")
|
||||
|
||||
await manager.send_update_to_user(to_user_id, "call_signaling", {
|
||||
"type": "call_screen_share_toggle",
|
||||
"fromUserId": user.id,
|
||||
"toUserId": to_user_id,
|
||||
"data": {"enabled": payload.get("enabled", False)}
|
||||
}, db)
|
||||
|
||||
log(manager, websocket, user, "call_screen_share_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@websocket_handler("subscribeStatus", authRequired=True)
|
||||
async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Subscribe to status updates for a user."""
|
||||
user_id_to_subscribe = int(data["userId"])
|
||||
manager.ws_subscriptions[websocket].add(user_id_to_subscribe)
|
||||
|
||||
# Get current status of the user
|
||||
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
|
||||
if target_user:
|
||||
# Send current status directly (not through return value)
|
||||
await websocket.send_json({
|
||||
"type": "statusUpdate",
|
||||
"data": {
|
||||
"userId": user_id_to_subscribe,
|
||||
"online": target_user.online,
|
||||
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
|
||||
}
|
||||
})
|
||||
log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe)
|
||||
return {"status": "ok"}
|
||||
else:
|
||||
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")
|
||||
|
||||
|
||||
@websocket_handler("unsubscribeStatus", authRequired=True)
|
||||
async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Unsubscribe from status updates for a user."""
|
||||
user_id_to_unsubscribe = int(data["userId"])
|
||||
manager.ws_subscriptions[websocket].discard(user_id_to_unsubscribe)
|
||||
|
||||
log(manager, websocket, user, "unsubscribeStatus", target_user_id=user_id_to_unsubscribe)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@websocket_handler("typing", authRequired=True)
|
||||
async def typing(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
||||
"""Handle typing indicator start for public chat."""
|
||||
was_typing = manager.typing_state.get(user.id, False)
|
||||
manager.typing_users[user.id] = time.time()
|
||||
|
||||
# Only send update if state changed (started typing)
|
||||
if not was_typing:
|
||||
manager.typing_state[user.id] = True
|
||||
# Broadcast to all connected users
|
||||
await manager.broadcast({
|
||||
"type": "typing",
|
||||
"data": {
|
||||
"userId": user.id,
|
||||
"username": user.username
|
||||
}
|
||||
}, db)
|
||||
|
||||
# No confirmation response - privacy protection
|
||||
|
||||
|
||||
@websocket_handler("stopTyping", authRequired=True)
|
||||
async def stopTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
||||
"""Handle typing indicator stop for public chat."""
|
||||
was_typing = manager.typing_state.get(user.id, False)
|
||||
if user.id in manager.typing_users:
|
||||
del manager.typing_users[user.id]
|
||||
|
||||
# Only send update if state changed (stopped typing)
|
||||
if was_typing:
|
||||
manager.typing_state[user.id] = False
|
||||
# Broadcast to all connected users
|
||||
await manager.broadcast({
|
||||
"type": "stopTyping",
|
||||
"data": {
|
||||
"userId": user.id,
|
||||
"username": user.username
|
||||
}
|
||||
}, db)
|
||||
|
||||
# No confirmation response - privacy protection
|
||||
log(manager, websocket, user, "stopTyping")
|
||||
|
||||
|
||||
@websocket_handler("dmTyping", authRequired=True)
|
||||
async def dmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
||||
"""Handle typing indicator start for DM."""
|
||||
recipient_id = int(data["recipientId"])
|
||||
|
||||
if user.id not in manager.dm_typing_users:
|
||||
manager.dm_typing_users[user.id] = {}
|
||||
if user.id not in manager.dm_typing_state:
|
||||
manager.dm_typing_state[user.id] = {}
|
||||
|
||||
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
|
||||
manager.dm_typing_users[user.id][recipient_id] = time.time()
|
||||
|
||||
# Only send update if state changed (started typing)
|
||||
if not was_typing:
|
||||
manager.dm_typing_state[user.id][recipient_id] = True
|
||||
# Send only to recipient
|
||||
await manager.send_update_to_user(recipient_id, "dmTyping", {
|
||||
"userId": user.id,
|
||||
"username": user.username
|
||||
}, db)
|
||||
|
||||
# No confirmation response - privacy protection
|
||||
|
||||
|
||||
@websocket_handler("stopDmTyping", authRequired=True)
|
||||
async def stopDmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
||||
"""Handle typing indicator stop for DM."""
|
||||
recipient_id = int(data["recipientId"])
|
||||
|
||||
was_typing = False
|
||||
if user.id in manager.dm_typing_state:
|
||||
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
|
||||
|
||||
if user.id in manager.dm_typing_users and recipient_id in manager.dm_typing_users[user.id]:
|
||||
del manager.dm_typing_users[user.id][recipient_id]
|
||||
if not manager.dm_typing_users[user.id]:
|
||||
del manager.dm_typing_users[user.id]
|
||||
|
||||
# Only send update if state changed (stopped typing)
|
||||
if was_typing:
|
||||
if user.id in manager.dm_typing_state:
|
||||
manager.dm_typing_state[user.id][recipient_id] = False
|
||||
# Send only to recipient
|
||||
await manager.send_update_to_user(recipient_id, "stopDmTyping", {
|
||||
"userId": user.id,
|
||||
"username": user.username
|
||||
}, db)
|
||||
|
||||
# No confirmation response - privacy protection
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class WebSocketHandlerRegistry:
|
||||
"""Registry for WebSocket message handlers with authentication support."""
|
||||
|
||||
def __init__(self):
|
||||
self._handlers: dict[str, tuple[Callable, bool]] = {}
|
||||
|
||||
def register(self, message_type: str, authRequired: bool = True):
|
||||
"""Register a handler for a message type.
|
||||
|
||||
Args:
|
||||
message_type: The WebSocket message type to handle
|
||||
authRequired: If True, handler will receive authenticated User (not None) or raise 401
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
self._handlers[message_type] = (func, authRequired)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
def get_handler(self, message_type: str) -> tuple[Callable, bool] | None:
|
||||
"""Get handler and authRequired flag for a message type.
|
||||
|
||||
Returns:
|
||||
Tuple of (handler function, authRequired flag) or None if not found
|
||||
"""
|
||||
return self._handlers.get(message_type)
|
||||
|
||||
def get_all_types(self) -> list[str]:
|
||||
"""Get all registered message types for debugging/logging."""
|
||||
return list(self._handlers.keys())
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from types import SimpleNamespace
|
||||
from dependencies import get_current_user
|
||||
from models import User
|
||||
|
||||
|
||||
def extract_token_from_data(data: dict) -> str | None:
|
||||
"""Extract authentication token from WebSocket message data.
|
||||
|
||||
Args:
|
||||
data: WebSocket message data dictionary
|
||||
|
||||
Returns:
|
||||
Token string or None if not present
|
||||
"""
|
||||
credentials = data.get("credentials")
|
||||
if credentials and isinstance(credentials, dict):
|
||||
return credentials.get("credentials")
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user_from_token(token: str, db: Session) -> User | None:
|
||||
"""Get user from authentication token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
User object or None if token is invalid
|
||||
"""
|
||||
try:
|
||||
# Ensure session is in a usable state before querying
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dummy_request = SimpleNamespace()
|
||||
dummy_request.state = SimpleNamespace()
|
||||
|
||||
try:
|
||||
from fastapi.security import HTTPBearer
|
||||
security = HTTPBearer()
|
||||
# We need to create credentials manually
|
||||
credentials = HTTPAuthorizationCredentials(
|
||||
scheme="Bearer",
|
||||
credentials=token
|
||||
)
|
||||
return get_current_user(dummy_request, credentials, db)
|
||||
except HTTPException:
|
||||
return None
|
||||
except Exception:
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None:
|
||||
"""Authenticate user from WebSocket message data.
|
||||
|
||||
Args:
|
||||
data: WebSocket message data dictionary
|
||||
db: Database session
|
||||
authRequired: If True, raises 401 on missing/invalid token
|
||||
|
||||
Returns:
|
||||
User object (guaranteed not None if authRequired=True) or None
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if authRequired=True and token is missing/invalid
|
||||
"""
|
||||
token = extract_token_from_data(data)
|
||||
|
||||
if authRequired:
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Missing credentials")
|
||||
|
||||
user = get_current_user_from_token(token, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
return user
|
||||
else:
|
||||
if token:
|
||||
return get_current_user_from_token(token, db)
|
||||
return None
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.fromchat.ru}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8301:80"
|
||||
restart: always
|
||||
@@ -1,34 +0,0 @@
|
||||
# Node.js
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ipynb_checkpoints
|
||||
.venv
|
||||
venv/
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Common
|
||||
# Exclude editor and OS files, as well as test results
|
||||
dist
|
||||
dist-electron
|
||||
build
|
||||
coverage
|
||||
test_results/
|
||||
out
|
||||
|
||||
data
|
||||
logs
|
||||
@@ -1,29 +0,0 @@
|
||||
# 1. Install pip dependencies
|
||||
FROM python:3.12 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
RUN python3 -m venv .venv
|
||||
COPY backend/requirements.txt .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
./.venv/bin/pip3 install -r requirements.txt
|
||||
|
||||
# 2. Runtime stage
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
# 2.1. Non-root user
|
||||
WORKDIR /app
|
||||
RUN useradd -u 1000 app && \
|
||||
chown -R app /app
|
||||
|
||||
# 2.2. Copy content and create dirs
|
||||
COPY --chown=app backend .
|
||||
COPY --from=builder --chown=app /app/.venv .venv
|
||||
RUN mkdir -p /app/data /app/logs && \
|
||||
chown -R app /app/data /app/logs && \
|
||||
printf '#!/bin/sh\nexec /app/.venv/bin/python /app/admin_cli.py "$@"\n' > /usr/local/bin/admin-cli && \
|
||||
chmod +x /usr/local/bin/admin-cli
|
||||
|
||||
USER app
|
||||
|
||||
# 3. Final command
|
||||
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
|
||||
@@ -1,114 +0,0 @@
|
||||
fromchat.ru {
|
||||
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 {
|
||||
lb_policy first
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||
X-Frame-Options "DENY" # Prevent clickjacking
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||
}
|
||||
|
||||
rate_limit {
|
||||
zone global {
|
||||
key {remote_ip}
|
||||
window 1m
|
||||
burst 20
|
||||
events 500
|
||||
}
|
||||
}
|
||||
|
||||
handle_errors {
|
||||
@errors {
|
||||
expression {err.status_code} >= 400
|
||||
}
|
||||
|
||||
handle @errors {
|
||||
rewrite * /{err.status_code}
|
||||
reverse_proxy https://http.cat {
|
||||
header_up Host {upstream_hostport}
|
||||
replace_status {err.status_code}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beta.fromchat.ru {
|
||||
reverse_proxy 95.165.0.162:8301 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||
X-Frame-Options "DENY" # Prevent clickjacking
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||
}
|
||||
|
||||
rate_limit {
|
||||
zone global {
|
||||
key {remote_ip}
|
||||
window 1m
|
||||
burst 20
|
||||
events 1000
|
||||
}
|
||||
}
|
||||
|
||||
handle_errors {
|
||||
@errors {
|
||||
expression {err.status_code} >= 400
|
||||
}
|
||||
|
||||
handle @errors {
|
||||
rewrite * /{err.status_code}
|
||||
reverse_proxy https://http.cat {
|
||||
header_up Host {upstream_hostport}
|
||||
replace_status {err.status_code}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
git.fromchat.ru {
|
||||
reverse_proxy 172.18.0.1:3000 host.docker.internal:3000 172.17.0.1:3000 {
|
||||
lb_policy first
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||
X-Frame-Options "DENY" # Prevent clickjacking
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
}
|
||||
|
||||
rate_limit {
|
||||
zone global {
|
||||
key {remote_ip}
|
||||
window 1m
|
||||
burst 20
|
||||
events 500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api.getgadgets.toolbox-io.ru {
|
||||
reverse_proxy 95.165.0.162:8400
|
||||
}
|
||||
|
||||
getgadgets.toolbox-io.ru {
|
||||
reverse_proxy 95.165.0.162:8401
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
#
|
||||
# Custom Caddy built with:
|
||||
# - Rate limit plugin
|
||||
#
|
||||
|
||||
FROM caddy:2-builder AS builder
|
||||
RUN xcaddy build \
|
||||
--with github.com/mholt/caddy-ratelimit
|
||||
|
||||
FROM caddy:2
|
||||
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
@@ -1,67 +0,0 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
dockerfile: deployment/Dockerfile.backend
|
||||
context: ..
|
||||
environment:
|
||||
PORT: 8300
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||
volumes:
|
||||
- data:/app/data
|
||||
- logs:/app/logs
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- action: sync+restart
|
||||
path: ../backend
|
||||
target: /app
|
||||
- action: rebuild
|
||||
path: ../backend/requirements.txt
|
||||
|
||||
frontend:
|
||||
build:
|
||||
dockerfile: deployment/frontend/Dockerfile
|
||||
context: ..
|
||||
environment:
|
||||
PORT: 8301
|
||||
BACKEND_HOST: http://backend:8300
|
||||
ports:
|
||||
- "8301:8301"
|
||||
depends_on:
|
||||
- backend
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ../frontend
|
||||
- action: sync+restart
|
||||
path: server.js
|
||||
target: /server/server.js
|
||||
- action: rebuild
|
||||
path: package.json
|
||||
|
||||
caddy:
|
||||
build:
|
||||
context: ./caddy
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- certs:/root/site/certs
|
||||
environment:
|
||||
XDG_DATA_HOME: /root/site/certs
|
||||
XDG_CONFIG_HOME: /root/site/certs
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: fromchat-data
|
||||
logs:
|
||||
name: fromchat-logs
|
||||
certs:
|
||||
name: fromchat-certs
|
||||
@@ -1,32 +0,0 @@
|
||||
[Unit]
|
||||
Description=FromChat server
|
||||
After=multi-user.target
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=/bin/docker compose up
|
||||
ExecStop=/bin/docker compose down
|
||||
WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/log
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
StandardInput=tty-force
|
||||
SyslogIdentifier=fromchat
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,54 +0,0 @@
|
||||
# 1. Build the frontend
|
||||
FROM node:24 AS frontend
|
||||
|
||||
# 1.1. Install npm dependencies
|
||||
WORKDIR /app
|
||||
# Copy package.json and workspace package directory first (needed for workspace resolution)
|
||||
COPY package.json .
|
||||
COPY frontend/packages/ frontend/packages/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 1.2. Copy remaining frontend code and build
|
||||
COPY frontend frontend
|
||||
RUN npm run frontend:build
|
||||
|
||||
|
||||
# 2. Build the static file server
|
||||
FROM node:24 AS server
|
||||
|
||||
# 2.1. Install npm dependencies
|
||||
WORKDIR /server
|
||||
COPY deployment/frontend/package.json .
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install
|
||||
|
||||
# 2.2. Copy the code
|
||||
COPY deployment/frontend/ .
|
||||
|
||||
# 2.3. Build
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# 3. Put it all together
|
||||
FROM node:24-slim
|
||||
|
||||
# 3.1. Non-root user
|
||||
RUN useradd -u 1001 app && \
|
||||
mkdir -p /app && \
|
||||
chown -R app /app && \
|
||||
mkdir /server && \
|
||||
chown -R app /server
|
||||
USER app
|
||||
|
||||
# 3.1. Frontend static files
|
||||
WORKDIR /app
|
||||
COPY --from=frontend --chown=app /app/frontend/build/normal/dist .
|
||||
|
||||
# 3.2. Static file server
|
||||
WORKDIR /server
|
||||
COPY --from=server --chown=app /server .
|
||||
|
||||
# 4. Final command
|
||||
ENV STATIC_FILE_PATH=/app
|
||||
ENTRYPOINT ["npm", "run", "start:prod"]
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "frontend-server",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node server.ts",
|
||||
"build": "tsc -b",
|
||||
"start:prod": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0",
|
||||
"ts-node": "^10.9.0"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import express from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
||||
const filePath = process.env.STATIC_FILE_PATH || ".";
|
||||
|
||||
// API proxy middleware
|
||||
app.use('/api', createProxyMiddleware({
|
||||
target: backendHost,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
ws: true
|
||||
}));
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(resolve(filePath)));
|
||||
|
||||
// SPA routing - catch all handler for client-side routing
|
||||
app.use((_req, res) => {
|
||||
res.sendFile(resolve(filePath, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server launched on http://localhost:${port}`);
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"server.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { ElectronInterface, Platform } from "../electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("electronInterface", {
|
||||
desktop: true,
|
||||
platform: process.platform as Platform,
|
||||
notifications: {
|
||||
requestPermission: () => ipcRenderer.invoke('request-notification-permission'),
|
||||
show: (options) => ipcRenderer.invoke('show-notification', options)
|
||||
}
|
||||
} satisfies ElectronInterface);
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
src/
|
||||
tsconfig.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
# Publishing FromChat Protocol
|
||||
|
||||
This guide explains how to publish the `@fromchat/protocol` package to npm or GitHub Packages.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **npm account**: Create one at [npmjs.com](https://www.npmjs.com/signup)
|
||||
2. **GitHub account**: For GitHub Packages
|
||||
3. **Node.js**: Version 18 or higher
|
||||
|
||||
## Publishing to npm
|
||||
|
||||
### Important: Scoped Package Setup
|
||||
|
||||
The package uses the `@fromchat` scope. You have two options:
|
||||
|
||||
**Option A: Create an npm organization (Recommended)**
|
||||
1. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create)
|
||||
2. Create an organization named `fromchat`
|
||||
3. Add yourself as a member
|
||||
4. Then proceed with publishing below
|
||||
|
||||
**Option B: Use unscoped package name**
|
||||
If you prefer not to create an organization, change the package name in `package.json`:
|
||||
```json
|
||||
{
|
||||
"name": "fromchat-protocol" // Remove the @fromchat/ scope
|
||||
}
|
||||
```
|
||||
Then update all imports in your codebase from `@fromchat/protocol` to `fromchat-protocol`.
|
||||
|
||||
### 1. Build the package
|
||||
|
||||
```bash
|
||||
cd frontend/packages/fromchat-protocol
|
||||
npm run build
|
||||
```
|
||||
|
||||
This compiles TypeScript to JavaScript in the `dist/` directory.
|
||||
|
||||
### 2. Login to npm
|
||||
|
||||
```bash
|
||||
npm login
|
||||
```
|
||||
|
||||
Enter your npm username, password, and email.
|
||||
|
||||
### 3. Publish
|
||||
|
||||
**If using scoped package (`@fromchat/protocol`):**
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
**If using unscoped package (`fromchat-protocol`):**
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
The `--access public` flag is required for scoped packages (packages starting with `@`).
|
||||
|
||||
### 4. Verify
|
||||
|
||||
Check your package at: `https://www.npmjs.com/package/@fromchat/protocol`
|
||||
|
||||
### 5. Update version for future releases
|
||||
|
||||
```bash
|
||||
# Patch version (1.0.0 -> 1.0.1)
|
||||
npm version patch
|
||||
|
||||
# Minor version (1.0.0 -> 1.1.0)
|
||||
npm version minor
|
||||
|
||||
# Major version (1.0.0 -> 2.0.0)
|
||||
npm version major
|
||||
|
||||
# Then publish
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
## Publishing to GitHub Packages
|
||||
|
||||
### 1. Create a GitHub Personal Access Token
|
||||
|
||||
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
|
||||
2. Generate a new token with `write:packages` and `read:packages` permissions
|
||||
3. Save the token securely
|
||||
|
||||
### 2. Configure npm to use GitHub Packages
|
||||
|
||||
Create or edit `~/.npmrc`:
|
||||
|
||||
```
|
||||
@fromchat:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
|
||||
```
|
||||
|
||||
Or add to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update package.json
|
||||
|
||||
Update the repository URL to match your GitHub repository:
|
||||
|
||||
```json
|
||||
{
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/YOUR_USERNAME/YOUR_REPO.git",
|
||||
"directory": "frontend/packages/fromchat-protocol"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Build and publish
|
||||
|
||||
```bash
|
||||
cd frontend/packages/fromchat-protocol
|
||||
npm run build
|
||||
npm publish
|
||||
```
|
||||
|
||||
### 5. Install from GitHub Packages
|
||||
|
||||
Users can install your package with:
|
||||
|
||||
```bash
|
||||
npm install @fromchat/protocol@npm:@fromchat/protocol
|
||||
```
|
||||
|
||||
Or add to `.npmrc`:
|
||||
|
||||
```
|
||||
@fromchat:registry=https://npm.pkg.github.com
|
||||
```
|
||||
|
||||
## Using the Published Package
|
||||
|
||||
### From npm
|
||||
|
||||
```bash
|
||||
npm install @fromchat/protocol
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { FromChatProtocol } from "@fromchat/protocol";
|
||||
```
|
||||
|
||||
### From GitHub Packages
|
||||
|
||||
```bash
|
||||
npm install @fromchat/protocol@npm:@fromchat/protocol
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The package is built to `dist/` directory
|
||||
- Source files in `src/` are excluded from the published package
|
||||
- Only `dist/` and `README.md` are included in the published package
|
||||
- The package uses ES modules (ESM) format
|
||||
- TypeScript definitions are included in `dist/`
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# FromChat Protocol
|
||||
|
||||
Simple ECDH-based encryption protocol for direct messages.
|
||||
|
||||
## Overview
|
||||
|
||||
The FromChat Protocol provides end-to-end encryption for direct messages using:
|
||||
- **X25519** (ECDH) for key exchange
|
||||
- **HKDF** for key derivation
|
||||
- **AES-GCM** for symmetric encryption
|
||||
|
||||
This module is completely independent and can be used in any JavaScript/TypeScript project.
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
### Encryption
|
||||
|
||||
1. Generate a random message key (mk) - 32 bytes
|
||||
2. Generate a random salt (wkSalt) - 16 bytes
|
||||
3. Derive shared secret from ECDH: `ecdhSharedSecret(myPrivateKey, theirPublicKey)`
|
||||
4. Derive wrapping key: `deriveWrappingKey(sharedSecret, wkSalt, info)` using HKDF
|
||||
5. Encrypt message with mk using AES-GCM → (iv, ciphertext)
|
||||
6. Encrypt (wrap) mk with wrapping key using AES-GCM → (iv2, wrappedMk)
|
||||
7. Send: `{ iv, ciphertext, salt, iv2, wrappedMk }`
|
||||
|
||||
### Decryption
|
||||
|
||||
1. Derive shared secret from ECDH
|
||||
2. Derive wrapping key from shared secret using salt from message
|
||||
3. Decrypt wrappedMk to get mk
|
||||
4. Decrypt ciphertext with mk
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { FromChatProtocol } from "@fromchat/protocol";
|
||||
|
||||
// Initialize with your private key
|
||||
const protocol = new FromChatProtocol(privateKey);
|
||||
|
||||
// Encrypt a message
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, "Hello!");
|
||||
|
||||
// Decrypt a message
|
||||
const decrypted = await protocol.decryptMessage(senderPublicKey, encrypted);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `FromChatProtocol`
|
||||
|
||||
#### Constructor
|
||||
- `constructor(privateKey: Uint8Array)` - Initialize protocol with your X25519 private key
|
||||
|
||||
#### Methods
|
||||
- `encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage>` - Encrypt a message
|
||||
- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string>` - Decrypt a message
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
interface EncryptedMessage {
|
||||
iv: string; // Base64 encoded IV for message encryption
|
||||
ciphertext: string; // Base64 encoded encrypted message
|
||||
salt: string; // Base64 encoded salt for wrapping key derivation
|
||||
iv2: string; // Base64 encoded IV for message key wrapping
|
||||
wrappedMk: string; // Base64 encoded wrapped message key
|
||||
}
|
||||
```
|
||||
|
||||
## Backup & Key Management
|
||||
|
||||
The protocol also includes utilities for backing up and restoring private keys:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
encryptBackupWithPassword,
|
||||
decryptBackupWithPassword,
|
||||
encodeBlob,
|
||||
decodeBlob
|
||||
} from "@fromchat/protocol";
|
||||
|
||||
// Create a backup of a private key
|
||||
const bundle = { version: 1, privateKey: myPrivateKey };
|
||||
const encrypted = await encryptBackupWithPassword("my-password", bundle);
|
||||
const backupString = encodeBlob(encrypted); // Store this string
|
||||
|
||||
// Restore from backup
|
||||
const encryptedBlob = decodeBlob(backupString);
|
||||
const restored = await decryptBackupWithPassword("my-password", encryptedBlob);
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Each message uses a fresh random message key
|
||||
- The protocol does not provide forward secrecy
|
||||
- Keys are derived using HKDF with SHA-256
|
||||
- All encryption uses AES-GCM with 12-byte IVs
|
||||
- Backup encryption uses PBKDF2 with 210,000 iterations
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"name": "@fromchat/protocol",
|
||||
"version": "1.0.0",
|
||||
"description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"encryption",
|
||||
"ecdh",
|
||||
"e2ee",
|
||||
"end-to-end-encryption",
|
||||
"x25519",
|
||||
"aes-gcm",
|
||||
"hkdf"
|
||||
],
|
||||
"author": "denis0001-dev",
|
||||
"license": "GPL-3.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Toolbox-io/FromChat.git",
|
||||
"directory": "frontend/packages/fromchat-protocol"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Toolbox-io/FromChat/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Toolbox-io/FromChat#readme",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.2",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./user/auth";
|
||||
import type { IceServersResponse } from "@/core/types";
|
||||
|
||||
/**
|
||||
* Fetches ICE server configuration for WebRTC
|
||||
*/
|
||||
export async function iceServers(token: string): Promise<IceServersResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/webrtc/ice`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch ICE servers");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import { getCurrentKeys } from "../user/auth";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "../crypto/identity";
|
||||
import { fetchUsers, searchUsers } from "../user/search";
|
||||
import { getOrInitProtocol } from "@/utils/crypto/fromchatInit";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol";
|
||||
|
||||
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const protocol = getOrInitProtocol();
|
||||
const senderPublicKey = ub64(senderPublicKeyB64);
|
||||
|
||||
return await protocol.decryptMessage(senderPublicKey, envelope);
|
||||
}
|
||||
|
||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return { messages: [], has_more: false };
|
||||
const data = await response.json();
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const protocol = getOrInitProtocol();
|
||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
...encrypted
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
// For files, we need to use the same message key for both the message and files
|
||||
// So we'll do the encryption manually here to reuse the mk
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// Encrypt file with same mk
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name;
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Encrypt the plaintext JSON with the same mk
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const protocol = getOrInitProtocol();
|
||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
...encrypted
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
|
||||
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface ConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a DM as read
|
||||
*/
|
||||
export async function markRead(id: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmMarkRead",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id }
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
@@ -1,176 +0,0 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||
import { getCurrentKeys } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Obtain the key
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||
|
||||
// Decrypt
|
||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||
return new TextDecoder().decode(msg);
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// Encrypt file with same mk
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name; // server uses provided name
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
try {
|
||||
obj = JSON.parse(plaintextJson);
|
||||
} catch {
|
||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
||||
}
|
||||
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||
import { getCurrentKeys } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Obtain the key
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||
|
||||
// Decrypt
|
||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||
return new TextDecoder().decode(msg);
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// Encrypt file with same mk
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name; // server uses provided name
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
try {
|
||||
obj = JSON.parse(plaintextJson);
|
||||
} catch {
|
||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
||||
}
|
||||
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { IceServersResponse } from "@/core/types";
|
||||
|
||||
/**
|
||||
* Fetches ICE server configuration for WebRTC
|
||||
*/
|
||||
export async function getIceServers(token: string): Promise<IceServersResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/webrtc/ice`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch ICE servers");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "@/core/api";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
verified: boolean;
|
||||
userId?: number;
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) {
|
||||
const [isSimilarToVerified, setIsSimilarToVerified] = useState(false);
|
||||
const { user } = useUserStore();
|
||||
|
||||
const className = `status-badge ${size}`;
|
||||
|
||||
// Check similarity for unverified users
|
||||
useEffect(() => {
|
||||
if (!verified && userId && user.authToken) {
|
||||
api.user.profile.checkSimilarity(userId, user.authToken)
|
||||
.then(result => {
|
||||
setIsSimilarToVerified(result?.isSimilar || false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking similarity:', error);
|
||||
setIsSimilarToVerified(false);
|
||||
});
|
||||
} else {
|
||||
setIsSimilarToVerified(false);
|
||||
}
|
||||
}, [verified, userId, user.authToken]);
|
||||
|
||||
if (verified) {
|
||||
return (
|
||||
<span className={`${className} verified`} title="Подтверждённый аккаунт">
|
||||
<MaterialIcon name="verified--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSimilarToVerified) {
|
||||
return (
|
||||
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
|
||||
<MaterialIcon name="warning--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show anything if not verified and not similar
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Application configuration constants
|
||||
* @description Contains all configuration values used throughout the application
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
export const BASE_DOMAIN = import.meta.env.VITE_API_BASE_URL || "fromchat.ru";
|
||||
export const API_BASE_URL = `${location.host ? "" : `https://${BASE_DOMAIN}`}/api`;
|
||||
export const API_WS_BASE_URL = `${location.host || BASE_DOMAIN}/api`;
|
||||
export const PRODUCT_NAME = "FromChat";
|
||||
export const MINIMUM_WIDTH = 800;
|
||||
+11
-27
@@ -3,38 +3,28 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "frontend/build/electron/core/main.js",
|
||||
"main": "build/electron/core/main.js",
|
||||
"description": "A 100% Open Source Messenger",
|
||||
"license": "GPL-3.0",
|
||||
"authors": "denis0001-dev",
|
||||
"scripts": {
|
||||
"backend:run": "bash ./scripts/backend:run.sh",
|
||||
"backend:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt",
|
||||
"backend:reinstall": "rm -rf .venv && npm run backend:dependencies",
|
||||
"backend:clean": "rm -rf backend/data",
|
||||
"frontend:dev": "dotenv -e deployment/.env -- vite frontend",
|
||||
"frontend:typecheck": "tsc --project frontend",
|
||||
"frontend:build": "npm run frontend:typecheck && dotenv -e deployment/.env -- vite build frontend",
|
||||
"frontend:dev": "vite",
|
||||
"frontend:typecheck": "tsc --project tsconfig.json",
|
||||
"frontend:build": "npm run frontend:typecheck && vite build",
|
||||
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev",
|
||||
"frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force --arch arm64,x64",
|
||||
"frontend:preview": "vite preview frontend",
|
||||
"frontend:preview": "vite preview",
|
||||
"frontend:dependencies": "npm install --ignore-scripts",
|
||||
"frontend:clean": "rm -rf frontend/dist",
|
||||
"dev": "concurrently 'npm run frontend:dev' 'npm run backend:run'",
|
||||
"dev:electron": "concurrently 'npm run frontend:electron:dev' 'npm run backend:run'",
|
||||
"frontend:clean": "rm -rf build",
|
||||
"build:electron": "npm run frontend:electron:build",
|
||||
"build": "npm run frontend:build && npm run build:electron",
|
||||
"preview": "cd deployment && docker compose up --build --watch",
|
||||
"preview:clean": "cd deployment && docker compose down -v",
|
||||
"clean": "npm run backend:clean && npm run frontend:clean && npm run preview:clean",
|
||||
"install": "npm run backend:dependencies && if [[ ! -f deployment/.env ]]; then npm run generate:env; fi && npm run install:pussh",
|
||||
"install:pussh": "bash ./scripts/install:pussh.sh",
|
||||
"preview": "docker compose -f docker-compose.yml up --build --watch",
|
||||
"preview:clean": "docker compose -f docker-compose.yml down -v --remove-orphans",
|
||||
"prepare": "husky",
|
||||
"generate:env": "bash ./scripts/generate:env.sh",
|
||||
"deploy": "dotenv -e deployment/.env -- bash ./scripts/deploy.sh"
|
||||
"install": "if [ ! -e .env ]; then cp .env.example .env; fi"
|
||||
},
|
||||
"files": [
|
||||
"frontend/build/electron"
|
||||
"build/electron"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.9.0",
|
||||
@@ -51,8 +41,6 @@
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"electron": "^39.2.7",
|
||||
"husky": "^9.1.7",
|
||||
@@ -68,11 +56,7 @@
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-sass-dts": "^1.3.34"
|
||||
},
|
||||
"workspaces": [
|
||||
"frontend/packages/fromchat-protocol"
|
||||
],
|
||||
"dependencies": {
|
||||
"@fromchat/protocol": "workspace:*",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"he": "^1.2.0",
|
||||
@@ -88,6 +72,6 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"config": {
|
||||
"forge": "frontend/forge.config.ts"
|
||||
"forge": "src/electron/forge.config.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
cd backend
|
||||
dotenv -e ../deployment/.env -- \
|
||||
../.venv/bin/uvicorn main:app \
|
||||
--host 127.0.0.1 \
|
||||
--port 8300 \
|
||||
--reload \
|
||||
--reload-exclude './alembic' \
|
||||
--reload-exclude './alembic/*' \
|
||||
--reload-exclude './alembic/versions/*' \
|
||||
--access-log
|
||||
@@ -1,612 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Complete deployment script: build and push to server
|
||||
# Usage: ./scripts/deploy.sh [server_user@server_host] [deployment_path] [platform]
|
||||
# Example: ./scripts/deploy.sh user@example.com /home/user/fromchat linux/arm64
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
NC='\033[0m' # No Color
|
||||
BOLD='\033[1m'
|
||||
|
||||
# Helper functions
|
||||
info() { echo -e "${BLUE}ℹ${NC} $1"; }
|
||||
success() { echo -e "${GREEN}✓${NC} $1"; }
|
||||
warning() { echo -e "${YELLOW}⚠${NC} $1"; }
|
||||
error() { echo -e "${RED}✗${NC} $1"; }
|
||||
step() { echo -e "${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; }
|
||||
substep() {
|
||||
if [ "$2" = "-n" ]; then
|
||||
echo -n -e " ${GREEN}•${NC} $1"
|
||||
else
|
||||
echo -e " ${GREEN}•${NC} $1"
|
||||
fi
|
||||
}
|
||||
|
||||
echo -e "${MAGENTA}${BOLD}🚀 Deployment${NC}\n"
|
||||
|
||||
# Read password with asterisks
|
||||
read_password() {
|
||||
local password=""
|
||||
local char
|
||||
local old_stty
|
||||
|
||||
# Save current terminal settings
|
||||
old_stty=$(stty -g 2>/dev/null)
|
||||
|
||||
# Disable echo
|
||||
stty -echo 2>/dev/null
|
||||
|
||||
# Read characters one by one
|
||||
while IFS= read -rs -n 1 char; do
|
||||
# Check for Enter key (empty means Enter was pressed)
|
||||
if [ -z "$char" ]; then
|
||||
break
|
||||
fi
|
||||
# Check for backspace/delete (ASCII 127)
|
||||
if [ "$char" = $'\177' ] || [ "$char" = $'\b' ]; then
|
||||
if [ ${#password} -gt 0 ]; then
|
||||
password="${password%?}"
|
||||
printf "\b \b" >&2
|
||||
fi
|
||||
else
|
||||
password+="$char"
|
||||
printf "*" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# Restore terminal settings
|
||||
stty "$old_stty" 2>/dev/null
|
||||
echo "" >&2
|
||||
echo "$password"
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DEPLOYMENT_DIR="$PROJECT_ROOT/deployment"
|
||||
ENV_FILE="$DEPLOYMENT_DIR/.env"
|
||||
|
||||
# Load .env file if it exists
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
# Export variables from .env file (ignore comments and empty lines)
|
||||
set -a
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
# Skip comments and empty lines
|
||||
case "$line" in
|
||||
\#*|'') continue ;;
|
||||
*)
|
||||
# Export the variable
|
||||
export "$line" 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
done < "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Read server from environment variable (from .env), command line argument, or fallback
|
||||
SERVER="${1:-${DEPLOYMENT_SERVER:-}}"
|
||||
REPO_NAME="FromChat"
|
||||
DEPLOY_PATH="~/actions-runner/_work/$REPO_NAME/$REPO_NAME"
|
||||
PLATFORM="linux/arm64"
|
||||
|
||||
# Check if server is provided
|
||||
if [ -z "$SERVER" ]; then
|
||||
error "Server not specified. Usage: $0 [user@host] [deployment_path] [platform]"
|
||||
echo " Or set DEPLOYMENT_SERVER in $ENV_FILE or as an environment variable"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " $0 user@example.com /home/user/fromchat linux/arm64"
|
||||
echo " Or add to $ENV_FILE: DEPLOYMENT_SERVER=user@example.com"
|
||||
echo " Or: DEPLOYMENT_SERVER=user@example.com $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# SSH AUTHENTICATION
|
||||
# ============================================================================
|
||||
|
||||
step "Authentication"
|
||||
SSH_KEY_FILE="$HOME/.ssh/id_rsa"
|
||||
SSH_KEY_PUB_FILE="$SSH_KEY_FILE.pub"
|
||||
|
||||
# Ensure ssh-agent is running
|
||||
if [ -z "$SSH_AUTH_SOCK" ]; then
|
||||
eval "$(ssh-agent -s)" > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Check if SSH key exists
|
||||
if [ ! -f "$SSH_KEY_FILE" ]; then
|
||||
error "SSH key not found at $SSH_KEY_FILE"
|
||||
echo " Please generate an SSH key pair first:"
|
||||
echo " ssh-keygen -t rsa -b 4096 -C 'your_email@example.com'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add SSH key to agent if not already loaded
|
||||
KEY_LOADED=false
|
||||
if ssh-add -l > /dev/null 2>&1; then
|
||||
# Check if this specific key is loaded by trying to match the public key
|
||||
KEY_FINGERPRINT=$(ssh-keygen -lf "$SSH_KEY_FILE" 2>/dev/null | awk '{print $2}')
|
||||
if [ -n "$KEY_FINGERPRINT" ] && ssh-add -l 2>/dev/null | grep -q "$KEY_FINGERPRINT"; then
|
||||
KEY_LOADED=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$KEY_LOADED" = false ]; then
|
||||
substep "Adding SSH key to agent..."
|
||||
if ! ssh-add "$SSH_KEY_FILE" 2>/dev/null; then
|
||||
error "Failed to add SSH key to agent. Check your key passphrase."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if SSH key authentication already works
|
||||
if ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SERVER" "echo 'SSH key works'" >/dev/null 2>&1; then
|
||||
# SSH key already works, no need to copy
|
||||
true
|
||||
else
|
||||
# Check if our public key is already on the server
|
||||
KEY_CONTENT=$(cat "$SSH_KEY_PUB_FILE")
|
||||
if ssh -o BatchMode=no -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SERVER" "
|
||||
grep -q '$KEY_CONTENT' ~/.ssh/authorized_keys 2>/dev/null
|
||||
" >/dev/null 2>&1; then
|
||||
# Key exists but authentication failed - might be permissions issue
|
||||
error "SSH key found on server but authentication failed. Check server SSH configuration."
|
||||
exit 1
|
||||
else
|
||||
# Key not on server, need to copy it
|
||||
substep "SSH password: " -n
|
||||
SSH_PASSWORD=$(read_password)
|
||||
|
||||
if [ -z "$SSH_PASSWORD" ]; then
|
||||
error "No SSH password provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
substep "Copying SSH key to server..."
|
||||
if command -v expect >/dev/null 2>&1; then
|
||||
expect << EOF >/dev/null 2>&1
|
||||
spawn ssh-copy-id -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i "$SSH_KEY_PUB_FILE" "$SERVER"
|
||||
expect "password:"
|
||||
send "$SSH_PASSWORD\r"
|
||||
expect eof
|
||||
EOF
|
||||
if [ $? -eq 0 ]; then
|
||||
true
|
||||
else
|
||||
error "Failed to copy SSH key to server"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
error "expect not available - cannot copy SSH key"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# SUDO AUTHENTICATION
|
||||
# ============================================================================
|
||||
|
||||
SUDO_PASSWORD=""
|
||||
# If SSH password was provided, try using it for sudo first
|
||||
if [ -n "$SSH_PASSWORD" ]; then
|
||||
if echo "$SSH_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then
|
||||
SUDO_PASSWORD="$SSH_PASSWORD"
|
||||
export SUDO_PASSWORD
|
||||
fi
|
||||
fi
|
||||
|
||||
# If we don't have a working sudo password yet, prompt for it
|
||||
if [ -z "$SUDO_PASSWORD" ]; then
|
||||
while true; do
|
||||
substep "Sudo password: " -n
|
||||
SUDO_PASSWORD=$(read_password)
|
||||
|
||||
if [ -z "$SUDO_PASSWORD" ]; then
|
||||
warning "No password provided - assuming passwordless sudo"
|
||||
break
|
||||
fi
|
||||
|
||||
if echo "$SUDO_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then
|
||||
export SUDO_PASSWORD
|
||||
break
|
||||
else
|
||||
echo -n " " && error "Invalid password, please try again"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# BUILD PHASE
|
||||
# ============================================================================
|
||||
|
||||
echo -e "\n${MAGENTA}${BOLD}🔨 Building Docker images${NC}\n"
|
||||
|
||||
# Determine project name
|
||||
if [ -n "$SERVER" ]; then
|
||||
COMPOSE_DIR=$(ssh "$SERVER" "dirname $DEPLOY_PATH/deployment/docker-compose.yml" 2>/dev/null || echo "$DEPLOY_PATH/deployment")
|
||||
PROJECT_NAME=$(ssh "$SERVER" "basename $COMPOSE_DIR" 2>/dev/null || echo "deployment")
|
||||
else
|
||||
PROJECT_NAME=$(basename "$DEPLOYMENT_DIR")
|
||||
fi
|
||||
|
||||
# Check if Docker daemon is running
|
||||
check_docker_daemon() {
|
||||
docker info > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Start Docker Desktop
|
||||
start_docker_desktop() {
|
||||
substep "Starting Docker Desktop..."
|
||||
if ! docker desktop start > /dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Wait for Docker to be ready (max 60 seconds)
|
||||
substep "Waiting for Docker to start..." -n
|
||||
local max_wait=60
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
if check_docker_daemon; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
echo -n "."
|
||||
done
|
||||
echo ""
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check buildx
|
||||
if ! docker buildx version > /dev/null 2>&1; then
|
||||
error "Docker buildx not available. Install Docker Desktop."
|
||||
fi
|
||||
|
||||
# Check Docker daemon
|
||||
if ! check_docker_daemon; then
|
||||
warning "Docker daemon is not running"
|
||||
if ! start_docker_desktop; then
|
||||
error "Failed to start Docker Desktop. Please start it manually and try again."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Setup buildx builder
|
||||
step "Setting up buildx builder"
|
||||
BUILDER_NAME="fromchat-builder"
|
||||
BUILDER_EXISTS=false
|
||||
|
||||
if docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
BUILDER_EXISTS=true
|
||||
if ! docker buildx use "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
substep "Recreating builder..."
|
||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
||||
BUILDER_EXISTS=false
|
||||
elif ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
substep "Recreating builder (inspection failed)..."
|
||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
||||
BUILDER_EXISTS=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$BUILDER_EXISTS" = false ]; then
|
||||
substep "Creating builder with persistent cache..."
|
||||
docker buildx create \
|
||||
--name "$BUILDER_NAME" \
|
||||
--driver docker-container \
|
||||
--driver-opt image=moby/buildkit:latest \
|
||||
--use \
|
||||
--bootstrap > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
docker buildx use "$BUILDER_NAME" > /dev/null 2>&1
|
||||
|
||||
# Detect services
|
||||
step "Detecting services"
|
||||
cd "$DEPLOYMENT_DIR"
|
||||
SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null)
|
||||
|
||||
if [ -z "$SERVICES" ]; then
|
||||
error "No services found in docker-compose.yml"
|
||||
fi
|
||||
|
||||
BUILT_IMAGES=()
|
||||
|
||||
for SERVICE in $SERVICES; do
|
||||
HAS_BUILD=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
||||
grep -A 30 "^[[:space:]]*${SERVICE}:" | \
|
||||
grep -q "build:" && echo "yes" || echo "no")
|
||||
|
||||
if [ "$HAS_BUILD" != "yes" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
IMAGE_TAG="${PROJECT_NAME}-${SERVICE}:latest"
|
||||
|
||||
substep "Building ${CYAN}$SERVICE${NC} -> ${CYAN}$IMAGE_TAG${NC}..."
|
||||
|
||||
BUILD_OUTPUT=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
||||
grep -A 15 "^[[:space:]]*${SERVICE}:" | \
|
||||
grep -A 10 "build:")
|
||||
|
||||
DOCKERFILE_REL=$(echo "$BUILD_OUTPUT" | grep "dockerfile:" | \
|
||||
sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \
|
||||
tr -d '"' | tr -d "'" | xargs)
|
||||
|
||||
CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \
|
||||
sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \
|
||||
tr -d '"' | tr -d "'" | xargs)
|
||||
|
||||
if [ -z "$CONTEXT_REL" ]; then
|
||||
CONTEXT_REL=".."
|
||||
fi
|
||||
|
||||
if [[ "$CONTEXT_REL" == ".." ]]; then
|
||||
BUILD_CONTEXT="$PROJECT_ROOT"
|
||||
elif [[ "$CONTEXT_REL" == /* ]]; then
|
||||
BUILD_CONTEXT="$CONTEXT_REL"
|
||||
else
|
||||
BUILD_CONTEXT="$DEPLOYMENT_DIR/$CONTEXT_REL"
|
||||
fi
|
||||
|
||||
if [ -n "$DOCKERFILE_REL" ]; then
|
||||
if [[ "$DOCKERFILE_REL" == /* ]]; then
|
||||
DOCKERFILE="$DOCKERFILE_REL"
|
||||
else
|
||||
if [[ "$CONTEXT_REL" == ".." ]] || [[ "$BUILD_CONTEXT" == "$PROJECT_ROOT" ]]; then
|
||||
DOCKERFILE="$PROJECT_ROOT/$DOCKERFILE_REL"
|
||||
else
|
||||
DOCKERFILE="$BUILD_CONTEXT/$DOCKERFILE_REL"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [ -f "$DEPLOYMENT_DIR/Dockerfile.$SERVICE" ]; then
|
||||
DOCKERFILE="$DEPLOYMENT_DIR/Dockerfile.$SERVICE"
|
||||
elif [ -f "$DEPLOYMENT_DIR/$SERVICE/Dockerfile" ]; then
|
||||
DOCKERFILE="$DEPLOYMENT_DIR/$SERVICE/Dockerfile"
|
||||
else
|
||||
error "Could not determine Dockerfile for $SERVICE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if docker buildx build \
|
||||
--platform "$PLATFORM" \
|
||||
--file "$DOCKERFILE" \
|
||||
--tag "$IMAGE_TAG" \
|
||||
--load \
|
||||
"$BUILD_CONTEXT"; then
|
||||
echo -e " ${GREEN}✓${NC} Built ${CYAN}$SERVICE${NC}"
|
||||
BUILT_IMAGES+=("$IMAGE_TAG")
|
||||
echo ""
|
||||
else
|
||||
error "Build failed for $SERVICE"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
success "Build complete! ${#BUILT_IMAGES[@]} image(s) ready"
|
||||
|
||||
# ============================================================================
|
||||
# DEPLOY PHASE
|
||||
# ============================================================================
|
||||
|
||||
echo -e "\n${MAGENTA}${BOLD}🚀 Deploying to ${SERVER}${NC}\n"
|
||||
|
||||
|
||||
# Check docker pussh
|
||||
if ! docker pussh --help > /dev/null 2>&1; then
|
||||
error "docker pussh plugin not installed"
|
||||
echo " Install: npm run install:pussh"
|
||||
fi
|
||||
|
||||
# Detect images based on docker-compose.yml (prefer explicit `image:` entries; fall back to built tags)
|
||||
cd "$DEPLOYMENT_DIR"
|
||||
COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null || true)
|
||||
IMAGES=()
|
||||
|
||||
for S in $COMPOSE_SERVICES; do
|
||||
# Try to read explicit image: field from the compose config for this service
|
||||
IMAGE_FROM_COMPOSE=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
||||
grep -A5 "^[[:space:]]*${S}:" | \
|
||||
grep -m1 "image:" || true)
|
||||
|
||||
IMAGE_FROM_COMPOSE=$(echo "$IMAGE_FROM_COMPOSE" | sed 's/.*image:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs || true)
|
||||
|
||||
if [ -n "$IMAGE_FROM_COMPOSE" ]; then
|
||||
IMAGES+=("$IMAGE_FROM_COMPOSE")
|
||||
else
|
||||
# If service has a build section (we built it above), use the tag pattern used during build
|
||||
TAG="${PROJECT_NAME}-${S}:latest"
|
||||
# Only include the tag if the image exists locally (avoid pushing unrelated images)
|
||||
if docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${TAG}$"; then
|
||||
IMAGES+=("$TAG")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Deduplicate while preserving order
|
||||
if [ ${#IMAGES[@]} -gt 0 ]; then
|
||||
IMAGES=($(printf "%s\n" "${IMAGES[@]}" | awk '!seen[$0]++'))
|
||||
fi
|
||||
|
||||
# Verify that all built images are among the detected images to be pushed.
|
||||
# This prevents accidentally pushing unrelated images.
|
||||
BUILT_COUNT=${#BUILT_IMAGES[@]}
|
||||
MATCHING_BUILT=0
|
||||
MISSING_FROM_DETECTED=()
|
||||
for BI in "${BUILT_IMAGES[@]}"; do
|
||||
found=false
|
||||
for DI in "${IMAGES[@]}"; do
|
||||
if [ "$BI" = "$DI" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$found" = true ]; then
|
||||
MATCHING_BUILT=$((MATCHING_BUILT + 1))
|
||||
else
|
||||
MISSING_FROM_DETECTED+=("$BI")
|
||||
fi
|
||||
done
|
||||
|
||||
# Also list detected images that weren't built locally (these are likely external images)
|
||||
NOT_BUILT_DETECTED=()
|
||||
for DI in "${IMAGES[@]}"; do
|
||||
built=false
|
||||
for BI in "${BUILT_IMAGES[@]}"; do
|
||||
if [ "$DI" = "$BI" ]; then
|
||||
built=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$built" = false ]; then
|
||||
NOT_BUILT_DETECTED+=("$DI")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$BUILT_COUNT" -ne "$MATCHING_BUILT" ]; then
|
||||
error "Mismatch between built images (${BUILT_COUNT}) and detected built images (${MATCHING_BUILT})."
|
||||
if [ ${#MISSING_FROM_DETECTED[@]} -gt 0 ]; then
|
||||
echo " Built but not detected: ${MISSING_FROM_DETECTED[*]}"
|
||||
fi
|
||||
if [ ${#NOT_BUILT_DETECTED[@]} -gt 0 ]; then
|
||||
echo " Detected but not built (external images): ${NOT_BUILT_DETECTED[*]}"
|
||||
fi
|
||||
echo "Aborting to avoid pushing incorrect images."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ${#IMAGES[@]} -eq 0 ]; then
|
||||
error "No images found in docker-compose.yml or built locally for project ${PROJECT_NAME}"
|
||||
fi
|
||||
|
||||
# Pre-pull unregistry image if needed
|
||||
UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry"
|
||||
if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then
|
||||
substep "Pulling unregistry image (one-time setup)..."
|
||||
ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Transfer images
|
||||
step "Transferring images"
|
||||
PUSH_FAILED=0
|
||||
for IMAGE in "${IMAGES[@]}"; do
|
||||
substep "Pushing ${CYAN}$IMAGE${NC}..."
|
||||
if docker pussh "$IMAGE" "$SERVER"; then
|
||||
echo ""
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Failed to push ${CYAN}$IMAGE${NC}"
|
||||
PUSH_FAILED=1
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $PUSH_FAILED -eq 1 ]; then
|
||||
error "Image transfer failed"
|
||||
fi
|
||||
|
||||
# Transfer files
|
||||
step "Transferring deployment files"
|
||||
|
||||
# Ensure destination directory exists with proper permissions
|
||||
if [ -n "$SUDO_PASSWORD" ]; then
|
||||
ssh "$SERVER" bash << REMOTE_SUDO_SCRIPT > /dev/null 2>&1
|
||||
set -e
|
||||
echo '$SUDO_PASSWORD' | sudo -S -p '' mkdir -p $DEPLOY_PATH/deployment 2>/dev/null || true
|
||||
echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment 2>/dev/null || true
|
||||
REMOTE_SUDO_SCRIPT
|
||||
else
|
||||
ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment && sudo chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Copy deployment directory excluding gitignored files
|
||||
cd "$PROJECT_ROOT"
|
||||
substep "Copying deployment directory..."
|
||||
|
||||
# Generate exclude file for rsync using git ls-files to list ignored files
|
||||
EXCLUDE_FILE="/tmp/fromchat-rsync-exclude-$$"
|
||||
RSYNC_ERROR="/tmp/fromchat-rsync-error-$$"
|
||||
|
||||
# Get ignored files in deployment directory and convert to rsync exclude patterns
|
||||
git ls-files --others --ignored --exclude-standard deployment/ 2>/dev/null | \
|
||||
sed 's|^deployment/||' > "$EXCLUDE_FILE" || true
|
||||
|
||||
# Use rsync with native --exclude-from option
|
||||
if rsync -avz --delete --exclude-from="$EXCLUDE_FILE" \
|
||||
"$DEPLOYMENT_DIR/" \
|
||||
"$SERVER:$DEPLOY_PATH/deployment/" > "$RSYNC_ERROR" 2>&1; then
|
||||
rm -f "$EXCLUDE_FILE" "$RSYNC_ERROR"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Rsync failed. Error output:"
|
||||
cat "$RSYNC_ERROR" | sed 's/^/ /'
|
||||
rm -f "$EXCLUDE_FILE" "$RSYNC_ERROR"
|
||||
echo -n " " && error "Failed to copy deployment directory"
|
||||
fi
|
||||
|
||||
# Copy .env.prod to .env on server (bypassing gitignore)
|
||||
if [ -f "$DEPLOYMENT_DIR/.env.prod" ]; then
|
||||
substep "Copying .env.prod to .env..."
|
||||
if ! scp "$DEPLOYMENT_DIR/.env.prod" "$SERVER:$DEPLOY_PATH/deployment/.env" > /dev/null 2>&1; then
|
||||
warning "Failed to copy .env.prod to .env"
|
||||
fi
|
||||
else
|
||||
warning ".env.prod not found in deployment directory"
|
||||
fi
|
||||
|
||||
# Deploy on server
|
||||
step "Deploying on server"
|
||||
ssh "$SERVER" SUDO_PASSWORD="$SUDO_PASSWORD" DEPLOY_PATH="$DEPLOY_PATH" bash << 'REMOTE_SCRIPT'
|
||||
set -e
|
||||
|
||||
REMOTE_SUDO_PASS="${SUDO_PASSWORD:-}"
|
||||
REMOTE_DEPLOY_PATH="${DEPLOY_PATH:-}"
|
||||
export SUDO_PROMPT=""
|
||||
|
||||
sudo_cmd() {
|
||||
if [ -n "$REMOTE_SUDO_PASS" ]; then
|
||||
echo "$REMOTE_SUDO_PASS" | sudo -S -p '' "$@" 2>/dev/null
|
||||
else
|
||||
sudo "$@" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "$REMOTE_DEPLOY_PATH" ]; then
|
||||
echo "❌ DEPLOY_PATH is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$REMOTE_DEPLOY_PATH/deployment"
|
||||
cd "$REMOTE_DEPLOY_PATH/deployment"
|
||||
|
||||
if [ ! -f "$REMOTE_DEPLOY_PATH/deployment/.env" ]; then
|
||||
echo "⚠️ Warning: .env file not found"
|
||||
fi
|
||||
|
||||
if systemctl is-active --quiet fromchat; then
|
||||
sudo_cmd systemctl stop fromchat
|
||||
fi
|
||||
|
||||
docker compose down > /dev/null 2>&1 || true
|
||||
|
||||
sudo_cmd cp -f "$REMOTE_DEPLOY_PATH/deployment/fromchat.service" /etc/systemd/system/fromchat.service
|
||||
sudo_cmd systemctl daemon-reload
|
||||
sudo_cmd systemctl restart fromchat
|
||||
|
||||
sleep 3
|
||||
if ! systemctl is-active --quiet fromchat; then
|
||||
echo "❌ Service failed to start"
|
||||
sudo_cmd journalctl --no-pager -xeu fromchat -n 30
|
||||
exit 1
|
||||
fi
|
||||
REMOTE_SCRIPT
|
||||
|
||||
echo
|
||||
success "Deployment complete!"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo > deployment/.env
|
||||
|
||||
./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env
|
||||
|
||||
cat >> deployment/.env <<EOF
|
||||
JWT_SECRET="$(openssl rand -base64 32)"
|
||||
TURN_USERNAME=<set>
|
||||
TURN_SECRET=<set>
|
||||
DEPLOYMENT_SERVER=<set>
|
||||
FIREBASE_CERT=<set>
|
||||
RELEASES_TOKEN=<set>
|
||||
EOF
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Don't use set -e here - we want to continue if Homebrew installation fails
|
||||
|
||||
# Install docker-pussh plugin for Docker CLI
|
||||
# This script installs the unregistry docker-pussh plugin
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "📦 Installing docker-pussh plugin..."
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker > /dev/null 2>&1; then
|
||||
echo "⚠️ Docker is not installed. Skipping docker-pussh installation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create docker plugins directory if it doesn't exist
|
||||
PLUGIN_DIR="$HOME/.docker/cli-plugins"
|
||||
mkdir -p "$PLUGIN_DIR"
|
||||
|
||||
# Check if already installed
|
||||
if [ -f "$PLUGIN_DIR/docker-pussh" ] && docker pussh --help > /dev/null 2>&1; then
|
||||
echo " ✓ docker-pussh is already installed"
|
||||
docker pussh --version 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Try installing via Homebrew first
|
||||
if command -v brew > /dev/null 2>&1; then
|
||||
echo " Attempting installation via Homebrew..."
|
||||
if brew install psviderski/tap/docker-pussh 2>/dev/null; then
|
||||
# Create symlink to use as Docker CLI plugin
|
||||
BREW_PREFIX=$(brew --prefix 2>/dev/null || echo "/opt/homebrew")
|
||||
if [ -f "$BREW_PREFIX/bin/docker-pussh" ]; then
|
||||
mkdir -p "$PLUGIN_DIR"
|
||||
ln -sf "$BREW_PREFIX/bin/docker-pussh" "$PLUGIN_DIR/docker-pussh" 2>/dev/null || true
|
||||
|
||||
# Verify installation
|
||||
if docker pussh --help > /dev/null 2>&1; then
|
||||
echo " ✓ docker-pussh installed successfully via Homebrew"
|
||||
docker pussh --version 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo " ⚠️ Homebrew installation failed or incomplete, trying direct download..."
|
||||
fi
|
||||
|
||||
# Fallback: Download and install docker-pussh directly (using latest from main branch)
|
||||
echo " Downloading docker-pussh from unregistry..."
|
||||
if curl -sSL https://raw.githubusercontent.com/psviderski/unregistry/main/docker-pussh \
|
||||
-o "$PLUGIN_DIR/docker-pussh" 2>/dev/null; then
|
||||
chmod +x "$PLUGIN_DIR/docker-pussh"
|
||||
|
||||
# Verify installation
|
||||
if docker pussh --help > /dev/null 2>&1; then
|
||||
echo " ✓ docker-pussh installed successfully"
|
||||
docker pussh --version 2>/dev/null || true
|
||||
else
|
||||
echo " ⚠️ Installation completed but plugin verification failed"
|
||||
echo " You may need to restart your terminal or Docker Desktop"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ Failed to download docker-pussh"
|
||||
echo " You can install it manually:"
|
||||
echo ""
|
||||
echo " Via Homebrew:"
|
||||
echo " brew install psviderski/tap/docker-pussh"
|
||||
echo " mkdir -p ~/.docker/cli-plugins"
|
||||
echo " ln -sf \$(brew --prefix)/bin/docker-pussh ~/.docker/cli-plugins/docker-pussh"
|
||||
echo ""
|
||||
echo " Or via direct download:"
|
||||
echo " mkdir -p ~/.docker/cli-plugins"
|
||||
echo " curl -sSL https://raw.githubusercontent.com/psviderski/unregistry/main/docker-pussh \\"
|
||||
echo " -o ~/.docker/cli-plugins/docker-pussh"
|
||||
echo " chmod +x ~/.docker/cli-plugins/docker-pussh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Transfer unregistry image from local machine to server
|
||||
# Usage: ./scripts/transfer:unregistry.sh [server_user@server_host]
|
||||
|
||||
set -e
|
||||
|
||||
SERVER="${1:-${DEPLOY_SERVER:-}}"
|
||||
|
||||
if [ -z "$SERVER" ]; then
|
||||
echo "❌ Server not specified. Usage: $0 [user@host]"
|
||||
echo " Or set DEPLOY_SERVER environment variable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 Transferring unregistry image to $SERVER..."
|
||||
|
||||
# Pull the image locally if not already present
|
||||
if ! docker images ghcr.io/psviderski/unregistry:0.3.1 --format "{{.Repository}}:{{.Tag}}" | grep -q "unregistry:0.3.1"; then
|
||||
echo " Pulling unregistry:0.3.1 locally..."
|
||||
docker pull ghcr.io/psviderski/unregistry:0.3.1
|
||||
fi
|
||||
|
||||
# Save and transfer the image
|
||||
echo " Saving and transferring image to server..."
|
||||
docker save ghcr.io/psviderski/unregistry:0.3.1 | ssh "$SERVER" "docker load"
|
||||
|
||||
echo " ✓ Unregistry image transferred successfully"
|
||||
echo ""
|
||||
echo " Now you can run on the server:"
|
||||
echo " docker run -d \\"
|
||||
echo " --name unregistry \\"
|
||||
echo " -p 5000:5000 \\"
|
||||
echo " -v /run/containerd/containerd.sock:/run/containerd/containerd.sock \\"
|
||||
echo " --restart unless-stopped \\"
|
||||
echo " ghcr.io/psviderski/unregistry:0.3.1"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export default {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
},
|
||||
outDir: "frontend/build/electron/forge",
|
||||
outDir: "build/electron/forge",
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
|
||||
import path from "path";
|
||||
import type { NotificationShowOptions } from '../electron.d.ts';
|
||||
import type { NotificationShowOptions } from "./electron.d.ts";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
@@ -23,7 +23,7 @@ app.whenReady().then(() => {
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile('frontend/build/electron/dist/index.html');
|
||||
mainWindow.loadFile('build/electron/dist/index.html');
|
||||
}
|
||||
|
||||
// Handle notification permission requests
|
||||
@@ -35,7 +35,7 @@ app.whenReady().then(() => {
|
||||
});
|
||||
|
||||
// Handle showing notifications
|
||||
ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => {
|
||||
ipcMain.handle("show-notification", async (_event, options: NotificationShowOptions) => {
|
||||
if (Notification.isSupported()) {
|
||||
try {
|
||||
const notification = new Notification({
|
||||
@@ -0,0 +1,11 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { ElectronInterface, NotificationShowOptions, Platform } from "./electron.d.ts";
|
||||
|
||||
contextBridge.exposeInMainWorld("electronInterface", {
|
||||
desktop: true,
|
||||
platform: process.platform as Platform,
|
||||
notifications: {
|
||||
requestPermission: () => ipcRenderer.invoke("request-notification-permission"),
|
||||
show: (options: NotificationShowOptions) => ipcRenderer.invoke("show-notification", options)
|
||||
}
|
||||
} satisfies ElectronInterface);
|
||||
@@ -4,10 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Loading...</title>
|
||||
<link rel="icon" href="./src/images/logo.svg" />
|
||||
<link rel="icon" href="./images/logo.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="src/main.tsx" type="module"></script>
|
||||
<script src="main/main.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { delay } from "./utils/utils";
|
||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||
const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
|
||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||
const PrivacyPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.PrivacyPage })));
|
||||
const TermsPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.TermsPage })));
|
||||
|
||||
const routeConfig: RouteObject[] = [
|
||||
{ path: "/", element: <HomePage /> },
|
||||
@@ -22,6 +24,8 @@ const routeConfig: RouteObject[] = [
|
||||
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
|
||||
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
|
||||
{ path: "/download-app", element: <DownloadAppPage /> },
|
||||
{ path: "/privacy", element: <PrivacyPage /> },
|
||||
{ path: "/terms", element: <TermsPage /> },
|
||||
{
|
||||
path: "/chat",
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -142,7 +142,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(currentPublicKey, token);
|
||||
await uploadPublicKey(pair.publicKey, token);
|
||||
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(newBlob), token);
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(currentPublicKey, token);
|
||||
await uploadPublicKey(pair.publicKey, token);
|
||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||
|
||||
@@ -170,45 +170,6 @@ export async function verifyUser(userId: number, token: string): Promise<{verifi
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cache for user similarity results
|
||||
* Key: userId, Value: similarity result
|
||||
*/
|
||||
const similarityCache = new Map<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)
|
||||
*/
|
||||
@@ -0,0 +1,323 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import { getCurrentKeys } from "../user/auth";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "../crypto/identity";
|
||||
import { fetchUsers, searchUsers } from "../user/search";
|
||||
import { deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||
import tweetnacl from "tweetnacl";
|
||||
|
||||
/**
|
||||
* Unwrap a MEK using the appropriate wrapping key for the current user
|
||||
*/
|
||||
export async function unwrapMek(wrappedMekB64: string, envelope: DmEnvelope, userId?: number): Promise<Uint8Array> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Determine context based on whether we're sender or recipient
|
||||
const currentUserId = userId || parseInt(localStorage.getItem('userId') || '0');
|
||||
const isRecipient = envelope.recipientId === currentUserId;
|
||||
const context = isRecipient ? "recipient_wrap_key" : "sender_wrap_key";
|
||||
|
||||
// Derive wrapping key from our public key
|
||||
const salt = new Uint8Array(16).fill(0); // 16 zero bytes salt
|
||||
const wrappingKeyRaw = await deriveWrappingKey(keys.publicKey, salt, new TextEncoder().encode(context));
|
||||
const wrappingKey = await importAesGcmKey(wrappingKeyRaw);
|
||||
|
||||
// Unwrap the MEK using AES-256-GCM
|
||||
const wrappedMekBytes = ub64(wrappedMekB64);
|
||||
const mekNonce = wrappedMekBytes.slice(0, 12);
|
||||
const mekCiphertext = wrappedMekBytes.slice(12);
|
||||
|
||||
return await aesGcmDecrypt(wrappingKey, mekNonce, mekCiphertext);
|
||||
}
|
||||
|
||||
export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<string> {
|
||||
try {
|
||||
// Use the wrapped MEK provided for this user
|
||||
const wrappedMekB64 = envelope.wrapped_mek_b64;
|
||||
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
|
||||
|
||||
// Unwrap the MEK using shared logic
|
||||
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
|
||||
|
||||
// Decrypt the message using the unwrapped MEK
|
||||
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
|
||||
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
|
||||
const messageKey = await importAesGcmKey(mek);
|
||||
const messageNonce = ub64(envelope.iv_b64 || "");
|
||||
const messageCiphertext = ub64(envelope.ciphertext_b64);
|
||||
|
||||
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
|
||||
const result = new TextDecoder().decode(plaintext);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to decrypt DM envelope:", error);
|
||||
console.error("Error details:", {
|
||||
envelope: envelope,
|
||||
userId: userId,
|
||||
localStorageUserId: localStorage.getItem('userId')
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return { messages: [], has_more: false };
|
||||
const data = await response.json();
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the transport public key from the server
|
||||
*/
|
||||
async function getTransportPublicKey(): Promise<string> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch transport key: HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.public_key_b64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt message using transport key (client-side only)
|
||||
*/
|
||||
function encryptWithTransportKey(plaintext: string, transportPublicKeyB64: string): { client_public_key_b64: string; nonce_b64: string; ciphertext_b64: string } {
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const transportPublicKeyBytes = new Uint8Array(
|
||||
atob(transportPublicKeyB64)
|
||||
.split("")
|
||||
.map((c: string) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
const nonce = tweetnacl.randomBytes(24);
|
||||
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||
|
||||
return {
|
||||
client_public_key_b64: btoa(String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])),
|
||||
nonce_b64: btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[])),
|
||||
ciphertext_b64: btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]))
|
||||
};
|
||||
}
|
||||
|
||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number, attachments?: Array<{name:string,path:string,wrapped_mek_b64?:string,nonce_b64?:string}>): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption only
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key (from current keys)
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send to server (server will handle envelope encryption)
|
||||
const bodyPayload: any = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId
|
||||
};
|
||||
if (attachments && attachments.length > 0) bodyPayload["files"] = attachments;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(bodyPayload)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function sendWithFiles(
|
||||
recipientId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
files: File[],
|
||||
plaintext: string,
|
||||
authToken: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error("No files provided");
|
||||
}
|
||||
|
||||
// Get transport key for encryption (shared across message + files)
|
||||
const transportKeyResponse = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!transportKeyResponse.ok) {
|
||||
throw new Error("Failed to get transport key");
|
||||
}
|
||||
const transportKeyData = await transportKeyResponse.json();
|
||||
const transportPublicKeyB64 = transportKeyData.public_key_b64;
|
||||
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const transportPublicKey = ub64(transportPublicKeyB64);
|
||||
|
||||
// One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob).
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const messagePlaintextBytes = new TextEncoder().encode(plaintext || "");
|
||||
const messageNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const messageCiphertext = tweetnacl.box(
|
||||
messagePlaintextBytes,
|
||||
messageNonce,
|
||||
transportPublicKey,
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const client_public_key_b64 = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||
);
|
||||
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageNonce) as number[]));
|
||||
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageCiphertext) as number[]));
|
||||
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Base64 encode helper (chunked)
|
||||
const uint8ToB64 = (uint8: Uint8Array): string => {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = "";
|
||||
for (let i = 0; i < uint8.length; i += CHUNK) {
|
||||
binary += String.fromCharCode.apply(null, Array.from(uint8.subarray(i, i + CHUNK)) as number[]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
// Transport-encrypt files; server will envelope-encrypt them with the SAME MEK as the message.
|
||||
const transport_files: Array<{ encrypted_file_data_b64: string; filename: string; file_size: number }> = [];
|
||||
for (const file of files) {
|
||||
const fileData = await file.arrayBuffer();
|
||||
const transportNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const transportEncrypted = tweetnacl.box(
|
||||
new Uint8Array(fileData),
|
||||
transportNonce,
|
||||
transportPublicKey,
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
|
||||
transportEncryptedWithNonce.set(transportNonce);
|
||||
transportEncryptedWithNonce.set(transportEncrypted, transportNonce.length);
|
||||
|
||||
transport_files.push({
|
||||
encrypted_file_data_b64: uint8ToB64(transportEncryptedWithNonce),
|
||||
filename: file.name,
|
||||
file_size: file.size
|
||||
});
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId,
|
||||
transport_files
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface ConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a DM as read
|
||||
*/
|
||||
export async function markRead(id: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmMarkRead",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id }
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMessage(
|
||||
messageId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
plaintext: string,
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Get transport key for initial encryption
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption (same as sending)
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send transport-encrypted data to the edit endpoint (it will handle envelope encryption)
|
||||
const editResponse = await fetch(`${API_BASE_URL}/dm/edit/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64
|
||||
})
|
||||
});
|
||||
|
||||
if (!editResponse.ok) throw new Error(`Failed to edit DM: HTTP ${editResponse.status}`);
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
@@ -0,0 +1,237 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
/**
|
||||
* Decrypt a DM envelope using client-side MEK unwrapping.
|
||||
* This delegates to the chats/dm module which has the updated implementation.
|
||||
*/
|
||||
export async function decryptDm(envelope: DmEnvelope): Promise<string> {
|
||||
// Import and use the updated implementation from chats/dm
|
||||
const { decrypt } = await import("./chats/dm");
|
||||
return decrypt(envelope);
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
/**
|
||||
* Send DM via WebSocket using transport encryption.
|
||||
* This delegates to the HTTP endpoint which handles envelope encryption on server.
|
||||
*/
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
// Import and use the updated implementation from chats/dm
|
||||
const { send } = await import("./chats/dm");
|
||||
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Envelope Encryption (Private DMs with compliance support)
|
||||
// ============================================================================
|
||||
|
||||
interface TransportKey {
|
||||
key_id: string;
|
||||
public_key_b64: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface TransportEncryptedMessage {
|
||||
client_public_key_b64: string;
|
||||
nonce_b64: string;
|
||||
ciphertext_b64: string;
|
||||
}
|
||||
|
||||
let cachedTransportKey: TransportKey | null = null;
|
||||
|
||||
/**
|
||||
* Fetch current transport public key from messaging service.
|
||||
* Caches result with validation.
|
||||
*/
|
||||
export async function getTransportPublicKey(): Promise<TransportKey> {
|
||||
if (cachedTransportKey) {
|
||||
return cachedTransportKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data: TransportKey = await response.json();
|
||||
cachedTransportKey = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transport public key:", error);
|
||||
}
|
||||
|
||||
throw new Error("Failed to fetch transport public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message using the transport public key (X25519 + ChaCha20).
|
||||
*/
|
||||
function encryptMessageWithTransportKey(
|
||||
plaintext: string | Uint8Array,
|
||||
transportPublicKeyB64: string
|
||||
): { nonce_b64: string; ciphertext_b64: string; client_public_key_b64: string } {
|
||||
const tweetnacl = require("tweetnacl");
|
||||
|
||||
// Convert plaintext to bytes if string
|
||||
const plaintextBytes = typeof plaintext === "string" ? new TextEncoder().encode(plaintext) : plaintext;
|
||||
|
||||
// Generate ephemeral keypair for this message
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
|
||||
// Decode transport public key
|
||||
const transportPublicKeyBytes = new Uint8Array(
|
||||
atob(transportPublicKeyB64)
|
||||
.split("")
|
||||
.map((c: string) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
// Perform ECDH (shared secret via tweetnacl's box)
|
||||
const nonce = tweetnacl.randomBytes(24);
|
||||
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||
|
||||
// Encode to base64
|
||||
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[]));
|
||||
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]));
|
||||
const client_public_key_b64 = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||
);
|
||||
|
||||
return { nonce_b64, ciphertext_b64, client_public_key_b64 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext with transport public key for sending to server.
|
||||
* Server will handle envelope encryption (MEK generation and wrapping).
|
||||
*/
|
||||
export async function encryptMessageForTransport(plaintext: string): Promise<TransportEncryptedMessage> {
|
||||
const transportKey = await getTransportPublicKey();
|
||||
return encryptMessageWithTransportKey(plaintext, transportKey.public_key_b64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an encrypted DM message using envelope encryption.
|
||||
* Client encrypts with transport key, server handles envelope encryption.
|
||||
*/
|
||||
export async function sendEncryptedDM(
|
||||
recipientId: number,
|
||||
plaintext: string,
|
||||
token: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Client-side transport encryption
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } =
|
||||
await encryptMessageForTransport(plaintext);
|
||||
|
||||
// Send to server
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(token, true)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
reply_to_id: replyToId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send encrypted DM:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encrypted conversation history with another user.
|
||||
*/
|
||||
export async function getEncryptedConversation(
|
||||
otherUserId: number,
|
||||
token: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const url = new URL(`${API_BASE_URL}/dm/conversation/${otherUserId}`);
|
||||
url.searchParams.append("limit", String(limit));
|
||||
url.searchParams.append("offset", String(offset));
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch encrypted conversation with user ${otherUserId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an encrypted message.
|
||||
*/
|
||||
export async function deleteEncryptedDM(messageId: number, token: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete encrypted DM ${messageId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached keys (useful on logout).
|
||||
*/
|
||||
export function clearCachedKeys(): void {
|
||||
cachedTransportKey = null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope): Promise<string> {
|
||||
const { decrypt } = await import("./chats/dm");
|
||||
return decrypt(envelope);
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const { send } = await import("./chats/dm");
|
||||
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user