mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 19:45:05 +03:00
Compare commits
6 Commits
@@ -1,51 +1,179 @@
|
|||||||
# Security Audit Command
|
# Security Audit Command
|
||||||
|
|
||||||
Perform a comprehensive security audit of the FromChat **Android application** only.
|
Perform a comprehensive security audit of the FromChat application codebase.
|
||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
**FromChat Android** is a 100% open source secure messaging mobile application built with:
|
**FromChat** is a 100% open source secure messaging application with:
|
||||||
|
|
||||||
- Kotlin Multiplatform (KMP) shared code
|
|
||||||
- Jetpack Compose UI framework
|
|
||||||
- End-to-End Encryption (NaCl, AES-GCM)
|
|
||||||
- WebSocket support for real-time features
|
|
||||||
- LiveKit integration for calls
|
|
||||||
- Local database storage (SQLite)
|
|
||||||
|
|
||||||
## Scope: Android Only
|
|
||||||
|
|
||||||
**OUT OF SCOPE:**
|
|
||||||
|
|
||||||
- Web backend (Python FastAPI, Caddy infrastructure)
|
|
||||||
- React/TypeScript frontend
|
- React/TypeScript frontend
|
||||||
|
- Python FastAPI backend
|
||||||
**IN SCOPE:**
|
- End-to-end encryption for DMs and calls
|
||||||
|
- Caddy reverse proxy with security headers
|
||||||
- Android app code (`app/android`, `app/shared/src/androidMain`)
|
- WebSocket support for real-time features
|
||||||
- Shared cross-platform code (`app/shared/src/commonMain`)
|
- Electron support for desktop app
|
||||||
- Local encryption implementation (NaCl, AES-GCM)
|
|
||||||
- Secure storage (Android Keystore, encrypted SharedPreferences)
|
|
||||||
- WebSocket client security
|
|
||||||
- Permission usage and handling
|
|
||||||
- Call security (LiveKit integration)
|
|
||||||
- Memory safety and injection attacks
|
|
||||||
- Backend
|
|
||||||
|
|
||||||
## Important Design Decisions (NOT Vulnerabilities)
|
## Important Design Decisions (NOT Vulnerabilities)
|
||||||
|
|
||||||
When auditing, remember these are **intentional design choices**:
|
When auditing, remember these are **intentional design choices**:
|
||||||
|
|
||||||
1. **Local message caching** - Messages downloaded and stored locally (by design)
|
1. **Public messages endpoint** - Open forum accessible without authentication (by design)
|
||||||
- Messages are end-to-end encrypted at rest in local DB
|
- The public chat is meant to be an open forum
|
||||||
- Public DMs are not encrypted (messages are public)
|
- Private DMs are properly E2E encrypted and require authentication
|
||||||
- Private DMs use NaCl encryption
|
|
||||||
- Cache persists across app restarts for offline access
|
2. **Public user list** - All users visible in DMs tab (by design)
|
||||||
2. **Local key storage** - Encryption keys stored on device (by design)
|
- Users can see all registered accounts
|
||||||
- Keys protected by Android Keystore (hardware-backed when available)
|
- This is intentional for a community-based chat app
|
||||||
- Encrypted with device-specific secrets
|
|
||||||
- User data never leaves device in plaintext
|
3. **XSS protection** - Multi-layer defense already implemented:
|
||||||
- Do NOT flag key storage as critical (Keystore is production-ready)
|
- 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
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
@@ -55,8 +183,8 @@ Provide a **clean, concise report** with:
|
|||||||
2. **Security Status** - Critical issues (if any) and recommendations
|
2. **Security Status** - Critical issues (if any) and recommendations
|
||||||
3. **Security Strengths** - What's done well
|
3. **Security Strengths** - What's done well
|
||||||
4. **Component Ratings** - Table format for quick reference
|
4. **Component Ratings** - Table format for quick reference
|
||||||
5. **Architecture Review** - Data flow, encryption boundaries
|
5. **Design Decisions** - Clarify what's intentional vs vulnerable
|
||||||
6. **Threat Analysis** - Current realistic threats (e.g., rooted device, malicious APK)
|
6. **Threat Analysis** - Current realistic threats only
|
||||||
7. **Recommendations** - Prioritized with time estimates
|
7. **Recommendations** - Prioritized with time estimates
|
||||||
8. **Conclusion** - Clear production readiness statement
|
8. **Conclusion** - Clear production readiness statement
|
||||||
|
|
||||||
@@ -65,8 +193,43 @@ Provide a **clean, concise report** with:
|
|||||||
## Common False Positives to Avoid
|
## Common False Positives to Avoid
|
||||||
|
|
||||||
❌ **DO NOT FLAG THESE AS ISSUES:**
|
❌ **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)
|
||||||
|
|
||||||
- Local message caching (intentional for offline access)
|
## Key Security Features to Verify
|
||||||
- Public message viewing without auth (intentional design)
|
|
||||||
- Debuggable APK (only relevant if signed/released)
|
✅ **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
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ When working with this project, follow these rules:
|
|||||||
## File Operations
|
## File Operations
|
||||||
- If possible, try to update files in a single edit when making multiple changes.
|
- If possible, try to update files in a single edit when making multiple changes.
|
||||||
- Do NOT "cd" to the project directory.
|
- 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
|
## Testing & Validation
|
||||||
- Do NOT "test the implementation" when you are done. The only exception is when you
|
- Do NOT "test the implementation" when you are done. The only exception is when you
|
||||||
@@ -33,7 +31,8 @@ When working with this project, follow these rules:
|
|||||||
- If the typecheck passed, there's no need for checking the linter errors.
|
- If the typecheck passed, there's no need for checking the linter errors.
|
||||||
|
|
||||||
## Async Operations
|
## 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
|
## Database
|
||||||
- NEVER create database migrations, they are auto-generated.
|
- NEVER create database migrations, they are auto-generated.
|
||||||
@@ -59,9 +58,4 @@ When working with this project, follow these rules:
|
|||||||
- Put SCSS into one folder per page
|
- Put SCSS into one folder per page
|
||||||
|
|
||||||
## Animations with Framer Motion
|
## 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>`
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
+9
-44
@@ -1,47 +1,12 @@
|
|||||||
# Shared build context ignore (used by deployment images with context: ..)
|
# Exclude data directory to prevent local database from being copied into production images
|
||||||
|
backend/data/
|
||||||
|
|
||||||
# VCS / editor
|
# Exclude logs
|
||||||
.git
|
backend/logs/
|
||||||
.github
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
.cursor
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Secrets / local env
|
# Exclude development files
|
||||||
.env
|
node_modules/
|
||||||
deployment/.env
|
.git/
|
||||||
|
.gitignore
|
||||||
# Node
|
README.md
|
||||||
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
|
*.log
|
||||||
|
|
||||||
# Deploy cache (hashes)
|
|
||||||
.deploy-cache
|
|
||||||
|
|
||||||
# Firebase cert: bind-mounted at runtime; do not send to docker build context
|
|
||||||
firebase-cert.json
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# HTTP API host.
|
|
||||||
# Example for local backend: http://localhost:8300
|
|
||||||
VITE_API_BASE_URL=http://localhost:8300
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 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
|
||||||
+1
-23
@@ -35,9 +35,6 @@ Temporary Items
|
|||||||
# iCloud generated files
|
# iCloud generated files
|
||||||
*.icloud
|
*.icloud
|
||||||
|
|
||||||
### FromChat local tools (downloaded LiveKit server binary) ###
|
|
||||||
.tools/
|
|
||||||
|
|
||||||
### Node ###
|
### Node ###
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -121,12 +118,6 @@ web_modules/
|
|||||||
.env.production.local
|
.env.production.local
|
||||||
.env.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/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
.parcel-cache
|
.parcel-cache
|
||||||
@@ -576,9 +567,7 @@ buck-out/
|
|||||||
|
|
||||||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
# 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/data
|
||||||
backend/files
|
|
||||||
.vite
|
.vite
|
||||||
*.db
|
*.db
|
||||||
package-lock.json
|
package-lock.json
|
||||||
@@ -587,15 +576,4 @@ backend/alembic/**
|
|||||||
!backend/alembic/script.py.mako
|
!backend/alembic/script.py.mako
|
||||||
!frontend/src/css/lib
|
!frontend/src/css/lib
|
||||||
**/*.module.scss.d.ts
|
**/*.module.scss.d.ts
|
||||||
|
.cursor/plans
|
||||||
.cursor/plans
|
|
||||||
tmp
|
|
||||||
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,11 +1,3 @@
|
|||||||
{
|
|
||||||
"npm.autoDetect": "off",
|
|
||||||
"files.exclude": {
|
|
||||||
".husky": true,
|
|
||||||
"build": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/__pycache__": true,
|
"**/__pycache__": true,
|
||||||
@@ -15,6 +7,5 @@
|
|||||||
"**/.venv": true,
|
"**/.venv": true,
|
||||||
"**/node_modules": true
|
"**/node_modules": true
|
||||||
},
|
},
|
||||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
|
||||||
"python.terminal.activateEnvironment": false
|
"python.terminal.activateEnvironment": false
|
||||||
}
|
}
|
||||||
Vendored
+47
-13
@@ -1,10 +1,28 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"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)",
|
"label": "Frontend (Web)",
|
||||||
"type": "shell",
|
"type": "npm",
|
||||||
"command": "npm run frontend:dev",
|
"script": "frontend:dev",
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
@@ -21,8 +39,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Frontend (Electron)",
|
"label": "Frontend (Electron)",
|
||||||
"type": "shell",
|
"type": "npm",
|
||||||
"command": "npm run frontend:electron:dev",
|
"script": "frontend:electron:dev",
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
@@ -40,7 +58,25 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
"label": "Web",
|
"label": "Web",
|
||||||
"dependsOn": ["Frontend (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)"],
|
||||||
"dependsOrder": "parallel",
|
"dependsOrder": "parallel",
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "build"
|
"kind": "build"
|
||||||
@@ -53,17 +89,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Electron",
|
"label": "Deploy",
|
||||||
"dependsOn": ["Frontend (Electron)"],
|
"type": "shell",
|
||||||
"dependsOrder": "parallel",
|
"command": "npm run deploy",
|
||||||
"group": {
|
|
||||||
"kind": "build"
|
|
||||||
},
|
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"echo": true,
|
"echo": true,
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"focus": false,
|
"focus": true,
|
||||||
"panel": "shared"
|
"panel": "dedicated",
|
||||||
|
"clear": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
GNU GENERAL PUBLIC LICENSE
|
||||||
Version 3, 19 November 2007
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
@@ -7,15 +7,17 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
The GNU General Public License is a free, copyleft license for
|
||||||
software and other kinds of works, specifically designed to ensure
|
software and other kinds of works.
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users.
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
|
|||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
To protect your rights, we need to prevent others from denying you
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
you this License which gives you legal permission to copy, distribute
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
and/or modify the software.
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
For example, if you distribute copies of such a program, whether
|
||||||
improvements made in alternate versions of the program, if they
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
receive widespread use, become available for other developers to
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
incorporate. Many developers of free software are heartened and
|
or can get the source code. And you must show them these terms so they
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
know their rights.
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
ensure that, in such cases, the modified source code becomes available
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
to the community. It requires the operator of a network server to
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
that there is no warranty for this free software. For both users' and
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
changed, so that their problems will not be attributed erroneously to
|
||||||
this license.
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@@ -60,7 +72,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
|
|||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU General Public License into a single
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the work with which it is combined will remain governed by version
|
but the special requirements of the GNU Affero General Public License,
|
||||||
3 of the GNU General Public License.
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
the GNU General Public License from time to time. Such new versions will
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
Program specifies that a certain numbered version of the GNU General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
GNU General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU Affero General Public License as published
|
it under the terms of the GNU General Public License as published by
|
||||||
by the Free Software Foundation, either version 3 of the License, or
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
If the program does terminal interaction, make it output a short
|
||||||
network, you should also make sure that it provides a way for users to
|
notice like this when it starts in an interactive mode:
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
<program> Copyright (C) <year> <name of author>
|
||||||
of the code. There are many ways you could offer source, and different
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
solutions will be better for different programs; see section 13 for the
|
This is free software, and you are welcome to redistribute it
|
||||||
specific requirements.
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
|
|||||||
-182
@@ -1,182 +0,0 @@
|
|||||||
# 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,117 +1,30 @@
|
|||||||
# FromChat Web Client — веб-приложение для обмена сообщениями
|
# FromChat
|
||||||
|
|
||||||
[Read in other languages: English](./README.en.md)
|
FromChat - полностью открытый мессенджер.
|
||||||
|
|
||||||
_Написано ИИ. Могут быть ошибки._
|
Его можно попробовать на [сайте](http://fromchat.ru).
|
||||||
|
|
||||||
## 📝 Описание
|
## Содержание:
|
||||||
|
- [Основные моменты](#highlights)
|
||||||
|
- [Использование](#usage)
|
||||||
|
- [Часто задаваемые вопросы](#faq)
|
||||||
|
- [Внос вклада](#contributing)
|
||||||
|
|
||||||
Веб-клиент FromChat — React/TypeScript приложение (браузер и Electron) для работы с сервером FromChat.
|
## Основные моменты
|
||||||
|
- Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд).
|
||||||
|
- 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя.
|
||||||
|
|
||||||
## Развернуть в 1 клик
|
## Использование
|
||||||
|
_В разработке._
|
||||||
|
|
||||||
```bash
|
## Часто задаваемые вопросы
|
||||||
docker run -d --restart always -p 8301:80 fromchat/web:latest
|
<!--
|
||||||
```
|
Вопрос: __Чему равно 2+2?__
|
||||||
|
Ответ: __4__
|
||||||
|
Вопрос: __Какая цитата Джейсона Стетхема на ваш взгляд является лучшей?__
|
||||||
|
Ответ: __"Одна ошибка, и ты ошибся."__
|
||||||
|
-->
|
||||||
|
_В разработке._
|
||||||
|
|
||||||
## ✨ Возможности
|
## Внос вклада
|
||||||
|
Внести свой вклад в разработку 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)
|
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 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 = 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 is set dynamically from DATABASE_URL environment variable
|
||||||
|
|
||||||
|
|
||||||
|
[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
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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 backend.shared.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()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""${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"}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
# Gateway doesn't need direct model access - it's a stateless proxy
|
||||||
|
# Gateway doesn't need constants - it's a stateless proxy
|
||||||
|
from backend.shared.utils import get_client_ip
|
||||||
|
|
||||||
|
# Gateway doesn't need database access - it's a stateless proxy
|
||||||
|
from backend.logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||||
|
from backend.security.audit import log_access
|
||||||
|
from backend.security.rate_limit import limiter
|
||||||
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
|
|
||||||
|
# Service URL mapping for routing
|
||||||
|
SERVICE_URLS = {
|
||||||
|
"account": os.getenv("ACCOUNT_SERVICE_URL", "http://account_service:8302"),
|
||||||
|
"profile": os.getenv("PROFILE_SERVICE_URL", "http://profile_service:8303"),
|
||||||
|
"devices": os.getenv("DEVICE_SERVICE_URL", "http://device_service:8304"),
|
||||||
|
"messaging": os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305"),
|
||||||
|
"push": os.getenv("PUSH_SERVICE_URL", "http://push_service:8306"),
|
||||||
|
"webrtc": os.getenv("WEBRTC_SERVICE_URL", "http://webrtc_service:8307"),
|
||||||
|
"moderation": os.getenv("MODERATION_SERVICE_URL", "http://moderation_service:8308"),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Gateway is a stateless proxy - no database operations or background tasks needed
|
||||||
|
logger.info("Gateway proxy service initialized - routing to microservices")
|
||||||
|
yield
|
||||||
|
logger.info("Gateway proxy service shutting down.")
|
||||||
|
|
||||||
|
# Инициализация 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=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Common API endpoints - route to appropriate services (defined first for priority)
|
||||||
|
@app.api_route("/login", methods=["POST"])
|
||||||
|
async def login(request: Request):
|
||||||
|
"""Login endpoint - routes to account service."""
|
||||||
|
return await _proxy_to_service("account", "login", request)
|
||||||
|
|
||||||
|
@app.api_route("/register", methods=["POST"])
|
||||||
|
async def register(request: Request):
|
||||||
|
"""Register endpoint - routes to account service."""
|
||||||
|
return await _proxy_to_service("account", "register", request)
|
||||||
|
|
||||||
|
|
||||||
|
# API routes - route to appropriate microservices
|
||||||
|
@app.api_route("/account/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def proxy_account(path: str, request: Request):
|
||||||
|
"""Proxy account service requests."""
|
||||||
|
return await _proxy_to_service("account", path, request)
|
||||||
|
|
||||||
|
@app.api_route("/profile/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def proxy_profile(path: str, request: Request):
|
||||||
|
"""Proxy profile service requests."""
|
||||||
|
return await _proxy_to_service("profile", path, request)
|
||||||
|
|
||||||
|
@app.api_route("/devices/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def proxy_devices(path: str, request: Request):
|
||||||
|
"""Proxy device service requests."""
|
||||||
|
return await _proxy_to_service("devices", path, request)
|
||||||
|
|
||||||
|
@app.api_route("/messaging/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def proxy_messaging(path: str, request: Request):
|
||||||
|
"""Proxy messaging service requests."""
|
||||||
|
return await _proxy_to_service("messaging", path, request)
|
||||||
|
|
||||||
|
@app.api_route("/push/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def proxy_push(path: str, request: Request):
|
||||||
|
"""Proxy push service requests."""
|
||||||
|
return await _proxy_to_service("push", path, request)
|
||||||
|
|
||||||
|
@app.api_route("/webrtc/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def proxy_webrtc(path: str, request: Request):
|
||||||
|
"""Proxy WebRTC service requests."""
|
||||||
|
return await _proxy_to_service("webrtc", path, request)
|
||||||
|
|
||||||
|
@app.api_route("/moderation/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
|
async def proxy_moderation(path: str, request: Request):
|
||||||
|
"""Proxy moderation service requests."""
|
||||||
|
return await _proxy_to_service("moderation", path, request)
|
||||||
|
|
||||||
|
async def _proxy_to_service(service: str, path: str, request: Request):
|
||||||
|
"""Helper function to proxy requests to microservices."""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
service_url = SERVICE_URLS[service]
|
||||||
|
target_url = f"{service_url}/{service}/{path}"
|
||||||
|
|
||||||
|
# Get request body
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
# Prepare headers (remove host header)
|
||||||
|
headers = dict(request.headers)
|
||||||
|
headers.pop("host", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
params=request.query_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return response with the same status code and content
|
||||||
|
content = response.content
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers={"content-type": response.headers.get("content-type", "application/json")}
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
logging.error(f"Error communicating with {service} service: {exc}")
|
||||||
|
raise HTTPException(status_code=503, detail=f"Service {service} unavailable")
|
||||||
|
|
||||||
|
# Routes are handled by the catch-all proxy above
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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")
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from backend.shared.constants import *
|
||||||
|
from backend.shared.db import *
|
||||||
|
from backend.shared.models import *
|
||||||
|
from backend.shared.validation import *
|
||||||
|
from backend.shared.utils import *
|
||||||
|
from backend.shared.dependencies import *
|
||||||
|
from app import *
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
"""
|
||||||
|
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 backend.shared.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.
|
||||||
|
"""
|
||||||
|
# Get the directory where this script is located
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
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 backend.shared.models import Base
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("All tables created successfully from models.")
|
||||||
|
|
||||||
|
# Note: Problematic migrations are now cleaned up during Docker build
|
||||||
|
|
||||||
|
# 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 no migration files exist after cleanup, create initial migration
|
||||||
|
if not migration_files:
|
||||||
|
logger.info("No migration files found after cleanup. Creating initial migration...")
|
||||||
|
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
|
||||||
|
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||||
|
logger.info(f"Created {len(migration_files)} initial migration(s)")
|
||||||
|
|
||||||
|
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:
|
||||||
|
error_msg = str(upgrade_error)
|
||||||
|
# Handle PostgreSQL "already exists" errors gracefully
|
||||||
|
if "already exists" in error_msg.lower() or "relation" in error_msg.lower() and "exists" in error_msg.lower():
|
||||||
|
pass
|
||||||
|
elif "Can't locate revision identified by 'direct_creation'" in error_msg:
|
||||||
|
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.")
|
||||||
|
# Note: Index-related errors are now prevented by Docker build cleanup
|
||||||
|
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 backend.shared.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 backend.shared.models import Base
|
||||||
|
from backend.shared.db import get_engine
|
||||||
|
from sqlalchemy import text, inspect
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
engine = get_engine(DATABASE_URL)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
# 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 INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING"))
|
||||||
|
else:
|
||||||
|
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
|
||||||
|
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()
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
PyJWT>=2.8.0
|
||||||
|
fastapi[standard]>=0.116.1
|
||||||
|
pydantic>=2.11.7
|
||||||
|
sqlalchemy>=2.0.43
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
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
|
||||||
|
PyNaCl>=1.5.0
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
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 backend.shared.constants import OWNER_USERNAME
|
||||||
|
from backend.shared.dependencies import get_current_user, get_db
|
||||||
|
from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup
|
||||||
|
from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||||
|
from backend.shared.validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||||
|
import os
|
||||||
|
|
||||||
|
from backend.security.audit import log_security
|
||||||
|
from backend.security.profanity import contains_profanity
|
||||||
|
from backend.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.is_online,
|
||||||
|
"username": user.username,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"profile_picture": user.avatar_url,
|
||||||
|
"bio": user.bio,
|
||||||
|
"admin": _is_admin(user),
|
||||||
|
"verified": user.verified,
|
||||||
|
"suspended": user.suspended,
|
||||||
|
"suspension_reason": user.suspension_reason,
|
||||||
|
"deleted": user.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.hashed_password):
|
||||||
|
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="Неверное имя пользователя или пароль"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate session ID for JWT (device session will be created on first device service access)
|
||||||
|
session_id = uuid.uuid4().hex
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Parse user agent for logging
|
||||||
|
ua = parse_ua(raw_ua or "")
|
||||||
|
device_type = "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"login_success",
|
||||||
|
username=user.username,
|
||||||
|
user_id=user.id,
|
||||||
|
ip=client_ip,
|
||||||
|
session_id=session_id,
|
||||||
|
device=device_type,
|
||||||
|
os=ua.os.family,
|
||||||
|
browser=ua.browser.family,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
salt="", # Not used since bcrypt includes salt in hash
|
||||||
|
is_online=True,
|
||||||
|
last_seen=datetime.now(),
|
||||||
|
verified=is_owner
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
|
||||||
|
# Generate a temporary session ID for the token (device session will be created on first device service access)
|
||||||
|
session_id = uuid.uuid4().hex
|
||||||
|
token = create_token(new_user.id, new_user.username, session_id)
|
||||||
|
|
||||||
|
# Parse user agent for logging
|
||||||
|
raw_ua = request.headers.get("user-agent")
|
||||||
|
ua = parse_ua(raw_ua or "")
|
||||||
|
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 backend.shared.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 - TODO: Move to device service
|
||||||
|
# from backend.shared.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=None, # TODO: Get session_id from device service
|
||||||
|
)
|
||||||
|
|
||||||
|
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.hashed_password):
|
||||||
|
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
|
||||||
|
|
||||||
|
# Update password hash to hash of new derived password
|
||||||
|
current_user.hashed_password = get_password_hash(password_request.newPasswordDerived.strip())
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Optionally revoke all other sessions, keeping the current one - TODO: Move to device service
|
||||||
|
# if password_request.logoutAllExceptCurrent:
|
||||||
|
# from backend.shared.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.hashed_password = ""
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.shared.dependencies import get_current_user, get_db
|
||||||
|
from backend.shared.models import User, DeviceSession
|
||||||
|
from backend.shared.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"}
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from backend.shared.constants import OWNER_USERNAME
|
||||||
|
from backend.shared.dependencies import get_current_user
|
||||||
|
from backend.shared.models import User
|
||||||
|
from backend.security.audit import log_security
|
||||||
|
from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||||
|
from backend.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"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
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 backend.shared.dependencies import get_db, get_current_user
|
||||||
|
from backend.shared.models import User, UpdateBioRequest, UserProfileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from backend.shared.validation import is_valid_username, is_valid_display_name
|
||||||
|
from backend.shared.similarity import is_user_similar_to_verified
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
from backend.security.audit import log_security
|
||||||
|
from backend.security.profanity import contains_profanity
|
||||||
|
from backend.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(__file__).resolve().parent.parent / "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:
|
||||||
|
messaging_service_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305")
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{messaging_service_url}/messaging/send-suspension",
|
||||||
|
json={
|
||||||
|
"user_id": user_id,
|
||||||
|
"reason": request.reason
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from backend.shared.dependencies import get_current_user, get_db
|
||||||
|
from backend.shared.models import User, PushSubscriptionRequest
|
||||||
|
from backend.services.push.files import push_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
class SendPublicNotificationRequest(BaseModel):
|
||||||
|
message_id: int
|
||||||
|
exclude_user_id: int
|
||||||
|
|
||||||
|
@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))
|
||||||
|
|
||||||
|
@router.post("/send-public-notification")
|
||||||
|
async def send_public_message_notification(
|
||||||
|
request: SendPublicNotificationRequest,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Send push notification for public message (called by messaging service)"""
|
||||||
|
try:
|
||||||
|
# Get the message from database
|
||||||
|
from backend.shared.models import Message
|
||||||
|
message = db.query(Message).filter(Message.id == request.message_id).first()
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=404, detail="Message not found")
|
||||||
|
|
||||||
|
await push_service.send_public_message_notification(db, message, exclude_user_id=request.exclude_user_id)
|
||||||
|
return {"status": "success"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from backend.shared.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
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Local development server that runs all services in a single FastAPI application.
|
||||||
|
This provides the same monolithic experience as before, but with microservice separation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import uvicorn
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Import service routers
|
||||||
|
from backend.routes.account import router as account_router
|
||||||
|
from backend.routes.profile import router as profile_router
|
||||||
|
from backend.routes.devices import router as device_router
|
||||||
|
from backend.routes.messaging import router as messaging_router
|
||||||
|
from backend.routes.push import router as push_router
|
||||||
|
from backend.routes.webrtc import router as webrtc_router
|
||||||
|
from backend.routes.moderation import router as moderation_router
|
||||||
|
|
||||||
|
# Import security modules
|
||||||
|
from security.audit import log_access
|
||||||
|
from security.rate_limit import limiter
|
||||||
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
|
|
||||||
|
# Create main FastAPI app
|
||||||
|
app = FastAPI(title="FromChat Local Development")
|
||||||
|
|
||||||
|
# Add rate limiting middleware
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
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=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount service routers with appropriate prefixes
|
||||||
|
app.include_router(account_router, prefix="/account")
|
||||||
|
app.include_router(profile_router, prefix="/profile")
|
||||||
|
app.include_router(device_router, prefix="/devices")
|
||||||
|
app.include_router(messaging_router, prefix="/messaging")
|
||||||
|
app.include_router(push_router, prefix="/push")
|
||||||
|
app.include_router(webrtc_router, prefix="/webrtc")
|
||||||
|
app.include_router(moderation_router, prefix="/moderation")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run the server
|
||||||
|
port = int(os.getenv("PORT", "8301"))
|
||||||
|
host = os.getenv("HOST", "127.0.0.1")
|
||||||
|
|
||||||
|
print(f"Starting FromChat local development server on {host}:{port}")
|
||||||
|
print("Available services:")
|
||||||
|
print(" - Account: http://127.0.0.1:8301/account/")
|
||||||
|
print(" - Profile: http://127.0.0.1:8301/profile/")
|
||||||
|
print(" - Devices: http://127.0.0.1:8301/devices/")
|
||||||
|
print(" - Messaging: http://127.0.0.1:8301/messaging/")
|
||||||
|
print(" - Push: http://127.0.0.1:8301/push/")
|
||||||
|
print(" - WebRTC: http://127.0.0.1:8301/webrtc/")
|
||||||
|
print(" - Moderation: http://127.0.0.1:8301/moderation/")
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"run_local:app",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
reload=True,
|
||||||
|
reload_dirs=["backend"]
|
||||||
|
)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Package marker for security utilities
|
||||||
|
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from html import unescape
|
||||||
|
from typing import Any, Callable, Dict, List
|
||||||
|
|
||||||
|
from backend.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)
|
||||||
|
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
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(__file__).resolve().parent.parent / "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
|
||||||
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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 backend.shared.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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Account Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/account.py /app/backend/routes/account.py
|
||||||
|
COPY backend/services/account/main.py /app/backend/services/account/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=account
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.account import router as account_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Account Service")
|
||||||
|
app.include_router(account_router, prefix="/account")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Device Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/devices.py /app/backend/routes/devices.py
|
||||||
|
COPY backend/services/device/main.py /app/backend/services/device/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=device
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.devices import router as device_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Device Service")
|
||||||
|
app.include_router(device_router, prefix="/devices")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Gateway Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/app.py /app/backend/app.py
|
||||||
|
COPY backend/main.py /app/backend/main.py
|
||||||
|
COPY backend/dependencies.py /app/backend/dependencies.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=gateway
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Gateway service - runs the main gateway app from backend/app.py
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from backend.app import app
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8300)))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Messaging Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
|
||||||
|
COPY backend/websocket /app/backend/websocket/
|
||||||
|
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=messaging
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from .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"]
|
||||||
|
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
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 backend.services.messaging.files.websocket.registry import WebSocketHandlerRegistry
|
||||||
|
from backend.routes.messaging import (
|
||||||
|
MessaggingSocketManager,
|
||||||
|
_send_message_internal,
|
||||||
|
_edit_message_internal,
|
||||||
|
get_messages,
|
||||||
|
edit_message,
|
||||||
|
delete_message,
|
||||||
|
add_reaction,
|
||||||
|
add_dm_reaction,
|
||||||
|
)
|
||||||
|
from backend.shared.models import (
|
||||||
|
User,
|
||||||
|
SendMessageRequest,
|
||||||
|
EditMessageRequest,
|
||||||
|
DMEnvelope,
|
||||||
|
ReactionRequest,
|
||||||
|
DMReactionRequest,
|
||||||
|
UpdateLog,
|
||||||
|
)
|
||||||
|
from backend.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
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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())
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from backend.shared.dependencies import get_current_user
|
||||||
|
from backend.shared.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
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.messaging import router as messaging_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Messaging Service")
|
||||||
|
app.include_router(messaging_router, prefix="/messaging")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Migration Runner Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy migration files
|
||||||
|
COPY backend/alembic /app/backend/alembic/
|
||||||
|
COPY backend/migration.py /app/backend/migration.py
|
||||||
|
|
||||||
|
# Copy migration runner
|
||||||
|
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=migration_runner
|
||||||
|
|
||||||
|
# Override entrypoint to run migrations
|
||||||
|
ENTRYPOINT ["python", "-m", "backend.services.migration_runner.main"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration runner service - executes database migrations and exits.
|
||||||
|
This service runs Alembic migrations against PostgreSQL and terminates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
def run_migrations():
|
||||||
|
"""Run Alembic migrations."""
|
||||||
|
print("Starting database migrations...")
|
||||||
|
|
||||||
|
# Change to backend directory to run migrations
|
||||||
|
backend_dir = Path(__file__).parent.parent.parent
|
||||||
|
os.chdir(backend_dir)
|
||||||
|
|
||||||
|
# Ensure shared models are imported for alembic
|
||||||
|
import backend.shared.models
|
||||||
|
|
||||||
|
# Set DATABASE_URL from environment if not set
|
||||||
|
db_url = os.getenv("DATABASE_URL")
|
||||||
|
if not db_url:
|
||||||
|
print("ERROR: DATABASE_URL environment variable not set")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Export DATABASE_URL for alembic
|
||||||
|
os.environ["DATABASE_URL"] = db_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the robust migration system from migration.py
|
||||||
|
# This handles all edge cases and recovery scenarios automatically
|
||||||
|
print("Starting database migrations...")
|
||||||
|
|
||||||
|
# Import and run the migration function
|
||||||
|
from backend.migration import run_migrations
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
print("Database migrations completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to run migrations: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_migrations()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Moderation Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
|
||||||
|
COPY backend/security/profanity.py /app/backend/security/profanity.py
|
||||||
|
COPY backend/similarity.py /app/backend/similarity.py
|
||||||
|
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=moderation
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.moderation import router as moderation_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Moderation Service")
|
||||||
|
app.include_router(moderation_router, prefix="/moderation")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Profile Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/profile.py /app/backend/routes/profile.py
|
||||||
|
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=profile
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.profile import router as profile_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Profile Service")
|
||||||
|
app.include_router(profile_router, prefix="/profile")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Push Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/push.py /app/backend/routes/push.py
|
||||||
|
COPY backend/push_service.py /app/backend/push_service.py
|
||||||
|
COPY backend/services/push/main.py /app/backend/services/push/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=push
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
from backend.shared.models import PushSubscription, User, Message, DMEnvelope, 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()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.push import router as push_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Push Service")
|
||||||
|
app.include_router(push_router, prefix="/push")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# WebRTC Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
|
||||||
|
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=webrtc
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.webrtc import router as webrtc_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="WebRTC Service")
|
||||||
|
app.include_router(webrtc_router, prefix="/webrtc")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Shared modules package
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/database.db")
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY = os.getenv("JWT_SECRET", "default-jwt-secret-for-development")
|
||||||
|
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 user
|
||||||
|
OWNER_USERNAME = os.getenv("OWNER_USERNAME", "owner")
|
||||||
|
|
||||||
|
# Push notifications
|
||||||
|
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "")
|
||||||
|
VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "")
|
||||||
|
VAPID_SUBJECT = os.getenv("VAPID_SUBJECT", "mailto:admin@example.com")
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||||
|
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
||||||
|
|
||||||
|
# File uploads
|
||||||
|
MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "10485760")) # 10MB
|
||||||
|
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".avi", ".mp3", ".wav"}
|
||||||
|
|
||||||
|
# WebSocket
|
||||||
|
WEBSOCKET_PING_INTERVAL = 30
|
||||||
|
WEBSOCKET_PING_TIMEOUT = 60
|
||||||
|
|
||||||
|
# Encryption
|
||||||
|
ENCRYPTION_KEY_LENGTH = 32
|
||||||
|
ENCRYPTION_NONCE_LENGTH = 12
|
||||||
|
|
||||||
|
# Moderation
|
||||||
|
PROFANITY_THRESHOLD = float(os.getenv("PROFANITY_THRESHOLD", "0.8"))
|
||||||
|
SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", "0.85"))
|
||||||
|
|
||||||
|
# WebRTC
|
||||||
|
WEBRTC_ICE_SERVERS = [
|
||||||
|
{"urls": "stun:stun.l.google.com:19302"},
|
||||||
|
{"urls": "stun:stun1.l.google.com:19302"}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||||
|
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import os
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from .constants import DATABASE_URL
|
||||||
|
|
||||||
|
# Database connection settings
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_engine_from_url(database_url: str):
|
||||||
|
"""Create SQLAlchemy engine from database URL."""
|
||||||
|
connect_args = {}
|
||||||
|
if database_url.startswith("sqlite"):
|
||||||
|
connect_args["check_same_thread"] = False
|
||||||
|
|
||||||
|
engine_kwargs = {
|
||||||
|
"pool_size": POOL_SIZE,
|
||||||
|
"max_overflow": MAX_OVERFLOW,
|
||||||
|
"pool_recycle": POOL_RECYCLE,
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
"pool_timeout": POOL_TIMEOUT,
|
||||||
|
}
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
database_url,
|
||||||
|
connect_args=connect_args,
|
||||||
|
**engine_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return engine
|
||||||
|
|
||||||
|
# Create engine - this should be called by each service with its own DATABASE_URL
|
||||||
|
def get_engine(database_url: str = None):
|
||||||
|
"""Get SQLAlchemy engine for the given database URL."""
|
||||||
|
url = database_url or DATABASE_URL
|
||||||
|
return create_engine_from_url(url)
|
||||||
|
|
||||||
|
# Session factory - create per service
|
||||||
|
def get_session_factory(database_url: str = None):
|
||||||
|
"""Get session factory for the given database URL."""
|
||||||
|
engine = get_engine(database_url)
|
||||||
|
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
# Dependency for FastAPI - create per service
|
||||||
|
def get_db(database_url: str = None):
|
||||||
|
"""FastAPI dependency to get database session."""
|
||||||
|
SessionLocal = get_session_factory(database_url)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from backend.shared.utils import verify_token
|
||||||
|
from backend.shared.models import User, DeviceSession
|
||||||
|
from backend.shared.db import get_session_factory
|
||||||
|
import logging
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
# Зависимость для получения сессии БД
|
||||||
|
SessionLocal = get_session_factory()
|
||||||
|
|
||||||
|
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 backend.shared.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
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float, JSON, BigInteger, UniqueConstraint
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
__table_args__ = {"schema": "account_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
salt = Column(String(64), nullable=False)
|
||||||
|
display_name = Column(String(100), nullable=True)
|
||||||
|
bio = Column(Text, nullable=True)
|
||||||
|
avatar_url = Column(String(255), nullable=True)
|
||||||
|
is_online = Column(Boolean, default=False)
|
||||||
|
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
verified = Column(Boolean, default=False)
|
||||||
|
verification_token = Column(String(255), nullable=True)
|
||||||
|
reset_token = Column(String(255), nullable=True)
|
||||||
|
reset_token_expires = Column(DateTime, nullable=True)
|
||||||
|
two_factor_enabled = Column(Boolean, default=False)
|
||||||
|
two_factor_secret = Column(String(255), nullable=True)
|
||||||
|
login_attempts = Column(Integer, default=0)
|
||||||
|
locked_until = Column(DateTime, nullable=True)
|
||||||
|
public_key = Column(Text, nullable=True)
|
||||||
|
private_key = Column(Text, nullable=True)
|
||||||
|
encryption_enabled = Column(Boolean, default=False)
|
||||||
|
suspended = Column(Boolean, default=False)
|
||||||
|
suspension_reason = Column(Text, nullable=True)
|
||||||
|
deleted = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
messages = relationship("Message", back_populates="sender", cascade="all, delete-orphan")
|
||||||
|
message_recipients = relationship("MessageRecipient", back_populates="recipient", cascade="all, delete-orphan")
|
||||||
|
devices = relationship("Device", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
content_type = Column(String(50), default="text")
|
||||||
|
encrypted_content = Column(Text, nullable=True)
|
||||||
|
signature = Column(Text, nullable=True)
|
||||||
|
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
edited_at = Column(DateTime, nullable=True)
|
||||||
|
edited = Column(Boolean, default=False)
|
||||||
|
deleted = Column(Boolean, default=False)
|
||||||
|
reply_to_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
||||||
|
thread_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
||||||
|
is_public = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
sender = relationship("User", back_populates="messages")
|
||||||
|
recipients = relationship("MessageRecipient", back_populates="message", cascade="all, delete-orphan")
|
||||||
|
reply_to = relationship("Message", remote_side=[id], foreign_keys=[reply_to_id])
|
||||||
|
thread = relationship("Message", remote_side=[id], foreign_keys=[thread_id])
|
||||||
|
reactions = relationship("MessageReaction", back_populates="message", cascade="all, delete-orphan")
|
||||||
|
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class MessageRecipient(Base):
|
||||||
|
__tablename__ = "message_recipients"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||||
|
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
read_at = Column(DateTime, nullable=True)
|
||||||
|
delivered_at = Column(DateTime, nullable=True)
|
||||||
|
encrypted_key = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
message = relationship("Message", back_populates="recipients")
|
||||||
|
recipient = relationship("User", back_populates="message_recipients")
|
||||||
|
|
||||||
|
class MessageReaction(Base):
|
||||||
|
__tablename__ = "message_reactions"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
reaction = Column(String(50), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
message = relationship("Message", back_populates="reactions")
|
||||||
|
|
||||||
|
class Device(Base):
|
||||||
|
__tablename__ = "devices"
|
||||||
|
__table_args__ = {"schema": "device_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
device_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
device_name = Column(String(255), nullable=True)
|
||||||
|
device_type = Column(String(50), nullable=True)
|
||||||
|
public_key = Column(Text, nullable=True)
|
||||||
|
signed_prekey = Column(Text, nullable=True)
|
||||||
|
one_time_prekeys = Column(JSON, nullable=True)
|
||||||
|
last_active = Column(DateTime, default=datetime.utcnow)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="devices")
|
||||||
|
push_subscriptions = relationship("PushSubscription", back_populates="device", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class PushSubscription(Base):
|
||||||
|
__tablename__ = "push_subscriptions"
|
||||||
|
__table_args__ = {"schema": "push_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
device_id = Column(BigInteger, ForeignKey("device_schema.devices.id"), nullable=True, index=True)
|
||||||
|
endpoint = Column(String(500), nullable=False)
|
||||||
|
p256dh = Column(String(255), nullable=False)
|
||||||
|
auth = Column(String(255), nullable=False)
|
||||||
|
user_agent = Column(String(500), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="push_subscriptions")
|
||||||
|
device = relationship("Device", back_populates="push_subscriptions")
|
||||||
|
|
||||||
|
class WebRTCSession(Base):
|
||||||
|
__tablename__ = "webrtc_sessions"
|
||||||
|
__table_args__ = {"schema": "webrtc_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
session_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
initiator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
participant_ids = Column(JSON, nullable=False)
|
||||||
|
offer = Column(JSON, nullable=True)
|
||||||
|
answer = Column(JSON, nullable=True)
|
||||||
|
ice_candidates = Column(JSON, nullable=True)
|
||||||
|
status = Column(String(50), default="pending")
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
class ModerationAction(Base):
|
||||||
|
__tablename__ = "moderation_actions"
|
||||||
|
__table_args__ = {"schema": "moderation_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
moderator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
target_user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=True)
|
||||||
|
target_message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
||||||
|
action_type = Column(String(50), nullable=False)
|
||||||
|
reason = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFile(Base):
|
||||||
|
__tablename__ = "message_file"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.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(BigInteger, ForeignKey("account_schema.users.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(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
|
||||||
|
blob_json = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DMEnvelope(Base):
|
||||||
|
__tablename__ = "dm_envelope"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.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"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
message_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
|
||||||
|
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
name = Column(Text, nullable=False)
|
||||||
|
path = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
message = relationship("DMEnvelope", back_populates="files")
|
||||||
|
|
||||||
|
|
||||||
|
class FcmToken(Base):
|
||||||
|
__tablename__ = "fcm_token"
|
||||||
|
__table_args__ = {"schema": "push_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.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"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.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"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
dm_envelope_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.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"
|
||||||
|
__table_args__ = {"schema": "device_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.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 models
|
||||||
|
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"
|
||||||
|
__table_args__ = {"schema": "public"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.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"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
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, ""
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
import re
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
import nacl.secret
|
||||||
|
import nacl.utils
|
||||||
|
from fastapi import Request
|
||||||
|
import jwt
|
||||||
|
import bcrypt
|
||||||
|
from backend.shared.constants import JWT_SECRET_KEY, JWT_ALGORITHM, MAX_TOKEN_LIFETIME_HOURS
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
def generate_secure_token(length: int = 32) -> str:
|
||||||
|
"""Generate a cryptographically secure random token."""
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
def hash_password(password: str, salt: Optional[bytes] = None) -> tuple[str, bytes]:
|
||||||
|
"""Hash a password with PBKDF2 and return (hash, salt)."""
|
||||||
|
if salt is None:
|
||||||
|
salt = secrets.token_bytes(32)
|
||||||
|
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt,
|
||||||
|
iterations=100000,
|
||||||
|
backend=default_backend()
|
||||||
|
)
|
||||||
|
|
||||||
|
key = kdf.derive(password.encode())
|
||||||
|
return base64.b64encode(key).decode(), salt
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str, salt: bytes) -> bool:
|
||||||
|
"""Verify a password against its hash and salt."""
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(hashed)
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt,
|
||||||
|
iterations=100000,
|
||||||
|
backend=default_backend()
|
||||||
|
)
|
||||||
|
kdf.verify(password.encode(), key)
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def generate_verification_token() -> str:
|
||||||
|
"""Generate a verification token for email verification."""
|
||||||
|
return generate_secure_token(64)
|
||||||
|
|
||||||
|
def generate_reset_token() -> str:
|
||||||
|
"""Generate a password reset token."""
|
||||||
|
return generate_secure_token(64)
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""Extract the real client IP from the request."""
|
||||||
|
# Check X-Forwarded-For header first
|
||||||
|
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded_for:
|
||||||
|
# Take the first IP in case of multiple proxies
|
||||||
|
client_ip = forwarded_for.split(",")[0].strip()
|
||||||
|
try:
|
||||||
|
# Validate IP address
|
||||||
|
ipaddress.ip_address(client_ip)
|
||||||
|
return client_ip
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check X-Real-IP header
|
||||||
|
real_ip = request.headers.get("X-Real-IP")
|
||||||
|
if real_ip:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(real_ip)
|
||||||
|
return real_ip
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to request.client.host
|
||||||
|
client_host = request.client.host if request.client else "unknown"
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(client_host)
|
||||||
|
return client_host
|
||||||
|
except ValueError:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def validate_email(email: str) -> bool:
|
||||||
|
"""Validate email address format."""
|
||||||
|
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||||
|
return re.match(pattern, email) is not None
|
||||||
|
|
||||||
|
def validate_username(username: str) -> bool:
|
||||||
|
"""Validate username format."""
|
||||||
|
if not username or len(username) < 3 or len(username) > 50:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Allow alphanumeric, underscore, and hyphen
|
||||||
|
pattern = r'^[a-zA-Z0-9_-]+$'
|
||||||
|
return re.match(pattern, username) is not None
|
||||||
|
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
"""Sanitize filename to prevent directory traversal."""
|
||||||
|
return re.sub(r'[^\w\.-]', '_', filename)
|
||||||
|
|
||||||
|
def generate_file_hash(content: bytes) -> str:
|
||||||
|
"""Generate SHA256 hash of file content."""
|
||||||
|
return hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
def encrypt_data(data: str, key: bytes) -> str:
|
||||||
|
"""Encrypt data using NaCl secret box."""
|
||||||
|
box = nacl.secret.SecretBox(key)
|
||||||
|
encrypted = box.encrypt(data.encode())
|
||||||
|
return base64.b64encode(encrypted).decode()
|
||||||
|
|
||||||
|
def decrypt_data(encrypted_data: str, key: bytes) -> str:
|
||||||
|
"""Decrypt data using NaCl secret box."""
|
||||||
|
box = nacl.secret.SecretBox(key)
|
||||||
|
encrypted = base64.b64decode(encrypted_data)
|
||||||
|
decrypted = box.decrypt(encrypted)
|
||||||
|
return decrypted.decode()
|
||||||
|
|
||||||
|
def generate_encryption_key() -> bytes:
|
||||||
|
"""Generate a new encryption key."""
|
||||||
|
return nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
|
||||||
|
|
||||||
|
def format_datetime(dt: datetime) -> str:
|
||||||
|
"""Format datetime for API responses."""
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
|
def parse_datetime(dt_str: str) -> Optional[datetime]:
|
||||||
|
"""Parse datetime from API requests."""
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calculate_age(birth_date: datetime) -> int:
|
||||||
|
"""Calculate age from birth date."""
|
||||||
|
today = datetime.now()
|
||||||
|
age = today.year - birth_date.year
|
||||||
|
if today.month < birth_date.month or (today.month == birth_date.month and today.day < birth_date.day):
|
||||||
|
age -= 1
|
||||||
|
return age
|
||||||
|
|
||||||
|
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
|
||||||
|
"""Truncate text to max length with suffix."""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
return text[:max_length - len(suffix)] + suffix
|
||||||
|
|
||||||
|
def is_valid_url(url: str) -> bool:
|
||||||
|
"""Validate URL format."""
|
||||||
|
pattern = r'^https?://[^\s/$.?#].[^\s]*$'
|
||||||
|
return re.match(pattern, url) is not None
|
||||||
|
|
||||||
|
def generate_device_id() -> str:
|
||||||
|
"""Generate a unique device identifier."""
|
||||||
|
return generate_secure_token(32)
|
||||||
|
|
||||||
|
def normalize_phone_number(phone: str) -> str:
|
||||||
|
"""Normalize phone number format."""
|
||||||
|
# Remove all non-digit characters except +
|
||||||
|
normalized = re.sub(r'[^\d+]', '', phone)
|
||||||
|
|
||||||
|
# Ensure it starts with +
|
||||||
|
if not normalized.startswith('+'):
|
||||||
|
if normalized.startswith('00'):
|
||||||
|
normalized = '+' + normalized[2:]
|
||||||
|
else:
|
||||||
|
normalized = '+' + normalized
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
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 get_password_hash(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
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 _is_admin(user) -> bool:
|
||||||
|
return user.id == 1
|
||||||
|
|
||||||
|
|
||||||
|
def convert_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
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, validator
|
||||||
|
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
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=50)
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
display_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
|
||||||
|
@validator('username')
|
||||||
|
def username_alphanumeric(cls, v):
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
|
||||||
|
raise ValueError('Username must be alphanumeric with underscores or hyphens')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('password')
|
||||||
|
def password_strength(cls, v):
|
||||||
|
if not re.search(r'[A-Z]', v):
|
||||||
|
raise ValueError('Password must contain at least one uppercase letter')
|
||||||
|
if not re.search(r'[a-z]', v):
|
||||||
|
raise ValueError('Password must contain at least one lowercase letter')
|
||||||
|
if not re.search(r'\d', v):
|
||||||
|
raise ValueError('Password must contain at least one digit')
|
||||||
|
return v
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
username_or_email: str = Field(min_length=1, max_length=100)
|
||||||
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
display_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
bio: Optional[str] = Field(None, max_length=500)
|
||||||
|
avatar_url: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
|
@validator('avatar_url')
|
||||||
|
def validate_avatar_url(cls, v):
|
||||||
|
if v and not v.startswith(('http://', 'https://')):
|
||||||
|
raise ValueError('Avatar URL must be a valid HTTP/HTTPS URL')
|
||||||
|
return v
|
||||||
|
|
||||||
|
class MessageCreate(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=10000)
|
||||||
|
content_type: str = Field(default="text", pattern=r'^(text|image|video|audio|file)$')
|
||||||
|
reply_to_id: Optional[int] = None
|
||||||
|
recipient_ids: list[int] = Field(min_items=1, max_items=100)
|
||||||
|
|
||||||
|
class MessageUpdate(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
class DeviceRegister(BaseModel):
|
||||||
|
device_id: str = Field(min_length=1, max_length=255)
|
||||||
|
device_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
device_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
public_key: Optional[str] = Field(None, max_length=10000)
|
||||||
|
|
||||||
|
class PushSubscriptionCreate(BaseModel):
|
||||||
|
endpoint: str = Field(max_length=500)
|
||||||
|
p256dh: str = Field(max_length=255)
|
||||||
|
auth: str = Field(max_length=255)
|
||||||
|
device_id: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
|
class WebRTCOffer(BaseModel):
|
||||||
|
offer: dict
|
||||||
|
participant_ids: list[int] = Field(min_items=1, max_items=10)
|
||||||
|
|
||||||
|
class WebRTCAnswer(BaseModel):
|
||||||
|
answer: dict
|
||||||
|
session_id: str = Field(max_length=255)
|
||||||
|
|
||||||
|
class WebRTCIceCandidate(BaseModel):
|
||||||
|
candidate: dict
|
||||||
|
session_id: str = Field(max_length=255)
|
||||||
|
|
||||||
|
class ModerationActionCreate(BaseModel):
|
||||||
|
target_user_id: Optional[int] = None
|
||||||
|
target_message_id: Optional[int] = None
|
||||||
|
action_type: str = Field(pattern=r'^(ban|mute|delete|warn)$')
|
||||||
|
reason: Optional[str] = Field(None, max_length=1000)
|
||||||
|
duration_hours: Optional[int] = Field(None, gt=0, le=8760) # Max 1 year
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
class PasswordReset(BaseModel):
|
||||||
|
token: str = Field(min_length=64, max_length=64)
|
||||||
|
new_password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
@validator('new_password')
|
||||||
|
def password_strength(cls, v):
|
||||||
|
if not re.search(r'[A-Z]', v):
|
||||||
|
raise ValueError('Password must contain at least one uppercase letter')
|
||||||
|
if not re.search(r'[a-z]', v):
|
||||||
|
raise ValueError('Password must contain at least one lowercase letter')
|
||||||
|
if not re.search(r'\d', v):
|
||||||
|
raise ValueError('Password must contain at least one digit')
|
||||||
|
return v
|
||||||
|
|
||||||
|
class TwoFactorSetup(BaseModel):
|
||||||
|
code: str = Field(pattern=r'^\d{6}$')
|
||||||
|
|
||||||
|
class TwoFactorVerify(BaseModel):
|
||||||
|
code: str = Field(pattern=r'^\d{6}$')
|
||||||
|
|
||||||
|
class EmailVerification(BaseModel):
|
||||||
|
token: str = Field(min_length=64, max_length=64)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getgadgets.toolbox-io.ru {
|
||||||
|
reverse_proxy 95.165.0.162:8400
|
||||||
|
}
|
||||||
|
|
||||||
|
getgadgets.toolbox-io.ru {
|
||||||
|
reverse_proxy 95.165.0.162:8401
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- Initialize database roles and schemas for FromChat microservices
|
||||||
|
-- This script creates dedicated users with limited privileges for each service
|
||||||
|
|
||||||
|
-- Create service-specific database roles with limited privileges
|
||||||
|
-- All services use the same password
|
||||||
|
CREATE ROLE account_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE profile_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE device_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE messaging_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE push_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE webrtc_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE moderation_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE gateway_user LOGIN PASSWORD 'development';
|
||||||
|
|
||||||
|
-- Create dedicated schemas for each service
|
||||||
|
CREATE SCHEMA IF NOT EXISTS account_schema AUTHORIZATION account_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS profile_schema AUTHORIZATION profile_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS device_schema AUTHORIZATION device_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS messaging_schema AUTHORIZATION messaging_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS push_schema AUTHORIZATION push_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS webrtc_schema AUTHORIZATION webrtc_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS moderation_schema AUTHORIZATION moderation_service_user;
|
||||||
|
|
||||||
|
-- Grant basic connection privileges
|
||||||
|
GRANT CONNECT ON DATABASE fromchat TO account_service_user, profile_service_user, device_service_user, messaging_service_user, push_service_user, webrtc_service_user, moderation_service_user;
|
||||||
|
|
||||||
|
-- Grant schema-level privileges (limited to each service's schema)
|
||||||
|
-- Account service
|
||||||
|
GRANT USAGE ON SCHEMA account_schema TO account_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA account_schema TO account_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA account_schema TO account_service_user;
|
||||||
|
|
||||||
|
-- Profile service
|
||||||
|
GRANT USAGE ON SCHEMA profile_schema TO profile_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA profile_schema TO profile_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA profile_schema TO profile_service_user;
|
||||||
|
|
||||||
|
-- Device service
|
||||||
|
GRANT USAGE ON SCHEMA device_schema TO device_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA device_schema TO device_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA device_schema TO device_service_user;
|
||||||
|
|
||||||
|
-- Messaging service
|
||||||
|
GRANT USAGE ON SCHEMA messaging_schema TO messaging_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA messaging_schema TO messaging_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA messaging_schema TO messaging_service_user;
|
||||||
|
|
||||||
|
-- Push service
|
||||||
|
GRANT USAGE ON SCHEMA push_schema TO push_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA push_schema TO push_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA push_schema TO push_service_user;
|
||||||
|
|
||||||
|
-- WebRTC service
|
||||||
|
GRANT USAGE ON SCHEMA webrtc_schema TO webrtc_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA webrtc_schema TO webrtc_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA webrtc_schema TO webrtc_service_user;
|
||||||
|
|
||||||
|
-- Moderation service
|
||||||
|
GRANT USAGE ON SCHEMA moderation_schema TO moderation_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA moderation_schema TO moderation_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA moderation_schema TO moderation_service_user;
|
||||||
|
|
||||||
|
-- Set default privileges for future objects
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA account_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO account_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA profile_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO profile_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA device_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO device_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA messaging_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO messaging_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA push_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO push_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA webrtc_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO webrtc_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA moderation_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO moderation_service_user;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
-- Create tables for FromChat microservices
|
||||||
|
-- This script creates the necessary tables in their respective schemas
|
||||||
|
|
||||||
|
-- Note: In production, tables will be created by Alembic migrations
|
||||||
|
-- This script provides a fallback or reference for manual setup
|
||||||
|
|
||||||
|
-- Account schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS account_schema.users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
hashed_password VARCHAR(255) NOT NULL,
|
||||||
|
salt VARCHAR(64) NOT NULL,
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
bio TEXT,
|
||||||
|
avatar_url VARCHAR(255),
|
||||||
|
is_online BOOLEAN DEFAULT FALSE,
|
||||||
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
verified BOOLEAN DEFAULT FALSE,
|
||||||
|
verification_token VARCHAR(255),
|
||||||
|
reset_token VARCHAR(255),
|
||||||
|
reset_token_expires TIMESTAMP,
|
||||||
|
two_factor_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
two_factor_secret VARCHAR(255),
|
||||||
|
login_attempts INTEGER DEFAULT 0,
|
||||||
|
locked_until TIMESTAMP,
|
||||||
|
public_key TEXT,
|
||||||
|
private_key TEXT,
|
||||||
|
encryption_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
suspended BOOLEAN DEFAULT FALSE,
|
||||||
|
suspension_reason TEXT,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Profile schema tables (references account_schema.users)
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_schema.user_profiles (
|
||||||
|
user_id BIGINT PRIMARY KEY REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
bio TEXT,
|
||||||
|
avatar_url VARCHAR(255),
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Device schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS device_schema.devices (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
device_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
device_name VARCHAR(255),
|
||||||
|
device_type VARCHAR(50),
|
||||||
|
public_key TEXT,
|
||||||
|
signed_prekey TEXT,
|
||||||
|
one_time_prekeys JSONB,
|
||||||
|
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS device_schema.device_session (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
raw_user_agent TEXT,
|
||||||
|
device_name VARCHAR(128),
|
||||||
|
device_type VARCHAR(32),
|
||||||
|
os_name VARCHAR(64),
|
||||||
|
os_version VARCHAR(64),
|
||||||
|
browser_name VARCHAR(64),
|
||||||
|
browser_version VARCHAR(64),
|
||||||
|
brand VARCHAR(64),
|
||||||
|
model VARCHAR(64),
|
||||||
|
session_id VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revoked BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Messaging schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS messaging_schema.messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
sender_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
content_type VARCHAR(50) DEFAULT 'text',
|
||||||
|
encrypted_content TEXT,
|
||||||
|
signature TEXT,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
edited_at TIMESTAMP,
|
||||||
|
edited BOOLEAN DEFAULT FALSE,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE,
|
||||||
|
reply_to_id BIGINT REFERENCES messaging_schema.messages(id),
|
||||||
|
thread_id BIGINT REFERENCES messaging_schema.messages(id),
|
||||||
|
is_public BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messaging_schema.message_recipients (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
|
||||||
|
recipient_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMP,
|
||||||
|
delivered_at TIMESTAMP,
|
||||||
|
encrypted_key TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messaging_schema.message_reactions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
reaction VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Push schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS push_schema.push_subscriptions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
device_id BIGINT REFERENCES device_schema.devices(id),
|
||||||
|
endpoint VARCHAR(500) NOT NULL,
|
||||||
|
p256dh VARCHAR(255) NOT NULL,
|
||||||
|
auth VARCHAR(255) NOT NULL,
|
||||||
|
user_agent VARCHAR(500),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- WebRTC schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS webrtc_schema.webrtc_sessions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
initiator_id BIGINT REFERENCES account_schema.users(id),
|
||||||
|
participant_ids JSONB NOT NULL,
|
||||||
|
offer JSONB,
|
||||||
|
answer JSONB,
|
||||||
|
ice_candidates JSONB,
|
||||||
|
status VARCHAR(50) DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Moderation schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS moderation_schema.moderation_actions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
moderator_id BIGINT REFERENCES account_schema.users(id),
|
||||||
|
target_user_id BIGINT REFERENCES account_schema.users(id),
|
||||||
|
target_message_id BIGINT REFERENCES messaging_schema.messages(id),
|
||||||
|
action_type VARCHAR(50) NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grant permissions on sequences (after all tables are created)
|
||||||
|
GRANT USAGE ON SEQUENCE account_schema.users_id_seq TO account_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE device_schema.devices_id_seq TO device_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE device_schema.device_session_id_seq TO account_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE messaging_schema.messages_id_seq TO messaging_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE messaging_schema.message_recipients_id_seq TO messaging_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE messaging_schema.message_reactions_id_seq TO messaging_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE push_schema.push_subscriptions_id_seq TO push_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE webrtc_schema.webrtc_sessions_id_seq TO webrtc_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE moderation_schema.moderation_actions_id_seq TO moderation_service_user;
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
services:
|
||||||
|
# Database service
|
||||||
|
database:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: fromchat
|
||||||
|
POSTGRES_USER: fromchat_admin
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- database:/var/lib/postgresql/data
|
||||||
|
- ./db-init:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U fromchat_admin -d fromchat"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Migration runner - runs once before other services
|
||||||
|
migration_runner:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.multi
|
||||||
|
target: migration_runner
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD}@database:5432/fromchat
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: backend/alembic.ini
|
||||||
|
target: /app/backend/alembic.ini
|
||||||
|
- action: sync
|
||||||
|
path: backend/alembic
|
||||||
|
target: /app/backend/alembic
|
||||||
|
- action: sync
|
||||||
|
path: backend/migration.py
|
||||||
|
target: /app/backend/migration.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/services/migration_runner
|
||||||
|
target: /app/backend/services/migration_runner
|
||||||
|
- action: sync+restart
|
||||||
|
path: backend/shared
|
||||||
|
target: /app/backend/shared
|
||||||
|
|
||||||
|
# Gateway service - handles complex operations
|
||||||
|
gateway:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.multi
|
||||||
|
target: gateway
|
||||||
|
ports: ["8300:8300"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD}@database:5432/fromchat
|
||||||
|
PORT: 8300
|
||||||
|
ACCOUNT_SERVICE_URL: http://account_service:8302
|
||||||
|
PROFILE_SERVICE_URL: http://profile_service:8303
|
||||||
|
DEVICE_SERVICE_URL: http://device_service:8304
|
||||||
|
MESSAGING_SERVICE_URL: http://messaging_service:8305
|
||||||
|
PUSH_SERVICE_URL: http://push_service:8306
|
||||||
|
WEBRTC_SERVICE_URL: http://webrtc_service:8307
|
||||||
|
MODERATION_SERVICE_URL: http://moderation_service:8308
|
||||||
|
depends_on:
|
||||||
|
migration_runner:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
- fromchat_external
|
||||||
|
restart: unless-stopped
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: backend/app.py
|
||||||
|
target: /app/backend/app.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/main.py
|
||||||
|
target: /app/backend/main.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/dependencies.py
|
||||||
|
target: /app/backend/dependencies.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/security
|
||||||
|
target: /app/backend/security
|
||||||
|
- action: sync
|
||||||
|
path: backend/services/gateway
|
||||||
|
target: /app/backend/services/gateway
|
||||||
|
- action: sync+restart
|
||||||
|
path: backend/shared
|
||||||
|
target: /app/backend/shared
|
||||||
|
|
||||||
|
# Account service
|
||||||
|
account_service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.multi
|
||||||
|
target: account_service
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://account_service_user:${DB_PASSWORD}@database:5432/fromchat
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
PORT: 8302
|
||||||
|
depends_on:
|
||||||
|
migration_runner:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: backend/routes/account.py
|
||||||
|
target: /app/backend/routes/account.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/services/account
|
||||||
|
target: /app/backend/services/account
|
||||||
|
- action: sync+restart
|
||||||
|
path: backend/shared
|
||||||
|
target: /app/backend/shared
|
||||||
|
|
||||||
|
# Profile service
|
||||||
|
profile_service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.multi
|
||||||
|
target: profile_service
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://profile_service_user:${DB_PASSWORD}@database:5432/fromchat
|
||||||
|
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||||
|
MESSAGING_SERVICE_URL: http://messaging_service:8305
|
||||||
|
PORT: 8303
|
||||||
|
depends_on:
|
||||||
|
migration_runner:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: backend/routes/profile.py
|
||||||
|
target: /app/backend/routes/profile.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/services/profile
|
||||||
|
target: /app/backend/services/profile
|
||||||
|
- action: sync+restart
|
||||||
|
path: backend/shared
|
||||||
|
target: /app/backend/shared
|
||||||
|
|
||||||
|
# Device service
|
||||||
|
device_service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.multi
|
||||||
|
target: device_service
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://device_service_user:${DB_PASSWORD}@database:5432/fromchat
|
||||||
|
PORT: 8304
|
||||||
|
depends_on:
|
||||||
|
migration_runner:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: backend/routes/devices.py
|
||||||
|
target: /app/backend/routes/devices.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/services/device
|
||||||
|
target: /app/backend/services/device
|
||||||
|
- action: sync+restart
|
||||||
|
path: backend/shared
|
||||||
|
target: /app/backend/shared
|
||||||
|
|
||||||
|
# Messaging service
|
||||||
|
messaging_service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.multi
|
||||||
|
target: messaging_service
|
||||||
|
ports:
|
||||||
|
- "8305:8305"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD}@database:5432/fromchat
|
||||||
|
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||||
|
PUSH_SERVICE_URL: http://push_service:8306
|
||||||
|
PORT: 8305
|
||||||
|
depends_on:
|
||||||
|
migration_runner:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: backend/routes/messaging.py
|
||||||
|
target: /app/backend/routes/messaging.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/websocket
|
||||||
|
target: /app/backend/websocket
|
||||||
|
- action: sync
|
||||||
|
path: backend/services/messaging
|
||||||
|
target: /app/backend/services/messaging
|
||||||
|
- action: sync+restart
|
||||||
|
path: backend/shared
|
||||||
|
target: /app/backend/shared
|
||||||
|
|
||||||
|
# Push service
|
||||||
|
push_service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.multi
|
||||||
|
target: push_service
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://push_service_user:${DB_PASSWORD}@database:5432/fromchat
|
||||||
|
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||||
|
PORT: 8306
|
||||||
|
depends_on:
|
||||||
|
migration_runner:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: backend/routes/push.py
|
||||||
|
target: /app/backend/routes/push.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/push_service.py
|
||||||
|
target: /app/backend/push_service.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/services/push
|
||||||
|
target: /app/backend/services/push
|
||||||
|
- action: sync+restart
|
||||||
|
path: backend/shared
|
||||||
|
target: /app/backend/shared
|
||||||
|
|
||||||
|
# WebRTC service
|
||||||
|
webrtc_service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.multi
|
||||||
|
target: webrtc_service
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://webrtc_service_user:${DB_PASSWORD}@database:5432/fromchat
|
||||||
|
PORT: 8307
|
||||||
|
depends_on:
|
||||||
|
migration_runner:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: backend/routes/webrtc.py
|
||||||
|
target: /app/backend/routes/webrtc.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/services/webrtc
|
||||||
|
target: /app/backend/services/webrtc
|
||||||
|
- action: sync+restart
|
||||||
|
path: backend/shared
|
||||||
|
target: /app/backend/shared
|
||||||
|
|
||||||
|
# Moderation service
|
||||||
|
moderation_service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.multi
|
||||||
|
target: moderation_service
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://moderation_service_user:${DB_PASSWORD}@database:5432/fromchat
|
||||||
|
PORT: 8308
|
||||||
|
depends_on:
|
||||||
|
migration_runner:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- fromchat_internal
|
||||||
|
restart: unless-stopped
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: backend/routes/moderation.py
|
||||||
|
target: /app/backend/routes/moderation.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/security
|
||||||
|
target: /app/backend/security
|
||||||
|
- action: sync
|
||||||
|
path: backend/similarity.py
|
||||||
|
target: /app/backend/similarity.py
|
||||||
|
- action: sync
|
||||||
|
path: backend/services/moderation
|
||||||
|
target: /app/backend/services/moderation
|
||||||
|
- action: sync+restart
|
||||||
|
path: backend/shared
|
||||||
|
target: /app/backend/shared
|
||||||
|
|
||||||
|
# Caddy reverse proxy
|
||||||
|
caddy:
|
||||||
|
build:
|
||||||
|
context: ./caddy
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- certs:/root/site/certs
|
||||||
|
networks:
|
||||||
|
- fromchat_external
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
- XDG_DATA_HOME=/root/site/certs
|
||||||
|
- XDG_CONFIG_HOME=/root/site/certs
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- gateway
|
||||||
|
profiles: ["prod"]
|
||||||
|
|
||||||
|
# Frontend service - serves the React app
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deployment/frontend/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8301:8301"
|
||||||
|
environment:
|
||||||
|
- PORT=8301
|
||||||
|
- BACKEND_HOST=http://gateway:8300
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- fromchat_external
|
||||||
|
- fromchat_internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
database:
|
||||||
|
name: fromchat-database
|
||||||
|
certs:
|
||||||
|
name: fromchat-certs
|
||||||
|
logs:
|
||||||
|
name: fromchat-logs
|
||||||
|
data:
|
||||||
|
name: fromchat-data
|
||||||
|
|
||||||
|
networks:
|
||||||
|
fromchat_internal:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
|
fromchat_external:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = Number(process.env.PORT) || 8301;
|
||||||
|
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
||||||
|
const filePath = process.env.STATIC_FILE_PATH || ".";
|
||||||
|
|
||||||
|
// Direct WebSocket proxy for chat - bypass gateway (must come before general API proxy)
|
||||||
|
app.use('/api/chat/ws', createProxyMiddleware({
|
||||||
|
target: 'http://messaging_service:8305',
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: { '^/api/chat/ws': '/messaging/chat/ws' },
|
||||||
|
ws: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// API proxy middleware (exclude WebSocket paths)
|
||||||
|
app.use('/api', (req, res, next) => {
|
||||||
|
// Skip WebSocket upgrade requests - let them be handled by specific proxies
|
||||||
|
if (req.headers.upgrade === 'websocket') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
createProxyMiddleware({
|
||||||
|
target: backendHost,
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: { '^/api': '' },
|
||||||
|
ws: true
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files
|
||||||
|
app.use(express.static(resolve(filePath)));
|
||||||
|
|
||||||
|
// SPA routing - catch all handler for client-side routing
|
||||||
|
app.use((_req: Request, res: Response) => {
|
||||||
|
res.sendFile(resolve(filePath, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, '0.0.0.0', () => {
|
||||||
|
console.log(`Backend host: ${backendHost}`);
|
||||||
|
console.log(`Server launched on http://0.0.0.0:${port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Multi-stage Dockerfile for all FromChat microservices
|
||||||
|
# This combines the base image and all services in one file
|
||||||
|
|
||||||
|
# Base stage - common setup for all services
|
||||||
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpq5 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd --create-home --shell /bin/bash fromchat
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create logs and data directories with proper permissions
|
||||||
|
RUN mkdir -p /app/backend/logs /app/backend/data /app/backend/data/profanity /app/backend/data/uploads /app/backend/data/uploads/pfp && \
|
||||||
|
chown -R fromchat:fromchat /app/backend/logs /app/backend/data && \
|
||||||
|
chmod -R 755 /app/backend/logs /app/backend/data
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY backend/requirements.txt /app/requirements.txt
|
||||||
|
|
||||||
|
# Install Python dependencies with cache mounts
|
||||||
|
RUN --mount=type=cache,target=/home/fromchat/.cache/pip \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy shared modules
|
||||||
|
COPY backend/shared /app/backend/shared/
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY docker/entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER fromchat
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|
||||||
|
# Account service - minimal files only
|
||||||
|
FROM base AS account_service
|
||||||
|
COPY backend/routes/account.py /app/backend/routes/account.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/services/account/main.py /app/backend/services/account/main.py
|
||||||
|
ENV SERVICE_NAME=account
|
||||||
|
|
||||||
|
# Profile service - minimal files only
|
||||||
|
FROM base AS profile_service
|
||||||
|
COPY backend/routes/profile.py /app/backend/routes/profile.py
|
||||||
|
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
|
||||||
|
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
|
||||||
|
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
|
||||||
|
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
|
||||||
|
ENV SERVICE_NAME=profile
|
||||||
|
|
||||||
|
# Device service - minimal files only
|
||||||
|
FROM base AS device_service
|
||||||
|
COPY backend/routes/devices.py /app/backend/routes/devices.py
|
||||||
|
COPY backend/services/device/main.py /app/backend/services/device/main.py
|
||||||
|
ENV SERVICE_NAME=device
|
||||||
|
|
||||||
|
# Messaging service - minimal files only
|
||||||
|
FROM base AS messaging_service
|
||||||
|
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
|
||||||
|
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
|
||||||
|
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
|
||||||
|
ENV SERVICE_NAME=messaging
|
||||||
|
|
||||||
|
# Push service - minimal files only
|
||||||
|
FROM base AS push_service
|
||||||
|
COPY backend/routes/push.py /app/backend/routes/push.py
|
||||||
|
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
||||||
|
COPY backend/services/push/main.py /app/backend/services/push/main.py
|
||||||
|
ENV SERVICE_NAME=push
|
||||||
|
|
||||||
|
# WebRTC service - minimal files only
|
||||||
|
FROM base AS webrtc_service
|
||||||
|
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
|
||||||
|
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
|
||||||
|
ENV SERVICE_NAME=webrtc
|
||||||
|
|
||||||
|
# Moderation service - minimal files only
|
||||||
|
FROM base AS moderation_service
|
||||||
|
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
|
||||||
|
ENV SERVICE_NAME=moderation
|
||||||
|
|
||||||
|
# Gateway service - minimal files only
|
||||||
|
FROM base AS gateway
|
||||||
|
COPY backend/app.py /app/backend/app.py
|
||||||
|
COPY backend/main.py /app/backend/main.py
|
||||||
|
COPY backend/dependencies.py /app/backend/dependencies.py
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
|
||||||
|
ENV SERVICE_NAME=gateway
|
||||||
|
|
||||||
|
# Migration runner - needs alembic config and migration files
|
||||||
|
FROM base AS migration_runner
|
||||||
|
# Temporarily switch back to root to manage file permissions
|
||||||
|
USER root
|
||||||
|
COPY backend/alembic.ini /app/backend/alembic.ini
|
||||||
|
COPY backend/alembic /app/backend/alembic/
|
||||||
|
COPY backend/migration.py /app/backend/migration.py
|
||||||
|
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
|
||||||
|
# Clean up problematic migrations as root
|
||||||
|
RUN find /app/backend/alembic/versions -name "*auto_generated_migration_for_schema_*" | xargs rm -f || true
|
||||||
|
# Switch back to fromchat user
|
||||||
|
USER fromchat
|
||||||
|
ENV SERVICE_NAME=migration_runner
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Entrypoint script for FromChat microservices
|
||||||
|
|
||||||
|
# Run the service module
|
||||||
|
exec python -m backend.services.${SERVICE_NAME}.main
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
|
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { NotificationShowOptions } from "./electron.d.ts";
|
import type { NotificationShowOptions } from '../electron.d.ts';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ app.whenReady().then(() => {
|
|||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
if (process.env.VITE_DEV_SERVER_URL) {
|
||||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.loadFile('build/electron/dist/index.html');
|
mainWindow.loadFile('frontend/build/electron/dist/index.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle notification permission requests
|
// Handle notification permission requests
|
||||||
@@ -35,7 +35,7 @@ app.whenReady().then(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle showing notifications
|
// Handle showing notifications
|
||||||
ipcMain.handle("show-notification", async (_event, options: NotificationShowOptions) => {
|
ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => {
|
||||||
if (Notification.isSupported()) {
|
if (Notification.isSupported()) {
|
||||||
try {
|
try {
|
||||||
const notification = new Notification({
|
const notification = new Notification({
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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);
|
||||||
@@ -6,7 +6,7 @@ export default {
|
|||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
asar: true,
|
asar: true,
|
||||||
},
|
},
|
||||||
outDir: "build/electron/forge",
|
outDir: "frontend/build/electron/forge",
|
||||||
rebuildConfig: {},
|
rebuildConfig: {},
|
||||||
makers: [
|
makers: [
|
||||||
{
|
{
|
||||||
@@ -4,11 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Loading...</title>
|
<title>Loading...</title>
|
||||||
<link rel="icon" href="./images/logo.svg" />
|
<link rel="icon" href="./src/images/logo.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="main/main.tsx" type="module"></script>
|
<script src="src/main.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
src/
|
||||||
|
tsconfig.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# 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/`
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user