mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Compare commits
41 Commits
@@ -1,179 +1,51 @@
|
|||||||
# Security Audit Command
|
# Security Audit Command
|
||||||
|
|
||||||
Perform a comprehensive security audit of the FromChat application codebase.
|
Perform a comprehensive security audit of the FromChat **Android application** only.
|
||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
**FromChat** is a 100% open source secure messaging application with:
|
**FromChat Android** is a 100% open source secure messaging mobile application built with:
|
||||||
- React/TypeScript frontend
|
|
||||||
- Python FastAPI backend
|
- Kotlin Multiplatform (KMP) shared code
|
||||||
- End-to-end encryption for DMs and calls
|
- Jetpack Compose UI framework
|
||||||
- Caddy reverse proxy with security headers
|
- End-to-End Encryption (NaCl, AES-GCM)
|
||||||
- WebSocket support for real-time features
|
- WebSocket support for real-time features
|
||||||
- Electron support for desktop app
|
- LiveKit integration for calls
|
||||||
|
- Local database storage (SQLite)
|
||||||
|
|
||||||
|
## Scope: Android Only
|
||||||
|
|
||||||
|
**OUT OF SCOPE:**
|
||||||
|
|
||||||
|
- Web backend (Python FastAPI, Caddy infrastructure)
|
||||||
|
- React/TypeScript frontend
|
||||||
|
|
||||||
|
**IN SCOPE:**
|
||||||
|
|
||||||
|
- Android app code (`app/android`, `app/shared/src/androidMain`)
|
||||||
|
- Shared cross-platform code (`app/shared/src/commonMain`)
|
||||||
|
- Local encryption implementation (NaCl, AES-GCM)
|
||||||
|
- Secure storage (Android Keystore, encrypted SharedPreferences)
|
||||||
|
- WebSocket client security
|
||||||
|
- Permission usage and handling
|
||||||
|
- Call security (LiveKit integration)
|
||||||
|
- Memory safety and injection attacks
|
||||||
|
- Backend
|
||||||
|
|
||||||
## Important Design Decisions (NOT Vulnerabilities)
|
## Important Design Decisions (NOT Vulnerabilities)
|
||||||
|
|
||||||
When auditing, remember these are **intentional design choices**:
|
When auditing, remember these are **intentional design choices**:
|
||||||
|
|
||||||
1. **Public messages endpoint** - Open forum accessible without authentication (by design)
|
1. **Local message caching** - Messages downloaded and stored locally (by design)
|
||||||
- The public chat is meant to be an open forum
|
- Messages are end-to-end encrypted at rest in local DB
|
||||||
- Private DMs are properly E2E encrypted and require authentication
|
- Public DMs are not encrypted (messages are public)
|
||||||
|
- Private DMs use NaCl encryption
|
||||||
2. **Public user list** - All users visible in DMs tab (by design)
|
- Cache persists across app restarts for offline access
|
||||||
- Users can see all registered accounts
|
2. **Local key storage** - Encryption keys stored on device (by design)
|
||||||
- This is intentional for a community-based chat app
|
- Keys protected by Android Keystore (hardware-backed when available)
|
||||||
|
- Encrypted with device-specific secrets
|
||||||
3. **XSS protection** - Multi-layer defense already implemented:
|
- User data never leaves device in plaintext
|
||||||
- React auto-escaping
|
- Do NOT flag key storage as critical (Keystore is production-ready)
|
||||||
- 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
|
||||||
|
|
||||||
@@ -183,8 +55,8 @@ Provide a **clean, concise report** with:
|
|||||||
2. **Security Status** - Critical issues (if any) and recommendations
|
2. **Security Status** - Critical issues (if any) and recommendations
|
||||||
3. **Security Strengths** - What's done well
|
3. **Security Strengths** - What's done well
|
||||||
4. **Component Ratings** - Table format for quick reference
|
4. **Component Ratings** - Table format for quick reference
|
||||||
5. **Design Decisions** - Clarify what's intentional vs vulnerable
|
5. **Architecture Review** - Data flow, encryption boundaries
|
||||||
6. **Threat Analysis** - Current realistic threats only
|
6. **Threat Analysis** - Current realistic threats (e.g., rooted device, malicious APK)
|
||||||
7. **Recommendations** - Prioritized with time estimates
|
7. **Recommendations** - Prioritized with time estimates
|
||||||
8. **Conclusion** - Clear production readiness statement
|
8. **Conclusion** - Clear production readiness statement
|
||||||
|
|
||||||
@@ -193,43 +65,8 @@ 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)
|
|
||||||
|
|
||||||
## Key Security Features to Verify
|
- Local message caching (intentional for offline access)
|
||||||
|
- Public message viewing without auth (intentional design)
|
||||||
✅ **MUST CHECK:**
|
- Debuggable APK (only relevant if signed/released)
|
||||||
- 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,6 +21,8 @@ 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
|
||||||
@@ -31,16 +33,14 @@ 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,
|
- 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.
|
||||||
make it async.
|
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
- NEVER create database migrations, they are auto-generated.
|
- NEVER create database migrations, they are auto-generated.
|
||||||
|
|
||||||
## Project Structure Awareness
|
## Project Structure Awareness
|
||||||
- This is a React/TypeScript frontend with Python FastAPI backend
|
- This is a React/TypeScript browser frontend (desktop is Compose Multiplatform in the Android/KMP repo)
|
||||||
- Uses MDUI components for UI
|
- Uses MDUI components for UI
|
||||||
- Has Electron support for desktop app
|
|
||||||
- Uses Zustand for state management
|
- Uses Zustand for state management
|
||||||
- Uses use-immer for immutable state updates
|
- Uses use-immer for immutable state updates
|
||||||
- Uses React Router for navigation
|
- Uses React Router for navigation
|
||||||
@@ -58,4 +58,9 @@ 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>`
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: Do not filter cache, API, or DB fields by matching fixed UI/placeholder English strings
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# No magic-string “sanitization” of user or message data
|
||||||
|
|
||||||
|
- **Never** strip, null out, or rewrite stored or displayed values by comparing them to hard-coded UI strings (e.g. `"Direct messages"`, `"Direct message"`, `"User 123"`, etc.). Those strings can be legitimate **usernames, display names, or message text**.
|
||||||
|
- **Prefer**: fix the source (don’t persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
|
||||||
|
- Applies especially to: local storage/cache layers, list previews, and any code that “cleans” strings before show or read.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
name: decrypt
|
||||||
|
description: Decrypts a DM message on localhost by granting temporary compliance extract access, running extract and decrypt CLI commands, then reverting all temporary changes. Use when the user asks to decrypt a message or run the compliance decryption workflow on localhost.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Decrypt (local debug)
|
||||||
|
|
||||||
|
End-to-end workflow for extracting and decrypting one message on **localhost**. All temporary backend access must be removed when finished.
|
||||||
|
|
||||||
|
**After decrypt:** what to do with the plaintext and files (compare, inspect, report, etc.) comes from the **current conversation** — not from this skill.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Web dev server running at `http://localhost:8301` (browser rules default).
|
||||||
|
- `compliance_keypair.txt` at the Web repo root (`Web/compliance_keypair.txt`).
|
||||||
|
- Python deps for `scripts/compliance/decryption/` (`cryptography`, etc.).
|
||||||
|
|
||||||
|
## Step 0 — Read the compliance keypair
|
||||||
|
|
||||||
|
Read `Web/compliance_keypair.txt` before any decrypt step. Confirm the **private** key line exists (line after `PRIVATE KEY` header, base64).
|
||||||
|
|
||||||
|
The decrypt CLI loads `compliance_keypair.txt` from the **current working directory**. Always run extract/decrypt with `cd` to the Web repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/Web
|
||||||
|
```
|
||||||
|
|
||||||
|
If `load_compliance_private_key` fails, the file format may need a `PRIVATE_KEY=<base64>` line (the loader expects that or a 43-char base64 line; 32-byte keys are often 44 chars with padding).
|
||||||
|
|
||||||
|
## Step 1 — Create a temporary user and capture token
|
||||||
|
|
||||||
|
Generate random credentials (username 3–20 chars: letters, digits, `-`, `_`; password 5–50 chars, no spaces):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEBUG_USER="decryptdbg$(openssl rand -hex 3)"
|
||||||
|
DEBUG_PASS="$(openssl rand -base64 12 | tr -d '/+=' | head -c 16)"
|
||||||
|
echo "user=$DEBUG_USER pass=$DEBUG_PASS"
|
||||||
|
```
|
||||||
|
|
||||||
|
Register and save **`token`** from the JSON response (not the derived login secret):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REGISTER_JSON=$(curl -sS -X POST "http://localhost:8301/api/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"${DEBUG_USER}\",\"display_name\":\"DecryptDebug\",\"password\":\"${DEBUG_PASS}\",\"confirm_password\":\"${DEBUG_PASS}\"}")
|
||||||
|
echo "$REGISTER_JSON"
|
||||||
|
DEBUG_TOKEN=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' <<< "$REGISTER_JSON")
|
||||||
|
echo "token saved (length ${#DEBUG_TOKEN})"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively export for the CLI: `export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"`
|
||||||
|
|
||||||
|
Save `user.id` from the same JSON if needed for debugging.
|
||||||
|
|
||||||
|
## Step 2 — Temporary compliance extract permission (marker blocks)
|
||||||
|
|
||||||
|
`GET /api/dm/compliance/extract/{message_id}` is restricted to **user id 1** in:
|
||||||
|
|
||||||
|
`backend/services/main/routes/envelope_messaging.py` (function `extract_message_for_compliance`).
|
||||||
|
|
||||||
|
Add a **temporary** bypass between searchable markers (replace `DEBUG_USERNAME` with the user from step 1):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# TEMP_COMPLIANCE_DEBUG_START — remove after decrypt debug run
|
||||||
|
_TEMP_COMPLIANCE_DEBUG_USERNAMES = {"DEBUG_USERNAME"}
|
||||||
|
# TEMP_COMPLIANCE_DEBUG_END
|
||||||
|
|
||||||
|
# Security check: only user ID 1 can access this
|
||||||
|
if current_user.id != 1 and current_user.username not in _TEMP_COMPLIANCE_DEBUG_USERNAMES:
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend will auto-reload after this change, you don't need to do anything.
|
||||||
|
|
||||||
|
To remove later: search the repo for `TEMP_COMPLIANCE_DEBUG` and delete the marker block + revert the `if` to only `current_user.id != 1`.
|
||||||
|
|
||||||
|
## Step 3 — Extract bundle (online)
|
||||||
|
|
||||||
|
From Web repo root, set variables and run (user supplies `MESSAGE_ID`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MESSAGE_ID=<message_id>
|
||||||
|
RANDOM_FOLDER="run_$(openssl rand -hex 4)"
|
||||||
|
BUNDLE_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}"
|
||||||
|
DECRYPT_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}_dec"
|
||||||
|
mkdir -p /tmp/debug_decrypt
|
||||||
|
|
||||||
|
cd /path/to/Web
|
||||||
|
python scripts/compliance/decryption/main.py extract \
|
||||||
|
--server localhost:8301 \
|
||||||
|
--http \
|
||||||
|
--token "${DEBUG_TOKEN}" \
|
||||||
|
--message-ids "${MESSAGE_ID}" \
|
||||||
|
--out-dir "${BUNDLE_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent using env (no `--token` flag):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"
|
||||||
|
python scripts/compliance/decryption/main.py extract \
|
||||||
|
--server localhost:8301 \
|
||||||
|
--http \
|
||||||
|
--message-ids "${MESSAGE_ID}" \
|
||||||
|
--out-dir "${BUNDLE_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm `${BUNDLE_DIR}/bundle.json` exists.
|
||||||
|
|
||||||
|
**Auth flags:** use `--token` (Bearer from register/login), or `--username` + `--password` (CLI derives the login secret). Do not pass both.
|
||||||
|
|
||||||
|
## Step 4 — Decrypt bundle (offline)
|
||||||
|
|
||||||
|
Still from Web repo root (`compliance_keypair.txt` must resolve):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/compliance/decryption/main.py decrypt \
|
||||||
|
--bundle-dir "${BUNDLE_DIR}" \
|
||||||
|
--output-dir "${DECRYPT_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `${DECRYPT_DIR}/messages/<message_id>/message.decrypted.txt` — message plaintext
|
||||||
|
- `${DECRYPT_DIR}/messages/<message_id>/files/` — decrypted attachments
|
||||||
|
- `${DECRYPT_DIR}/index.html` — HTML report
|
||||||
|
|
||||||
|
## Step 5 — Use decrypted output (conversation-driven)
|
||||||
|
|
||||||
|
Follow the **user’s request in the current chat** for what to do next (e.g. compare hashes, inspect text, verify a specific attachment). This skill stops at producing `${DECRYPT_DIR}`; do not assume a fixed post-decrypt task.
|
||||||
|
|
||||||
|
## Step 6 — Cleanup (required)
|
||||||
|
|
||||||
|
1. **Remove decrypt dirs:**
|
||||||
|
```bash
|
||||||
|
rm -rf "${BUNDLE_DIR}" "${DECRYPT_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Delete temp user** (local SQLite default DB):
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/data/database.db "DELETE FROM users WHERE username='${DEBUG_USER}';"
|
||||||
|
```
|
||||||
|
If your deployment uses another DB, delete the same username there.
|
||||||
|
|
||||||
|
3. **Remove temporary permission:** search `TEMP_COMPLIANCE_DEBUG` in the repo, delete the marker block, restore the original `if current_user.id != 1:` check, restart backend.
|
||||||
|
|
||||||
|
4. Do **not** commit `compliance_keypair.txt` or any decrypt output.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Read compliance_keypair.txt
|
||||||
|
- [ ] Register DEBUG_USER; save DEBUG_TOKEN from response
|
||||||
|
- [ ] Add TEMP_COMPLIANCE_DEBUG_* bypass; restart backend
|
||||||
|
- [ ] extract --token → bundle.json present
|
||||||
|
- [ ] decrypt → output under DECRYPT_DIR
|
||||||
|
- [ ] Post-decrypt work per conversation context
|
||||||
|
- [ ] rm -rf BUNDLE_DIR and DECRYPT_DIR
|
||||||
|
- [ ] DELETE temp user from DB
|
||||||
|
- [ ] Remove TEMP_COMPLIANCE_DEBUG markers; restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- Extract API: `GET /api/dm/compliance/extract/{message_id}` (see `bundle_extract.py`).
|
||||||
|
- Decrypt implementation: `scripts/compliance/decryption/bundle_decrypt.py`, `crypto.py`.
|
||||||
|
- Extract auth: `--token` (Bearer), env `FROMCHAT_API_TOKEN` / `FROMCHAT_TOKEN`, or `--username` + plain `--password` (CLI calls `derive_auth_secret` for login only).
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- NEVER use sleep in any command.
|
||||||
|
- Set a timeout on EVERY command that may request user input.
|
||||||
+43
-9
@@ -1,12 +1,46 @@
|
|||||||
# Exclude data directory to prevent local database from being copied into production images
|
# Shared build context ignore (used by deployment images with context: ..)
|
||||||
backend/data/
|
|
||||||
|
|
||||||
# Exclude logs
|
# VCS / editor
|
||||||
backend/logs/
|
.git
|
||||||
|
.github
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.cursor
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# Exclude development files
|
# Secrets / local env
|
||||||
node_modules/
|
.env
|
||||||
.git/
|
deployment/.env
|
||||||
.gitignore
|
|
||||||
README.md
|
# Node
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.vite
|
||||||
|
dist
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# HTTP API host.
|
||||||
|
# Example for local backend: http://localhost:8300
|
||||||
|
VITE_API_BASE_URL=http://localhost:8300
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
name: Build Electron app
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
apiBaseUrl:
|
|
||||||
description: "API base URL"
|
|
||||||
required: false
|
|
||||||
default: "fromchat.ru"
|
|
||||||
type: string
|
|
||||||
push:
|
|
||||||
tags: ["v**"]
|
|
||||||
paths:
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'package.json'
|
|
||||||
- '.github/workflows/build.yml'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: build-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: node_modules
|
|
||||||
key: npm-ubuntu-latest-node-24-${{ hashFiles('package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
npm-ubuntu-latest-node-24-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install --ignore-scripts --no-audit --no-fund
|
|
||||||
|
|
||||||
- name: Cache Electron downloads
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cache/electron
|
|
||||||
~/.cache/electron-builder
|
|
||||||
key: electron-ubuntu-latest-${{ hashFiles('package.json', 'package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
electron-ubuntu-latest-
|
|
||||||
|
|
||||||
- name: Build Electron app
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
VITE_API_BASE_URL: ${{ inputs.apiBaseUrl }}
|
|
||||||
run: npm run build:electron
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: FromChat-linux
|
|
||||||
path: |
|
|
||||||
frontend/build/electron/forge/make/*/**
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-macos:
|
|
||||||
name: Build macOS
|
|
||||||
runs-on: macos-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: node_modules
|
|
||||||
key: npm-macos-latest-node-24-${{ hashFiles('package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
npm-macos-latest-node-24-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install --ignore-scripts --no-audit --no-fund
|
|
||||||
|
|
||||||
# - name: Cache Electron downloads
|
|
||||||
# uses: actions/cache@v4
|
|
||||||
# with:
|
|
||||||
# path: |
|
|
||||||
# ~/.cache/electron
|
|
||||||
# ~/.cache/electron-builder
|
|
||||||
# key: electron-macos-latest-${{ hashFiles('package.json') }}
|
|
||||||
# restore-keys: |
|
|
||||||
# electron-macos-latest-
|
|
||||||
|
|
||||||
- name: Build Electron app
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
VITE_API_BASE_URL: ${{ inputs.apiBaseUrl }}
|
|
||||||
run: npm run build:electron
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: FromChat-macOS
|
|
||||||
path: |
|
|
||||||
frontend/build/electron/forge/make/zip/darwin/*/**.zip
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-windows:
|
|
||||||
name: Build Windows
|
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Force npm to use Bash
|
|
||||||
run: npm config set script-shell "C:\Program Files\Git\bin\bash.exe"
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: node_modules
|
|
||||||
key: npm-windows-latest-node-24-${{ hashFiles('package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
npm-windows-latest-node-24-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install --ignore-scripts --no-audit --no-fund
|
|
||||||
|
|
||||||
- name: Cache Electron downloads
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~\AppData\Local\electron\Cache
|
|
||||||
~\AppData\Local\electron-builder\Cache
|
|
||||||
key: electron-windows-latest-${{ hashFiles('package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
electron-windows-latest-
|
|
||||||
|
|
||||||
- name: Build Electron app
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
VITE_API_BASE_URL: ${{ inputs.apiBaseUrl }}
|
|
||||||
run: npm run build:electron
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: FromChat-windows
|
|
||||||
path: |
|
|
||||||
frontend/build/electron/forge/make/zip/win32/x64
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Simple workflow for deploying to the self-hosted server
|
|
||||||
name: Deploy to server
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Runs on pushes targeting the default branch
|
|
||||||
# push:
|
|
||||||
# branches: ["main"]
|
|
||||||
# paths:
|
|
||||||
# - "backend/**"
|
|
||||||
# - "frontend/**"
|
|
||||||
# - "deployment/**"
|
|
||||||
# - "**/package.json"
|
|
||||||
# - ".nvmrc"
|
|
||||||
# - ".github/workflows/deploy.yml"
|
|
||||||
# - "!frontend/electron/**"
|
|
||||||
# - "!**.d.ts"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
|
||||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
|
||||||
concurrency:
|
|
||||||
group: "pages"
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: raspberry-pi
|
|
||||||
env:
|
|
||||||
HOME: "/root"
|
|
||||||
environment:
|
|
||||||
name: production
|
|
||||||
url: https://fromchat.ru
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Set up environment
|
|
||||||
run: |
|
|
||||||
mkdir -p deployment
|
|
||||||
touch deployment/.env
|
|
||||||
cat > deployment/.env << EOF
|
|
||||||
JWT_SECRET=${{ secrets.JWT_SECRET }}
|
|
||||||
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
|
||||||
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
|
||||||
TURN_USERNAME=${{ vars.TURN_USERNAME }}
|
|
||||||
TURN_PASSWORD=${{ secrets.TURN_PASSWORD }}
|
|
||||||
EOF
|
|
||||||
- name: Build container
|
|
||||||
run: |
|
|
||||||
cd deployment
|
|
||||||
docker compose build
|
|
||||||
- name: Set up the service
|
|
||||||
run: |
|
|
||||||
cp -f deployment/fromchat.service /etc/systemd/system/fromchat.service
|
|
||||||
systemctl daemon-reload
|
|
||||||
- name: Start the server
|
|
||||||
run: |
|
|
||||||
if ! systemctl restart fromchat && sleep 10 && systemctl status fromchat; then
|
|
||||||
journalctl --no-pager -xeu fromchat
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
+22
-1
@@ -35,6 +35,9 @@ Temporary Items
|
|||||||
# iCloud generated files
|
# iCloud generated files
|
||||||
*.icloud
|
*.icloud
|
||||||
|
|
||||||
|
### FromChat local tools (downloaded LiveKit server binary) ###
|
||||||
|
.tools/
|
||||||
|
|
||||||
### Node ###
|
### Node ###
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -118,6 +121,12 @@ 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
|
||||||
@@ -567,7 +576,9 @@ 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
|
||||||
@@ -579,4 +590,14 @@ backend/alembic/**
|
|||||||
|
|
||||||
.cursor/plans
|
.cursor/plans
|
||||||
tmp
|
tmp
|
||||||
compliance_keypair.txt
|
compliance_keypair.txt
|
||||||
|
backend/files
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
.deploy-cache
|
||||||
|
|
||||||
|
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/
|
||||||
|
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/lib
|
||||||
|
|
||||||
|
*.module.d.scss.ts
|
||||||
Vendored
+9
@@ -1,3 +1,11 @@
|
|||||||
|
{
|
||||||
|
"npm.autoDetect": "off",
|
||||||
|
"files.exclude": {
|
||||||
|
".husky": true,
|
||||||
|
"build": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/__pycache__": true,
|
"**/__pycache__": true,
|
||||||
@@ -7,5 +15,6 @@
|
|||||||
"**/.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
+4
-71
@@ -1,28 +1,10 @@
|
|||||||
{
|
{
|
||||||
"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": "npm",
|
"type": "shell",
|
||||||
"script": "frontend:dev",
|
"command": "npm run frontend:dev",
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
@@ -37,46 +19,9 @@
|
|||||||
},
|
},
|
||||||
"isBackground": true
|
"isBackground": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"label": "Frontend (Electron)",
|
|
||||||
"type": "npm",
|
|
||||||
"script": "frontend:electron:dev",
|
|
||||||
"options": {
|
|
||||||
"cwd": "${workspaceFolder}"
|
|
||||||
},
|
|
||||||
"presentation": {
|
|
||||||
"echo": true,
|
|
||||||
"reveal": "always",
|
|
||||||
"focus": false,
|
|
||||||
"panel": "shared"
|
|
||||||
},
|
|
||||||
"group": {
|
|
||||||
"kind": "build"
|
|
||||||
},
|
|
||||||
"isBackground": true
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
"label": "Web",
|
"label": "Web",
|
||||||
"dependsOn": ["Backend", "Frontend (Web)"],
|
"dependsOn": ["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"
|
||||||
@@ -87,18 +32,6 @@
|
|||||||
"focus": false,
|
"focus": false,
|
||||||
"panel": "shared"
|
"panel": "shared"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Deploy",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "npm run deploy",
|
|
||||||
"presentation": {
|
|
||||||
"echo": true,
|
|
||||||
"reveal": "always",
|
|
||||||
"focus": true,
|
|
||||||
"panel": "dedicated",
|
|
||||||
"clear": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# 1. Frontend production build
|
||||||
|
FROM node:24 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 1.1. Install dependencies
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm install --ignore-scripts
|
||||||
|
|
||||||
|
# 1.2. Copy sources and configure
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ARG NODE_ENV=production
|
||||||
|
# Overridable at build time. Must be a real http(s) URL — never a compose ${...} stub.
|
||||||
|
ARG VITE_API_BASE_URL=https://api.fromchat.ru
|
||||||
|
|
||||||
|
# Fail the build if the arg is not a real URL (e.g. unexpanded Compose ${...}).
|
||||||
|
RUN if ! printf "%s" "${VITE_API_BASE_URL}" | grep -Eq '^https?://'; then \
|
||||||
|
echo "ERROR: VITE_API_BASE_URL must be an http(s) URL, got: ${VITE_API_BASE_URL}" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi && \
|
||||||
|
export NODE_ENV=production VITE_API_BASE_URL && \
|
||||||
|
npm run frontend:build
|
||||||
|
|
||||||
|
|
||||||
|
# 2. Production web static server
|
||||||
|
FROM joseluisq/static-web-server:latest AS production
|
||||||
|
|
||||||
|
# 2.1. Copy files to server root (/var/public -> /home/sws/public)
|
||||||
|
COPY --from=builder /app/build/normal/dist /var/public
|
||||||
|
|
||||||
|
# 2.2. Configure (defaults serve the image's built-in landing page instead of our app)
|
||||||
|
ENV SERVER_ROOT=/var/public
|
||||||
|
ENV SERVER_FALLBACK_PAGE=/var/public/index.html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
# 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 / Desktop](https://github.com/fromchat-messenger/android) • [🌍 Website](https://github.com/fromchat-messenger/site)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Description
|
||||||
|
|
||||||
|
FromChat Web is a React/TypeScript browser client for the FromChat server. The desktop client is Compose Multiplatform in the [Android/KMP repository](https://github.com/fromchat-messenger/android).
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
│ └── 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 / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||||
|
- [Website](https://github.com/fromchat-messenger/site)
|
||||||
|
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ
|
||||||
|
|
||||||
|
**Q: How do I run locally?**
|
||||||
|
A: Start the backend on `:8300`, then `npm run frontend:dev` and open `http://localhost:8301`.
|
||||||
|
|
||||||
|
**Q: Which browsers?**
|
||||||
|
A: Current Chrome, Firefox, Safari, Edge.
|
||||||
|
|
||||||
|
**Q: Do calls work on web?**
|
||||||
|
A: Yes — voice/video and screen share (server needs LiveKit).
|
||||||
|
|
||||||
|
**Q: How do I report a bug?**
|
||||||
|
A: GitHub Issues with reproduction steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[⬆ back to top](#fromchat-web-client--messaging-web-app)**
|
||||||
@@ -1,30 +1,112 @@
|
|||||||
# FromChat
|
# FromChat Web Client — веб-приложение для обмена сообщениями
|
||||||
|
|
||||||
FromChat - полностью открытый мессенджер.
|
[Read in other languages: English](./README.en.md)
|
||||||
|
|
||||||
Его можно попробовать на [сайте](http://fromchat.ru).
|
_Написано ИИ. Могут быть ошибки._
|
||||||
|
|
||||||
## Содержание:
|
## 📝 Описание
|
||||||
- [Основные моменты](#highlights)
|
|
||||||
- [Использование](#usage)
|
|
||||||
- [Часто задаваемые вопросы](#faq)
|
|
||||||
- [Внос вклада](#contributing)
|
|
||||||
|
|
||||||
## Основные моменты
|
Веб-клиент FromChat — React/TypeScript приложение для браузера. Десктоп-клиент — Compose Multiplatform в [Android/KMP репозитории](https://github.com/fromchat-messenger/android).
|
||||||
- Написан на 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 для реал-тайма
|
||||||
|
- Тёмный режим
|
||||||
|
|
||||||
|
## 🏗️ Технологический стек
|
||||||
|
|
||||||
|
| Компонент | Примечание |
|
||||||
|
|---|---|
|
||||||
|
| React 19 | UI |
|
||||||
|
| TypeScript | строгая типизация |
|
||||||
|
| Vite 7 | dev-сервер и сборка |
|
||||||
|
| MDUI | Material Design |
|
||||||
|
| Zustand + use-immer | состояние |
|
||||||
|
| Motion | анимации |
|
||||||
|
| TweetNaCl.js | криптография |
|
||||||
|
|
||||||
|
## 🔧 Разработка
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- 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 собранного фронта
|
||||||
|
```
|
||||||
|
|
||||||
|
### Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── main/ # React-приложение (@/)
|
||||||
|
│ │ ├── pages/ # auth, chat, profile, …
|
||||||
|
│ │ ├── core/ # API, websocket, calls, …
|
||||||
|
│ │ ├── state/ # Zustand stores
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ └── css/ # SCSS (Material Design)
|
||||||
|
│ └── 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 / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||||
|
- [Website](https://github.com/fromchat-messenger/site)
|
||||||
|
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||||
|
|||||||
@@ -1,417 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import sys
|
|
||||||
from getpass import getpass
|
|
||||||
from typing import Iterable, List, Optional, Tuple
|
|
||||||
import readline
|
|
||||||
import httpx
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.panel import Panel
|
|
||||||
from rich.table import Table
|
|
||||||
|
|
||||||
|
|
||||||
class CLIError(Exception):
|
|
||||||
"""Generic CLI error with a human-readable message."""
|
|
||||||
|
|
||||||
|
|
||||||
def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
|
|
||||||
return hmac.new(salt, ikm, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
|
|
||||||
blocks: list[bytes] = []
|
|
||||||
previous = b""
|
|
||||||
counter = 1
|
|
||||||
while len(b"".join(blocks)) < length:
|
|
||||||
previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest()
|
|
||||||
blocks.append(previous)
|
|
||||||
counter += 1
|
|
||||||
return b"".join(blocks)[:length]
|
|
||||||
|
|
||||||
|
|
||||||
def derive_auth_secret(username: str, password: str) -> str:
|
|
||||||
salt = f"fromchat.user:{username}".encode("utf-8")
|
|
||||||
prk = _hkdf_extract(salt, password.encode("utf-8"))
|
|
||||||
okm = _hkdf_expand(prk, b"auth-secret", 32)
|
|
||||||
return base64.b64encode(okm).decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_single_key() -> str:
|
|
||||||
try: # Windows
|
|
||||||
import msvcrt # type: ignore
|
|
||||||
|
|
||||||
ch = msvcrt.getch()
|
|
||||||
return ch.decode("utf-8", errors="ignore").lower()
|
|
||||||
except ImportError:
|
|
||||||
import termios
|
|
||||||
import tty
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setraw(fd)
|
|
||||||
ch = sys.stdin.read(1)
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
return ch.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminCLI:
|
|
||||||
def __init__(self, api_url: str) -> None:
|
|
||||||
self.console = Console()
|
|
||||||
self.api_url = api_url.rstrip("/")
|
|
||||||
self.client = httpx.Client(base_url=self.api_url, timeout=30.0)
|
|
||||||
self.username: Optional[str] = None
|
|
||||||
self.token: Optional[str] = None
|
|
||||||
|
|
||||||
# --------------------------- HTTP helpers --------------------------- #
|
|
||||||
def _auth_headers(self) -> dict:
|
|
||||||
headers: dict = {}
|
|
||||||
if self.token:
|
|
||||||
headers["Authorization"] = f"Bearer {self.token}"
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def _request(self, method: str, path: str, *, auth: bool = True, **kwargs) -> httpx.Response:
|
|
||||||
rel_path = path.lstrip("/")
|
|
||||||
headers = kwargs.pop("headers", {})
|
|
||||||
if auth:
|
|
||||||
headers.update(self._auth_headers())
|
|
||||||
response = self.client.request(method, rel_path, headers=headers, **kwargs)
|
|
||||||
if response.status_code >= 400:
|
|
||||||
detail = ""
|
|
||||||
try:
|
|
||||||
payload = response.json()
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
detail = payload.get("detail") or payload.get("message") or ""
|
|
||||||
except Exception:
|
|
||||||
detail = response.text
|
|
||||||
message = f"{response.status_code} {response.reason_phrase}"
|
|
||||||
if detail:
|
|
||||||
message = f"{message}: {detail}"
|
|
||||||
raise CLIError(message.strip())
|
|
||||||
return response
|
|
||||||
|
|
||||||
# --------------------------- CLI primitives ------------------------- #
|
|
||||||
def _require_auth(self) -> None:
|
|
||||||
if not self.token:
|
|
||||||
raise CLIError("You must login before running this command.")
|
|
||||||
|
|
||||||
def _resolve_user(self, identifier: str) -> dict:
|
|
||||||
self._require_auth()
|
|
||||||
if identifier.isdigit():
|
|
||||||
response = self._request("GET", f"user/id/{identifier}")
|
|
||||||
else:
|
|
||||||
response = self._request("GET", f"user/{identifier.replace('@', '')}")
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def _confirm(self, prompt: str) -> bool:
|
|
||||||
self.console.print(f"[bold yellow]{prompt}[/] [green](y)[/] / [red](n)[/]: ", end="")
|
|
||||||
choice = _read_single_key()
|
|
||||||
self.console.print("") # move to next line
|
|
||||||
return choice == "y"
|
|
||||||
|
|
||||||
def _render_user(self, user: dict) -> None:
|
|
||||||
table = Table(show_header=False)
|
|
||||||
table.add_row("ID", str(user.get("id")))
|
|
||||||
table.add_row("Username", user.get("username", ""))
|
|
||||||
table.add_row("Display name", user.get("display_name", ""))
|
|
||||||
table.add_row("Verified", "✅" if user.get("verified") else "❌")
|
|
||||||
if user.get("suspended"):
|
|
||||||
table.add_row("Suspended", f"🚫 ({user.get('suspension_reason') or 'no reason'})")
|
|
||||||
else:
|
|
||||||
table.add_row("Suspended", "✅ Active")
|
|
||||||
self.console.print(table)
|
|
||||||
|
|
||||||
# --------------------------- Commands ------------------------------- #
|
|
||||||
def cmd_login(self, args: List[str]) -> None:
|
|
||||||
if args:
|
|
||||||
username = args[0]
|
|
||||||
else:
|
|
||||||
username = self.console.input("[bold cyan]Username[/]: ").strip()
|
|
||||||
if not username:
|
|
||||||
raise CLIError("Username is required.")
|
|
||||||
|
|
||||||
password = getpass("Password: ")
|
|
||||||
derived_password = derive_auth_secret(username, password)
|
|
||||||
payload = {"username": username, "password": derived_password}
|
|
||||||
response = self._request("POST", "login", json=payload, auth=False)
|
|
||||||
body = response.json()
|
|
||||||
token = body.get("token")
|
|
||||||
if not token:
|
|
||||||
raise CLIError("Authentication succeeded but token was not returned.")
|
|
||||||
self.token = token
|
|
||||||
self.username = username
|
|
||||||
self.console.print("[bold green]Login successful.[/]")
|
|
||||||
|
|
||||||
def cmd_suspend(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: suspend <user_id|username>")
|
|
||||||
identifier = args[0]
|
|
||||||
user = self._resolve_user(identifier)
|
|
||||||
self.console.print(Panel.fit("[bold red]Suspend user[/]", style="red"))
|
|
||||||
self._render_user(user)
|
|
||||||
reason = self.console.input("[bold yellow]Reason (press Enter to leave empty)[/]: ").strip()
|
|
||||||
if not self._confirm(f"Confirm suspension of {user.get('username')}?"):
|
|
||||||
self.console.print("[yellow]Suspension cancelled.[/]")
|
|
||||||
return
|
|
||||||
payload = {"reason": reason}
|
|
||||||
self._request("POST", f"user/{user['id']}/suspend", json=payload)
|
|
||||||
log_reason = reason or "no reason provided"
|
|
||||||
self.console.print(f"[bold red]User {user['username']} suspended ({log_reason}).[/]")
|
|
||||||
|
|
||||||
def cmd_unsuspend(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: unsuspend <user_id|username>")
|
|
||||||
identifier = args[0]
|
|
||||||
user = self._resolve_user(identifier)
|
|
||||||
self.console.print(Panel.fit("[bold green]Unsuspend user[/]", style="green"))
|
|
||||||
self._render_user(user)
|
|
||||||
if not self._confirm(f"Unsuspend {user.get('username')}?"):
|
|
||||||
self.console.print("[yellow]Unsuspension cancelled.[/]")
|
|
||||||
return
|
|
||||||
self._request("POST", f"user/{user['id']}/unsuspend")
|
|
||||||
self.console.print(f"[bold green]User {user['username']} unsuspended.[/]")
|
|
||||||
|
|
||||||
def cmd_block_word(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: block-word <word or phrase> [additional words...]")
|
|
||||||
self._require_auth()
|
|
||||||
words = args
|
|
||||||
response = self._request("POST", "moderation/blocklist", json={"words": words})
|
|
||||||
data = response.json()
|
|
||||||
added = data.get("added", [])
|
|
||||||
current = data.get("words", [])
|
|
||||||
if added:
|
|
||||||
self.console.print(f"[bold green]Added {len(added)} entr{'y' if len(added)==1 else 'ies'} to blocklist.[/]")
|
|
||||||
else:
|
|
||||||
self.console.print("[yellow]No new words added.[/]")
|
|
||||||
self.console.print(f"Blocklist size: {len(current)}")
|
|
||||||
|
|
||||||
def cmd_list_users(self) -> None:
|
|
||||||
self._require_auth()
|
|
||||||
payload = self._request("GET", "user/list").json()
|
|
||||||
users = payload.get("users", [])
|
|
||||||
table = Table(title="Users", show_lines=False)
|
|
||||||
table.add_column("ID")
|
|
||||||
table.add_column("Username")
|
|
||||||
table.add_column("Display name")
|
|
||||||
table.add_column("Suspended")
|
|
||||||
for user in users:
|
|
||||||
table.add_row(
|
|
||||||
str(user.get("id")),
|
|
||||||
user.get("username", ""),
|
|
||||||
user.get("display_name", ""),
|
|
||||||
"🚫" if user.get("suspended") else "✅",
|
|
||||||
)
|
|
||||||
self.console.print(table)
|
|
||||||
|
|
||||||
def cmd_user(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: user <user_id|username>")
|
|
||||||
user = self._resolve_user(args[0])
|
|
||||||
self._render_user(user)
|
|
||||||
|
|
||||||
def cmd_delete(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: delete <user_id|username>")
|
|
||||||
user = self._resolve_user(args[0])
|
|
||||||
self.console.print(Panel.fit("[bold red]Delete user[/]", style="red"))
|
|
||||||
self._render_user(user)
|
|
||||||
if not self._confirm(f"Permanently delete {user.get('username')}?"):
|
|
||||||
self.console.print("[yellow]Deletion cancelled.[/]")
|
|
||||||
return
|
|
||||||
self._request("POST", f"user/{user['id']}/delete")
|
|
||||||
self.console.print(f"[bold red]User {user['username']} deleted.[/]")
|
|
||||||
|
|
||||||
def cmd_unblock_word(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: unblock-word <word or phrase> [additional words...]")
|
|
||||||
self._require_auth()
|
|
||||||
response = self._request("DELETE", "moderation/blocklist", json={"words": args})
|
|
||||||
data = response.json()
|
|
||||||
removed = data.get("removed", [])
|
|
||||||
current = data.get("words", [])
|
|
||||||
if removed:
|
|
||||||
self.console.print(f"[bold green]Removed {len(removed)} entr{'y' if len(removed)==1 else 'ies'} from blocklist.[/]")
|
|
||||||
else:
|
|
||||||
self.console.print("[yellow]No matching words removed.[/]")
|
|
||||||
self.console.print(f"Blocklist size: {len(current)}")
|
|
||||||
|
|
||||||
def cmd_verify(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: verify <user_id|username>")
|
|
||||||
user = self._resolve_user(args[0])
|
|
||||||
if user.get("verified"):
|
|
||||||
self.console.print(f"[yellow]{user['username']} is already verified.[/]")
|
|
||||||
return
|
|
||||||
self._request("POST", f"user/{user['id']}/verify")
|
|
||||||
self.console.print(f"[bold green]{user['username']} marked as verified.[/]")
|
|
||||||
|
|
||||||
def cmd_unverify(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: unverify <user_id|username>")
|
|
||||||
user = self._resolve_user(args[0])
|
|
||||||
if not user.get("verified"):
|
|
||||||
self.console.print(f"[yellow]{user['username']} is already unverified.[/]")
|
|
||||||
return
|
|
||||||
self._request("POST", f"user/{user['id']}/verify")
|
|
||||||
self.console.print(f"[bold green]{user['username']} is now unverified.[/]")
|
|
||||||
|
|
||||||
def cmd_list_blocklist(self) -> None:
|
|
||||||
self._require_auth()
|
|
||||||
response = self._request("GET", "moderation/blocklist")
|
|
||||||
words = response.json().get("words", [])
|
|
||||||
if not words:
|
|
||||||
self.console.print("[cyan]Blocklist is empty.[/]")
|
|
||||||
return
|
|
||||||
table = Table(title="Blocked Words", show_lines=True)
|
|
||||||
table.add_column("Word / Phrase")
|
|
||||||
for entry in words:
|
|
||||||
table.add_row(entry)
|
|
||||||
self.console.print(table)
|
|
||||||
|
|
||||||
def cmd_unblock_ip(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: unblock-ip <ip_address>")
|
|
||||||
self._require_auth()
|
|
||||||
ip = args[0].strip()
|
|
||||||
if not ip:
|
|
||||||
raise CLIError("IP address cannot be empty")
|
|
||||||
response = self._request("POST", "moderation/unblock-ip", json={"ip": ip})
|
|
||||||
data = response.json()
|
|
||||||
message = data.get("message", "IP unblocked")
|
|
||||||
self.console.print(f"[bold green]{message}[/]")
|
|
||||||
|
|
||||||
def cmd_clear_all_rate_limits(self) -> None:
|
|
||||||
"""Clear all rate limit entries. Use with caution."""
|
|
||||||
self._require_auth()
|
|
||||||
if not self._confirm("Clear ALL rate limit entries? This affects all IPs."):
|
|
||||||
self.console.print("[yellow]Operation cancelled.[/]")
|
|
||||||
return
|
|
||||||
response = self._request("POST", "moderation/clear-all-rate-limits")
|
|
||||||
data = response.json()
|
|
||||||
message = data.get("message", "Rate limits cleared")
|
|
||||||
self.console.print(f"[bold green]{message}[/]")
|
|
||||||
|
|
||||||
def cmd_help(self) -> None:
|
|
||||||
cmds = {
|
|
||||||
"login [username]": "Authenticate as owner/admin.",
|
|
||||||
"suspend <user>": "Suspend account (alias: ban).",
|
|
||||||
"unsuspend <user>": "Unsuspend account (alias: unban).",
|
|
||||||
"delete <user>": "Permanently delete the user account.",
|
|
||||||
"verify <user>": "Mark user as verified.",
|
|
||||||
"unverify <user>": "Remove verification flag.",
|
|
||||||
"block-word <words>": "Add words/phrases to chat filter.",
|
|
||||||
"unblock-word <words>": "Remove words/phrases from filter.",
|
|
||||||
"blocklist": "Show current blocklist.",
|
|
||||||
"unblock-ip <ip>": "Unblock an IP address from rate limiting.",
|
|
||||||
"clear-all-rate-limits": "Clear all rate limit entries (use with caution).",
|
|
||||||
"list": "List all users.",
|
|
||||||
"user <user>": "Show detailed user information.",
|
|
||||||
"whoami": "Display current session context.",
|
|
||||||
"help": "Show this help panel.",
|
|
||||||
"exit": "Quit the CLI.",
|
|
||||||
}
|
|
||||||
table = Table(title="Available Commands")
|
|
||||||
table.add_column("Command", style="cyan")
|
|
||||||
table.add_column("Description", style="white")
|
|
||||||
for cmd, desc in cmds.items():
|
|
||||||
table.add_row(cmd, desc)
|
|
||||||
self.console.print(table)
|
|
||||||
|
|
||||||
def cmd_whoami(self) -> None:
|
|
||||||
if not self.token:
|
|
||||||
self.console.print("[yellow]Not authenticated.[/]")
|
|
||||||
return
|
|
||||||
self.console.print(f"[green]Logged in as[/] [bold]{self.username}[/] ({self.api_url})")
|
|
||||||
|
|
||||||
# --------------------------- Main loop ------------------------------ #
|
|
||||||
def run(self) -> None:
|
|
||||||
self.console.print(Panel.fit("[bold magenta]FromChat Admin CLI[/]", style="magenta"))
|
|
||||||
while True:
|
|
||||||
prompt_identity = self.username or "guest"
|
|
||||||
try:
|
|
||||||
prompt_str = f"\033[36m{prompt_identity}\033[0m \033[1m>\033[0m "
|
|
||||||
raw = input(prompt_str).strip()
|
|
||||||
except (KeyboardInterrupt, EOFError):
|
|
||||||
self.console.print("\n[red]Exiting...[/]")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
parts = shlex.split(raw)
|
|
||||||
except ValueError as exc:
|
|
||||||
self.console.print(f"[red]Parse error:[/] {exc}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
command = parts[0].lstrip("/").lower()
|
|
||||||
args = parts[1:]
|
|
||||||
|
|
||||||
if command in {"exit", "quit"}:
|
|
||||||
self.console.print("[red]Goodbye.[/]")
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
if command == "login":
|
|
||||||
self.cmd_login(args)
|
|
||||||
elif command in {"suspend", "ban"}:
|
|
||||||
self.cmd_suspend(args)
|
|
||||||
elif command in {"unsuspend", "unban"}:
|
|
||||||
self.cmd_unsuspend(args)
|
|
||||||
elif command == "block-word":
|
|
||||||
self.cmd_block_word(args)
|
|
||||||
elif command == "unblock-word":
|
|
||||||
self.cmd_unblock_word(args)
|
|
||||||
elif command == "blocklist":
|
|
||||||
self.cmd_list_blocklist()
|
|
||||||
elif command == "unblock-ip":
|
|
||||||
self.cmd_unblock_ip(args)
|
|
||||||
elif command == "clear-all-rate-limits":
|
|
||||||
self.cmd_clear_all_rate_limits()
|
|
||||||
elif command == "verify":
|
|
||||||
self.cmd_verify(args)
|
|
||||||
elif command == "unverify":
|
|
||||||
self.cmd_unverify(args)
|
|
||||||
elif command in {"delete", "remove"}:
|
|
||||||
self.cmd_delete(args)
|
|
||||||
elif command == "list":
|
|
||||||
self.cmd_list_users()
|
|
||||||
elif command == "user":
|
|
||||||
self.cmd_user(args)
|
|
||||||
elif command == "help":
|
|
||||||
self.cmd_help()
|
|
||||||
elif command == "whoami":
|
|
||||||
self.cmd_whoami()
|
|
||||||
else:
|
|
||||||
self.console.print("[yellow]Unknown command. Type /help for a list of commands.[/]")
|
|
||||||
except CLIError as err:
|
|
||||||
self.console.print(f"[red]Error:[/] {err}")
|
|
||||||
except httpx.RequestError as err:
|
|
||||||
self.console.print(f"[red]Network error:[/] {err}")
|
|
||||||
|
|
||||||
self.client.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[Iterable[str]] = None) -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="FromChat Emergency Admin CLI")
|
|
||||||
parser.add_argument(
|
|
||||||
"--api-url",
|
|
||||||
default=os.getenv("FC_ADMIN_API_URL", "http://127.0.0.1:8300"),
|
|
||||||
help="Base API URL for the FromChat backend (default: %(default)s).",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
||||||
cli = AdminCLI(args.api_url)
|
|
||||||
cli.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
# A generic, single database configuration.
|
|
||||||
|
|
||||||
[alembic]
|
|
||||||
# path to migration scripts.
|
|
||||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
|
||||||
# format, relative to the token %(here)s which refers to the location of this
|
|
||||||
# ini file
|
|
||||||
script_location = %(here)s/alembic
|
|
||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
|
||||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
|
||||||
# for all available tokens
|
|
||||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
||||||
|
|
||||||
# sys.path path, will be prepended to sys.path if present.
|
|
||||||
# defaults to the current working directory. for multiple paths, the path separator
|
|
||||||
# is defined by "path_separator" below.
|
|
||||||
prepend_sys_path = .
|
|
||||||
|
|
||||||
|
|
||||||
# timezone to use when rendering the date within the migration file
|
|
||||||
# as well as the filename.
|
|
||||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
|
||||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
|
||||||
# string value is passed to ZoneInfo()
|
|
||||||
# leave blank for localtime
|
|
||||||
# timezone =
|
|
||||||
|
|
||||||
# max length of characters to apply to the "slug" field
|
|
||||||
# truncate_slug_length = 40
|
|
||||||
|
|
||||||
# set to 'true' to run the environment during
|
|
||||||
# the 'revision' command, regardless of autogenerate
|
|
||||||
# revision_environment = false
|
|
||||||
|
|
||||||
# set to 'true' to allow .pyc and .pyo files without
|
|
||||||
# a source .py file to be detected as revisions in the
|
|
||||||
# versions/ directory
|
|
||||||
# sourceless = false
|
|
||||||
|
|
||||||
# version location specification; This defaults
|
|
||||||
# to <script_location>/versions. When using multiple version
|
|
||||||
# directories, initial revisions must be specified with --version-path.
|
|
||||||
# The path separator used here should be the separator specified by "path_separator"
|
|
||||||
# below.
|
|
||||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
|
||||||
|
|
||||||
# path_separator; This indicates what character is used to split lists of file
|
|
||||||
# paths, including version_locations and prepend_sys_path within configparser
|
|
||||||
# files such as alembic.ini.
|
|
||||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
|
||||||
# to provide os-dependent path splitting.
|
|
||||||
#
|
|
||||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
|
||||||
# take place if path_separator is not present in alembic.ini. If this
|
|
||||||
# option is omitted entirely, fallback logic is as follows:
|
|
||||||
#
|
|
||||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
|
||||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
|
||||||
# behavior of splitting on spaces and/or commas.
|
|
||||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
|
||||||
# behavior of splitting on spaces, commas, or colons.
|
|
||||||
#
|
|
||||||
# Valid values for path_separator are:
|
|
||||||
#
|
|
||||||
# path_separator = :
|
|
||||||
# path_separator = ;
|
|
||||||
# path_separator = space
|
|
||||||
# path_separator = newline
|
|
||||||
#
|
|
||||||
# Use os.pathsep. Default configuration used for new projects.
|
|
||||||
path_separator = os
|
|
||||||
|
|
||||||
# set to 'true' to search source files recursively
|
|
||||||
# in each "version_locations" directory
|
|
||||||
# new in Alembic version 1.10
|
|
||||||
# recursive_version_locations = false
|
|
||||||
|
|
||||||
# the output encoding used when revision files
|
|
||||||
# are written from script.py.mako
|
|
||||||
# output_encoding = utf-8
|
|
||||||
|
|
||||||
# database URL. This is consumed by the user-maintained env.py script only.
|
|
||||||
# other means of configuring database URLs may be customized within the env.py
|
|
||||||
# file.
|
|
||||||
sqlalchemy.url = sqlite:///./data/database.db
|
|
||||||
|
|
||||||
|
|
||||||
[post_write_hooks]
|
|
||||||
# post_write_hooks defines scripts or Python functions that are run
|
|
||||||
# on newly generated revision scripts. See the documentation for further
|
|
||||||
# detail and examples
|
|
||||||
|
|
||||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
|
||||||
# hooks = black
|
|
||||||
# black.type = console_scripts
|
|
||||||
# black.entrypoint = black
|
|
||||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
|
||||||
|
|
||||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
|
||||||
# hooks = ruff
|
|
||||||
# ruff.type = module
|
|
||||||
# ruff.module = ruff
|
|
||||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
||||||
|
|
||||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
|
||||||
# hooks = ruff
|
|
||||||
# ruff.type = exec
|
|
||||||
# ruff.executable = ruff
|
|
||||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
||||||
|
|
||||||
# Logging configuration. This is also consumed by the user-maintained
|
|
||||||
# env.py script only.
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARNING
|
|
||||||
handlers = console
|
|
||||||
qualname =
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARNING
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
datefmt = %H:%M:%S
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
from logging.config import fileConfig
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config
|
|
||||||
from sqlalchemy import pool
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
|
||||||
# access to the values within the .ini file in use.
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
|
||||||
# This line sets up loggers basically.
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
# add your model's MetaData object here
|
|
||||||
# for 'autogenerate' support
|
|
||||||
from models import Base
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
|
||||||
# can be acquired:
|
|
||||||
# my_important_option = config.get_main_option("my_important_option")
|
|
||||||
# ... etc.
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
"""Run migrations in 'offline' mode.
|
|
||||||
|
|
||||||
This configures the context with just a URL
|
|
||||||
and not an Engine, though an Engine is acceptable
|
|
||||||
here as well. By skipping the Engine creation
|
|
||||||
we don't even need a DBAPI to be available.
|
|
||||||
|
|
||||||
Calls to context.execute() here emit the given string to the
|
|
||||||
script output.
|
|
||||||
|
|
||||||
"""
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
|
||||||
context.configure(
|
|
||||||
url=url,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
literal_binds=True,
|
|
||||||
dialect_opts={"paramstyle": "named"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
"""Run migrations in 'online' mode.
|
|
||||||
|
|
||||||
In this scenario we need to create an Engine
|
|
||||||
and associate a connection with the context.
|
|
||||||
|
|
||||||
"""
|
|
||||||
connectable = engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
|
||||||
context.configure(
|
|
||||||
connection=connection, target_metadata=target_metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = ${repr(up_revision)}
|
|
||||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
||||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
-179
@@ -1,179 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import time
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
from routes import account, messaging, profile, push, webrtc, devices, moderation, download
|
|
||||||
import logging
|
|
||||||
from models import User
|
|
||||||
from constants import OWNER_USERNAME
|
|
||||||
from utils import get_client_ip
|
|
||||||
|
|
||||||
from db import POOL_CONFIG, SessionLocal
|
|
||||||
from logging_config import access_logger # noqa: F401 - ensure loggers configured
|
|
||||||
from security.audit import log_access
|
|
||||||
from security.rate_limit import limiter
|
|
||||||
from slowapi.middleware import SlowAPIMiddleware
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
# Startup - run migration in subprocess to avoid logging interference
|
|
||||||
try:
|
|
||||||
logger.info("Starting database migration check...")
|
|
||||||
# Run migration in a separate process
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
|
||||||
],
|
|
||||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to run database migrations: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
with SessionLocal() as db:
|
|
||||||
owner = db.query(User).filter(User.id == 1).first()
|
|
||||||
if owner and not owner.verified:
|
|
||||||
owner.verified = True
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Owner user '{OWNER_USERNAME}' has been verified")
|
|
||||||
elif owner and owner.verified:
|
|
||||||
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to ensure owner verification: {e}")
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
|
|
||||||
POOL_CONFIG["pool_size"],
|
|
||||||
POOL_CONFIG["max_overflow"],
|
|
||||||
POOL_CONFIG["pool_timeout"],
|
|
||||||
POOL_CONFIG["pool_recycle"],
|
|
||||||
POOL_CONFIG["pool_pre_ping"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start the messaging cleanup task
|
|
||||||
try:
|
|
||||||
from routes.messaging import messagingManager
|
|
||||||
messagingManager.start_cleanup_task()
|
|
||||||
logger.info("Messaging cleanup task started")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start messaging cleanup task: {e}")
|
|
||||||
|
|
||||||
# Reset all rate limits on startup to ensure clean state
|
|
||||||
# This prevents rate limits from persisting across restarts
|
|
||||||
try:
|
|
||||||
from security.rate_limit import reset_all_rate_limits
|
|
||||||
cleared = reset_all_rate_limits()
|
|
||||||
if cleared > 0:
|
|
||||||
logger.info(f"Cleared {cleared} rate limit entries on startup")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
|
||||||
|
|
||||||
# Start the rate limit cleanup task
|
|
||||||
try:
|
|
||||||
from security.rate_limit import start_rate_limit_cleanup_task
|
|
||||||
cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task())
|
|
||||||
logger.info("Rate limit cleanup task started")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start rate limit cleanup task: {e}")
|
|
||||||
cleanup_task = None
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
# Shutdown - cancel cleanup task if it exists
|
|
||||||
if cleanup_task:
|
|
||||||
cleanup_task.cancel()
|
|
||||||
try:
|
|
||||||
await cleanup_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Инициализация FastAPI
|
|
||||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
|
||||||
|
|
||||||
# Add rate limiting middleware
|
|
||||||
app.state.limiter = limiter
|
|
||||||
app.add_middleware(SlowAPIMiddleware)
|
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def access_logging_middleware(request: Request, call_next):
|
|
||||||
# Log incoming request and Authorization header presence for debugging auth issues
|
|
||||||
try:
|
|
||||||
auth_header = request.headers.get("authorization")
|
|
||||||
if auth_header:
|
|
||||||
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
|
|
||||||
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
|
|
||||||
else:
|
|
||||||
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
start = time.perf_counter()
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
except Exception as exc:
|
|
||||||
duration = time.perf_counter() - start
|
|
||||||
user = getattr(getattr(request, "state", None), "current_user", None)
|
|
||||||
log_access(
|
|
||||||
"http_error",
|
|
||||||
method=request.method,
|
|
||||||
path=request.url.path,
|
|
||||||
status="error",
|
|
||||||
user=getattr(user, "username", None),
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
duration=f"{duration:.3f}s",
|
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
duration = time.perf_counter() - start
|
|
||||||
user = getattr(getattr(request, "state", None), "current_user", None)
|
|
||||||
log_access(
|
|
||||||
"http_request",
|
|
||||||
method=request.method,
|
|
||||||
path=request.url.path,
|
|
||||||
status=response.status_code,
|
|
||||||
user=getattr(user, "username", None),
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
duration=f"{duration:.3f}s",
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=[
|
|
||||||
"https://fromchat.ru",
|
|
||||||
"https://beta.fromchat.ru",
|
|
||||||
"https://www.fromchat.ru",
|
|
||||||
"http://127.0.0.1:8301",
|
|
||||||
"http://127.0.0.1:8300",
|
|
||||||
"http://localhost:8301",
|
|
||||||
"http://localhost:8300",
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Routes
|
|
||||||
app.include_router(account.router)
|
|
||||||
app.include_router(messaging.router)
|
|
||||||
app.include_router(profile.router)
|
|
||||||
app.include_router(push.router, prefix="/push")
|
|
||||||
app.include_router(webrtc.router, prefix="/webrtc")
|
|
||||||
app.include_router(devices.router, prefix="/devices")
|
|
||||||
app.include_router(moderation.router)
|
|
||||||
app.include_router(download.router)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
DATABASE_URL = "sqlite:///./data/database.db"
|
|
||||||
JWT_ALGORITHM = "HS256"
|
|
||||||
# Token inactivity expiration - token expires if not used for this duration
|
|
||||||
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
|
||||||
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
|
|
||||||
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
|
|
||||||
OWNER_USERNAME = "denis0001-dev"
|
|
||||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET")
|
|
||||||
|
|
||||||
if not JWT_SECRET_KEY:
|
|
||||||
raise ValueError("JWT secret key empty")
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import os
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from constants import DATABASE_URL
|
|
||||||
|
|
||||||
# Ensure data directory exists
|
|
||||||
os.makedirs("data", exist_ok=True)
|
|
||||||
|
|
||||||
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
|
|
||||||
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
|
|
||||||
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
|
||||||
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
|
||||||
|
|
||||||
POOL_CONFIG = {
|
|
||||||
"pool_size": POOL_SIZE,
|
|
||||||
"max_overflow": MAX_OVERFLOW,
|
|
||||||
"pool_recycle": POOL_RECYCLE,
|
|
||||||
"pool_timeout": POOL_TIMEOUT,
|
|
||||||
"pool_pre_ping": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
engine_kwargs = {
|
|
||||||
"pool_size": POOL_SIZE,
|
|
||||||
"max_overflow": MAX_OVERFLOW,
|
|
||||||
"pool_recycle": POOL_RECYCLE,
|
|
||||||
"pool_pre_ping": True,
|
|
||||||
"pool_timeout": POOL_TIMEOUT,
|
|
||||||
}
|
|
||||||
|
|
||||||
connect_args = {}
|
|
||||||
if DATABASE_URL.startswith("sqlite"):
|
|
||||||
connect_args["check_same_thread"] = False
|
|
||||||
|
|
||||||
engine = create_engine(
|
|
||||||
DATABASE_URL,
|
|
||||||
connect_args=connect_args,
|
|
||||||
**engine_kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from utils import verify_token
|
|
||||||
from models import User, DeviceSession
|
|
||||||
from db import SessionLocal
|
|
||||||
import logging
|
|
||||||
|
|
||||||
security = HTTPBearer()
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
# Зависимость для получения сессии БД
|
|
||||||
def get_db():
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# Зависимость для получения текущего пользователя
|
|
||||||
def get_current_user(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> User:
|
|
||||||
token = credentials.credentials
|
|
||||||
try:
|
|
||||||
payload = verify_token(token)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("get_current_user: token verification error: %s", str(e))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid or expired token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
if not payload:
|
|
||||||
logger.info("get_current_user: verify_token returned empty payload")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid or expired token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
|
||||||
if not user:
|
|
||||||
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="User not found",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if user.id == 1 and user.suspended:
|
|
||||||
user.suspended = False
|
|
||||||
user.suspension_reason = None
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
|
|
||||||
# Validate device session from JWT
|
|
||||||
session_id = payload.get("session_id")
|
|
||||||
if not session_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid session",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
device_session = (
|
|
||||||
db.query(DeviceSession)
|
|
||||||
.filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not device_session or device_session.revoked:
|
|
||||||
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Session revoked or not found",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if session has been inactive for too long (sliding expiration)
|
|
||||||
from constants import TOKEN_INACTIVITY_EXPIRE_HOURS
|
|
||||||
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
|
|
||||||
if device_session.last_seen < inactivity_threshold:
|
|
||||||
# Session expired due to inactivity - revoke it
|
|
||||||
device_session.revoked = True
|
|
||||||
db.commit()
|
|
||||||
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Session expired due to inactivity",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Touch last_seen on valid session (sliding expiration - extends token life)
|
|
||||||
device_session.last_seen = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Check if user is suspended
|
|
||||||
if user.suspended:
|
|
||||||
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Account suspended",
|
|
||||||
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if user is deleted
|
|
||||||
if user.deleted:
|
|
||||||
logger.info("get_current_user: account deleted for user_id=%s", user.id)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Account deleted",
|
|
||||||
)
|
|
||||||
|
|
||||||
request.state.current_user = user
|
|
||||||
request.state.session_id = session_id
|
|
||||||
|
|
||||||
return user
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Generate VAPID keys for push notifications
|
|
||||||
Run this script to generate new VAPID keys for your application
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import base64
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
|
|
||||||
def generate_vapid_keys():
|
|
||||||
"""Generate VAPID keys for push notifications"""
|
|
||||||
try:
|
|
||||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
|
||||||
public_key = private_key.public_key()
|
|
||||||
|
|
||||||
# Convert to base64 for web push
|
|
||||||
private_key_b64 = base64.urlsafe_b64encode(
|
|
||||||
private_key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.DER,
|
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
|
||||||
encryption_algorithm=serialization.NoEncryption()
|
|
||||||
)
|
|
||||||
).decode('utf-8').rstrip('=')
|
|
||||||
|
|
||||||
# Get the raw uncompressed public key point (65 bytes: 0x04 + 32 bytes x + 32 bytes y)
|
|
||||||
public_numbers = public_key.public_numbers()
|
|
||||||
x_bytes = public_numbers.x.to_bytes(32, 'big')
|
|
||||||
y_bytes = public_numbers.y.to_bytes(32, 'big')
|
|
||||||
public_key_raw = b'\x04' + x_bytes + y_bytes
|
|
||||||
|
|
||||||
public_key_b64 = base64.urlsafe_b64encode(public_key_raw).decode('utf-8').rstrip('=')
|
|
||||||
|
|
||||||
print(f"VAPID_PRIVATE_KEY=\"{private_key_b64}\"")
|
|
||||||
print(f"VAPID_PUBLIC_KEY=\"{public_key_b64}\"")
|
|
||||||
|
|
||||||
return private_key_b64, public_key_b64
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error generating VAPID keys: {e}", file=sys.stderr)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
generate_vapid_keys()
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from pathlib import Path
|
|
||||||
from threading import RLock
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
LOGS_DIR = Path(__file__).resolve().parent / "logs"
|
|
||||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
class HumanReadableFileHandler(RotatingFileHandler):
|
|
||||||
def __init__(self, filename: Path, level: int) -> None:
|
|
||||||
super().__init__(filename, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", delay=True)
|
|
||||||
self.level = level
|
|
||||||
self._lock = RLock()
|
|
||||||
self._last_date: str | None = None
|
|
||||||
self._previous_entry: str | None = None
|
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord) -> None:
|
|
||||||
try:
|
|
||||||
message = record.getMessage().strip()
|
|
||||||
if not message:
|
|
||||||
return
|
|
||||||
|
|
||||||
timestamp = datetime.fromtimestamp(record.created)
|
|
||||||
date_str = timestamp.strftime("%d.%m.%Y")
|
|
||||||
time_str = timestamp.strftime("%H:%M:%S")
|
|
||||||
lines = [line.rstrip() for line in message.splitlines() if line.strip()]
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
if self.stream is None:
|
|
||||||
self.stream = self._open()
|
|
||||||
|
|
||||||
if self._last_date != date_str:
|
|
||||||
if self._last_date is not None:
|
|
||||||
self.stream.write("\n")
|
|
||||||
separator = "-" * 11
|
|
||||||
self.stream.write(f"\n\n{separator}\n{date_str}\n{separator}\n\n")
|
|
||||||
self._last_date = date_str
|
|
||||||
|
|
||||||
entry_lines: list[str] = []
|
|
||||||
if lines:
|
|
||||||
entry_lines.append(f"{time_str} {lines[0]}")
|
|
||||||
for line in lines[1:]:
|
|
||||||
if line.startswith("|"):
|
|
||||||
entry_lines.append(f" {line}")
|
|
||||||
else:
|
|
||||||
entry_lines.append(f" ↳ {line}")
|
|
||||||
else:
|
|
||||||
entry_lines.append(time_str)
|
|
||||||
entry_text = "\n".join(entry_lines)
|
|
||||||
if entry_text == self._previous_entry:
|
|
||||||
return
|
|
||||||
self.stream.write(entry_text + "\n")
|
|
||||||
self._previous_entry = entry_text
|
|
||||||
self.flush()
|
|
||||||
except Exception:
|
|
||||||
self.handleError(record)
|
|
||||||
|
|
||||||
|
|
||||||
_HANDLED_FILES: Dict[str, Path] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_logger(name: str, filename: str, level: int = logging.INFO) -> logging.Logger:
|
|
||||||
logger = logging.getLogger(name)
|
|
||||||
target_path = LOGS_DIR / filename
|
|
||||||
|
|
||||||
if _HANDLED_FILES.get(name) == target_path:
|
|
||||||
return logger
|
|
||||||
|
|
||||||
logger.handlers.clear()
|
|
||||||
|
|
||||||
handler = HumanReadableFileHandler(target_path, level)
|
|
||||||
handler.setLevel(level)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(level)
|
|
||||||
logger.propagate = False
|
|
||||||
|
|
||||||
_HANDLED_FILES[name] = target_path
|
|
||||||
return logger
|
|
||||||
|
|
||||||
|
|
||||||
security_logger = _configure_logger("security", "security.log")
|
|
||||||
public_chat_logger = _configure_logger("public_chat", "public-chat.log")
|
|
||||||
dm_logger = _configure_logger("dm", "dm.log")
|
|
||||||
access_logger = _configure_logger("access", "access.log")
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from constants import *
|
|
||||||
from db import *
|
|
||||||
from models import *
|
|
||||||
from validation import *
|
|
||||||
from utils import *
|
|
||||||
from dependencies import *
|
|
||||||
from app import *
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
"""
|
|
||||||
Database migration utility using Alembic.
|
|
||||||
This module handles running database migrations on startup.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
from alembic import command
|
|
||||||
from alembic.config import Config
|
|
||||||
from alembic.runtime.migration import MigrationContext
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from constants import DATABASE_URL
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def run_migrations():
|
|
||||||
"""
|
|
||||||
Run database migrations using Alembic.
|
|
||||||
This function will upgrade the database to the latest migration.
|
|
||||||
Fully automated - handles all scenarios automatically.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# FIRST: Check if database has any application tables (excluding alembic_version)
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
with engine.connect() as connection:
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
inspector = inspect(connection)
|
|
||||||
existing_tables = [table for table in inspector.get_table_names()
|
|
||||||
if not table.startswith('sqlite_') and table != 'alembic_version']
|
|
||||||
|
|
||||||
# If no application tables exist, create them directly from models
|
|
||||||
if not existing_tables:
|
|
||||||
logger.info("No application tables found. Creating all tables directly from models...")
|
|
||||||
from models import Base
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
logger.info("All tables created successfully from models.")
|
|
||||||
|
|
||||||
# Get the directory where this script is located
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
# Create Alembic configuration
|
|
||||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
|
||||||
|
|
||||||
# Disable Alembic's logging configuration to avoid interfering with FastAPI
|
|
||||||
alembic_cfg.set_main_option("configure_logging", "false")
|
|
||||||
|
|
||||||
# Set the database URL in the config
|
|
||||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
|
||||||
|
|
||||||
# Check if any migration files exist
|
|
||||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
|
||||||
|
|
||||||
if not os.path.exists(versions_dir):
|
|
||||||
os.makedirs(versions_dir)
|
|
||||||
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
|
||||||
|
|
||||||
if not migration_files:
|
|
||||||
logger.info("No migration files found. Creating initial migration...")
|
|
||||||
# Check if database exists and has tables
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
with engine.connect() as connection:
|
|
||||||
from sqlalchemy import text
|
|
||||||
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
|
|
||||||
existing_tables = result.fetchall()
|
|
||||||
|
|
||||||
if existing_tables:
|
|
||||||
logger.info("Found existing database with tables. Creating migration to match current schema...")
|
|
||||||
# Create migration with autogenerate to detect differences
|
|
||||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration from existing database")
|
|
||||||
|
|
||||||
# Check if the generated migration is empty (common with existing databases)
|
|
||||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
|
||||||
if migration_files:
|
|
||||||
latest_migration = max(migration_files)
|
|
||||||
migration_path = os.path.join(versions_dir, latest_migration)
|
|
||||||
|
|
||||||
# Check if migration is empty
|
|
||||||
with open(migration_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content:
|
|
||||||
logger.info("Generated migration is empty. Creating complete schema migration...")
|
|
||||||
# Remove the empty migration
|
|
||||||
os.remove(migration_path)
|
|
||||||
# Create a complete migration
|
|
||||||
_create_complete_migration(alembic_cfg)
|
|
||||||
else:
|
|
||||||
logger.info("No existing tables found. Creating fresh migration...")
|
|
||||||
# Create fresh migration
|
|
||||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
|
|
||||||
logger.info("Initial migration created successfully.")
|
|
||||||
else:
|
|
||||||
# Migration files exist, check if we need to create a new migration for schema changes
|
|
||||||
logger.info("Migration files exist. Checking for pending schema changes...")
|
|
||||||
try:
|
|
||||||
# Create a new migration to detect any schema changes
|
|
||||||
command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration for schema changes")
|
|
||||||
|
|
||||||
# Check if the new migration is empty (no changes detected)
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
|
||||||
if migration_files:
|
|
||||||
latest_migration = max(migration_files)
|
|
||||||
migration_path = os.path.join(versions_dir, latest_migration)
|
|
||||||
|
|
||||||
# Check if migration is empty
|
|
||||||
with open(migration_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content and 'op.drop_table' not in content and 'op.drop_column' not in content:
|
|
||||||
logger.info("No schema changes detected. Removing empty migration...")
|
|
||||||
# Remove the empty migration
|
|
||||||
os.remove(migration_path)
|
|
||||||
else:
|
|
||||||
logger.info("Schema changes detected. New migration created.")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.info(f"No new migrations needed or error creating migration: {e}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if database is in an inconsistent state (has alembic_version but no tables)
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
with engine.connect() as connection:
|
|
||||||
from sqlalchemy import text, inspect
|
|
||||||
inspector = inspect(connection)
|
|
||||||
existing_tables = inspector.get_table_names()
|
|
||||||
|
|
||||||
# Check if we have alembic_version but no actual tables
|
|
||||||
if 'alembic_version' in existing_tables and len(existing_tables) == 1:
|
|
||||||
logger.info("Database has alembic_version but no actual tables - resetting migration state...")
|
|
||||||
# Clear alembic_version and start fresh
|
|
||||||
connection.execute(text("DELETE FROM alembic_version"))
|
|
||||||
connection.commit()
|
|
||||||
logger.info("Reset migration state - will create fresh migration")
|
|
||||||
|
|
||||||
# Run the upgrade command
|
|
||||||
logger.info("Running database migrations...")
|
|
||||||
try:
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
logger.info("Database migrations completed successfully.")
|
|
||||||
except Exception as upgrade_error:
|
|
||||||
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
|
||||||
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
|
||||||
# Clear the alembic_version table and start fresh
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
with engine.connect() as connection:
|
|
||||||
from sqlalchemy import text
|
|
||||||
connection.execute(text("DELETE FROM alembic_version"))
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
# Set the correct revision in alembic_version table
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
|
||||||
|
|
||||||
if migration_files:
|
|
||||||
# Get the latest migration file and extract its revision ID
|
|
||||||
latest_migration = max(migration_files)
|
|
||||||
migration_path = os.path.join(versions_dir, latest_migration)
|
|
||||||
|
|
||||||
with open(migration_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
# Extract revision ID from the file
|
|
||||||
import re
|
|
||||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
|
||||||
if revision_match:
|
|
||||||
revision_id = revision_match.group(1)
|
|
||||||
logger.info(f"Setting alembic_version to {revision_id}")
|
|
||||||
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
# Try upgrade again
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
logger.info("Database migrations completed successfully after reset.")
|
|
||||||
elif "no such table" in str(upgrade_error).lower():
|
|
||||||
logger.info("Database tables missing - resetting migration state...")
|
|
||||||
# Clear the alembic_version table and start fresh
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
with engine.connect() as connection:
|
|
||||||
from sqlalchemy import text
|
|
||||||
connection.execute(text("DELETE FROM alembic_version"))
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
# Try upgrade again
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
logger.info("Database migrations completed successfully after reset.")
|
|
||||||
else:
|
|
||||||
raise upgrade_error
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error running database migrations: {e}")
|
|
||||||
# Fully automated recovery - handle ALL error scenarios
|
|
||||||
logger.info("Attempting automated recovery...")
|
|
||||||
try:
|
|
||||||
# Clear the alembic_version table to reset state
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
with engine.connect() as connection:
|
|
||||||
from sqlalchemy import text
|
|
||||||
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
# Check if we have existing migration files
|
|
||||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
|
||||||
|
|
||||||
if migration_files:
|
|
||||||
# We have migration files, just fix the alembic_version table
|
|
||||||
logger.info("Found existing migration files, fixing alembic_version table...")
|
|
||||||
latest_migration = max(migration_files)
|
|
||||||
migration_path = os.path.join(versions_dir, latest_migration)
|
|
||||||
|
|
||||||
with open(migration_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
import re
|
|
||||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
|
||||||
if revision_match:
|
|
||||||
revision_id = revision_match.group(1)
|
|
||||||
logger.info(f"Setting alembic_version to {revision_id}")
|
|
||||||
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
# Try upgrade again
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
logger.info("Automated recovery completed successfully.")
|
|
||||||
else:
|
|
||||||
# No migration files, create fresh ones
|
|
||||||
logger.info("No migration files found, creating fresh migration...")
|
|
||||||
_create_complete_migration(alembic_cfg)
|
|
||||||
|
|
||||||
# Run the migration
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
logger.info("Automated recovery completed successfully.")
|
|
||||||
|
|
||||||
except Exception as recovery_error:
|
|
||||||
logger.error(f"Automated recovery failed: {recovery_error}")
|
|
||||||
# Last resort: create database using SQLAlchemy directly
|
|
||||||
logger.info("Using fallback: creating database directly...")
|
|
||||||
_create_database_directly()
|
|
||||||
logger.info("Database created successfully using fallback method.")
|
|
||||||
|
|
||||||
|
|
||||||
def _create_complete_migration(alembic_cfg):
|
|
||||||
"""Create a complete migration file with all database schema."""
|
|
||||||
# Create a new migration file
|
|
||||||
command.revision(alembic_cfg, message="Complete schema migration")
|
|
||||||
|
|
||||||
# Get the latest migration file
|
|
||||||
versions_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "alembic", "versions")
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
|
||||||
latest_migration = max(migration_files) if migration_files else None
|
|
||||||
|
|
||||||
if latest_migration:
|
|
||||||
migration_path = os.path.join(versions_dir, latest_migration)
|
|
||||||
_populate_migration_file(migration_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _populate_migration_file(migration_path):
|
|
||||||
"""Populate a migration file with the complete database schema from models."""
|
|
||||||
# Generate the migration content dynamically from models
|
|
||||||
migration_content = _generate_migration_from_models()
|
|
||||||
|
|
||||||
# Read the current migration file
|
|
||||||
with open(migration_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Add datetime import if needed
|
|
||||||
if "datetime.now" in migration_content and "from datetime import datetime" not in content:
|
|
||||||
# Insert the import after the existing imports
|
|
||||||
import re
|
|
||||||
content = re.sub(
|
|
||||||
r'(from alembic import op\nimport sqlalchemy as sa\n)',
|
|
||||||
r'\1from datetime import datetime\n',
|
|
||||||
content
|
|
||||||
)
|
|
||||||
|
|
||||||
# Replace the empty upgrade/downgrade functions
|
|
||||||
import re
|
|
||||||
# More flexible regex to match the actual content
|
|
||||||
content = re.sub(
|
|
||||||
r'def upgrade\(\) -> None:.*?pass.*?(?=\n\ndef downgrade|\n\nif __name__|\Z)',
|
|
||||||
migration_content,
|
|
||||||
content,
|
|
||||||
flags=re.DOTALL
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write the updated content back
|
|
||||||
with open(migration_path, 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_migration_from_models():
|
|
||||||
"""Generate migration content dynamically from SQLAlchemy models."""
|
|
||||||
from models import Base
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# Generate migration content using Alembic's op functions
|
|
||||||
upgrade_statements = []
|
|
||||||
downgrade_statements = []
|
|
||||||
|
|
||||||
# Get all tables from Base metadata
|
|
||||||
for table_name, table in Base.metadata.tables.items():
|
|
||||||
if table_name != 'alembic_version': # Skip alembic_version table
|
|
||||||
# Check if table exists and compare schema
|
|
||||||
schema_diff = _detect_schema_differences(table_name, table)
|
|
||||||
|
|
||||||
if schema_diff['table_exists']:
|
|
||||||
if schema_diff['needs_update']:
|
|
||||||
# Generate ALTER TABLE statements for existing table
|
|
||||||
upgrade_statements.append(f" # Update {table_name} table schema")
|
|
||||||
for statement in schema_diff['alter_statements']:
|
|
||||||
upgrade_statements.append(f" {statement}")
|
|
||||||
else:
|
|
||||||
# Table exists and is up to date - skip creating it
|
|
||||||
upgrade_statements.append(f" # Table {table_name} already exists and is up to date")
|
|
||||||
else:
|
|
||||||
# Generate CREATE TABLE for new table
|
|
||||||
table_code = _generate_table_creation_code(table_name, table)
|
|
||||||
upgrade_statements.append(f" # Create {table_name} table")
|
|
||||||
upgrade_statements.append(table_code)
|
|
||||||
|
|
||||||
# Only add to downgrade if table actually exists
|
|
||||||
if schema_diff['table_exists']:
|
|
||||||
downgrade_statements.append(f" # op.drop_table('{table_name}') # Skipped - table exists")
|
|
||||||
else:
|
|
||||||
downgrade_statements.append(f" op.drop_table('{table_name}')")
|
|
||||||
|
|
||||||
# Combine all statements
|
|
||||||
upgrade_content = "def upgrade() -> None:\n \"\"\"Upgrade schema.\"\"\"\n" + "\n".join(upgrade_statements)
|
|
||||||
downgrade_content = "def downgrade() -> None:\n \"\"\"Downgrade schema.\"\"\"\n" + "\n".join(downgrade_statements)
|
|
||||||
|
|
||||||
return upgrade_content + "\n\n" + downgrade_content
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_schema_differences(table_name, expected_table):
|
|
||||||
"""Detect differences between existing table and expected schema."""
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
|
|
||||||
with engine.connect() as connection:
|
|
||||||
from sqlalchemy import text, inspect
|
|
||||||
|
|
||||||
# Check if table exists
|
|
||||||
inspector = inspect(connection)
|
|
||||||
if table_name not in inspector.get_table_names():
|
|
||||||
return {
|
|
||||||
'table_exists': False,
|
|
||||||
'needs_update': False,
|
|
||||||
'alter_statements': []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get existing columns
|
|
||||||
existing_columns = inspector.get_columns(table_name)
|
|
||||||
existing_column_names = {col['name'] for col in existing_columns}
|
|
||||||
|
|
||||||
# Get expected columns
|
|
||||||
expected_column_names = {col.name for col in expected_table.columns}
|
|
||||||
|
|
||||||
# Check for missing columns
|
|
||||||
missing_columns = expected_column_names - existing_column_names
|
|
||||||
extra_columns = existing_column_names - expected_column_names
|
|
||||||
|
|
||||||
alter_statements = []
|
|
||||||
|
|
||||||
# Add missing columns
|
|
||||||
for column in expected_table.columns:
|
|
||||||
if column.name in missing_columns:
|
|
||||||
column_def = _generate_column_definition(column)
|
|
||||||
alter_statements.append(f"op.add_column('{table_name}', {column_def})")
|
|
||||||
|
|
||||||
# Add missing indexes
|
|
||||||
for index in expected_table.indexes:
|
|
||||||
if not index.unique:
|
|
||||||
cols = "', '".join([col.name for col in index.columns])
|
|
||||||
alter_statements.append(f"op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'table_exists': True,
|
|
||||||
'needs_update': len(alter_statements) > 0,
|
|
||||||
'alter_statements': alter_statements
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_column_definition(column):
|
|
||||||
"""Generate column definition for ALTER TABLE."""
|
|
||||||
type_def = _get_column_type(column)
|
|
||||||
nullable = "nullable=True" if column.nullable else "nullable=False"
|
|
||||||
|
|
||||||
definition = f"sa.Column('{column.name}', {type_def}, {nullable}"
|
|
||||||
|
|
||||||
# Handle default values properly
|
|
||||||
if column.default is not None:
|
|
||||||
if hasattr(column.default, 'arg'):
|
|
||||||
# Handle callable defaults
|
|
||||||
if callable(column.default.arg):
|
|
||||||
definition += f", default=datetime.now"
|
|
||||||
else:
|
|
||||||
definition += f", default={repr(column.default.arg)}"
|
|
||||||
else:
|
|
||||||
definition += f", default={repr(column.default)}"
|
|
||||||
|
|
||||||
definition += ")"
|
|
||||||
return definition
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_table_creation_code(table_name, table):
|
|
||||||
"""Generate op.create_table code for a SQLAlchemy table."""
|
|
||||||
lines = [f" op.create_table('{table_name}',"]
|
|
||||||
|
|
||||||
# Collect all table items (columns + constraints)
|
|
||||||
all_items = []
|
|
||||||
|
|
||||||
# Add columns
|
|
||||||
for column in table.columns:
|
|
||||||
column_def = f" sa.Column('{column.name}', {_get_column_type(column)}, nullable={column.nullable}"
|
|
||||||
if column.default is not None:
|
|
||||||
# Handle callable defaults properly
|
|
||||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
|
||||||
column_def += f", default=datetime.now"
|
|
||||||
else:
|
|
||||||
column_def += f", default={repr(column.default)}"
|
|
||||||
column_def += ")"
|
|
||||||
all_items.append(column_def)
|
|
||||||
|
|
||||||
# Add constraints
|
|
||||||
for constraint in table.constraints:
|
|
||||||
if hasattr(constraint, 'columns'):
|
|
||||||
if constraint.__class__.__name__ == 'PrimaryKeyConstraint':
|
|
||||||
all_items.append(f" sa.PrimaryKeyConstraint('{constraint.columns.keys()[0]}')")
|
|
||||||
elif constraint.__class__.__name__ == 'UniqueConstraint':
|
|
||||||
cols = "', '".join(constraint.columns.keys())
|
|
||||||
all_items.append(f" sa.UniqueConstraint('{cols}')")
|
|
||||||
|
|
||||||
# Add foreign key constraints
|
|
||||||
for fk in table.foreign_keys:
|
|
||||||
all_items.append(f" sa.ForeignKeyConstraint(['{fk.parent.name}'], ['{fk.column.table.name}.{fk.column.name}'], )")
|
|
||||||
|
|
||||||
# Add all items with commas (except the last one)
|
|
||||||
for i, item in enumerate(all_items):
|
|
||||||
if i < len(all_items) - 1:
|
|
||||||
item += ","
|
|
||||||
lines.append(item)
|
|
||||||
|
|
||||||
lines.append(" )")
|
|
||||||
|
|
||||||
# Add indexes with IF NOT EXISTS equivalent using try/except
|
|
||||||
for index in table.indexes:
|
|
||||||
if not index.unique:
|
|
||||||
cols = "', '".join([col.name for col in index.columns])
|
|
||||||
lines.append(f" # Create index for {table_name}")
|
|
||||||
lines.append(f" try:")
|
|
||||||
lines.append(f" op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
|
||||||
lines.append(f" except Exception:")
|
|
||||||
lines.append(f" pass # Index may already exist")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_column_type(column):
|
|
||||||
"""Get SQLAlchemy column type string."""
|
|
||||||
type_name = column.type.__class__.__name__
|
|
||||||
|
|
||||||
if type_name == 'String':
|
|
||||||
return f"sa.String(length={column.type.length})"
|
|
||||||
elif type_name == 'Integer':
|
|
||||||
return "sa.Integer()"
|
|
||||||
elif type_name == 'Text':
|
|
||||||
return "sa.Text()"
|
|
||||||
elif type_name == 'Boolean':
|
|
||||||
return "sa.Boolean()"
|
|
||||||
elif type_name == 'DateTime':
|
|
||||||
return "sa.DateTime()"
|
|
||||||
else:
|
|
||||||
return f"sa.{type_name}()"
|
|
||||||
|
|
||||||
|
|
||||||
def _create_database_directly():
|
|
||||||
"""Fallback method: create database directly using SQLAlchemy."""
|
|
||||||
from models import Base
|
|
||||||
from db import engine
|
|
||||||
from sqlalchemy import text, inspect
|
|
||||||
|
|
||||||
# Check existing tables and update schema
|
|
||||||
with engine.connect() as connection:
|
|
||||||
inspector = inspect(connection)
|
|
||||||
existing_tables = inspector.get_table_names()
|
|
||||||
|
|
||||||
# For each model table, check if it needs updates
|
|
||||||
for table_name, table in Base.metadata.tables.items():
|
|
||||||
if table_name != 'alembic_version':
|
|
||||||
if table_name in existing_tables:
|
|
||||||
# Table exists, check for missing columns
|
|
||||||
existing_columns = {col['name'] for col in inspector.get_columns(table_name)}
|
|
||||||
expected_columns = {col.name for col in table.columns}
|
|
||||||
missing_columns = expected_columns - existing_columns
|
|
||||||
|
|
||||||
# Add missing columns
|
|
||||||
for column in table.columns:
|
|
||||||
if column.name in missing_columns:
|
|
||||||
# Convert to raw SQL for direct execution
|
|
||||||
sql_type = _get_sql_type(column)
|
|
||||||
nullable = "NULL" if column.nullable else "NOT NULL"
|
|
||||||
|
|
||||||
# Handle datetime columns without default (SQLite limitation)
|
|
||||||
if column.type.__class__.__name__ == 'DateTime':
|
|
||||||
# Add column without default, then update existing rows
|
|
||||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}"
|
|
||||||
try:
|
|
||||||
connection.execute(text(alter_sql))
|
|
||||||
logger.info(f"Added column {column.name} to {table_name}")
|
|
||||||
|
|
||||||
# Update existing rows with current timestamp
|
|
||||||
update_sql = f"UPDATE {table_name} SET {column.name} = CURRENT_TIMESTAMP WHERE {column.name} IS NULL"
|
|
||||||
connection.execute(text(update_sql))
|
|
||||||
logger.info(f"Updated {column.name} with current timestamp")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Could not add column {column.name}: {e}")
|
|
||||||
else:
|
|
||||||
# Handle other column types with defaults
|
|
||||||
default_clause = ""
|
|
||||||
if column.default is not None:
|
|
||||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
|
||||||
# Skip callable defaults for SQLite compatibility
|
|
||||||
pass
|
|
||||||
elif hasattr(column.default, 'arg'):
|
|
||||||
default_clause = f" DEFAULT {repr(column.default.arg)}"
|
|
||||||
|
|
||||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}{default_clause}"
|
|
||||||
try:
|
|
||||||
connection.execute(text(alter_sql))
|
|
||||||
logger.info(f"Added column {column.name} to {table_name}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Could not add column {column.name}: {e}")
|
|
||||||
else:
|
|
||||||
# Table doesn't exist, create it
|
|
||||||
logger.info(f"Creating table {table_name}")
|
|
||||||
|
|
||||||
# Create alembic_version table manually
|
|
||||||
connection.execute(text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS alembic_version (
|
|
||||||
version_num VARCHAR(32) NOT NULL,
|
|
||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# Get the correct revision ID from existing migration files
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
|
||||||
|
|
||||||
if migration_files:
|
|
||||||
latest_migration = max(migration_files)
|
|
||||||
migration_path = os.path.join(versions_dir, latest_migration)
|
|
||||||
|
|
||||||
with open(migration_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
import re
|
|
||||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
|
||||||
if revision_match:
|
|
||||||
revision_id = revision_match.group(1)
|
|
||||||
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
|
||||||
else:
|
|
||||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
|
||||||
else:
|
|
||||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
|
||||||
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_sql_type(column):
|
|
||||||
"""Get SQL type for direct SQL execution."""
|
|
||||||
type_name = column.type.__class__.__name__
|
|
||||||
|
|
||||||
if type_name == 'String':
|
|
||||||
return f"VARCHAR({column.type.length})"
|
|
||||||
elif type_name == 'Integer':
|
|
||||||
return "INTEGER"
|
|
||||||
elif type_name == 'Text':
|
|
||||||
return "TEXT"
|
|
||||||
elif type_name == 'Boolean':
|
|
||||||
return "BOOLEAN"
|
|
||||||
elif type_name == 'DateTime':
|
|
||||||
return "DATETIME"
|
|
||||||
else:
|
|
||||||
return "TEXT" # fallback
|
|
||||||
|
|
||||||
|
|
||||||
def check_migration_status():
|
|
||||||
"""
|
|
||||||
Check if the database needs migrations.
|
|
||||||
Returns True if migrations are needed, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Create engine
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
|
|
||||||
# Check if alembic_version table exists
|
|
||||||
with engine.connect() as connection:
|
|
||||||
# Check if alembic_version table exists
|
|
||||||
from sqlalchemy import text
|
|
||||||
result = connection.execute(
|
|
||||||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'")
|
|
||||||
)
|
|
||||||
alembic_table_exists = result.fetchone() is not None
|
|
||||||
|
|
||||||
if not alembic_table_exists:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Get current migration context
|
|
||||||
context = MigrationContext.configure(connection)
|
|
||||||
current_rev = context.get_current_revision()
|
|
||||||
|
|
||||||
# Get the latest revision from alembic
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
|
||||||
script_dir = command.ScriptDirectory.from_config(alembic_cfg)
|
|
||||||
head_rev = script_dir.get_current_head()
|
|
||||||
|
|
||||||
return current_rev != head_rev
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking migration status: {e}")
|
|
||||||
return True # Assume migrations are needed if we can't check
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# This allows running migrations directly
|
|
||||||
run_migrations()
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from datetime import datetime
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
# Модели базы данных
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "user"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
|
||||||
display_name = Column(String(64), nullable=False)
|
|
||||||
password_hash = Column(String(200), nullable=False)
|
|
||||||
profile_picture = Column(String(255), nullable=True)
|
|
||||||
bio = Column(Text, nullable=True)
|
|
||||||
online = Column(Boolean, default=False)
|
|
||||||
last_seen = Column(DateTime, default=datetime.now)
|
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
|
||||||
verified = Column(Boolean, default=False)
|
|
||||||
suspended = Column(Boolean, default=False)
|
|
||||||
suspension_reason = Column(Text, nullable=True)
|
|
||||||
deleted = Column(Boolean, default=False)
|
|
||||||
messages = relationship("Message", back_populates="author", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class Message(Base):
|
|
||||||
__tablename__ = "message"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
content = Column(Text, nullable=False)
|
|
||||||
timestamp = Column(DateTime, default=datetime.now)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
||||||
is_read = Column(Boolean, default=False)
|
|
||||||
reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True)
|
|
||||||
is_edited = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
author = relationship("User", back_populates="messages")
|
|
||||||
reply_to = relationship("Message", remote_side=[id])
|
|
||||||
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
|
||||||
reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class MessageFile(Base):
|
|
||||||
__tablename__ = "message_file"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
|
||||||
path = Column(Text, nullable=False)
|
|
||||||
name = Column(Text, nullable=False)
|
|
||||||
|
|
||||||
message = relationship("Message", back_populates="files")
|
|
||||||
|
|
||||||
|
|
||||||
class CryptoPublicKey(Base):
|
|
||||||
__tablename__ = "crypto_public_key"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
|
||||||
public_key_b64 = Column(Text, nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
class CryptoBackup(Base):
|
|
||||||
__tablename__ = "crypto_backup"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
|
||||||
blob_json = Column(Text, nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
class DMEnvelope(Base):
|
|
||||||
__tablename__ = "dm_envelope"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
||||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
||||||
iv_b64 = Column(Text, nullable=False)
|
|
||||||
ciphertext_b64 = Column(Text, nullable=False)
|
|
||||||
salt_b64 = Column(Text, nullable=False)
|
|
||||||
iv2_b64 = Column(Text, nullable=False)
|
|
||||||
wrapped_mk_b64 = Column(Text, nullable=False)
|
|
||||||
reply_to_id = Column(Integer, nullable=True)
|
|
||||||
timestamp = Column(DateTime, default=datetime.now)
|
|
||||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
|
||||||
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class DMFile(Base):
|
|
||||||
__tablename__ = "dm_file"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
|
||||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
||||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
||||||
name = Column(Text, nullable=False)
|
|
||||||
path = Column(Text, nullable=False)
|
|
||||||
|
|
||||||
message = relationship("DMEnvelope", back_populates="files")
|
|
||||||
|
|
||||||
|
|
||||||
class PushSubscription(Base):
|
|
||||||
__tablename__ = "push_subscription"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
||||||
endpoint = Column(Text, nullable=False)
|
|
||||||
p256dh_key = Column(Text, nullable=False)
|
|
||||||
auth_key = Column(Text, nullable=False)
|
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
|
||||||
|
|
||||||
|
|
||||||
class FcmToken(Base):
|
|
||||||
__tablename__ = "fcm_token"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
|
||||||
token = Column(Text, nullable=False, unique=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
|
||||||
|
|
||||||
|
|
||||||
class Reaction(Base):
|
|
||||||
__tablename__ = "reaction"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
||||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
|
||||||
timestamp = Column(DateTime, default=datetime.now)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user = relationship("User")
|
|
||||||
|
|
||||||
# Ensure unique combination of message, user, and emoji
|
|
||||||
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
|
|
||||||
|
|
||||||
|
|
||||||
class DMReaction(Base):
|
|
||||||
__tablename__ = "dm_reaction"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
||||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
|
||||||
timestamp = Column(DateTime, default=datetime.now)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user = relationship("User")
|
|
||||||
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
|
|
||||||
|
|
||||||
# Ensure unique combination of dm_envelope, user, and emoji
|
|
||||||
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
|
||||||
|
|
||||||
|
|
||||||
# Tracks authenticated device sessions per user
|
|
||||||
class DeviceSession(Base):
|
|
||||||
__tablename__ = "device_session"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
|
||||||
|
|
||||||
# Raw User-Agent for reference/debugging
|
|
||||||
raw_user_agent = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# Parsed fields
|
|
||||||
device_name = Column(String(128), nullable=True)
|
|
||||||
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
|
|
||||||
os_name = Column(String(64), nullable=True)
|
|
||||||
os_version = Column(String(64), nullable=True)
|
|
||||||
browser_name = Column(String(64), nullable=True)
|
|
||||||
browser_version = Column(String(64), nullable=True)
|
|
||||||
brand = Column(String(64), nullable=True)
|
|
||||||
model = Column(String(64), nullable=True)
|
|
||||||
|
|
||||||
# Session identity embedded into JWTs
|
|
||||||
session_id = Column(String(64), unique=True, nullable=False, index=True)
|
|
||||||
|
|
||||||
# Lifecycle
|
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
|
||||||
last_seen = Column(DateTime, default=datetime.now)
|
|
||||||
revoked = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Relationship back to user (optional lazy to avoid heavy loads)
|
|
||||||
user = relationship("User", lazy="select")
|
|
||||||
|
|
||||||
# Pydantic модели
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
display_name: str
|
|
||||||
password: str
|
|
||||||
confirm_password: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
|
||||||
currentPasswordDerived: str
|
|
||||||
newPasswordDerived: str
|
|
||||||
logoutAllExceptCurrent: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
|
||||||
content: str
|
|
||||||
reply_to_id: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class EditMessageRequest(BaseModel):
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteMessageRequest(BaseModel):
|
|
||||||
message_id: int
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateBioRequest(BaseModel):
|
|
||||||
bio: str
|
|
||||||
|
|
||||||
|
|
||||||
class PushSubscriptionRequest(BaseModel):
|
|
||||||
endpoint: str
|
|
||||||
keys: dict
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
username: str
|
|
||||||
display_name: str
|
|
||||||
profile_picture: str | None
|
|
||||||
bio: str | None
|
|
||||||
online: bool
|
|
||||||
last_seen: datetime | None
|
|
||||||
created_at: datetime | None
|
|
||||||
verified: bool
|
|
||||||
suspended: bool
|
|
||||||
suspension_reason: str | None
|
|
||||||
deleted: bool
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
content: str
|
|
||||||
timestamp: datetime
|
|
||||||
is_author: bool
|
|
||||||
is_read: bool
|
|
||||||
username: str
|
|
||||||
profile_picture: str | None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class ReactionRequest(BaseModel):
|
|
||||||
message_id: int
|
|
||||||
emoji: str
|
|
||||||
|
|
||||||
|
|
||||||
class ReactionResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
message_id: int
|
|
||||||
user_id: int
|
|
||||||
emoji: str
|
|
||||||
timestamp: datetime
|
|
||||||
username: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class DMReactionRequest(BaseModel):
|
|
||||||
dm_envelope_id: int
|
|
||||||
emoji: str
|
|
||||||
|
|
||||||
|
|
||||||
class DMReactionResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
dm_envelope_id: int
|
|
||||||
user_id: int
|
|
||||||
emoji: str
|
|
||||||
timestamp: datetime
|
|
||||||
username: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateLog(Base):
|
|
||||||
"""Stores update sequence numbers and updates for gap detection"""
|
|
||||||
__tablename__ = "update_log"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
|
||||||
sequence = Column(Integer, nullable=False, index=True)
|
|
||||||
updates = Column(Text, nullable=False) # JSON array of updates
|
|
||||||
timestamp = Column(DateTime, default=datetime.now, index=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("user_id", "sequence", name="uq_user_sequence"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Tables are now created through Alembic migrations
|
|
||||||
# Base.metadata.create_all(bind=engine)
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import List, Optional
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from pywebpush import webpush, WebPushException
|
|
||||||
from models import PushSubscription, User, Message, DMEnvelope
|
|
||||||
from models import FcmToken
|
|
||||||
import firebase_admin
|
|
||||||
from firebase_admin import credentials as firebase_credentials
|
|
||||||
from firebase_admin import messaging as firebase_messaging
|
|
||||||
import base64
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
class PushNotificationService:
|
|
||||||
def __init__(self):
|
|
||||||
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
|
|
||||||
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
|
|
||||||
# Firebase Admin initialization (modern API). Only FIREBASE_CERT env is supported.
|
|
||||||
self.firebase_initialized = False
|
|
||||||
try:
|
|
||||||
firebase_cert = os.getenv("FIREBASE_CERT")
|
|
||||||
if not firebase_cert:
|
|
||||||
raise RuntimeError("FIREBASE_CERT env variable is required for Firebase Admin SDK initialization")
|
|
||||||
|
|
||||||
# Support raw JSON or base64-encoded JSON in FIREBASE_CERT
|
|
||||||
decoded = base64.b64decode(firebase_cert).decode("utf-8")
|
|
||||||
sa_dict = json.loads(decoded)
|
|
||||||
|
|
||||||
cred = firebase_credentials.Certificate(sa_dict)
|
|
||||||
firebase_admin.initialize_app(cred)
|
|
||||||
self.firebase_initialized = True
|
|
||||||
logger.info("Firebase Admin SDK initialized for push sending (FIREBASE_CERT)")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to initialize Firebase Admin SDK from FIREBASE_CERT: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
if (not self.vapid_public_key) or (not self.vapid_private_key):
|
|
||||||
raise ValueError("VAPID public or private key is None")
|
|
||||||
|
|
||||||
self.vapid_claims = {
|
|
||||||
"sub": "mailto:support@fromchat.ru",
|
|
||||||
"aud": "https://fcm.googleapis.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
|
|
||||||
"""Subscribe a user to push notifications"""
|
|
||||||
try:
|
|
||||||
# Check if user already has a subscription
|
|
||||||
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
|
||||||
|
|
||||||
if existing_sub:
|
|
||||||
# Update existing subscription
|
|
||||||
existing_sub.endpoint = endpoint
|
|
||||||
existing_sub.p256dh_key = p256dh_key
|
|
||||||
existing_sub.auth_key = auth_key
|
|
||||||
else:
|
|
||||||
# Create new subscription
|
|
||||||
new_sub = PushSubscription(
|
|
||||||
user_id=user_id,
|
|
||||||
endpoint=endpoint,
|
|
||||||
p256dh_key=p256dh_key,
|
|
||||||
auth_key=auth_key
|
|
||||||
)
|
|
||||||
db.add(new_sub)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Push subscription saved for user {user_id}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
|
|
||||||
db.rollback()
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
|
|
||||||
"""Send push notification for a new public chat message"""
|
|
||||||
try:
|
|
||||||
# Get all users except the sender
|
|
||||||
users = db.query(User).filter(User.id != message.user_id)
|
|
||||||
if exclude_user_id:
|
|
||||||
users = users.filter(User.id != exclude_user_id)
|
|
||||||
|
|
||||||
for user in users:
|
|
||||||
# Check if user has push subscription before trying to send
|
|
||||||
# Try all FCM tokens first (Android). If none or all fail, fall back to web push subscription.
|
|
||||||
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == user.id).all()
|
|
||||||
payload_data = {
|
|
||||||
"type": "public_message",
|
|
||||||
"message_id": message.id,
|
|
||||||
"sender_id": message.user_id,
|
|
||||||
"sender_username": message.author.username
|
|
||||||
}
|
|
||||||
title = f"{message.author.username}"
|
|
||||||
body = message.content[:100] + ("..." if len(message.content) > 100 else "")
|
|
||||||
|
|
||||||
if fcm_rows and self.firebase_initialized:
|
|
||||||
for fcm in fcm_rows:
|
|
||||||
try:
|
|
||||||
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send FCM to user {user.id} token {fcm.token}: {e}")
|
|
||||||
# Check if this is a permanent failure and clean up the token
|
|
||||||
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
|
||||||
|
|
||||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
|
||||||
if subscription:
|
|
||||||
await self._send_notification_to_user(
|
|
||||||
db, user.id, title, body, message.author.profile_picture, payload_data
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send public message notifications: {e}")
|
|
||||||
|
|
||||||
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
|
||||||
"""Send push notification for a new DM"""
|
|
||||||
try:
|
|
||||||
title = f"{sender.username}"
|
|
||||||
body = "New direct message"
|
|
||||||
payload_data = {
|
|
||||||
"type": "dm",
|
|
||||||
"dm_id": dm_envelope.id,
|
|
||||||
"sender_id": sender.id,
|
|
||||||
"sender_username": sender.username
|
|
||||||
}
|
|
||||||
|
|
||||||
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == dm_envelope.recipient_id).all()
|
|
||||||
if fcm_rows and self.firebase_initialized:
|
|
||||||
for fcm in fcm_rows:
|
|
||||||
try:
|
|
||||||
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send FCM to user {dm_envelope.recipient_id} token {fcm.token}: {e}")
|
|
||||||
# Check if this is a permanent failure and clean up the token
|
|
||||||
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
|
||||||
|
|
||||||
await self._send_notification_to_user(
|
|
||||||
db, dm_envelope.recipient_id, title, body, sender.profile_picture, payload_data
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send DM notification: {e}")
|
|
||||||
|
|
||||||
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
|
|
||||||
"""Send a push notification to a specific user"""
|
|
||||||
try:
|
|
||||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
|
||||||
if not subscription:
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"title": title,
|
|
||||||
"body": body,
|
|
||||||
"icon": icon or "about:blank",
|
|
||||||
"tag": f"message_{user_id}",
|
|
||||||
"data": data
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription_info = {
|
|
||||||
"endpoint": subscription.endpoint,
|
|
||||||
"keys": {
|
|
||||||
"p256dh": subscription.p256dh_key,
|
|
||||||
"auth": subscription.auth_key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webpush(
|
|
||||||
subscription_info=subscription_info,
|
|
||||||
data=json.dumps(payload),
|
|
||||||
vapid_private_key=self.vapid_private_key,
|
|
||||||
vapid_claims=self.vapid_claims
|
|
||||||
)
|
|
||||||
|
|
||||||
except WebPushException as e:
|
|
||||||
logger.error(f"WebPush error for user {user_id}: {e}")
|
|
||||||
# If the subscription is invalid, remove it
|
|
||||||
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
|
|
||||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
|
||||||
db.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
|
||||||
|
|
||||||
def _send_fcm_to_token(self, token: str, title: str, body: str, data: dict):
|
|
||||||
"""Send an FCM data-only push to a single device token using Firebase Admin SDK.
|
|
||||||
Notification display is handled by the app, not FCM."""
|
|
||||||
if not self.firebase_initialized:
|
|
||||||
raise RuntimeError("Firebase Admin SDK not initialized (FIREBASE_CERT required)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Send only data payload - let the app handle notification display
|
|
||||||
# This prevents FCM from auto-showing notifications
|
|
||||||
msg = firebase_messaging.Message(
|
|
||||||
token=token,
|
|
||||||
data={
|
|
||||||
"title": title,
|
|
||||||
"body": body,
|
|
||||||
**{k: str(v) for k, v in (data or {}).items()}
|
|
||||||
},
|
|
||||||
android=firebase_messaging.AndroidConfig(priority="high"),
|
|
||||||
apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"})
|
|
||||||
)
|
|
||||||
resp = firebase_messaging.send(msg)
|
|
||||||
return resp
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Firebase Admin send failed for token {token}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _cleanup_failed_fcm_token(self, db: Session, fcm_token_entry, error_message: str):
|
|
||||||
"""Clean up FCM tokens that have permanent failures"""
|
|
||||||
try:
|
|
||||||
# Check for permanent failure indicators in the error message
|
|
||||||
permanent_errors = [
|
|
||||||
"unregistered", "invalidregistration", "notregistered",
|
|
||||||
"sender_id_mismatch", "invalid_argument"
|
|
||||||
]
|
|
||||||
|
|
||||||
error_lower = error_message.lower()
|
|
||||||
is_permanent = any(permanent_error in error_lower for permanent_error in permanent_errors)
|
|
||||||
|
|
||||||
if is_permanent:
|
|
||||||
logger.info(f"Removing permanently failed FCM token for user {fcm_token_entry.user_id}: {fcm_token_entry.token}")
|
|
||||||
db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete()
|
|
||||||
db.commit()
|
|
||||||
else:
|
|
||||||
logger.debug(f"Temporary FCM failure for token {fcm_token_entry.token}, keeping token: {error_message}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to cleanup FCM token {fcm_token_entry.token}: {e}")
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
|
||||||
"""Unsubscribe a user from push notifications"""
|
|
||||||
try:
|
|
||||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Push subscription removed for user {user_id}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
|
|
||||||
db.rollback()
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
push_service = PushNotificationService()
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
PyJWT>=2.8.0
|
|
||||||
fastapi[standard]>=0.116.1
|
|
||||||
pydantic>=2.11.7
|
|
||||||
sqlalchemy>=2.0.43
|
|
||||||
bcrypt>=4.3.0
|
|
||||||
websockets>=15.0.1
|
|
||||||
Pillow>=10.0.0
|
|
||||||
python-multipart>=0.0.6
|
|
||||||
pywebpush>=1.14.0
|
|
||||||
cryptography>=41.0.0
|
|
||||||
alembic>=1.13.2
|
|
||||||
better-profanity>=0.7.0
|
|
||||||
user-agents>=2.2.0
|
|
||||||
httpx>=0.27.2
|
|
||||||
rich>=13.9.4
|
|
||||||
slowapi>=0.1.9
|
|
||||||
firebase_admin>=7.1.0
|
|
||||||
@@ -1,574 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from collections import defaultdict, deque
|
|
||||||
import time
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import inspect, text
|
|
||||||
import uuid
|
|
||||||
from user_agents import parse as parse_ua
|
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
||||||
|
|
||||||
from constants import OWNER_USERNAME
|
|
||||||
from dependencies import get_current_user, get_db
|
|
||||||
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
|
||||||
from utils import create_token, get_password_hash, verify_password, get_client_ip
|
|
||||||
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
|
||||||
import os
|
|
||||||
|
|
||||||
from security.audit import log_security
|
|
||||||
from security.profanity import contains_profanity
|
|
||||||
from security.rate_limit import rate_limit_per_ip
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
|
||||||
_FAILED_ATTEMPT_THRESHOLD = 5
|
|
||||||
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_failed_login(identifier: str) -> bool:
|
|
||||||
now = time.time()
|
|
||||||
attempts = _failed_login_attempts[identifier]
|
|
||||||
attempts.append(now)
|
|
||||||
|
|
||||||
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
|
|
||||||
attempts.popleft()
|
|
||||||
|
|
||||||
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_failed_logins(identifier: str) -> None:
|
|
||||||
_failed_login_attempts.pop(identifier, None)
|
|
||||||
|
|
||||||
def _is_admin(user: User) -> bool:
|
|
||||||
return user.id == 1
|
|
||||||
|
|
||||||
def convert_user(user: User) -> dict:
|
|
||||||
return {
|
|
||||||
"id": user.id,
|
|
||||||
"created_at": user.created_at.isoformat(),
|
|
||||||
"last_seen": user.last_seen.isoformat(),
|
|
||||||
"online": user.online,
|
|
||||||
"username": user.username,
|
|
||||||
"display_name": user.display_name,
|
|
||||||
"profile_picture": user.profile_picture,
|
|
||||||
"bio": user.bio,
|
|
||||||
"admin": _is_admin(user),
|
|
||||||
"verified": user.verified,
|
|
||||||
"suspended": user.suspended or False,
|
|
||||||
"suspension_reason": user.suspension_reason,
|
|
||||||
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.get("/check_auth")
|
|
||||||
def check_auth(current_user: User = Depends(get_current_user)):
|
|
||||||
return {
|
|
||||||
"authenticated": True,
|
|
||||||
"username": current_user.username,
|
|
||||||
"admin": _is_admin(current_user)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
@rate_limit_per_ip("5/minute")
|
|
||||||
def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)):
|
|
||||||
username = login_request.username.strip()
|
|
||||||
client_ip = get_client_ip(request)
|
|
||||||
raw_ua = request.headers.get("user-agent")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.username == username).first()
|
|
||||||
|
|
||||||
if not user or not verify_password(login_request.password.strip(), user.password_hash):
|
|
||||||
log_security(
|
|
||||||
"login_failed",
|
|
||||||
severity="warning",
|
|
||||||
username=username,
|
|
||||||
ip=client_ip,
|
|
||||||
reason="invalid_credentials",
|
|
||||||
)
|
|
||||||
identifiers = [f"user:{username}"]
|
|
||||||
if client_ip:
|
|
||||||
identifiers.append(f"ip:{client_ip}")
|
|
||||||
|
|
||||||
suspicious = False
|
|
||||||
for identifier in identifiers:
|
|
||||||
if _record_failed_login(identifier):
|
|
||||||
suspicious = True
|
|
||||||
|
|
||||||
if suspicious:
|
|
||||||
total_failures = {
|
|
||||||
identifier: len(_failed_login_attempts.get(identifier, []))
|
|
||||||
for identifier in identifiers
|
|
||||||
}
|
|
||||||
log_security(
|
|
||||||
"auth_bruteforce_detected",
|
|
||||||
severity="warning",
|
|
||||||
username=username,
|
|
||||||
ip=client_ip,
|
|
||||||
failures=total_failures,
|
|
||||||
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Неверное имя пользователя или пароль"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create device session and embed into JWT
|
|
||||||
raw_ua = request.headers.get("user-agent")
|
|
||||||
device_name = request.headers.get("x-device-name")
|
|
||||||
ua = parse_ua(raw_ua or "")
|
|
||||||
session_id = uuid.uuid4().hex
|
|
||||||
|
|
||||||
device = DeviceSession(
|
|
||||||
user_id=user.id,
|
|
||||||
raw_user_agent=raw_ua,
|
|
||||||
device_name=device_name,
|
|
||||||
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
|
||||||
os_name=(ua.os.family or None),
|
|
||||||
os_version=(ua.os.version_string or None),
|
|
||||||
browser_name=(ua.browser.family or None),
|
|
||||||
browser_version=(ua.browser.version_string or None),
|
|
||||||
brand=(ua.device.brand or None),
|
|
||||||
model=(ua.device.model or None),
|
|
||||||
session_id=session_id,
|
|
||||||
created_at=datetime.now(),
|
|
||||||
last_seen=datetime.now(),
|
|
||||||
revoked=False,
|
|
||||||
)
|
|
||||||
db.add(device)
|
|
||||||
|
|
||||||
user.online = True
|
|
||||||
user.last_seen = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
token = create_token(user.id, user.username, session_id)
|
|
||||||
|
|
||||||
identifiers = [f"user:{username}"]
|
|
||||||
if client_ip:
|
|
||||||
identifiers.append(f"ip:{client_ip}")
|
|
||||||
for identifier in identifiers:
|
|
||||||
_reset_failed_logins(identifier)
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"login_success",
|
|
||||||
username=user.username,
|
|
||||||
user_id=user.id,
|
|
||||||
ip=client_ip,
|
|
||||||
session_id=session_id,
|
|
||||||
device=device.device_type,
|
|
||||||
os=device.os_name,
|
|
||||||
browser=device.browser_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Login successful",
|
|
||||||
"token": token,
|
|
||||||
"user": convert_user(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
|
||||||
@rate_limit_per_ip("3/hour")
|
|
||||||
def register(request: Request, register_request: RegisterRequest, db: Session = Depends(get_db)):
|
|
||||||
username = register_request.username.strip()
|
|
||||||
display_name = register_request.display_name.strip()
|
|
||||||
password = register_request.password.strip()
|
|
||||||
confirm_password = register_request.confirm_password.strip()
|
|
||||||
client_ip = get_client_ip(request)
|
|
||||||
raw_ua = request.headers.get("user-agent")
|
|
||||||
|
|
||||||
# Determine if owner already exists
|
|
||||||
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
|
||||||
|
|
||||||
# Validate input
|
|
||||||
if not is_valid_username(username):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
|
||||||
)
|
|
||||||
if contains_profanity(username):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Имя пользователя содержит запрещённые слова"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_valid_display_name(display_name):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
|
||||||
)
|
|
||||||
if contains_profanity(display_name):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Отображаемое имя содержит запрещённые слова"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_valid_password(password):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Пароль должен быть от 5 до 50 символов и не содержать пробелов"
|
|
||||||
)
|
|
||||||
|
|
||||||
if password != confirm_password:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Пароли не совпадают"
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_user = db.query(User).filter(User.username == username).first()
|
|
||||||
if existing_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Это имя пользователя уже занято"
|
|
||||||
)
|
|
||||||
|
|
||||||
hashed_password = get_password_hash(password)
|
|
||||||
|
|
||||||
# Set verified=True for the owner (first user to register)
|
|
||||||
is_owner = not owner_exists and username == OWNER_USERNAME
|
|
||||||
|
|
||||||
new_user = User(
|
|
||||||
username=username,
|
|
||||||
display_name=display_name,
|
|
||||||
password_hash=hashed_password,
|
|
||||||
online=True,
|
|
||||||
last_seen=datetime.now(),
|
|
||||||
verified=is_owner
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(new_user)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(new_user)
|
|
||||||
|
|
||||||
# Create initial device session
|
|
||||||
raw_ua = request.headers.get("user-agent")
|
|
||||||
device_name = request.headers.get("x-device-name")
|
|
||||||
ua = parse_ua(raw_ua or "")
|
|
||||||
session_id = uuid.uuid4().hex
|
|
||||||
device = DeviceSession(
|
|
||||||
user_id=new_user.id,
|
|
||||||
raw_user_agent=raw_ua,
|
|
||||||
device_name=device_name,
|
|
||||||
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
|
||||||
os_name=(ua.os.family or None),
|
|
||||||
os_version=(ua.os.version_string or None),
|
|
||||||
browser_name=(ua.browser.family or None),
|
|
||||||
browser_version=(ua.browser.version_string or None),
|
|
||||||
brand=(ua.device.brand or None),
|
|
||||||
model=(ua.device.model or None),
|
|
||||||
session_id=session_id,
|
|
||||||
created_at=datetime.now(),
|
|
||||||
last_seen=datetime.now(),
|
|
||||||
revoked=False,
|
|
||||||
)
|
|
||||||
db.add(device)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
token = create_token(new_user.id, new_user.username, session_id)
|
|
||||||
|
|
||||||
os_name = ua.os.family or "Unknown OS"
|
|
||||||
if ua.os.version_string:
|
|
||||||
os_name = f"{os_name} {ua.os.version_string}"
|
|
||||||
browser_name = ua.browser.family or "Unknown browser"
|
|
||||||
if ua.browser.version_string:
|
|
||||||
browser_name = f"{browser_name} {ua.browser.version_string}"
|
|
||||||
user_agent_summary = f"{os_name}, {browser_name}"
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"registration_success",
|
|
||||||
username=new_user.username,
|
|
||||||
display_name=new_user.display_name,
|
|
||||||
user_id=new_user.id,
|
|
||||||
ip=client_ip,
|
|
||||||
user_agent=user_agent_summary,
|
|
||||||
owner=is_owner,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Регистрация прошла успешно",
|
|
||||||
"token": token,
|
|
||||||
"user": convert_user(new_user)
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.get("/crypto/public-key")
|
|
||||||
def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
||||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
|
||||||
return {"publicKey": row.public_key_b64 if row else None}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/crypto/public-key")
|
|
||||||
def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
||||||
pk = payload.get("publicKey")
|
|
||||||
if not pk:
|
|
||||||
raise HTTPException(status_code=400, detail="publicKey required")
|
|
||||||
if not isinstance(pk, str) or len(pk) > 10000 or len(pk) < 10:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid publicKey format")
|
|
||||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
|
||||||
if row:
|
|
||||||
row.public_key_b64 = pk
|
|
||||||
else:
|
|
||||||
row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk)
|
|
||||||
db.add(row)
|
|
||||||
db.commit()
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/crypto/backup")
|
|
||||||
def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
||||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
|
||||||
return {"blob": row.blob_json if row else None}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/crypto/backup")
|
|
||||||
def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
||||||
blob = payload.get("blob")
|
|
||||||
if not blob:
|
|
||||||
raise HTTPException(status_code=400, detail="blob required")
|
|
||||||
if not isinstance(blob, str) or len(blob) > 1000000: # 1MB limit
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid blob format or size exceeds 1MB")
|
|
||||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
|
||||||
if row:
|
|
||||||
row.blob_json = blob
|
|
||||||
else:
|
|
||||||
row = CryptoBackup(user_id=current_user.id, blob_json=blob)
|
|
||||||
db.add(row)
|
|
||||||
db.commit()
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/admin/user/{user_id}")
|
|
||||||
def delete_user_as_owner(
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
# Only owner can delete users
|
|
||||||
if _is_admin(current_user):
|
|
||||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Prevent deleting the owner account via API
|
|
||||||
if _is_admin(user):
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete owner account")
|
|
||||||
|
|
||||||
# Manually delete user's messages to satisfy FK constraints
|
|
||||||
from models import Message # local import to avoid circular
|
|
||||||
db.query(Message).filter(Message.user_id == user.id).delete()
|
|
||||||
|
|
||||||
db.delete(user)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"admin_delete_user",
|
|
||||||
severity="warning",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_username=user.username,
|
|
||||||
target_id=user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "success", "deleted_user_id": user_id}
|
|
||||||
|
|
||||||
@router.get("/logout")
|
|
||||||
def logout(
|
|
||||||
http: Request,
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
# Revoke current session
|
|
||||||
from utils import verify_token as _verify_token
|
|
||||||
payload = _verify_token(credentials.credentials)
|
|
||||||
if payload and payload.get("session_id"):
|
|
||||||
db.query(DeviceSession).filter(
|
|
||||||
DeviceSession.user_id == current_user.id,
|
|
||||||
DeviceSession.session_id == payload["session_id"],
|
|
||||||
).update({DeviceSession.revoked: True})
|
|
||||||
|
|
||||||
current_user.online = False
|
|
||||||
current_user.last_seen = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
client_ip = get_client_ip(http)
|
|
||||||
log_security(
|
|
||||||
"logout",
|
|
||||||
username=current_user.username,
|
|
||||||
user_id=current_user.id,
|
|
||||||
ip=client_ip,
|
|
||||||
session_id=payload.get("session_id") if payload else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Logged out successfully"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password")
|
|
||||||
@rate_limit_per_ip("5/hour")
|
|
||||||
def change_password(
|
|
||||||
request: Request,
|
|
||||||
password_request: ChangePasswordRequest,
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
# Verify current derived password against stored hash
|
|
||||||
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
|
|
||||||
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
|
|
||||||
|
|
||||||
# Update password hash to hash of new derived password
|
|
||||||
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip())
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Optionally revoke all other sessions, keeping the current one
|
|
||||||
if password_request.logoutAllExceptCurrent:
|
|
||||||
from utils import verify_token as _verify_token
|
|
||||||
payload = _verify_token(credentials.credentials)
|
|
||||||
if not payload:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid token")
|
|
||||||
current_session_id = payload.get("session_id")
|
|
||||||
db.query(DeviceSession).filter(
|
|
||||||
DeviceSession.user_id == current_user.id,
|
|
||||||
DeviceSession.session_id != current_session_id,
|
|
||||||
).update({DeviceSession.revoked: True})
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
client_ip = get_client_ip(request)
|
|
||||||
log_security(
|
|
||||||
"password_changed",
|
|
||||||
username=current_user.username,
|
|
||||||
user_id=current_user.id,
|
|
||||||
ip=client_ip,
|
|
||||||
logout_others=bool(password_request.logoutAllExceptCurrent),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
|
||||||
@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse
|
|
||||||
def list_users(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
||||||
users = db.query(User).order_by(User.username.asc()).all()
|
|
||||||
return {
|
|
||||||
"users": [
|
|
||||||
convert_user(u) for u in users if u.id != current_user.id
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/crypto/public-key/of/{user_id}")
|
|
||||||
@rate_limit_per_ip("100/minute") # Per-IP limit to prevent abuse
|
|
||||||
def get_public_key_of(request: Request, user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
||||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
|
|
||||||
return {"publicKey": row.public_key_b64 if row else None}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/search")
|
|
||||||
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
|
||||||
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
||||||
if len(q.strip()) < 2:
|
|
||||||
return {"users": []}
|
|
||||||
|
|
||||||
# Case-insensitive partial match on username
|
|
||||||
users = db.query(User).filter(
|
|
||||||
User.username.ilike(f"%{q.strip()}%"),
|
|
||||||
User.id != current_user.id # Exclude current user
|
|
||||||
).order_by(User.username.asc()).limit(20).all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"users": [convert_user(u) for u in users]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _delete_user_data(user: User, db: Session):
|
|
||||||
"""
|
|
||||||
Helper function to delete user data - marks user as deleted, clears sensitive data,
|
|
||||||
deletes profile picture, removes non-whitelist user data, and sends WebSocket message.
|
|
||||||
"""
|
|
||||||
user_id = user.id
|
|
||||||
|
|
||||||
# Mark user as deleted and clear sensitive data
|
|
||||||
user.deleted = True
|
|
||||||
user.display_name = f"Deleted User #{user_id}"
|
|
||||||
user.bio = None
|
|
||||||
user.password_hash = ""
|
|
||||||
user.username = f"deleted_{user_id}"
|
|
||||||
user.profile_picture = None
|
|
||||||
user.last_seen = None # Clear last seen timestamp
|
|
||||||
user.created_at = None # Clear member since timestamp
|
|
||||||
|
|
||||||
# Delete profile picture file if exists
|
|
||||||
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"):
|
|
||||||
try:
|
|
||||||
filename = user.profile_picture.split("/")[-1]
|
|
||||||
filepath = os.path.join("data/uploads/pfp", filename)
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but don't fail the request
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Dynamic deletion of all non-whitelist data
|
|
||||||
WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
inspector = inspect(db.bind)
|
|
||||||
all_tables = inspector.get_table_names()
|
|
||||||
|
|
||||||
for table_name in all_tables:
|
|
||||||
if table_name in WHITELIST_TABLES or table_name == "user":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if table has user_id column
|
|
||||||
columns = inspector.get_columns(table_name)
|
|
||||||
has_user_id = any(col['name'] == 'user_id' for col in columns)
|
|
||||||
|
|
||||||
if has_user_id:
|
|
||||||
# Delete all records for this user
|
|
||||||
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
except Exception as e:
|
|
||||||
# Log error and rollback
|
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to delete user data")
|
|
||||||
|
|
||||||
# Send WebSocket deletion message
|
|
||||||
try:
|
|
||||||
from .messaging import messagingManager
|
|
||||||
await messagingManager.send_deletion_to_user(user_id)
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but don't fail the request
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete")
|
|
||||||
async def delete_account(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete the current user's own account - preserves messages/DMs/reactions/files
|
|
||||||
"""
|
|
||||||
# Prevent admin/owner account self-deletion
|
|
||||||
if _is_admin(current_user):
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
|
|
||||||
|
|
||||||
await _delete_user_data(current_user, db)
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"self_delete_account",
|
|
||||||
severity="warning",
|
|
||||||
user_id=current_user.id,
|
|
||||||
username=current_user.username,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Account deleted successfully"
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dependencies import get_current_user, get_db
|
|
||||||
from models import User, DeviceSession
|
|
||||||
from utils import verify_token
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_current_session_id(credentials: HTTPAuthorizationCredentials) -> str:
|
|
||||||
token = credentials.credentials
|
|
||||||
payload = verify_token(token)
|
|
||||||
if not payload or "session_id" not in payload:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid session")
|
|
||||||
return payload["session_id"]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_devices(
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
current_session_id = _get_current_session_id(credentials)
|
|
||||||
sessions = (
|
|
||||||
db.query(DeviceSession)
|
|
||||||
.filter(DeviceSession.user_id == current_user.id, DeviceSession.revoked == False)
|
|
||||||
.order_by(DeviceSession.last_seen.desc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"devices": [
|
|
||||||
{
|
|
||||||
"session_id": s.session_id,
|
|
||||||
"device_type": s.device_type,
|
|
||||||
"device_name": s.device_name,
|
|
||||||
"os_name": s.os_name,
|
|
||||||
"os_version": s.os_version,
|
|
||||||
"browser_name": s.browser_name,
|
|
||||||
"browser_version": s.browser_version,
|
|
||||||
"brand": s.brand,
|
|
||||||
"model": s.model,
|
|
||||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
|
||||||
"last_seen": s.last_seen.isoformat() if s.last_seen else None,
|
|
||||||
"revoked": s.revoked,
|
|
||||||
"current": s.session_id == current_session_id,
|
|
||||||
}
|
|
||||||
for s in sessions
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{session_id}")
|
|
||||||
def revoke_device(
|
|
||||||
session_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
if not session_id or len(session_id) > 64 or len(session_id) < 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid session ID")
|
|
||||||
|
|
||||||
s = (
|
|
||||||
db.query(DeviceSession)
|
|
||||||
.filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not s:
|
|
||||||
raise HTTPException(status_code=404, detail="Device session not found")
|
|
||||||
s.revoked = True
|
|
||||||
db.commit()
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout-all")
|
|
||||||
def logout_all_except_current(
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
current_session_id = _get_current_session_id(credentials)
|
|
||||||
db.query(DeviceSession).filter(
|
|
||||||
DeviceSession.user_id == current_user.id,
|
|
||||||
DeviceSession.session_id != current_session_id,
|
|
||||||
).update({DeviceSession.revoked: True})
|
|
||||||
db.commit()
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
"""
|
|
||||||
Download routes for FromChat desktop and mobile builds.
|
|
||||||
Fetches from GitHub Actions (PC) and GitHub Releases (mobile), with disk caching.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/download", tags=["download"])
|
|
||||||
|
|
||||||
GITHUB_API = "https://api.github.com"
|
|
||||||
WEB_OWNER, WEB_REPO = "fromchat-messenger", "web"
|
|
||||||
APP_OWNER, APP_REPO = "fromchat-messenger", "app"
|
|
||||||
WORKFLOW_FILE = "build.yml"
|
|
||||||
TIMEOUT = 10.0
|
|
||||||
|
|
||||||
ARTIFACT_NAMES = {
|
|
||||||
"windows": "FromChat-windows",
|
|
||||||
"linux": "FromChat-linux",
|
|
||||||
"macos": "FromChat-macOS",
|
|
||||||
}
|
|
||||||
|
|
||||||
CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "downloads"
|
|
||||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _headers() -> dict[str, str]:
|
|
||||||
token = os.environ.get("RELEASES_TOKEN")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=503, detail="RELEASES_TOKEN not configured")
|
|
||||||
return {
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Accept": "application/vnd.github+json",
|
|
||||||
"X-GitHub-Api-Version": "2022-11-28",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _etag_path(os_name: str) -> Path:
|
|
||||||
return CACHE_DIR / f"{os_name}.etag"
|
|
||||||
|
|
||||||
|
|
||||||
def _cached_file_path(os_name: str) -> Path:
|
|
||||||
ext = ".zip" if os_name in ARTIFACT_NAMES else (".apk" if os_name == "android" else ".ipa")
|
|
||||||
return CACHE_DIR / f"{os_name}{ext}"
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_pc_artifact_url(os_name: str) -> tuple[str, int]:
|
|
||||||
"""Fetch workflow runs, get latest run, find artifact. Returns (download_url, artifact_id)."""
|
|
||||||
artifact_name = ARTIFACT_NAMES[os_name]
|
|
||||||
logger.info("[download] Fetching PC artifact for %s: workflow=%s/%s/%s", os_name, WEB_OWNER, WEB_REPO, WORKFLOW_FILE)
|
|
||||||
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
|
||||||
runs_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/workflows/{WORKFLOW_FILE}/runs"
|
|
||||||
logger.info("[download] GitHub API: GET %s (per_page=1, status=success)", runs_url)
|
|
||||||
runs_resp = await client.get(
|
|
||||||
runs_url,
|
|
||||||
headers=_headers(),
|
|
||||||
params={"per_page": 1, "status": "success"},
|
|
||||||
)
|
|
||||||
logger.info("[download] GitHub workflow runs response: status=%s", runs_resp.status_code)
|
|
||||||
runs_resp.raise_for_status()
|
|
||||||
runs = runs_resp.json()
|
|
||||||
workflow_runs = runs.get("workflow_runs", [])
|
|
||||||
if not workflow_runs:
|
|
||||||
logger.warning("[download] No successful workflow runs for %s", artifact_name)
|
|
||||||
raise HTTPException(status_code=404, detail=f"No successful workflow run for {artifact_name}")
|
|
||||||
|
|
||||||
run_id = workflow_runs[0]["id"]
|
|
||||||
logger.info("[download] Latest run_id=%s, fetching artifacts", run_id)
|
|
||||||
artifacts_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/runs/{run_id}/artifacts"
|
|
||||||
artifacts_resp = await client.get(artifacts_url, headers=_headers())
|
|
||||||
logger.info("[download] GitHub artifacts response: status=%s", artifacts_resp.status_code)
|
|
||||||
artifacts_resp.raise_for_status()
|
|
||||||
data = artifacts_resp.json()
|
|
||||||
for artifact in data.get("artifacts", []):
|
|
||||||
if artifact["name"] == artifact_name:
|
|
||||||
url = artifact["archive_download_url"]
|
|
||||||
aid = artifact["id"]
|
|
||||||
logger.info("[download] Found artifact %s id=%s, download_url=%s", artifact_name, aid, url[:80] + "..." if len(url) > 80 else url)
|
|
||||||
return url, aid
|
|
||||||
logger.warning("[download] Artifact %s not found in run %s", artifact_name, run_id)
|
|
||||||
raise HTTPException(status_code=404, detail=f"Artifact {artifact_name} not found")
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_mobile_asset_url(os_name: str) -> str:
|
|
||||||
"""Fetch latest release, find asset by name. Returns browser_download_url."""
|
|
||||||
keyword = "android" if os_name == "android" else "ios"
|
|
||||||
logger.info("[download] Fetching mobile asset for %s: releases %s/%s", os_name, APP_OWNER, APP_REPO)
|
|
||||||
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
|
||||||
releases_url = f"{GITHUB_API}/repos/{APP_OWNER}/{APP_REPO}/releases"
|
|
||||||
logger.info("[download] GitHub API: GET %s (per_page=10)", releases_url)
|
|
||||||
resp = await client.get(
|
|
||||||
releases_url,
|
|
||||||
headers=_headers(),
|
|
||||||
params={"per_page": 10},
|
|
||||||
)
|
|
||||||
logger.info("[download] GitHub releases response: status=%s", resp.status_code)
|
|
||||||
resp.raise_for_status()
|
|
||||||
releases = resp.json()
|
|
||||||
for release in releases:
|
|
||||||
if release.get("draft"):
|
|
||||||
continue
|
|
||||||
for asset in release.get("assets", []):
|
|
||||||
if keyword.lower() in asset.get("name", "").lower():
|
|
||||||
url = asset["browser_download_url"]
|
|
||||||
logger.info("[download] Found %s asset: %s (release: %s)", os_name, asset.get("name"), release.get("tag_name"))
|
|
||||||
return url
|
|
||||||
logger.warning("[download] No %s asset in releases", os_name)
|
|
||||||
raise HTTPException(status_code=404, detail=f"No {os_name} asset found in releases")
|
|
||||||
|
|
||||||
|
|
||||||
async def _download_and_stream(
|
|
||||||
url: str,
|
|
||||||
os_name: str,
|
|
||||||
stored_etag: str | None,
|
|
||||||
) -> StreamingResponse | FileResponse:
|
|
||||||
"""Stream from GitHub to client and save to disk. If 304, serve from disk."""
|
|
||||||
etag_path = _etag_path(os_name)
|
|
||||||
cache_path = _cached_file_path(os_name)
|
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
headers = {**_headers(), "Accept": "*/*"}
|
|
||||||
if stored_etag:
|
|
||||||
headers["If-None-Match"] = stored_etag
|
|
||||||
|
|
||||||
logger.info("[download] Mobile %s: GET %s (etag=%s)", os_name, url[:100] + "..." if len(url) > 100 else url, stored_etag or "none")
|
|
||||||
|
|
||||||
async def stream_and_save():
|
|
||||||
total = 0
|
|
||||||
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
|
|
||||||
new_etag: str | None = None
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
||||||
async with client.stream("GET", url, headers=headers) as resp:
|
|
||||||
if resp.status_code == 304 and cache_path.exists():
|
|
||||||
yield None
|
|
||||||
return
|
|
||||||
if resp.status_code != 200:
|
|
||||||
if resp.status_code in (404, 410):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Release asset not found on GitHub",
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="GitHub returned an error while downloading asset",
|
|
||||||
)
|
|
||||||
new_etag = resp.headers.get("etag")
|
|
||||||
logger.info("[download] Mobile %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
|
|
||||||
with open(tmp_path, "wb") as f:
|
|
||||||
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
|
||||||
f.write(chunk)
|
|
||||||
total += len(chunk)
|
|
||||||
yield chunk
|
|
||||||
tmp_path.rename(cache_path)
|
|
||||||
if new_etag:
|
|
||||||
etag_path.write_text(new_etag)
|
|
||||||
logger.info("[download] Mobile %s: completed, saved %d bytes", os_name, total)
|
|
||||||
except httpx.StreamClosed:
|
|
||||||
logger.info("[download] Mobile %s: client disconnected after %d bytes", os_name, total)
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
if cache_path.exists():
|
|
||||||
raise _CacheFallback()
|
|
||||||
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
|
||||||
except HTTPException:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
class _CacheFallback(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
gen = stream_and_save()
|
|
||||||
try:
|
|
||||||
first = await gen.__anext__()
|
|
||||||
except StopAsyncIteration:
|
|
||||||
first = None
|
|
||||||
except _CacheFallback:
|
|
||||||
await gen.aclose()
|
|
||||||
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
|
|
||||||
if first is None:
|
|
||||||
await gen.aclose()
|
|
||||||
logger.info("[download] Mobile %s: serving from cache (304)", os_name)
|
|
||||||
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
|
|
||||||
|
|
||||||
async def body():
|
|
||||||
yield first
|
|
||||||
async for chunk in gen:
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
body(),
|
|
||||||
media_type="application/octet-stream",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_artifact_download_url(url: str) -> str:
|
|
||||||
"""Resolve artifact URL: GitHub 302 redirects to Azure; Azure rejects Authorization. Get Location without following."""
|
|
||||||
headers = {**_headers(), "Accept": "application/vnd.github+json"}
|
|
||||||
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
|
||||||
resp = await client.get(url, headers=headers)
|
|
||||||
if resp.status_code in (404, 410):
|
|
||||||
raise HTTPException(status_code=404, detail="Artifact not found on GitHub")
|
|
||||||
if resp.status_code != 302:
|
|
||||||
raise HTTPException(status_code=503, detail="GitHub returned an error while resolving artifact URL")
|
|
||||||
location = resp.headers.get("location")
|
|
||||||
if not location:
|
|
||||||
raise HTTPException(status_code=502, detail="No redirect location from GitHub")
|
|
||||||
return location
|
|
||||||
|
|
||||||
|
|
||||||
async def _download_artifact_and_stream(
|
|
||||||
url: str,
|
|
||||||
os_name: str,
|
|
||||||
artifact_id: int,
|
|
||||||
) -> StreamingResponse | FileResponse:
|
|
||||||
"""Download artifact (zip). GitHub redirects to Azure; Azure must be called WITHOUT Authorization."""
|
|
||||||
etag_path = _etag_path(os_name)
|
|
||||||
cache_path = _cached_file_path(os_name)
|
|
||||||
stored_id = etag_path.read_text().strip() if etag_path.exists() else None
|
|
||||||
if stored_id == str(artifact_id) and cache_path.exists():
|
|
||||||
logger.info("[download] PC %s: serving from cache (artifact_id=%s)", os_name, artifact_id)
|
|
||||||
return FileResponse(
|
|
||||||
str(cache_path),
|
|
||||||
media_type="application/zip",
|
|
||||||
filename=cache_path.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
download_url = await _resolve_artifact_download_url(url)
|
|
||||||
except HTTPException:
|
|
||||||
if cache_path.exists():
|
|
||||||
logger.info("[download] PC %s: GitHub error, serving from cache", os_name)
|
|
||||||
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
|
|
||||||
raise
|
|
||||||
|
|
||||||
logger.info("[download] PC %s: streaming from Azure URL (no auth)", os_name)
|
|
||||||
|
|
||||||
async def stream_and_save():
|
|
||||||
total = 0
|
|
||||||
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
||||||
async with client.stream("GET", download_url) as resp:
|
|
||||||
if resp.status_code != 200:
|
|
||||||
if resp.status_code in (404, 410):
|
|
||||||
raise HTTPException(status_code=404, detail="Artifact file not found on GitHub")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="GitHub returned an error while downloading artifact file",
|
|
||||||
)
|
|
||||||
logger.info("[download] PC %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
|
|
||||||
with open(tmp_path, "wb") as f:
|
|
||||||
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
|
||||||
f.write(chunk)
|
|
||||||
total += len(chunk)
|
|
||||||
yield chunk
|
|
||||||
tmp_path.rename(cache_path)
|
|
||||||
etag_path.write_text(str(artifact_id))
|
|
||||||
logger.info("[download] PC %s: completed, saved %d bytes", os_name, total)
|
|
||||||
except httpx.StreamClosed:
|
|
||||||
logger.info("[download] PC %s: client disconnected after %d bytes", os_name, total)
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
except HTTPException:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
gen = stream_and_save()
|
|
||||||
try:
|
|
||||||
first = await gen.__anext__()
|
|
||||||
except StopAsyncIteration:
|
|
||||||
first = None
|
|
||||||
except HTTPException:
|
|
||||||
if cache_path.exists():
|
|
||||||
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
|
|
||||||
raise
|
|
||||||
|
|
||||||
if first is None:
|
|
||||||
await gen.aclose()
|
|
||||||
raise HTTPException(status_code=502, detail="Empty response from download")
|
|
||||||
|
|
||||||
async def body():
|
|
||||||
yield first
|
|
||||||
async for chunk in gen:
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
body(),
|
|
||||||
media_type="application/zip",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _head_response(filename: str, content_length: int | None = None) -> Response:
|
|
||||||
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
|
||||||
if content_length is not None:
|
|
||||||
headers["Content-Length"] = str(content_length)
|
|
||||||
return Response(status_code=200, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/{os_name}", methods=["GET", "HEAD"])
|
|
||||||
async def download(request: Request, os_name: str):
|
|
||||||
"""Download app for the given OS: windows, linux, macos, android, ios."""
|
|
||||||
is_head = request.method == "HEAD"
|
|
||||||
os_name = os_name.lower()
|
|
||||||
logger.info("[download] %s /download/%s", request.method, os_name)
|
|
||||||
|
|
||||||
if os_name not in ("windows", "linux", "macos", "android", "ios"):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid os. Use: windows, linux, macos, android, ios")
|
|
||||||
|
|
||||||
try:
|
|
||||||
if os_name in ARTIFACT_NAMES:
|
|
||||||
try:
|
|
||||||
url, artifact_id = await asyncio.wait_for(
|
|
||||||
_fetch_pc_artifact_url(os_name),
|
|
||||||
timeout=TIMEOUT,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("[download] PC %s: GitHub API timeout", os_name)
|
|
||||||
cache_path = _cached_file_path(os_name)
|
|
||||||
if cache_path.exists():
|
|
||||||
if is_head:
|
|
||||||
return _head_response(cache_path.name, cache_path.stat().st_size)
|
|
||||||
return FileResponse(
|
|
||||||
str(cache_path),
|
|
||||||
media_type="application/zip",
|
|
||||||
filename=cache_path.name,
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
|
||||||
cache_path = _cached_file_path(os_name)
|
|
||||||
result = await _download_artifact_and_stream(url, os_name, artifact_id)
|
|
||||||
if is_head:
|
|
||||||
fn = getattr(result, "filename", None) or cache_path.name
|
|
||||||
size = cache_path.stat().st_size if cache_path.exists() else None
|
|
||||||
return _head_response(fn, size)
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
stored_etag = None
|
|
||||||
etag_path = _etag_path(os_name)
|
|
||||||
cache_path = _cached_file_path(os_name)
|
|
||||||
if etag_path.exists():
|
|
||||||
stored_etag = etag_path.read_text().strip() or None
|
|
||||||
|
|
||||||
try:
|
|
||||||
url = await asyncio.wait_for(
|
|
||||||
_fetch_mobile_asset_url(os_name),
|
|
||||||
timeout=TIMEOUT,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("[download] Mobile %s: GitHub API timeout", os_name)
|
|
||||||
if cache_path.exists():
|
|
||||||
if is_head:
|
|
||||||
return _head_response(cache_path.name, cache_path.stat().st_size)
|
|
||||||
return FileResponse(
|
|
||||||
str(cache_path),
|
|
||||||
media_type="application/octet-stream",
|
|
||||||
filename=cache_path.name,
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
|
||||||
|
|
||||||
result = await _download_and_stream(url, os_name, stored_etag)
|
|
||||||
if is_head:
|
|
||||||
fn = getattr(result, "filename", None) or cache_path.name
|
|
||||||
size = cache_path.stat().st_size if cache_path.exists() else None
|
|
||||||
return _head_response(fn, size)
|
|
||||||
return result
|
|
||||||
except HTTPException as exc:
|
|
||||||
if exc.status_code in (404, 410):
|
|
||||||
raise HTTPException(status_code=404, detail=exc.detail)
|
|
||||||
if exc.status_code in (502, 503, 504):
|
|
||||||
raise HTTPException(status_code=503, detail=exc.detail)
|
|
||||||
raise
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from constants import OWNER_USERNAME
|
|
||||||
from dependencies import get_current_user
|
|
||||||
from models import User
|
|
||||||
from security.audit import log_security
|
|
||||||
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
|
||||||
from security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
|
||||||
|
|
||||||
|
|
||||||
class BlocklistUpdateRequest(BaseModel):
|
|
||||||
words: List[str] = Field(default_factory=list, min_items=1)
|
|
||||||
|
|
||||||
|
|
||||||
class UnblockIPRequest(BaseModel):
|
|
||||||
ip: str = Field(..., min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/moderation", tags=["moderation"])
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_owner(user: User) -> None:
|
|
||||||
if user.username != OWNER_USERNAME:
|
|
||||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/blocklist")
|
|
||||||
def list_blocklist(current_user: User = Depends(get_current_user)):
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
return {"words": get_blocklist()}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/blocklist")
|
|
||||||
def append_blocklist(
|
|
||||||
request: BlocklistUpdateRequest,
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
added, updated = add_to_blocklist(request.words)
|
|
||||||
log_security(
|
|
||||||
"blocklist_add",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
added=added,
|
|
||||||
)
|
|
||||||
return {"added": added, "words": updated}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/blocklist")
|
|
||||||
def delete_from_blocklist(
|
|
||||||
request: BlocklistUpdateRequest,
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
removed, updated = remove_from_blocklist(request.words)
|
|
||||||
log_security(
|
|
||||||
"blocklist_remove",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
removed=removed,
|
|
||||||
)
|
|
||||||
return {"removed": removed, "words": updated}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/unblock-ip")
|
|
||||||
def unblock_ip(
|
|
||||||
request: UnblockIPRequest,
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""Unblock an IP address from rate limiting."""
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
ip = request.ip.strip()
|
|
||||||
|
|
||||||
if not ip:
|
|
||||||
raise HTTPException(status_code=400, detail="IP address is required")
|
|
||||||
|
|
||||||
cleared = reset_rate_limit_for_ip(ip)
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"rate_limit_unblock",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
ip=ip,
|
|
||||||
success=cleared,
|
|
||||||
)
|
|
||||||
|
|
||||||
if cleared:
|
|
||||||
return {"status": "success", "message": f"Rate limit cleared for IP: {ip}"}
|
|
||||||
else:
|
|
||||||
return {"status": "success", "message": f"No rate limit entries found for IP: {ip}"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clear-all-rate-limits")
|
|
||||||
def clear_all_rate_limits_endpoint(
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""Clear all rate limit entries. Use with caution."""
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
|
|
||||||
cleared = clear_all_rate_limits()
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"rate_limit_clear_all",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
entries_cleared=cleared,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "success", "message": f"Cleared {cleared} rate limit entries"}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from PIL import Image
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import io
|
|
||||||
from fastapi import Request
|
|
||||||
|
|
||||||
from dependencies import get_db, get_current_user
|
|
||||||
from models import User, UpdateBioRequest, UserProfileResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from validation import is_valid_username, is_valid_display_name
|
|
||||||
from similarity import is_user_similar_to_verified
|
|
||||||
from .messaging import messagingManager
|
|
||||||
from security.audit import log_security
|
|
||||||
from security.profanity import contains_profanity
|
|
||||||
from security.rate_limit import rate_limit_per_ip
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_owner_unsuspended(user: User | None, db: Session):
|
|
||||||
if user and user.id == 1 and user.suspended:
|
|
||||||
user.suspended = False
|
|
||||||
user.suspension_reason = None
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
|
|
||||||
# Request models
|
|
||||||
class UpdateProfileRequest(BaseModel):
|
|
||||||
username: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
# Create uploads directory if it doesn't exist
|
|
||||||
PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
|
|
||||||
|
|
||||||
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
@router.post("/upload-profile-picture")
|
|
||||||
@rate_limit_per_ip("10/minute")
|
|
||||||
async def upload_profile_picture(
|
|
||||||
request: Request,
|
|
||||||
profile_picture: UploadFile = File(...),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Upload and process a profile picture
|
|
||||||
"""
|
|
||||||
# Validate file type
|
|
||||||
if not profile_picture.content_type.startswith('image/'):
|
|
||||||
raise HTTPException(status_code=400, detail="File must be an image")
|
|
||||||
|
|
||||||
# Validate file size (max 5MB)
|
|
||||||
if profile_picture.size > 5 * 1024 * 1024:
|
|
||||||
raise HTTPException(status_code=400, detail="File size must be less than 5MB")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Read and process the image
|
|
||||||
image_data = await profile_picture.read()
|
|
||||||
|
|
||||||
# Open image with PIL
|
|
||||||
image = Image.open(io.BytesIO(image_data))
|
|
||||||
|
|
||||||
# Convert to RGB if necessary
|
|
||||||
if image.mode != 'RGB':
|
|
||||||
image = image.convert('RGB')
|
|
||||||
|
|
||||||
# Resize to a reasonable size (200x200)
|
|
||||||
image.thumbnail((200, 200), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Generate unique filename
|
|
||||||
filename = f"{current_user.id}_{uuid.uuid4().hex}.jpg"
|
|
||||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
|
||||||
|
|
||||||
# Save the processed image
|
|
||||||
image.save(filepath, 'JPEG', quality=85)
|
|
||||||
|
|
||||||
# Update user's profile picture in database
|
|
||||||
profile_picture_url = f"/api/profile-picture/{filename}"
|
|
||||||
current_user.profile_picture = profile_picture_url
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Profile picture uploaded successfully",
|
|
||||||
"profile_picture_url": profile_picture_url
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
|
|
||||||
|
|
||||||
@router.get("/profile-picture/{filename}")
|
|
||||||
async def get_profile_picture(filename: str):
|
|
||||||
"""
|
|
||||||
Serve profile picture files
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not re.match(r"^\d+_[0-9a-z]+\.jpg$", filename):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
|
||||||
|
|
||||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
|
||||||
raise HTTPException(status_code=404, detail="Profile picture not found")
|
|
||||||
|
|
||||||
return FileResponse(filepath, media_type="image/jpeg")
|
|
||||||
|
|
||||||
@router.get("/user/profile")
|
|
||||||
async def get_user_profile(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get current user's profile information
|
|
||||||
"""
|
|
||||||
_ensure_owner_unsuspended(current_user, db)
|
|
||||||
|
|
||||||
return UserProfileResponse(
|
|
||||||
id=current_user.id,
|
|
||||||
username=current_user.username,
|
|
||||||
display_name=current_user.display_name,
|
|
||||||
profile_picture=current_user.profile_picture,
|
|
||||||
bio=current_user.bio,
|
|
||||||
online=current_user.online,
|
|
||||||
last_seen=current_user.last_seen,
|
|
||||||
created_at=current_user.created_at,
|
|
||||||
verified=current_user.verified,
|
|
||||||
suspended=current_user.suspended or False,
|
|
||||||
suspension_reason=current_user.suspension_reason,
|
|
||||||
deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/user/list")
|
|
||||||
async def list_users(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
if current_user.id != 1:
|
|
||||||
raise HTTPException(status_code=403, detail="Only admin can list users")
|
|
||||||
|
|
||||||
_ensure_owner_unsuspended(current_user, db)
|
|
||||||
|
|
||||||
users = db.query(User).order_by(User.username.asc()).all()
|
|
||||||
return {
|
|
||||||
"users": [
|
|
||||||
UserProfileResponse(
|
|
||||||
id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
display_name=user.display_name,
|
|
||||||
profile_picture=user.profile_picture,
|
|
||||||
bio=user.bio,
|
|
||||||
online=user.online,
|
|
||||||
last_seen=user.last_seen,
|
|
||||||
created_at=user.created_at,
|
|
||||||
verified=user.verified,
|
|
||||||
suspended=user.suspended or False,
|
|
||||||
suspension_reason=user.suspension_reason,
|
|
||||||
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
|
|
||||||
).model_dump()
|
|
||||||
for user in users
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.put("/user/profile")
|
|
||||||
@rate_limit_per_ip("10/minute")
|
|
||||||
async def update_user_profile(
|
|
||||||
request: Request,
|
|
||||||
update_request: UpdateProfileRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update current user's profile information
|
|
||||||
"""
|
|
||||||
updated = False
|
|
||||||
|
|
||||||
# Update username if provided
|
|
||||||
if update_request.username is not None:
|
|
||||||
username = update_request.username.strip()
|
|
||||||
if not is_valid_username(username):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
|
||||||
)
|
|
||||||
if contains_profanity(username):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Имя пользователя содержит запрещённые слова"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if username is already taken by another user
|
|
||||||
existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first()
|
|
||||||
if existing_user:
|
|
||||||
raise HTTPException(status_code=400, detail="Это имя пользователя уже занято")
|
|
||||||
|
|
||||||
current_user.username = username
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
# Update display name if provided
|
|
||||||
if update_request.display_name is not None:
|
|
||||||
display_name = update_request.display_name.strip()
|
|
||||||
if not is_valid_display_name(display_name):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
|
||||||
)
|
|
||||||
if contains_profanity(display_name):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Отображаемое имя содержит запрещённые слова"
|
|
||||||
)
|
|
||||||
|
|
||||||
current_user.display_name = display_name
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
# Update bio if provided
|
|
||||||
if update_request.description is not None:
|
|
||||||
bio = update_request.description.strip()
|
|
||||||
if len(bio) > 500:
|
|
||||||
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
|
|
||||||
|
|
||||||
current_user.bio = bio
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
db.commit()
|
|
||||||
return {
|
|
||||||
"message": "Profile updated successfully",
|
|
||||||
"username": current_user.username,
|
|
||||||
"display_name": current_user.display_name,
|
|
||||||
"bio": current_user.bio
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"message": "No changes made",
|
|
||||||
"username": current_user.username,
|
|
||||||
"display_name": current_user.display_name,
|
|
||||||
"bio": current_user.bio
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/user/bio")
|
|
||||||
@rate_limit_per_ip("10/minute")
|
|
||||||
async def update_user_bio(
|
|
||||||
request: Request,
|
|
||||||
bio_request: UpdateBioRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update current user's bio
|
|
||||||
"""
|
|
||||||
if len(bio_request.bio) > 500: # Limit bio to 500 characters
|
|
||||||
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
|
|
||||||
|
|
||||||
current_user.bio = bio_request.bio.strip()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Bio updated successfully",
|
|
||||||
"bio": current_user.bio
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/user/{username}")
|
|
||||||
async def get_user_by_username(
|
|
||||||
username: str,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get user profile by username
|
|
||||||
"""
|
|
||||||
if not username or not is_valid_username(username):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid username format")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.username == username).first()
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
_ensure_owner_unsuspended(user, db)
|
|
||||||
|
|
||||||
# Handle deleted or suspended users
|
|
||||||
if user.deleted or user.suspended:
|
|
||||||
return UserProfileResponse(
|
|
||||||
id=user.id,
|
|
||||||
username="deleted",
|
|
||||||
display_name="Deleted User",
|
|
||||||
profile_picture=None,
|
|
||||||
bio=None,
|
|
||||||
online=False,
|
|
||||||
last_seen=None, # Clear last seen timestamp
|
|
||||||
created_at=None, # Clear member since timestamp
|
|
||||||
verified=False,
|
|
||||||
suspended=False,
|
|
||||||
suspension_reason=None,
|
|
||||||
deleted=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return UserProfileResponse(
|
|
||||||
id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
display_name=user.display_name,
|
|
||||||
profile_picture=user.profile_picture,
|
|
||||||
bio=user.bio,
|
|
||||||
online=user.online,
|
|
||||||
last_seen=user.last_seen,
|
|
||||||
created_at=user.created_at,
|
|
||||||
verified=user.verified,
|
|
||||||
suspended=user.suspended or False,
|
|
||||||
suspension_reason=user.suspension_reason,
|
|
||||||
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/user/id/{user_id}")
|
|
||||||
async def get_user_by_id(
|
|
||||||
user_id: int,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get user profile by user ID
|
|
||||||
"""
|
|
||||||
if user_id <= 0:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid user ID")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
_ensure_owner_unsuspended(user, db)
|
|
||||||
|
|
||||||
# Handle deleted or suspended users
|
|
||||||
if user.deleted or user.suspended:
|
|
||||||
return UserProfileResponse(
|
|
||||||
id=user.id,
|
|
||||||
username="deleted",
|
|
||||||
display_name="Deleted User",
|
|
||||||
profile_picture=None,
|
|
||||||
bio=None,
|
|
||||||
online=False,
|
|
||||||
last_seen=None, # Clear last seen timestamp
|
|
||||||
created_at=None, # Clear member since timestamp
|
|
||||||
verified=False,
|
|
||||||
suspended=False,
|
|
||||||
suspension_reason=None,
|
|
||||||
deleted=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return UserProfileResponse(
|
|
||||||
id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
display_name=user.display_name,
|
|
||||||
profile_picture=user.profile_picture,
|
|
||||||
bio=user.bio,
|
|
||||||
online=user.online,
|
|
||||||
last_seen=user.last_seen,
|
|
||||||
created_at=user.created_at,
|
|
||||||
verified=user.verified,
|
|
||||||
suspended=user.suspended or False,
|
|
||||||
suspension_reason=user.suspension_reason,
|
|
||||||
deleted=user.deleted or False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/user/{user_id}/verify")
|
|
||||||
async def verify_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Toggle verification status for a user (owner only)
|
|
||||||
"""
|
|
||||||
# Only user with ID 1 (owner) can verify users
|
|
||||||
if current_user.id != 1:
|
|
||||||
raise HTTPException(status_code=403, detail="Only owner can verify users")
|
|
||||||
|
|
||||||
target_user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not target_user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Toggle verification status
|
|
||||||
target_user.verified = not target_user.verified
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"admin_verify_toggle",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_username=target_user.username,
|
|
||||||
target_id=target_user.id,
|
|
||||||
verified=target_user.verified,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"verified": target_user.verified,
|
|
||||||
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/user/check-similarity/{user_id}")
|
|
||||||
async def check_user_similarity(
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Check if a user is similar to any verified user
|
|
||||||
"""
|
|
||||||
target_user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not target_user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Get all verified users
|
|
||||||
verified_users = db.query(User).filter(User.verified == True).all()
|
|
||||||
verified_users_data = [
|
|
||||||
{"username": user.username, "display_name": user.display_name}
|
|
||||||
for user in verified_users
|
|
||||||
]
|
|
||||||
|
|
||||||
# Check similarity
|
|
||||||
is_similar, similar_to = is_user_similar_to_verified(
|
|
||||||
target_user.username,
|
|
||||||
target_user.display_name,
|
|
||||||
verified_users_data
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"isSimilar": is_similar,
|
|
||||||
"similarTo": similar_to if is_similar else None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Admin endpoints for user management
|
|
||||||
class SuspendUserRequest(BaseModel):
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
@router.post("/user/{user_id}/suspend")
|
|
||||||
async def suspend_user(
|
|
||||||
user_id: int,
|
|
||||||
request: SuspendUserRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Suspend a user account (admin only)
|
|
||||||
"""
|
|
||||||
# Only user with ID 1 (admin) can suspend users
|
|
||||||
if current_user.id != 1:
|
|
||||||
raise HTTPException(status_code=403, detail="Only admin can suspend users")
|
|
||||||
|
|
||||||
target_user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not target_user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Cannot suspend admin
|
|
||||||
if target_user.id == 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot suspend admin account")
|
|
||||||
|
|
||||||
# Suspend the user
|
|
||||||
target_user.suspended = True
|
|
||||||
target_user.suspension_reason = request.reason
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"admin_suspend_user",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_username=target_user.username,
|
|
||||||
target_id=target_user.id,
|
|
||||||
reason=request.reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send WebSocket suspension message
|
|
||||||
try:
|
|
||||||
await messagingManager.send_suspension_to_user(user_id, request.reason)
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but don't fail the request
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"User {target_user.username} has been suspended",
|
|
||||||
"reason": request.reason
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/user/{user_id}/unsuspend")
|
|
||||||
async def unsuspend_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Unsuspend a user account (admin only)
|
|
||||||
"""
|
|
||||||
# Only user with ID 1 (admin) can unsuspend users
|
|
||||||
if current_user.id != 1:
|
|
||||||
raise HTTPException(status_code=403, detail="Only admin can unsuspend users")
|
|
||||||
|
|
||||||
target_user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not target_user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Unsuspend the user
|
|
||||||
target_user.suspended = False
|
|
||||||
target_user.suspension_reason = None
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"admin_unsuspend_user",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_username=target_user.username,
|
|
||||||
target_id=target_user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"User {target_user.username} has been unsuspended"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/user/{user_id}/delete")
|
|
||||||
async def delete_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete a user account (admin only) - preserves messages/DMs/reactions/files
|
|
||||||
"""
|
|
||||||
# Only user with ID 1 (admin) can delete users
|
|
||||||
if current_user.id != 1:
|
|
||||||
raise HTTPException(status_code=403, detail="Only admin can delete users")
|
|
||||||
|
|
||||||
target_user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not target_user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Cannot delete admin
|
|
||||||
if target_user.id == 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete admin account")
|
|
||||||
|
|
||||||
snapshot_username = target_user.username
|
|
||||||
snapshot_display_name = target_user.display_name
|
|
||||||
|
|
||||||
from .account import _delete_user_data
|
|
||||||
await _delete_user_data(target_user, db)
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"admin_delete_user",
|
|
||||||
severity="warning",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_username=snapshot_username,
|
|
||||||
target_display_name=snapshot_display_name,
|
|
||||||
target_id=target_user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"User {target_user.username} has been deleted"
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from dependencies import get_current_user, get_db
|
|
||||||
from models import User, PushSubscriptionRequest
|
|
||||||
from push_service import push_service
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.post("/subscribe")
|
|
||||||
async def subscribe_to_push_notifications(
|
|
||||||
request: PushSubscriptionRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Subscribe user to push notifications"""
|
|
||||||
try:
|
|
||||||
success = await push_service.subscribe_user(
|
|
||||||
db=db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
endpoint=request.endpoint,
|
|
||||||
p256dh_key=request.keys["p256dh"],
|
|
||||||
auth_key=request.keys["auth"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
return {"status": "success", "message": "Push notifications enabled"}
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to enable push notifications")
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@router.delete("/unsubscribe")
|
|
||||||
async def unsubscribe_from_push_notifications(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Unsubscribe user from push notifications"""
|
|
||||||
try:
|
|
||||||
success = await push_service.unsubscribe_user(db=db, user_id=current_user.id)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
return {"status": "success", "message": "Push notifications disabled"}
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
import hmac
|
|
||||||
import hashlib
|
|
||||||
import time
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from dependencies import get_current_user
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
|
|
||||||
def generate_turn_credentials(username: str, secret: str, expiration_minutes: int = 60):
|
|
||||||
"""Generate time-limited TURN credentials using TURN REST API format.
|
|
||||||
|
|
||||||
This creates temporary credentials that expire after the specified time.
|
|
||||||
The username format is: timestamp:username
|
|
||||||
The password is an HMAC hash of the username and secret.
|
|
||||||
"""
|
|
||||||
# Current timestamp (seconds since epoch)
|
|
||||||
timestamp = int(time.time()) + (expiration_minutes * 60)
|
|
||||||
|
|
||||||
# Create temporary username: timestamp:original_username
|
|
||||||
temp_username = f"{timestamp}:{username}"
|
|
||||||
|
|
||||||
# Generate password using HMAC-SHA1
|
|
||||||
temp_password = hmac.new(
|
|
||||||
secret.encode('utf-8'),
|
|
||||||
temp_username.encode('utf-8'),
|
|
||||||
hashlib.sha1
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
return temp_username, temp_password
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ice")
|
|
||||||
async def get_ice_servers(current_user = Depends(get_current_user)):
|
|
||||||
"""Return ICE server configuration (STUN/TURN) for WebRTC clients.
|
|
||||||
|
|
||||||
Generates time-limited TURN credentials that expire in 1 hour.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Prefer using your own coturn for both STUN and TURN
|
|
||||||
turn_domain = "fromchat.ru"
|
|
||||||
stun_urls = [
|
|
||||||
f"stun:{turn_domain}:3478",
|
|
||||||
f"stuns:{turn_domain}:5349",
|
|
||||||
]
|
|
||||||
|
|
||||||
turn_urls = [
|
|
||||||
f"turn:{turn_domain}:3478",
|
|
||||||
f"turns:{turn_domain}:5349",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Get TURN configuration from environment
|
|
||||||
turn_username = os.getenv("TURN_USERNAME")
|
|
||||||
turn_secret = os.getenv("TURN_SECRET")
|
|
||||||
|
|
||||||
# Check if required environment variables are set
|
|
||||||
if not turn_username:
|
|
||||||
logger.error("ERROR: TURN_USERNAME environment variable is not set")
|
|
||||||
raise ValueError("TURN_USERNAME environment variable is not set")
|
|
||||||
|
|
||||||
if not turn_secret:
|
|
||||||
logger.error("ERROR: TURN_SECRET environment variable is not set")
|
|
||||||
raise ValueError("TURN_SECRET environment variable is not set")
|
|
||||||
|
|
||||||
ice_servers: list[dict] = [{"urls": url} for url in stun_urls]
|
|
||||||
|
|
||||||
temp_username, temp_password = generate_turn_credentials(
|
|
||||||
turn_username,
|
|
||||||
turn_secret,
|
|
||||||
expiration_minutes=60 # Expires in 1 hour
|
|
||||||
)
|
|
||||||
|
|
||||||
ice_servers.append({
|
|
||||||
"urls": turn_urls,
|
|
||||||
"username": temp_username,
|
|
||||||
"credential": temp_password,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"iceServers": ice_servers}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"ERROR in /api/webrtc/ice: {str(e)}")
|
|
||||||
logger.error(f"ERROR type: {type(e).__name__}")
|
|
||||||
traceback.print_exc()
|
|
||||||
raise
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Package marker for security utilities
|
|
||||||
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from html import unescape
|
|
||||||
from typing import Any, Callable, Dict, List
|
|
||||||
|
|
||||||
from logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_username(username: Any) -> str:
|
|
||||||
if not username:
|
|
||||||
return "unknown user"
|
|
||||||
return f"@{username}"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_user(fields: Dict[str, Any], username_key: str = "username", user_id_key: str = "user_id") -> str:
|
|
||||||
username = fields.get(username_key)
|
|
||||||
if username is None and "_" in username_key:
|
|
||||||
base_key = username_key.split("_", 1)[0]
|
|
||||||
username = fields.get(base_key)
|
|
||||||
|
|
||||||
user_id = fields.get(user_id_key)
|
|
||||||
if user_id is None and "_" in user_id_key:
|
|
||||||
base_key = user_id_key.split("_", 1)[0]
|
|
||||||
user_id = fields.get(base_key)
|
|
||||||
|
|
||||||
if username and user_id is not None:
|
|
||||||
return f"{_clean_username(username)} (user id {user_id})"
|
|
||||||
if username:
|
|
||||||
return _clean_username(username)
|
|
||||||
if user_id is not None:
|
|
||||||
return f"user id {user_id}"
|
|
||||||
return "unknown user"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_actor(fields: Dict[str, Any], prefix: str) -> str:
|
|
||||||
return _format_user(fields, f"{prefix}_username", f"{prefix}_id")
|
|
||||||
|
|
||||||
|
|
||||||
def _plural(label: str, count: int) -> str:
|
|
||||||
return f"{count} {label if count == 1 else label + 's'}"
|
|
||||||
|
|
||||||
|
|
||||||
def _yes_no(flag: Any) -> str:
|
|
||||||
return "yes" if flag else "no"
|
|
||||||
|
|
||||||
|
|
||||||
def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
|
|
||||||
if action == "login_success":
|
|
||||||
lines = [f"Login approved for {_format_user(fields)}"]
|
|
||||||
session = fields.get("session_id")
|
|
||||||
if session:
|
|
||||||
lines.append(f"Session: {session}")
|
|
||||||
client_bits: List[str] = []
|
|
||||||
if fields.get("device"):
|
|
||||||
client_bits.append(fields["device"])
|
|
||||||
if fields.get("os"):
|
|
||||||
client_bits.append(fields["os"])
|
|
||||||
if fields.get("browser"):
|
|
||||||
client_bits.append(fields["browser"])
|
|
||||||
if client_bits:
|
|
||||||
lines.append(f"Client: {', '.join(client_bits)}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
return lines
|
|
||||||
if action == "login_failed":
|
|
||||||
lines = [f"Login denied for {_format_user(fields)}"]
|
|
||||||
if fields.get("reason"):
|
|
||||||
lines.append(f"Reason: {fields['reason']}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
return lines
|
|
||||||
if action == "auth_bruteforce_detected":
|
|
||||||
lines = ["Brute-force login pattern detected"]
|
|
||||||
lines.append(f"Target: {_format_user(fields)}")
|
|
||||||
failures = fields.get("failures")
|
|
||||||
if isinstance(failures, dict):
|
|
||||||
for key, value in failures.items():
|
|
||||||
lines.append(f"{key}: {value}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
if fields.get("window_seconds"):
|
|
||||||
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
|
||||||
return lines
|
|
||||||
if action == "registration_success":
|
|
||||||
ip_raw = fields.get("ip")
|
|
||||||
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
|
|
||||||
display_name = fields.get("display_name") or "Unknown"
|
|
||||||
username = fields.get("username")
|
|
||||||
user_id = fields.get("user_id")
|
|
||||||
user_agent = fields.get("user_agent") or "Unknown user agent"
|
|
||||||
lines = ["Account registered"]
|
|
||||||
lines.append(f"Display name: {display_name}")
|
|
||||||
lines.append(f"Username: {_clean_username(username) if username else 'unknown'}")
|
|
||||||
if ip_display:
|
|
||||||
lines.append(f"IP: {ip_display}")
|
|
||||||
if user_agent:
|
|
||||||
lines.append(f"User agent: {user_agent}")
|
|
||||||
if user_id is not None:
|
|
||||||
lines.append(f"User ID: {user_id}")
|
|
||||||
return lines
|
|
||||||
if action == "password_changed":
|
|
||||||
lines = [f"Password changed for {_format_user(fields)}"]
|
|
||||||
lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
return lines
|
|
||||||
if action == "logout":
|
|
||||||
lines = [f"Logout recorded for {_format_user(fields)}"]
|
|
||||||
if fields.get("session_id"):
|
|
||||||
lines.append(f"Session: {fields['session_id']}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
return lines
|
|
||||||
if action == "admin_delete_user":
|
|
||||||
return [
|
|
||||||
"Account removal",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
f"Target: {_format_actor(fields, 'target')}",
|
|
||||||
]
|
|
||||||
if action == "admin_suspend_user":
|
|
||||||
lines = [
|
|
||||||
"User suspension",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
f"Target: {_format_actor(fields, 'target')}",
|
|
||||||
]
|
|
||||||
if fields.get("reason"):
|
|
||||||
lines.append(f"Reason: {fields.get('reason')}")
|
|
||||||
return lines
|
|
||||||
if action == "admin_unsuspend_user":
|
|
||||||
return [
|
|
||||||
"User unsuspension",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
f"Target: {_format_actor(fields, 'target')}",
|
|
||||||
]
|
|
||||||
if action == "admin_verify_toggle":
|
|
||||||
return [
|
|
||||||
"User verification",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
f"Target: {_format_actor(fields, 'target')}",
|
|
||||||
f"Verified: {_yes_no(fields.get('verified'))}",
|
|
||||||
]
|
|
||||||
if action == "self_delete_account":
|
|
||||||
return [f"User {_format_user(fields)} deleted their account"]
|
|
||||||
if action == "auto_suspension_public_spam":
|
|
||||||
lines = [
|
|
||||||
f"Automatic suspension triggered for {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
match_type = fields.get("match_type")
|
|
||||||
if match_type:
|
|
||||||
lines.append(f"Match type: {match_type}")
|
|
||||||
similar = fields.get("similar_messages")
|
|
||||||
occurrences = fields.get("occurrences")
|
|
||||||
if similar:
|
|
||||||
lines.append(f"Similar messages detected: {similar}")
|
|
||||||
if occurrences and not similar:
|
|
||||||
lines.append(f"Occurrences: {occurrences}")
|
|
||||||
if fields.get("window_seconds"):
|
|
||||||
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
|
||||||
if fields.get("reason"):
|
|
||||||
lines.append(f"Reason: {fields['reason']}")
|
|
||||||
return lines
|
|
||||||
if action == "auto_suspension_public_burst":
|
|
||||||
lines = [
|
|
||||||
f"Automatic suspension triggered for {_format_user(fields)}",
|
|
||||||
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
|
|
||||||
]
|
|
||||||
if fields.get("reason"):
|
|
||||||
lines.append(f"Reason: {fields['reason']}")
|
|
||||||
return lines
|
|
||||||
if action == "public_message_burst":
|
|
||||||
return [
|
|
||||||
f"Rapid messaging spike for {_format_user(fields)}",
|
|
||||||
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
|
|
||||||
]
|
|
||||||
if action == "blocklist_add":
|
|
||||||
added = fields.get("added") or []
|
|
||||||
lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"]
|
|
||||||
if added:
|
|
||||||
lines.append(f"Added entries: {', '.join(added)}")
|
|
||||||
total = len(fields.get("words") or [])
|
|
||||||
lines.append(f"Total entries: {total}")
|
|
||||||
return lines
|
|
||||||
if action == "blocklist_remove":
|
|
||||||
removed = fields.get("removed") or []
|
|
||||||
lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"]
|
|
||||||
if removed:
|
|
||||||
lines.append(f"Removed entries: {', '.join(removed)}")
|
|
||||||
total = len(fields.get("words") or [])
|
|
||||||
lines.append(f"Total entries: {total}")
|
|
||||||
return lines
|
|
||||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
|
||||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
|
||||||
for key, value in fields.items()
|
|
||||||
if value is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]:
|
|
||||||
if action == "message_created":
|
|
||||||
lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"]
|
|
||||||
if fields.get("reply_to"):
|
|
||||||
lines.append(f"In reply to message #{fields['reply_to']}")
|
|
||||||
attachments = fields.get("attachments")
|
|
||||||
if attachments:
|
|
||||||
lines.append(f"Attachments: {_plural('file', attachments)}")
|
|
||||||
|
|
||||||
# If content was censored, log both raw and censored versions
|
|
||||||
if fields.get("raw_content") is not None:
|
|
||||||
lines.append("Raw content (before censoring):")
|
|
||||||
for line in unescape(fields["raw_content"]).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
lines.append("Censored content (stored):")
|
|
||||||
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
elif fields.get("content"):
|
|
||||||
lines.append("Content:")
|
|
||||||
for line in unescape(fields["content"]).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
return lines
|
|
||||||
if action == "message_edited":
|
|
||||||
lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"]
|
|
||||||
if fields.get("reply_to"):
|
|
||||||
lines.append(f"Reply to #{fields['reply_to']}")
|
|
||||||
if fields.get("previous_content"):
|
|
||||||
lines.append("Previous content:")
|
|
||||||
for line in unescape(fields["previous_content"] or "").splitlines() or [""]:
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
|
|
||||||
# If content was censored, log both raw and censored versions
|
|
||||||
if fields.get("raw_content") is not None:
|
|
||||||
lines.append("Raw content (before censoring):")
|
|
||||||
for line in unescape(fields["raw_content"]).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
lines.append("Censored content (stored):")
|
|
||||||
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
elif fields.get("content"):
|
|
||||||
lines.append("New content:")
|
|
||||||
for line in unescape(fields["content"] or "").splitlines() or [""]:
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
|
|
||||||
return lines
|
|
||||||
if action == "message_deleted":
|
|
||||||
lines = [
|
|
||||||
f"Message #{fields.get('message_id')} deleted",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
]
|
|
||||||
if fields.get("original_author_id") is not None:
|
|
||||||
lines.append(f"Original author: user #{fields['original_author_id']}")
|
|
||||||
if fields.get("content"):
|
|
||||||
lines.append("Previous content:")
|
|
||||||
for line in unescape(fields["content"]).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
return lines
|
|
||||||
if action == "reaction_update":
|
|
||||||
lines = [
|
|
||||||
f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}",
|
|
||||||
f"User: {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
if fields.get("emoji"):
|
|
||||||
lines.append(f"Emoji: {fields['emoji']}")
|
|
||||||
return lines
|
|
||||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
|
||||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
|
||||||
for key, value in fields.items()
|
|
||||||
if value is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]:
|
|
||||||
if action in {"message_sent", "message_sent_ws"}:
|
|
||||||
lines = [
|
|
||||||
f"Direct message #{fields.get('dm_envelope_id')} sent",
|
|
||||||
f"Sender: {_format_actor(fields, 'sender')}",
|
|
||||||
]
|
|
||||||
if fields.get("recipient_id") is not None:
|
|
||||||
lines.append(f"Recipient: user id {fields['recipient_id']}")
|
|
||||||
attachments = fields.get("attachment_count")
|
|
||||||
if attachments:
|
|
||||||
lines.append(f"Attachments: {_plural('file', attachments)}")
|
|
||||||
if fields.get("reply_to"):
|
|
||||||
lines.append(f"In reply to DM #{fields['reply_to']}")
|
|
||||||
return lines
|
|
||||||
if action == "message_edited":
|
|
||||||
return [
|
|
||||||
f"Direct message #{fields.get('dm_envelope_id')} edited",
|
|
||||||
f"Author: {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
if action == "message_deleted":
|
|
||||||
lines = [
|
|
||||||
f"Direct message #{fields.get('dm_envelope_id')} deleted",
|
|
||||||
f"Actor: {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
if fields.get("recipient_id") is not None:
|
|
||||||
lines.append(f"Recipient: user id {fields['recipient_id']}")
|
|
||||||
return lines
|
|
||||||
if action == "reaction_update":
|
|
||||||
lines = [
|
|
||||||
f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}",
|
|
||||||
f"User: {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
if fields.get("emoji"):
|
|
||||||
lines.append(f"Emoji: {fields['emoji']}")
|
|
||||||
return lines
|
|
||||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
|
||||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
|
||||||
for key, value in fields.items()
|
|
||||||
if value is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _render_access(action: str, fields: Dict[str, Any]) -> List[str]:
|
|
||||||
ip_raw = fields.get("ip")
|
|
||||||
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
|
|
||||||
if action == "http_request":
|
|
||||||
first_line = f"{fields.get('method')} {fields.get('path')}"
|
|
||||||
if ip_display:
|
|
||||||
first_line += f" from {ip_display}"
|
|
||||||
first_line += f" -> {fields.get('status')}"
|
|
||||||
lines = [first_line]
|
|
||||||
if fields.get("user"):
|
|
||||||
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
|
|
||||||
return lines
|
|
||||||
if action == "http_error":
|
|
||||||
first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}"
|
|
||||||
if ip_display:
|
|
||||||
first_line += f" from {ip_display}"
|
|
||||||
lines = [first_line]
|
|
||||||
if fields.get("error"):
|
|
||||||
lines.append(f"Exception: {fields['error']}")
|
|
||||||
if fields.get("user"):
|
|
||||||
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
|
|
||||||
return lines
|
|
||||||
if action == "ws_connect":
|
|
||||||
lines = ["WebSocket connected"]
|
|
||||||
if fields.get("path"):
|
|
||||||
lines.append(f"Endpoint: {fields['path']}")
|
|
||||||
if ip_display:
|
|
||||||
lines.append(f"IP: {ip_display}")
|
|
||||||
return lines
|
|
||||||
if action == "ws_disconnect":
|
|
||||||
lines = ["WebSocket disconnected"]
|
|
||||||
if fields.get("path"):
|
|
||||||
lines.append(f"Endpoint: {fields['path']}")
|
|
||||||
if fields.get("code") is not None:
|
|
||||||
reason = fields.get("reason") or "no reason"
|
|
||||||
lines.append(f"Code {fields['code']} ({reason})")
|
|
||||||
if ip_display:
|
|
||||||
lines.append(f"IP: {ip_display}")
|
|
||||||
return lines
|
|
||||||
if action == "ws_event":
|
|
||||||
event_name = fields.get("event")
|
|
||||||
path = fields.get("path")
|
|
||||||
first_line = "WS"
|
|
||||||
if path:
|
|
||||||
first_line += f" {path}"
|
|
||||||
if ip_display:
|
|
||||||
first_line += f" from {ip_display}"
|
|
||||||
if event_name:
|
|
||||||
first_line += f" -> {event_name}"
|
|
||||||
lines = [first_line]
|
|
||||||
if fields.get("user"):
|
|
||||||
lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}")
|
|
||||||
for key, value in fields.items():
|
|
||||||
if key in {"path", "event", "user", "user_id", "ip"} or value is None:
|
|
||||||
continue
|
|
||||||
lines.append(f"{key.replace('_', ' ').capitalize()}: {value}")
|
|
||||||
return lines
|
|
||||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
|
||||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
|
||||||
for key, value in fields.items()
|
|
||||||
if value is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _log_event(
|
|
||||||
logger: logging.Logger,
|
|
||||||
renderer: Callable[[str, Dict[str, Any]], List[str]],
|
|
||||||
action: str,
|
|
||||||
severity: str,
|
|
||||||
fields: Dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
lines = renderer(action, fields)
|
|
||||||
if not lines:
|
|
||||||
return
|
|
||||||
level = getattr(logging, severity.upper(), logging.INFO)
|
|
||||||
logger.log(level, "\n".join(lines))
|
|
||||||
|
|
||||||
|
|
||||||
def log_security(action: str, severity: str = "info", **fields: Any) -> None:
|
|
||||||
_log_event(security_logger, _render_security, action, severity, fields)
|
|
||||||
|
|
||||||
|
|
||||||
def log_public_chat(action: str, severity: str = "info", **fields: Any) -> None:
|
|
||||||
_log_event(public_chat_logger, _render_public_chat, action, severity, fields)
|
|
||||||
|
|
||||||
|
|
||||||
def log_dm(action: str, severity: str = "info", **fields: Any) -> None:
|
|
||||||
sanitized_fields = {key: value for key, value in fields.items() if key != "content"}
|
|
||||||
_log_event(dm_logger, _render_dm, action, severity, sanitized_fields)
|
|
||||||
|
|
||||||
|
|
||||||
def log_access(action: str, severity: str = "info", **fields: Any) -> None:
|
|
||||||
_log_event(access_logger, _render_access, action, severity, fields)
|
|
||||||
|
|
||||||
@@ -1,694 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import unicodedata
|
|
||||||
from pathlib import Path
|
|
||||||
from threading import RLock
|
|
||||||
from typing import Iterable, List, Set, Tuple
|
|
||||||
|
|
||||||
from better_profanity import Profanity
|
|
||||||
|
|
||||||
BLOCKLIST_PATH = Path("data/profanity/blocklist.json")
|
|
||||||
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
_CUSTOM_RU_TERMS: Set[str] = {
|
|
||||||
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
|
||||||
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
|
|
||||||
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
|
|
||||||
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
|
||||||
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор",
|
|
||||||
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
|
||||||
}
|
|
||||||
|
|
||||||
_ADULT_TERMS: Set[str] = {
|
|
||||||
"порно", "порнуха", "эротика", "эротический", "секс", "сексуальный",
|
|
||||||
"инцест", "порнография", "порностудия", "порновидео", "порносайт",
|
|
||||||
"сексчат", "сексчатик", "секслайв", "сексвидео",
|
|
||||||
}
|
|
||||||
|
|
||||||
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
|
|
||||||
|
|
||||||
# Words that should never be flagged as profanity (whitelist)
|
|
||||||
_WHITELIST: Set[str] = {
|
|
||||||
"говно", # Allow this word
|
|
||||||
}
|
|
||||||
|
|
||||||
# Phrase patterns - these will be applied to normalized text (without special chars)
|
|
||||||
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
|
||||||
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Patterns to check in original text (before normalization) to catch visual bypasses
|
|
||||||
# These patterns check for special character combinations that visually form letters
|
|
||||||
_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
|
||||||
# Catch "}{" used to visually form "х" followed by "С0С" or similar patterns
|
|
||||||
# This catches "хуесос" written as "}{¥€С0С" or variations
|
|
||||||
# Matches: }{ + any characters (including special chars) + С/с + 0 + С/с
|
|
||||||
# The pattern allows any characters between to catch special chars like ¥€
|
|
||||||
re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
|
||||||
# Also catch "}{" followed by "уесос" with 0 instead of о
|
|
||||||
re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Map for normalizing homoglyphs (similar-looking characters)
|
|
||||||
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
|
|
||||||
# Also includes Greek, full-width, and other Unicode variants
|
|
||||||
_LEET_MAP = {
|
|
||||||
# Numbers to letters
|
|
||||||
"0": "о",
|
|
||||||
"1": "и",
|
|
||||||
"3": "е",
|
|
||||||
"4": "а",
|
|
||||||
# Latin to Cyrillic (lowercase)
|
|
||||||
"a": "а",
|
|
||||||
"c": "с",
|
|
||||||
"e": "е",
|
|
||||||
"f": "ф",
|
|
||||||
"g": "г",
|
|
||||||
"i": "и",
|
|
||||||
"m": "м",
|
|
||||||
"n": "н",
|
|
||||||
"o": "о",
|
|
||||||
"p": "п",
|
|
||||||
"s": "с",
|
|
||||||
"t": "т",
|
|
||||||
"u": "у",
|
|
||||||
"v": "в",
|
|
||||||
"x": "х",
|
|
||||||
"y": "у",
|
|
||||||
"z": "з", # English 'z' to Cyrillic 'з'
|
|
||||||
# Latin to Cyrillic (uppercase)
|
|
||||||
"A": "а",
|
|
||||||
"C": "с",
|
|
||||||
"E": "е",
|
|
||||||
"F": "ф",
|
|
||||||
"G": "г",
|
|
||||||
"I": "и",
|
|
||||||
"M": "м",
|
|
||||||
"N": "н",
|
|
||||||
"O": "о",
|
|
||||||
"P": "п",
|
|
||||||
"S": "с",
|
|
||||||
"T": "т",
|
|
||||||
"U": "у",
|
|
||||||
"V": "в",
|
|
||||||
"X": "х",
|
|
||||||
"Y": "у",
|
|
||||||
"Z": "з", # English 'Z' to Cyrillic 'з'
|
|
||||||
# Greek letters that look like Cyrillic/Latin
|
|
||||||
"α": "а", # Greek alpha
|
|
||||||
"Α": "а",
|
|
||||||
"ο": "о", # Greek omicron
|
|
||||||
"Ο": "о",
|
|
||||||
"ρ": "р", # Greek rho (looks like Cyrillic р)
|
|
||||||
"Ρ": "р",
|
|
||||||
"υ": "у", # Greek upsilon
|
|
||||||
"Υ": "у",
|
|
||||||
"χ": "х", # Greek chi
|
|
||||||
"Χ": "х",
|
|
||||||
"ε": "е", # Greek epsilon
|
|
||||||
"Ε": "е",
|
|
||||||
"ι": "и", # Greek iota
|
|
||||||
"Ι": "и",
|
|
||||||
"ν": "н", # Greek nu
|
|
||||||
"Ν": "н",
|
|
||||||
"μ": "м", # Greek mu
|
|
||||||
"Μ": "м",
|
|
||||||
"π": "п", # Greek pi
|
|
||||||
"Π": "п",
|
|
||||||
"τ": "т", # Greek tau
|
|
||||||
"Τ": "т",
|
|
||||||
"γ": "г", # Greek gamma
|
|
||||||
"Γ": "г",
|
|
||||||
"σ": "с", # Greek sigma
|
|
||||||
"Σ": "с",
|
|
||||||
"φ": "ф", # Greek phi
|
|
||||||
"Φ": "ф",
|
|
||||||
# Full-width Latin characters
|
|
||||||
"a": "а",
|
|
||||||
"A": "а",
|
|
||||||
"c": "с",
|
|
||||||
"C": "с",
|
|
||||||
"e": "е",
|
|
||||||
"E": "е",
|
|
||||||
"f": "ф",
|
|
||||||
"F": "ф",
|
|
||||||
"g": "г",
|
|
||||||
"G": "г",
|
|
||||||
"i": "и",
|
|
||||||
"I": "и",
|
|
||||||
"m": "м",
|
|
||||||
"M": "м",
|
|
||||||
"n": "н",
|
|
||||||
"N": "н",
|
|
||||||
"o": "о",
|
|
||||||
"O": "о",
|
|
||||||
"p": "п",
|
|
||||||
"P": "п",
|
|
||||||
"s": "с",
|
|
||||||
"S": "с",
|
|
||||||
"t": "т",
|
|
||||||
"T": "т",
|
|
||||||
"u": "у",
|
|
||||||
"U": "у",
|
|
||||||
"v": "в",
|
|
||||||
"V": "в",
|
|
||||||
"x": "х",
|
|
||||||
"X": "х",
|
|
||||||
"y": "у",
|
|
||||||
"Y": "у",
|
|
||||||
"z": "з", # Full-width 'z' to Cyrillic 'з'
|
|
||||||
"Z": "з",
|
|
||||||
# Cyrillic to canonical Cyrillic (identity mappings)
|
|
||||||
"а": "а",
|
|
||||||
"с": "с",
|
|
||||||
"е": "е",
|
|
||||||
"ё": "е",
|
|
||||||
"ф": "ф",
|
|
||||||
"г": "г",
|
|
||||||
"и": "и",
|
|
||||||
"м": "м",
|
|
||||||
"н": "н",
|
|
||||||
"о": "о",
|
|
||||||
"п": "п",
|
|
||||||
"т": "т",
|
|
||||||
"у": "у",
|
|
||||||
"ү": "у", # Cyrillic capital U (U+04AE)
|
|
||||||
"Ү": "у", # Cyrillic capital U (U+04AE)
|
|
||||||
"в": "в",
|
|
||||||
"х": "х",
|
|
||||||
"р": "р",
|
|
||||||
"з": "з", # Cyrillic 'з'
|
|
||||||
"д": "д", # Cyrillic 'д'
|
|
||||||
"б": "б", # Cyrillic 'б'
|
|
||||||
"л": "л", # Cyrillic 'л'
|
|
||||||
"я": "я", # Cyrillic 'я'
|
|
||||||
"н": "н", # Already mapped, but explicit
|
|
||||||
# Special characters
|
|
||||||
"@": "а",
|
|
||||||
# Multi-character visual bypasses (handled separately in preprocessing)
|
|
||||||
# "}{" visually forms "х" - handled in _preprocess_visual_bypasses
|
|
||||||
}
|
|
||||||
|
|
||||||
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
|
||||||
("generic", ("айфон", "топ")),
|
|
||||||
("generic", ("самсунг", "говно")),
|
|
||||||
)
|
|
||||||
|
|
||||||
_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
|
|
||||||
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _preprocess_visual_bypasses(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Preprocess text to convert multi-character visual bypasses to their intended letters.
|
|
||||||
This handles cases like "}{" visually forming "х".
|
|
||||||
"""
|
|
||||||
result = text
|
|
||||||
# Convert "}{" to "х" (visual bypass for Cyrillic х)
|
|
||||||
# The curly braces visually form the letter х when placed together
|
|
||||||
result = result.replace("}{", "х")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_char(ch: str) -> str:
|
|
||||||
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
|
||||||
# First try direct mapping (preserves case for non-mapped chars)
|
|
||||||
if ch in _LEET_MAP:
|
|
||||||
return _LEET_MAP[ch]
|
|
||||||
# Then try lowercase mapping
|
|
||||||
lower = ch.lower()
|
|
||||||
if lower in _LEET_MAP:
|
|
||||||
return _LEET_MAP[lower]
|
|
||||||
# If no mapping and character is ASCII letter, return lowercase
|
|
||||||
# This preserves English words like "fromchat" as-is
|
|
||||||
if ch.isascii() and ch.isalpha():
|
|
||||||
return lower
|
|
||||||
# For other characters, return lowercase for consistency
|
|
||||||
return lower
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_token(token: str) -> str:
|
|
||||||
"""Normalize a token by mapping all homoglyphs."""
|
|
||||||
return "".join(_normalize_char(ch) for ch in token)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_text_for_profanity(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Normalize entire text by mapping homoglyphs to canonical forms.
|
|
||||||
This prevents bypasses like using English 'u' instead of Russian 'у'.
|
|
||||||
"""
|
|
||||||
return "".join(_normalize_char(ch) for ch in text)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_zero_width_chars(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Remove zero-width characters that could be used to bypass filters.
|
|
||||||
"""
|
|
||||||
# Zero-width space, zero-width non-joiner, zero-width joiner, etc.
|
|
||||||
zero_width_chars = [
|
|
||||||
'\u200B', # Zero-width space
|
|
||||||
'\u200C', # Zero-width non-joiner
|
|
||||||
'\u200D', # Zero-width joiner
|
|
||||||
'\uFEFF', # Zero-width no-break space
|
|
||||||
'\u2060', # Word joiner
|
|
||||||
'\u2061', # Function application
|
|
||||||
'\u2062', # Invisible times
|
|
||||||
'\u2063', # Invisible separator
|
|
||||||
'\u2064', # Invisible plus
|
|
||||||
]
|
|
||||||
result = text
|
|
||||||
for zw_char in zero_width_chars:
|
|
||||||
result = result.replace(zw_char, '')
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]:
|
|
||||||
"""
|
|
||||||
Extract only alphanumeric characters from text and create a mapping
|
|
||||||
from normalized positions to original positions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(normalized_text, position_map) where position_map[i] is the original
|
|
||||||
position of the i-th character in normalized_text
|
|
||||||
"""
|
|
||||||
# First preprocess visual bypasses (like "}{" -> "х")
|
|
||||||
text = _preprocess_visual_bypasses(text)
|
|
||||||
|
|
||||||
# Then normalize Unicode (composed vs decomposed)
|
|
||||||
normalized_unicode = unicodedata.normalize('NFKC', text)
|
|
||||||
|
|
||||||
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
|
||||||
if preserve_spaces:
|
|
||||||
zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064']
|
|
||||||
for zw_char in zero_width_chars:
|
|
||||||
normalized_unicode = normalized_unicode.replace(zw_char, ' ')
|
|
||||||
else:
|
|
||||||
# Strip zero-width characters
|
|
||||||
normalized_unicode = _strip_zero_width_chars(normalized_unicode)
|
|
||||||
|
|
||||||
normalized = []
|
|
||||||
position_map = []
|
|
||||||
|
|
||||||
for i, ch in enumerate(normalized_unicode):
|
|
||||||
# Check if character is alphanumeric (including Cyrillic)
|
|
||||||
if ch.isalnum():
|
|
||||||
# For phrase matching, preserve ASCII letters as-is (just lowercase)
|
|
||||||
# to allow English words in patterns to match
|
|
||||||
if preserve_spaces and ch.isascii() and ch.isalpha():
|
|
||||||
normalized.append(ch.lower())
|
|
||||||
else:
|
|
||||||
# Normalize this character (homoglyphs, Cyrillic, etc.)
|
|
||||||
normalized.append(_normalize_char(ch))
|
|
||||||
position_map.append(i)
|
|
||||||
elif preserve_spaces:
|
|
||||||
# For phrase matching, treat any whitespace or non-alphanumeric as word separator
|
|
||||||
if ch.isspace() or not ch.isalnum():
|
|
||||||
# Normalize to single space to allow patterns to match
|
|
||||||
if normalized and normalized[-1] != ' ': # Don't add consecutive spaces
|
|
||||||
normalized.append(' ')
|
|
||||||
position_map.append(i)
|
|
||||||
|
|
||||||
return "".join(normalized), position_map
|
|
||||||
|
|
||||||
|
|
||||||
def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]:
|
|
||||||
"""
|
|
||||||
Check for profane words as substrings or subsequences in normalized text.
|
|
||||||
This catches cases like "хуй" in "хууй" (with extra characters).
|
|
||||||
Returns list of (start, end) positions where profanity is found.
|
|
||||||
"""
|
|
||||||
spans = []
|
|
||||||
normalized_lower = normalized_text.lower()
|
|
||||||
|
|
||||||
for word in profane_words:
|
|
||||||
word_lower = word.lower()
|
|
||||||
|
|
||||||
# First try exact substring match
|
|
||||||
start = 0
|
|
||||||
while True:
|
|
||||||
pos = normalized_lower.find(word_lower, start)
|
|
||||||
if pos == -1:
|
|
||||||
break
|
|
||||||
spans.append((pos, pos + len(word_lower)))
|
|
||||||
start = pos + 1
|
|
||||||
|
|
||||||
# Also check if profane word appears as a subsequence (allowing extra chars)
|
|
||||||
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
|
|
||||||
# Now applies to ALL words, not just length >= 4, to prevent bypasses
|
|
||||||
word_chars = list(word_lower)
|
|
||||||
text_chars = list(normalized_lower)
|
|
||||||
|
|
||||||
# Stricter span limits based on word length to prevent false positives
|
|
||||||
# Shorter words get much stricter limits
|
|
||||||
if len(word_lower) <= 3:
|
|
||||||
max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй")
|
|
||||||
elif len(word_lower) == 4:
|
|
||||||
max_span_ratio = 1.4 # Strict for 4-char words
|
|
||||||
elif len(word_lower) <= 5:
|
|
||||||
max_span_ratio = 1.5 # Moderate for 5-char words
|
|
||||||
else:
|
|
||||||
max_span_ratio = 1.8 # Slightly more lenient for longer words
|
|
||||||
|
|
||||||
# Try to find the word as a subsequence
|
|
||||||
i = 0 # position in text
|
|
||||||
j = 0 # position in word
|
|
||||||
seq_start = None
|
|
||||||
|
|
||||||
while i < len(text_chars) and j < len(word_chars):
|
|
||||||
if text_chars[i] == word_chars[j]:
|
|
||||||
if seq_start is None:
|
|
||||||
seq_start = i
|
|
||||||
j += 1
|
|
||||||
if j == len(word_chars):
|
|
||||||
# Found the word as subsequence
|
|
||||||
seq_end = i + 1
|
|
||||||
# Check if the span is reasonable (not too long)
|
|
||||||
span_length = seq_end - seq_start
|
|
||||||
max_allowed_span = int(len(word_lower) * max_span_ratio)
|
|
||||||
if span_length <= max_allowed_span:
|
|
||||||
# Only add if it's not already covered by exact match
|
|
||||||
if (seq_start, seq_end) not in spans:
|
|
||||||
spans.append((seq_start, seq_end))
|
|
||||||
# Reset to find next occurrence - continue from after the end of this match
|
|
||||||
next_start = seq_start + 1
|
|
||||||
seq_start = None
|
|
||||||
j = 0
|
|
||||||
i = next_start
|
|
||||||
continue
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return spans
|
|
||||||
|
|
||||||
|
|
||||||
def _check_profanity_in_normalized(normalized_text: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if normalized text contains profanity.
|
|
||||||
Uses both better_profanity library and substring matching for better detection.
|
|
||||||
|
|
||||||
Returns True if profanity is found.
|
|
||||||
"""
|
|
||||||
if not normalized_text:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check normalized text for profanity using better_profanity
|
|
||||||
censored = _profanity.censor(normalized_text, censor_char="\\*")
|
|
||||||
|
|
||||||
# Check if better_profanity found anything
|
|
||||||
if "*" in censored:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня")
|
|
||||||
profane_words = _STATIC_TERMS
|
|
||||||
substring_spans = _check_profanity_substrings(normalized_text, profane_words)
|
|
||||||
|
|
||||||
# If we found any substring matches, there's profanity
|
|
||||||
if substring_spans:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
|
|
||||||
tokens: List[Tuple[int, int, str]] = []
|
|
||||||
start: int | None = None
|
|
||||||
buffer: List[str] = []
|
|
||||||
|
|
||||||
for idx, ch in enumerate(text):
|
|
||||||
if ch.isalnum() or ch in {"@", "#", "_"}:
|
|
||||||
if start is None:
|
|
||||||
start = idx
|
|
||||||
buffer.append(ch)
|
|
||||||
else:
|
|
||||||
if buffer and start is not None:
|
|
||||||
token_raw = "".join(buffer)
|
|
||||||
tokens.append((start, idx, _normalize_token(token_raw)))
|
|
||||||
buffer.clear()
|
|
||||||
start = None
|
|
||||||
if buffer and start is not None:
|
|
||||||
token_raw = "".join(buffer)
|
|
||||||
tokens.append((start, len(text), _normalize_token(token_raw)))
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
|
|
||||||
def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool:
|
|
||||||
if a == b:
|
|
||||||
return True
|
|
||||||
if max_distance <= 0:
|
|
||||||
return False
|
|
||||||
if abs(len(a) - len(b)) > max_distance:
|
|
||||||
return False
|
|
||||||
|
|
||||||
previous = list(range(len(b) + 1))
|
|
||||||
for i, ca in enumerate(a, 1):
|
|
||||||
current = [i]
|
|
||||||
best = current[0]
|
|
||||||
for j, cb in enumerate(b, 1):
|
|
||||||
insert_cost = current[j - 1] + 1
|
|
||||||
delete_cost = previous[j] + 1
|
|
||||||
replace_cost = previous[j - 1] + (0 if ca == cb else 1)
|
|
||||||
cost = min(insert_cost, delete_cost, replace_cost)
|
|
||||||
current.append(cost)
|
|
||||||
if cost < best:
|
|
||||||
best = cost
|
|
||||||
if best > max_distance:
|
|
||||||
return False
|
|
||||||
previous = current
|
|
||||||
return previous[-1] <= max_distance
|
|
||||||
|
|
||||||
|
|
||||||
def _load_sensitive_phrases() -> List[Tuple[str, ...]]:
|
|
||||||
if not _SENSITIVE_PHRASE_PATH.exists():
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8"))
|
|
||||||
phrases: List[Tuple[str, ...]] = []
|
|
||||||
if isinstance(payload, list):
|
|
||||||
for entry in payload:
|
|
||||||
if isinstance(entry, list) and entry:
|
|
||||||
normalized = tuple(str(part).strip() for part in entry if str(part).strip())
|
|
||||||
if normalized:
|
|
||||||
phrases.append(normalized)
|
|
||||||
return phrases
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]:
|
|
||||||
if group not in _PHRASE_CACHE:
|
|
||||||
base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group]
|
|
||||||
if group == "sensitive":
|
|
||||||
base.extend(_load_sensitive_phrases())
|
|
||||||
_PHRASE_CACHE[group] = tuple(
|
|
||||||
tuple(_normalize_token(part) for part in phrase)
|
|
||||||
for phrase in base
|
|
||||||
)
|
|
||||||
return _PHRASE_CACHE[group]
|
|
||||||
|
|
||||||
|
|
||||||
def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]:
|
|
||||||
tokens = _tokenize_with_spans(text)
|
|
||||||
if not tokens:
|
|
||||||
return []
|
|
||||||
|
|
||||||
spans: List[Tuple[int, int]] = []
|
|
||||||
normalized_phrases = _get_phrases(group)
|
|
||||||
|
|
||||||
for index in range(len(tokens)):
|
|
||||||
for phrase in normalized_phrases:
|
|
||||||
if index + len(phrase) > len(tokens):
|
|
||||||
continue
|
|
||||||
matches = True
|
|
||||||
for offset, target in enumerate(phrase):
|
|
||||||
token = tokens[index + offset][2]
|
|
||||||
if not _edit_distance_limited(token, target):
|
|
||||||
matches = False
|
|
||||||
break
|
|
||||||
if matches:
|
|
||||||
span_start = tokens[index][0]
|
|
||||||
span_end = tokens[index + len(phrase) - 1][1]
|
|
||||||
spans.append((span_start, span_end))
|
|
||||||
return spans
|
|
||||||
|
|
||||||
_dictionary_lock = RLock()
|
|
||||||
_blocklist_signature: Tuple[str, ...] | None = None
|
|
||||||
_profanity = Profanity()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_words(words: Iterable[str]) -> Set[str]:
|
|
||||||
normalized: Set[str] = set()
|
|
||||||
for raw in words:
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower()
|
|
||||||
if cleaned:
|
|
||||||
normalized.add(cleaned)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _load_blocklist() -> Set[str]:
|
|
||||||
if not BLOCKLIST_PATH.exists():
|
|
||||||
return set()
|
|
||||||
try:
|
|
||||||
data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8"))
|
|
||||||
if isinstance(data, list):
|
|
||||||
return _normalize_words(data)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def _write_blocklist(words: Iterable[str]) -> None:
|
|
||||||
BLOCKLIST_PATH.write_text(
|
|
||||||
json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n",
|
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rebuild_dictionary(force: bool = False) -> None:
|
|
||||||
global _profanity, _blocklist_signature
|
|
||||||
with _dictionary_lock:
|
|
||||||
blocklist_list = sorted(_load_blocklist())
|
|
||||||
signature = tuple(blocklist_list)
|
|
||||||
if not force and _blocklist_signature == signature and _blocklist_signature is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
profanity = Profanity()
|
|
||||||
profanity.load_censor_words()
|
|
||||||
# Remove whitelisted words from the default word list
|
|
||||||
try:
|
|
||||||
for word in _WHITELIST:
|
|
||||||
profanity.remove_censor_words([word])
|
|
||||||
except AttributeError:
|
|
||||||
# If remove_censor_words doesn't exist, we'll handle it in post-processing
|
|
||||||
pass
|
|
||||||
combined = set(_STATIC_TERMS)
|
|
||||||
combined.update(blocklist_list)
|
|
||||||
# Remove whitelisted words from our custom terms
|
|
||||||
combined -= _WHITELIST
|
|
||||||
if combined:
|
|
||||||
profanity.add_censor_words(list(combined))
|
|
||||||
|
|
||||||
_profanity = profanity
|
|
||||||
_blocklist_signature = signature
|
|
||||||
|
|
||||||
|
|
||||||
def _check_phrase_patterns(text: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if text matches any phrase patterns.
|
|
||||||
Returns True if any pattern matches.
|
|
||||||
"""
|
|
||||||
# Normalize text for phrase matching (remove special chars but preserve spaces)
|
|
||||||
normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True)
|
|
||||||
normalized_lower = normalized_text.lower()
|
|
||||||
|
|
||||||
# Check phrase patterns
|
|
||||||
for pattern in _PHRASE_PATTERNS:
|
|
||||||
if pattern.search(normalized_lower):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check fuzzy phrase spans
|
|
||||||
if _find_fuzzy_phrase_spans(normalized_lower, "generic"):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def contains_profanity(text: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if text contains profanity.
|
|
||||||
Returns True if profanity is detected.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
|
|
||||||
_rebuild_dictionary()
|
|
||||||
|
|
||||||
# Check original text patterns first (before normalization) to catch visual bypasses
|
|
||||||
# like "}{" used to form "х"
|
|
||||||
for pattern in _ORIGINAL_TEXT_PATTERNS:
|
|
||||||
if pattern.search(text):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check phrase patterns
|
|
||||||
if _check_phrase_patterns(text):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Normalize text for whitelist matching (to handle special characters)
|
|
||||||
normalized_for_whitelist, _ = _extract_alphanumeric_with_mapping(text)
|
|
||||||
normalized_for_whitelist_lower = normalized_for_whitelist.lower()
|
|
||||||
|
|
||||||
# Check if text contains whitelisted words - if the entire text is a whitelisted word, skip profanity check
|
|
||||||
for whitelist_word in _WHITELIST:
|
|
||||||
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
|
|
||||||
normalized_whitelist_lower = normalized_whitelist.lower()
|
|
||||||
|
|
||||||
# Check if the normalized text exactly matches a whitelisted word
|
|
||||||
if normalized_for_whitelist_lower == normalized_whitelist_lower:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Extract only alphanumeric characters and normalize homoglyphs
|
|
||||||
# This removes special characters, emojis, etc. that could be used to bypass the filter
|
|
||||||
normalized_text, _ = _extract_alphanumeric_with_mapping(text)
|
|
||||||
|
|
||||||
# Check profanity on normalized text (without special characters)
|
|
||||||
return _check_profanity_in_normalized(normalized_text)
|
|
||||||
|
|
||||||
|
|
||||||
def contains_sensitive_phrase(text: str) -> bool:
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
if _find_fuzzy_phrase_spans(text, "sensitive"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_blocklist() -> List[str]:
|
|
||||||
with _dictionary_lock:
|
|
||||||
return sorted(_load_blocklist())
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
|
||||||
normalized = _normalize_words(words)
|
|
||||||
if not normalized:
|
|
||||||
return [], get_blocklist()
|
|
||||||
|
|
||||||
with _dictionary_lock:
|
|
||||||
current = _load_blocklist()
|
|
||||||
added = sorted(normalized - current)
|
|
||||||
if not added:
|
|
||||||
return [], sorted(current)
|
|
||||||
|
|
||||||
updated = sorted(current | normalized)
|
|
||||||
_write_blocklist(updated)
|
|
||||||
_rebuild_dictionary(force=True)
|
|
||||||
return added, updated
|
|
||||||
|
|
||||||
|
|
||||||
def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
|
||||||
normalized = _normalize_words(words)
|
|
||||||
if not normalized:
|
|
||||||
return [], get_blocklist()
|
|
||||||
|
|
||||||
with _dictionary_lock:
|
|
||||||
current = _load_blocklist()
|
|
||||||
removed = sorted(word for word in normalized if word in current)
|
|
||||||
if not removed:
|
|
||||||
return [], sorted(current)
|
|
||||||
|
|
||||||
updated = sorted(current - normalized)
|
|
||||||
_write_blocklist(updated)
|
|
||||||
_rebuild_dictionary(force=True)
|
|
||||||
return removed, updated
|
|
||||||
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Callable
|
|
||||||
from fastapi import Request
|
|
||||||
from slowapi import Limiter
|
|
||||||
from slowapi.util import get_remote_address
|
|
||||||
|
|
||||||
from utils import get_client_ip
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
def get_ip_key(request: Request) -> str:
|
|
||||||
"""Get rate limit key based on IP address."""
|
|
||||||
return get_client_ip(request) or get_remote_address(request)
|
|
||||||
|
|
||||||
# Initialize limiter with IP-based key function
|
|
||||||
# Note: We don't set default_limits to avoid affecting all users if one IP is attacked.
|
|
||||||
# Each endpoint should have an explicit rate limit based on its sensitivity.
|
|
||||||
# Rate limits automatically expire after the time window - IPs are not permanently blocked.
|
|
||||||
limiter = Limiter(
|
|
||||||
key_func=get_ip_key,
|
|
||||||
default_limits=[], # No global default - each endpoint must have explicit limits
|
|
||||||
storage_uri="memory://", # In-memory storage (can be changed to Redis later)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Rate limit decorator for IP-based limiting
|
|
||||||
def rate_limit_per_ip(limit: str) -> Callable:
|
|
||||||
"""Rate limit based on IP address."""
|
|
||||||
return limiter.limit(limit, key_func=get_ip_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_storage_dict(storage) -> dict | None:
|
|
||||||
"""Get the internal storage dictionary from slowapi's memory storage."""
|
|
||||||
if hasattr(storage, "_storage") and isinstance(storage._storage, dict):
|
|
||||||
return storage._storage
|
|
||||||
elif hasattr(storage, "storage") and isinstance(storage.storage, dict):
|
|
||||||
return storage.storage
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def reset_all_rate_limits() -> int:
|
|
||||||
"""
|
|
||||||
Reset all rate limits by clearing the storage.
|
|
||||||
This should be called on startup to ensure a clean state.
|
|
||||||
Returns the number of entries cleared.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Access the private _storage attribute
|
|
||||||
storage = limiter._storage
|
|
||||||
storage_dict = _get_storage_dict(storage)
|
|
||||||
|
|
||||||
if storage_dict is None:
|
|
||||||
# Try using the storage's reset method if available
|
|
||||||
if hasattr(storage, "reset"):
|
|
||||||
try:
|
|
||||||
# Try reset() with no args first (clears all)
|
|
||||||
storage.reset()
|
|
||||||
logger.info("Reset all rate limits on startup using storage.reset()")
|
|
||||||
return 1 # Assume it worked
|
|
||||||
except TypeError:
|
|
||||||
# reset() might require arguments, try clearing differently
|
|
||||||
try:
|
|
||||||
# Some storage backends need explicit clearing
|
|
||||||
if hasattr(storage, "clear"):
|
|
||||||
storage.clear()
|
|
||||||
logger.info("Reset all rate limits on startup using storage.clear()")
|
|
||||||
return 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.warning("Could not reset rate limits: storage dict not accessible and no reset method")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
count = len(storage_dict)
|
|
||||||
if count > 0:
|
|
||||||
storage_dict.clear()
|
|
||||||
logger.info(f"Reset all rate limits on startup: cleared {count} entries")
|
|
||||||
return count
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def reset_rate_limit_for_ip(ip: str) -> bool:
|
|
||||||
"""
|
|
||||||
Manually reset rate limit for a specific IP address.
|
|
||||||
This clears all rate limit entries for the given IP.
|
|
||||||
Returns True if any entries were cleared, False otherwise.
|
|
||||||
"""
|
|
||||||
if not ip:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Access the private _storage attribute
|
|
||||||
storage = limiter._storage
|
|
||||||
storage_dict = _get_storage_dict(storage)
|
|
||||||
|
|
||||||
if storage_dict is None:
|
|
||||||
# Try alternative methods
|
|
||||||
if hasattr(storage, "reset"):
|
|
||||||
try:
|
|
||||||
storage.reset(ip)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
cleared = False
|
|
||||||
# slowapi stores entries with keys like "LIMITER:{ip}:{endpoint}"
|
|
||||||
# We need to find all keys that contain this IP
|
|
||||||
# Also handle cases where IP might be in different positions
|
|
||||||
keys_to_remove = []
|
|
||||||
|
|
||||||
for key in list(storage_dict.keys()):
|
|
||||||
if isinstance(key, str):
|
|
||||||
# Check multiple patterns:
|
|
||||||
# - "LIMITER:{ip}:{endpoint}"
|
|
||||||
# - Keys containing the IP anywhere
|
|
||||||
# - Keys starting with the IP
|
|
||||||
if (key.startswith(f"LIMITER:{ip}:") or
|
|
||||||
key.startswith(f"LIMITER:{ip}") or
|
|
||||||
f":{ip}:" in key or
|
|
||||||
key.endswith(f":{ip}") or
|
|
||||||
(ip in key and "LIMITER" in key)):
|
|
||||||
keys_to_remove.append(key)
|
|
||||||
|
|
||||||
for key in keys_to_remove:
|
|
||||||
try:
|
|
||||||
del storage_dict[key]
|
|
||||||
cleared = True
|
|
||||||
logger.info(f"Cleared rate limit key: {key}")
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if cleared:
|
|
||||||
logger.info(f"Successfully cleared rate limits for IP: {ip}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"No rate limit entries found for IP: {ip}")
|
|
||||||
|
|
||||||
return cleared
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to reset rate limit for IP {ip}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def clear_all_rate_limits() -> int:
|
|
||||||
"""
|
|
||||||
Clear all rate limit entries. Use with caution - this affects all IPs.
|
|
||||||
Returns the number of entries cleared.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Access the private _storage attribute
|
|
||||||
storage = limiter._storage
|
|
||||||
storage_dict = _get_storage_dict(storage)
|
|
||||||
|
|
||||||
if storage_dict is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
count = len(storage_dict)
|
|
||||||
storage_dict.clear()
|
|
||||||
logger.warning(f"Cleared all {count} rate limit entries")
|
|
||||||
return count
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to clear all rate limits: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired_rate_limits() -> int:
|
|
||||||
"""
|
|
||||||
Clean up expired rate limit entries from memory storage.
|
|
||||||
This helps prevent rate limits from being stuck indefinitely.
|
|
||||||
Returns the number of entries cleaned up.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Access the private _storage attribute
|
|
||||||
storage = limiter._storage
|
|
||||||
storage_dict = _get_storage_dict(storage)
|
|
||||||
|
|
||||||
if storage_dict is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# slowapi's memory storage stores entries as tuples: (count, reset_time)
|
|
||||||
# Entries should expire naturally, but we'll clean up any that are clearly expired
|
|
||||||
now = time.time()
|
|
||||||
cleaned = 0
|
|
||||||
keys_to_remove = []
|
|
||||||
|
|
||||||
for key, value in storage_dict.items():
|
|
||||||
if isinstance(value, (tuple, list)) and len(value) >= 2:
|
|
||||||
# Check if reset_time has passed (with some buffer)
|
|
||||||
reset_time = value[1] if isinstance(value[1], (int, float)) else 0
|
|
||||||
# Add 60 second buffer to ensure we don't remove active entries
|
|
||||||
if reset_time > 0 and now > (reset_time + 60):
|
|
||||||
keys_to_remove.append(key)
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
# Some storage formats use dicts with 'expiry' or 'reset' fields
|
|
||||||
expiry = value.get("expiry") or value.get("reset") or value.get("reset_time")
|
|
||||||
if expiry and isinstance(expiry, (int, float)) and now > (expiry + 60):
|
|
||||||
keys_to_remove.append(key)
|
|
||||||
|
|
||||||
for key in keys_to_remove:
|
|
||||||
try:
|
|
||||||
del storage_dict[key]
|
|
||||||
cleaned += 1
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if cleaned > 0:
|
|
||||||
logger.info(f"Cleaned up {cleaned} expired rate limit entries")
|
|
||||||
|
|
||||||
return cleaned
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to cleanup expired rate limits: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
async def start_rate_limit_cleanup_task() -> None:
|
|
||||||
"""Start a background task to periodically clean up expired rate limit entries."""
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(300) # Run every 5 minutes
|
|
||||||
cleanup_expired_rate_limits()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in rate limit cleanup task: {e}")
|
|
||||||
await asyncio.sleep(60) # Wait 1 minute before retrying
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"""
|
|
||||||
Similarity detection utilities for username and display name comparison.
|
|
||||||
Implements both edit distance and visual similarity detection.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def levenshtein_distance(s1: str, s2: str) -> int:
|
|
||||||
"""Calculate Levenshtein distance between two strings."""
|
|
||||||
if len(s1) < len(s2):
|
|
||||||
return levenshtein_distance(s2, s1)
|
|
||||||
|
|
||||||
if len(s2) == 0:
|
|
||||||
return len(s1)
|
|
||||||
|
|
||||||
previous_row = list(range(len(s2) + 1))
|
|
||||||
for i, c1 in enumerate(s1):
|
|
||||||
current_row = [i + 1]
|
|
||||||
for j, c2 in enumerate(s2):
|
|
||||||
insertions = previous_row[j + 1] + 1
|
|
||||||
deletions = current_row[j] + 1
|
|
||||||
substitutions = previous_row[j] + (c1 != c2)
|
|
||||||
current_row.append(min(insertions, deletions, substitutions))
|
|
||||||
previous_row = current_row
|
|
||||||
|
|
||||||
return previous_row[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def check_visual_similarity(s1: str, s2: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if two strings are visually similar using common homoglyphs.
|
|
||||||
Returns True if strings are visually similar.
|
|
||||||
"""
|
|
||||||
if len(s1) != len(s2):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Common homoglyph mappings
|
|
||||||
homoglyphs = {
|
|
||||||
'0': ['O', 'o', 'Q'],
|
|
||||||
'O': ['0', 'o', 'Q'],
|
|
||||||
'o': ['0', 'O', 'Q'],
|
|
||||||
'1': ['l', 'I', '|'],
|
|
||||||
'l': ['1', 'I', '|'],
|
|
||||||
'I': ['1', 'l', '|'],
|
|
||||||
'5': ['S', 's'],
|
|
||||||
'S': ['5', 's'],
|
|
||||||
's': ['5', 'S'],
|
|
||||||
'6': ['G', 'g'],
|
|
||||||
'G': ['6', 'g'],
|
|
||||||
'g': ['6', 'G'],
|
|
||||||
'8': ['B', 'b'],
|
|
||||||
'B': ['8', 'b'],
|
|
||||||
'b': ['8', 'B'],
|
|
||||||
'9': ['g', 'q'],
|
|
||||||
'g': ['9', 'q'],
|
|
||||||
'q': ['9', 'g'],
|
|
||||||
'2': ['Z', 'z'],
|
|
||||||
'Z': ['2', 'z'],
|
|
||||||
'z': ['2', 'Z'],
|
|
||||||
'3': ['E'],
|
|
||||||
'E': ['3'],
|
|
||||||
'4': ['A'],
|
|
||||||
'A': ['4'],
|
|
||||||
'7': ['T', 't'],
|
|
||||||
'T': ['7', 't'],
|
|
||||||
't': ['7', 'T'],
|
|
||||||
}
|
|
||||||
|
|
||||||
for i in range(len(s1)):
|
|
||||||
c1, c2 = s1[i], s2[i]
|
|
||||||
if c1 == c2:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if characters are homoglyphs
|
|
||||||
if (c1 in homoglyphs and c2 in homoglyphs[c1]) or \
|
|
||||||
(c2 in homoglyphs and c1 in homoglyphs[c2]):
|
|
||||||
continue
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def check_username_similarity(username1: str, username2: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if two usernames are similar using both edit distance and visual similarity.
|
|
||||||
Returns True if usernames are considered similar.
|
|
||||||
"""
|
|
||||||
if username1 == username2:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check edit distance (Levenshtein distance <= 2)
|
|
||||||
edit_distance = levenshtein_distance(username1.lower(), username2.lower())
|
|
||||||
if edit_distance <= 2:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check visual similarity
|
|
||||||
if check_visual_similarity(username1, username2):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_display_name_similarity(display_name1: str, display_name2: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if two display names are similar using both edit distance and visual similarity.
|
|
||||||
Returns True if display names are considered similar.
|
|
||||||
"""
|
|
||||||
if display_name1 == display_name2:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check edit distance (Levenshtein distance <= 2)
|
|
||||||
edit_distance = levenshtein_distance(display_name1.lower(), display_name2.lower())
|
|
||||||
if edit_distance <= 2:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check visual similarity
|
|
||||||
if check_visual_similarity(display_name1, display_name2):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_user_similar_to_verified(user_username: str, user_display_name: str,
|
|
||||||
verified_users: list[dict]) -> tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
Check if a user is similar to any verified user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_username: Username to check
|
|
||||||
user_display_name: Display name to check
|
|
||||||
verified_users: List of verified user dictionaries with 'username' and 'display_name' keys
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_similar, similar_to_username)
|
|
||||||
"""
|
|
||||||
for verified_user in verified_users:
|
|
||||||
verified_username = verified_user.get('username', '')
|
|
||||||
verified_display_name = verified_user.get('display_name', '')
|
|
||||||
|
|
||||||
# Check username similarity
|
|
||||||
if check_username_similarity(user_username, verified_username):
|
|
||||||
return True, verified_username
|
|
||||||
|
|
||||||
# Check display name similarity
|
|
||||||
if check_display_name_similarity(user_display_name, verified_display_name):
|
|
||||||
return True, verified_username
|
|
||||||
|
|
||||||
return False, ""
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
from fastapi import Request
|
|
||||||
import jwt
|
|
||||||
from typing import Optional, Any
|
|
||||||
import bcrypt
|
|
||||||
|
|
||||||
from constants import MAX_TOKEN_LIFETIME_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
|
|
||||||
|
|
||||||
# JWT Helper Functions
|
|
||||||
def create_token(user_id: int, username: str, session_id: str) -> str:
|
|
||||||
# Set a long expiration as safety net (actual expiration based on inactivity)
|
|
||||||
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
|
|
||||||
payload = {
|
|
||||||
"user_id": user_id,
|
|
||||||
"username": username,
|
|
||||||
"session_id": session_id,
|
|
||||||
"exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int)
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_token(token: str) -> Optional[dict]:
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
|
||||||
return payload
|
|
||||||
except jwt.ExpiredSignatureError:
|
|
||||||
return None
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
|
||||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request: Request) -> Optional[str]:
|
|
||||||
if not request:
|
|
||||||
return None
|
|
||||||
|
|
||||||
headers = request.headers
|
|
||||||
|
|
||||||
# First, check x-real-ip header (set by some proxies, or configured in Caddy)
|
|
||||||
real_ip = headers.get("x-real-ip") or headers.get("X-Real-IP")
|
|
||||||
if real_ip:
|
|
||||||
candidate = real_ip.strip()
|
|
||||||
if candidate:
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
# Fall back to x-forwarded-for header (Caddy sets this automatically)
|
|
||||||
forwarded = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For")
|
|
||||||
if forwarded:
|
|
||||||
# X-Forwarded-For can contain multiple IPs: "client, proxy1, proxy2"
|
|
||||||
# Take the first one (original client IP)
|
|
||||||
candidate = forwarded.split(",")[0].strip()
|
|
||||||
if candidate:
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
# Fall back to direct client connection (when not behind a proxy)
|
|
||||||
if request.client and request.client.host:
|
|
||||||
return request.client.host
|
|
||||||
|
|
||||||
# Last resort: check scope
|
|
||||||
if isinstance(request.scope, dict):
|
|
||||||
client_info = request.scope.get("client")
|
|
||||||
if isinstance(client_info, (list, tuple)) and client_info:
|
|
||||||
return client_info[0]
|
|
||||||
|
|
||||||
return None
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
def is_valid_username(username: str) -> bool:
|
|
||||||
if len(username) < 3 or len(username) > 20:
|
|
||||||
return False
|
|
||||||
# Only allow English letters, numbers, dashes and underscores
|
|
||||||
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_display_name(display_name: str) -> bool:
|
|
||||||
if len(display_name) < 1 or len(display_name) > 64:
|
|
||||||
return False
|
|
||||||
# Check if not blank (only whitespace)
|
|
||||||
if not display_name.strip():
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_password(password: str) -> bool:
|
|
||||||
if len(password) < 5 or len(password) > 50:
|
|
||||||
return False
|
|
||||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from websocket.registry import WebSocketHandlerRegistry
|
|
||||||
|
|
||||||
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
|
|
||||||
# Import them directly from websocket.handlers when needed
|
|
||||||
|
|
||||||
__all__ = ["WebSocketHandlerRegistry"]
|
|
||||||
|
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
from fastapi import HTTPException, WebSocket, Request
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from websocket.registry import WebSocketHandlerRegistry
|
|
||||||
from routes.messaging import (
|
|
||||||
MessaggingSocketManager,
|
|
||||||
_send_message_internal,
|
|
||||||
_edit_message_internal,
|
|
||||||
get_messages,
|
|
||||||
edit_message,
|
|
||||||
delete_message,
|
|
||||||
add_reaction,
|
|
||||||
add_dm_reaction,
|
|
||||||
)
|
|
||||||
from models import (
|
|
||||||
User,
|
|
||||||
SendMessageRequest,
|
|
||||||
EditMessageRequest,
|
|
||||||
DMEnvelope,
|
|
||||||
ReactionRequest,
|
|
||||||
DMReactionRequest,
|
|
||||||
UpdateLog,
|
|
||||||
)
|
|
||||||
from security.audit import log_access, log_dm
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
# Create global registry instance
|
|
||||||
handler_registry = WebSocketHandlerRegistry()
|
|
||||||
|
|
||||||
# Create decorator alias
|
|
||||||
websocket_handler = handler_registry.register
|
|
||||||
|
|
||||||
|
|
||||||
def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None:
|
|
||||||
"""Log WebSocket event."""
|
|
||||||
ws_path = getattr(getattr(websocket, "url", None), "path", None)
|
|
||||||
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
|
|
||||||
ws_path = websocket.scope.get("path")
|
|
||||||
ws_path = ws_path or "unknown"
|
|
||||||
headers = {}
|
|
||||||
if isinstance(getattr(websocket, "scope", None), dict):
|
|
||||||
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
|
|
||||||
xff = headers.get("x-forwarded-for")
|
|
||||||
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
|
|
||||||
|
|
||||||
log_access(
|
|
||||||
"ws_event",
|
|
||||||
path=ws_path,
|
|
||||||
event=event,
|
|
||||||
user=user.username if user else None,
|
|
||||||
user_id=user.id if user else None,
|
|
||||||
ip=client_ip,
|
|
||||||
**extra,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("getUpdates", authRequired=True)
|
|
||||||
async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Handle gap detection - client requests updates from a specific sequence number."""
|
|
||||||
last_seq = data.get("lastSeq", 0)
|
|
||||||
manager.last_seq_by_ws[websocket] = last_seq
|
|
||||||
current_seq = manager.sequence_numbers.get(user.id, 0)
|
|
||||||
|
|
||||||
# Query database for missed updates
|
|
||||||
missed_updates = []
|
|
||||||
if last_seq > 0 and last_seq < current_seq:
|
|
||||||
try:
|
|
||||||
# Get all updates between last_seq and current_seq
|
|
||||||
update_logs = db.query(UpdateLog).filter(
|
|
||||||
UpdateLog.user_id == user.id,
|
|
||||||
UpdateLog.sequence > last_seq,
|
|
||||||
UpdateLog.sequence <= current_seq
|
|
||||||
).order_by(UpdateLog.sequence.asc()).all()
|
|
||||||
|
|
||||||
# Each log entry contains a batch of updates with the same sequence number
|
|
||||||
for log_entry in update_logs:
|
|
||||||
updates = json.loads(log_entry.updates)
|
|
||||||
missed_updates.append({
|
|
||||||
"seq": log_entry.sequence,
|
|
||||||
"updates": updates
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to retrieve missed updates: {e}")
|
|
||||||
|
|
||||||
# Send missed updates directly (not through return value)
|
|
||||||
for batch in missed_updates:
|
|
||||||
await websocket.send_json({
|
|
||||||
"type": "updates",
|
|
||||||
"seq": batch["seq"],
|
|
||||||
"updates": batch["updates"]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Update the websocket's last sequence tracking
|
|
||||||
manager.last_seq_by_ws[websocket] = current_seq
|
|
||||||
log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"lastSeq": current_seq,
|
|
||||||
"missedCount": len(missed_updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("ping", authRequired=True)
|
|
||||||
async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Handle ping - authenticate and set user online."""
|
|
||||||
# Set user online in DB
|
|
||||||
user.online = True
|
|
||||||
user.last_seen = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
# Add to online users
|
|
||||||
manager.online_users.add(user.id)
|
|
||||||
# Broadcast status change
|
|
||||||
await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "ping")
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("getMessages", authRequired=True)
|
|
||||||
async def getMessages(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Get all public chat messages."""
|
|
||||||
result = await get_messages(user, db)
|
|
||||||
log(manager, websocket, user, "getMessages")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("sendMessage", authRequired=True)
|
|
||||||
async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Send a public chat message."""
|
|
||||||
message_request: SendMessageRequest = SendMessageRequest.model_validate(data)
|
|
||||||
|
|
||||||
# Call internal function directly (rate limiting is handled at infrastructure level via Caddy)
|
|
||||||
response = await _send_message_internal(message_request, user, db, [])
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "newMessage",
|
|
||||||
"data": response["message"]
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"])
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("dmSend", authRequired=True)
|
|
||||||
async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Send a direct message."""
|
|
||||||
payload = data
|
|
||||||
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
|
||||||
for key in required:
|
|
||||||
if key not in payload:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
|
||||||
|
|
||||||
env = DMEnvelope(
|
|
||||||
sender_id=user.id,
|
|
||||||
recipient_id=int(payload["recipientId"]),
|
|
||||||
iv_b64=payload["iv"],
|
|
||||||
ciphertext_b64=payload["ciphertext"],
|
|
||||||
salt_b64=payload["salt"],
|
|
||||||
iv2_b64=payload["iv2"],
|
|
||||||
wrapped_mk_b64=payload["wrappedMk"],
|
|
||||||
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
|
|
||||||
)
|
|
||||||
db.add(env)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(env)
|
|
||||||
|
|
||||||
payload_ws = {
|
|
||||||
"type": "dmNew",
|
|
||||||
"data": {
|
|
||||||
"id": env.id,
|
|
||||||
"senderId": env.sender_id,
|
|
||||||
"recipientId": env.recipient_id,
|
|
||||||
"iv": env.iv_b64,
|
|
||||||
"ciphertext": env.ciphertext_b64,
|
|
||||||
"salt": env.salt_b64,
|
|
||||||
"iv2": env.iv2_b64,
|
|
||||||
"wrappedMk": env.wrapped_mk_b64,
|
|
||||||
"timestamp": env.timestamp.isoformat(),
|
|
||||||
"replyToId": env.reply_to_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send push notification for DM
|
|
||||||
try:
|
|
||||||
from push_service import push_service
|
|
||||||
await push_service.send_dm_notification(db, env, user)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
|
||||||
|
|
||||||
await manager.send_update_to_user(env.recipient_id, "dmNew", payload_ws["data"], db)
|
|
||||||
await manager.send_update_to_user(env.sender_id, "dmNew", payload_ws["data"], db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id)
|
|
||||||
log_dm(
|
|
||||||
"message_sent_ws",
|
|
||||||
dm_envelope_id=env.id,
|
|
||||||
sender_id=user.id,
|
|
||||||
sender_username=user.username,
|
|
||||||
recipient_id=env.recipient_id,
|
|
||||||
reply_to=env.reply_to_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "ok", "id": env.id}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("editMessage", authRequired=True)
|
|
||||||
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Edit a public chat message."""
|
|
||||||
|
|
||||||
message_id = data["message_id"]
|
|
||||||
edit_request: EditMessageRequest = EditMessageRequest.model_validate(data)
|
|
||||||
|
|
||||||
response = await _edit_message_internal(message_id, edit_request, user, db)
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "messageEdited",
|
|
||||||
"data": response["message"]
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "editMessage", message_id=message_id)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("dmEdit", authRequired=True)
|
|
||||||
async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Edit a direct message."""
|
|
||||||
payload = data
|
|
||||||
env_id = int(payload["id"])
|
|
||||||
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
|
||||||
if not env:
|
|
||||||
raise HTTPException(status_code=404, detail="DM not found")
|
|
||||||
if env.sender_id != user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
|
||||||
|
|
||||||
# Replace ciphertext and iv
|
|
||||||
env.iv_b64 = payload["iv"]
|
|
||||||
env.ciphertext_b64 = payload["ciphertext"]
|
|
||||||
env.iv2_b64 = payload["iv2"]
|
|
||||||
env.wrapped_mk_b64 = payload["wrappedMk"]
|
|
||||||
env.salt_b64 = payload["salt"]
|
|
||||||
db.commit()
|
|
||||||
db.refresh(env)
|
|
||||||
|
|
||||||
payload_ws = {
|
|
||||||
"type": "dmEdited",
|
|
||||||
"data": {
|
|
||||||
"id": env.id,
|
|
||||||
"senderId": env.sender_id,
|
|
||||||
"recipientId": env.recipient_id,
|
|
||||||
"iv": env.iv_b64,
|
|
||||||
"ciphertext": env.ciphertext_b64,
|
|
||||||
"iv2": env.iv2_b64,
|
|
||||||
"wrappedMk": env.wrapped_mk_b64,
|
|
||||||
"salt": env.salt_b64,
|
|
||||||
"timestamp": env.timestamp.isoformat(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await manager.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db)
|
|
||||||
await manager.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "dmEdit", dm_envelope_id=env.id)
|
|
||||||
log_dm(
|
|
||||||
"message_edited",
|
|
||||||
dm_envelope_id=env.id,
|
|
||||||
user_id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "ok", "id": env.id}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("dmDelete", authRequired=True)
|
|
||||||
async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Delete a direct message."""
|
|
||||||
payload = data
|
|
||||||
env_id = int(payload["id"])
|
|
||||||
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
|
||||||
if not env:
|
|
||||||
raise HTTPException(status_code=404, detail="DM not found")
|
|
||||||
if env.sender_id != user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
|
||||||
|
|
||||||
db.delete(env)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
payload_ws = {
|
|
||||||
"type": "dmDeleted",
|
|
||||||
"data": {
|
|
||||||
"id": env_id,
|
|
||||||
"senderId": user.id,
|
|
||||||
"recipientId": payload.get("recipientId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await manager.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db)
|
|
||||||
await manager.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "dmDelete", dm_envelope_id=env_id)
|
|
||||||
log_dm(
|
|
||||||
"message_deleted",
|
|
||||||
dm_envelope_id=env_id,
|
|
||||||
user_id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
recipient_id=env.recipient_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "ok", "id": env_id}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("deleteMessage", authRequired=True)
|
|
||||||
async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Delete a public chat message."""
|
|
||||||
message_id = data["message_id"]
|
|
||||||
response = await delete_message(message_id, user, db)
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "messageDeleted",
|
|
||||||
"data": {"message_id": message_id}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "deleteMessage", message_id=message_id)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("addReaction", authRequired=True)
|
|
||||||
async def addReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Add or remove a reaction to a public chat message."""
|
|
||||||
reaction_request = ReactionRequest(
|
|
||||||
message_id=data["message_id"],
|
|
||||||
emoji=data["emoji"]
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await add_reaction(reaction_request, user, db)
|
|
||||||
|
|
||||||
# Broadcast reaction update
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "reactionUpdate",
|
|
||||||
"data": {
|
|
||||||
"message_id": data["message_id"],
|
|
||||||
"emoji": data["emoji"],
|
|
||||||
"action": response["action"],
|
|
||||||
"user_id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"reactions": response["reactions"]
|
|
||||||
}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "addReaction", message_id=data["message_id"], emoji=data["emoji"], action=response["action"])
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("addDmReaction", authRequired=True)
|
|
||||||
async def addDmReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Add or remove a reaction to a direct message."""
|
|
||||||
reaction_request = DMReactionRequest(
|
|
||||||
dm_envelope_id=data["dm_envelope_id"],
|
|
||||||
emoji=data["emoji"]
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await add_dm_reaction(reaction_request, user, db)
|
|
||||||
|
|
||||||
# Broadcast reaction update
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "dmReactionUpdate",
|
|
||||||
"data": {
|
|
||||||
"dm_envelope_id": data["dm_envelope_id"],
|
|
||||||
"emoji": data["emoji"],
|
|
||||||
"action": response["action"],
|
|
||||||
"user_id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"reactions": response["reactions"]
|
|
||||||
}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "addDmReaction", dm_envelope_id=data["dm_envelope_id"], emoji=data["emoji"], action=response["action"])
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("call_signaling", authRequired=True)
|
|
||||||
async def call_signaling(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Forward WebRTC signaling between peers."""
|
|
||||||
payload = data or {}
|
|
||||||
to_user_id = int(payload.get("toUserId") or 0)
|
|
||||||
if not to_user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing toUserId")
|
|
||||||
|
|
||||||
# Ensure sender is set by the server
|
|
||||||
payload["fromUserId"] = user.id
|
|
||||||
payload["fromUsername"] = user.username
|
|
||||||
|
|
||||||
await manager.send_to_user(to_user_id, {
|
|
||||||
"type": "call_signaling",
|
|
||||||
"data": payload
|
|
||||||
})
|
|
||||||
|
|
||||||
log(manager, websocket, user, "call_signaling", to_user_id=to_user_id)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("call_video_toggle", authRequired=True)
|
|
||||||
async def call_video_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Forward video toggle state between peers."""
|
|
||||||
payload = data or {}
|
|
||||||
to_user_id = int(payload.get("toUserId") or 0)
|
|
||||||
if not to_user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing toUserId")
|
|
||||||
|
|
||||||
await manager.send_update_to_user(to_user_id, "call_signaling", {
|
|
||||||
"type": "call_video_toggle",
|
|
||||||
"fromUserId": user.id,
|
|
||||||
"toUserId": to_user_id,
|
|
||||||
"data": {"enabled": payload.get("enabled", False)}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "call_video_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("call_screen_share_toggle", authRequired=True)
|
|
||||||
async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Forward screen share toggle state between peers."""
|
|
||||||
payload = data or {}
|
|
||||||
to_user_id = int(payload.get("toUserId") or 0)
|
|
||||||
if not to_user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing toUserId")
|
|
||||||
|
|
||||||
await manager.send_update_to_user(to_user_id, "call_signaling", {
|
|
||||||
"type": "call_screen_share_toggle",
|
|
||||||
"fromUserId": user.id,
|
|
||||||
"toUserId": to_user_id,
|
|
||||||
"data": {"enabled": payload.get("enabled", False)}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "call_screen_share_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("subscribeStatus", authRequired=True)
|
|
||||||
async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Subscribe to status updates for a user."""
|
|
||||||
user_id_to_subscribe = int(data["userId"])
|
|
||||||
manager.ws_subscriptions[websocket].add(user_id_to_subscribe)
|
|
||||||
|
|
||||||
# Get current status of the user
|
|
||||||
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
|
|
||||||
if target_user:
|
|
||||||
# Send current status directly (not through return value)
|
|
||||||
await websocket.send_json({
|
|
||||||
"type": "statusUpdate",
|
|
||||||
"data": {
|
|
||||||
"userId": user_id_to_subscribe,
|
|
||||||
"online": target_user.online,
|
|
||||||
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe)
|
|
||||||
return {"status": "ok"}
|
|
||||||
else:
|
|
||||||
log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found")
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("unsubscribeStatus", authRequired=True)
|
|
||||||
async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Unsubscribe from status updates for a user."""
|
|
||||||
user_id_to_unsubscribe = int(data["userId"])
|
|
||||||
manager.ws_subscriptions[websocket].discard(user_id_to_unsubscribe)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "unsubscribeStatus", target_user_id=user_id_to_unsubscribe)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("typing", authRequired=True)
|
|
||||||
async def typing(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
|
||||||
"""Handle typing indicator start for public chat."""
|
|
||||||
was_typing = manager.typing_state.get(user.id, False)
|
|
||||||
manager.typing_users[user.id] = time.time()
|
|
||||||
|
|
||||||
# Only send update if state changed (started typing)
|
|
||||||
if not was_typing:
|
|
||||||
manager.typing_state[user.id] = True
|
|
||||||
# Broadcast to all connected users
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "typing",
|
|
||||||
"data": {
|
|
||||||
"userId": user.id,
|
|
||||||
"username": user.username
|
|
||||||
}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
# No confirmation response - privacy protection
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("stopTyping", authRequired=True)
|
|
||||||
async def stopTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
|
||||||
"""Handle typing indicator stop for public chat."""
|
|
||||||
was_typing = manager.typing_state.get(user.id, False)
|
|
||||||
if user.id in manager.typing_users:
|
|
||||||
del manager.typing_users[user.id]
|
|
||||||
|
|
||||||
# Only send update if state changed (stopped typing)
|
|
||||||
if was_typing:
|
|
||||||
manager.typing_state[user.id] = False
|
|
||||||
# Broadcast to all connected users
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "stopTyping",
|
|
||||||
"data": {
|
|
||||||
"userId": user.id,
|
|
||||||
"username": user.username
|
|
||||||
}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
# No confirmation response - privacy protection
|
|
||||||
log(manager, websocket, user, "stopTyping")
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("dmTyping", authRequired=True)
|
|
||||||
async def dmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
|
||||||
"""Handle typing indicator start for DM."""
|
|
||||||
recipient_id = int(data["recipientId"])
|
|
||||||
|
|
||||||
if user.id not in manager.dm_typing_users:
|
|
||||||
manager.dm_typing_users[user.id] = {}
|
|
||||||
if user.id not in manager.dm_typing_state:
|
|
||||||
manager.dm_typing_state[user.id] = {}
|
|
||||||
|
|
||||||
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
|
|
||||||
manager.dm_typing_users[user.id][recipient_id] = time.time()
|
|
||||||
|
|
||||||
# Only send update if state changed (started typing)
|
|
||||||
if not was_typing:
|
|
||||||
manager.dm_typing_state[user.id][recipient_id] = True
|
|
||||||
# Send only to recipient
|
|
||||||
await manager.send_update_to_user(recipient_id, "dmTyping", {
|
|
||||||
"userId": user.id,
|
|
||||||
"username": user.username
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
# No confirmation response - privacy protection
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("stopDmTyping", authRequired=True)
|
|
||||||
async def stopDmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
|
||||||
"""Handle typing indicator stop for DM."""
|
|
||||||
recipient_id = int(data["recipientId"])
|
|
||||||
|
|
||||||
was_typing = False
|
|
||||||
if user.id in manager.dm_typing_state:
|
|
||||||
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
|
|
||||||
|
|
||||||
if user.id in manager.dm_typing_users and recipient_id in manager.dm_typing_users[user.id]:
|
|
||||||
del manager.dm_typing_users[user.id][recipient_id]
|
|
||||||
if not manager.dm_typing_users[user.id]:
|
|
||||||
del manager.dm_typing_users[user.id]
|
|
||||||
|
|
||||||
# Only send update if state changed (stopped typing)
|
|
||||||
if was_typing:
|
|
||||||
if user.id in manager.dm_typing_state:
|
|
||||||
manager.dm_typing_state[user.id][recipient_id] = False
|
|
||||||
# Send only to recipient
|
|
||||||
await manager.send_update_to_user(recipient_id, "stopDmTyping", {
|
|
||||||
"userId": user.id,
|
|
||||||
"username": user.username
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
# No confirmation response - privacy protection
|
|
||||||
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from typing import Callable
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketHandlerRegistry:
|
|
||||||
"""Registry for WebSocket message handlers with authentication support."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._handlers: dict[str, tuple[Callable, bool]] = {}
|
|
||||||
|
|
||||||
def register(self, message_type: str, authRequired: bool = True):
|
|
||||||
"""Register a handler for a message type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message_type: The WebSocket message type to handle
|
|
||||||
authRequired: If True, handler will receive authenticated User (not None) or raise 401
|
|
||||||
"""
|
|
||||||
def decorator(func: Callable):
|
|
||||||
self._handlers[message_type] = (func, authRequired)
|
|
||||||
return func
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
def get_handler(self, message_type: str) -> tuple[Callable, bool] | None:
|
|
||||||
"""Get handler and authRequired flag for a message type.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (handler function, authRequired flag) or None if not found
|
|
||||||
"""
|
|
||||||
return self._handlers.get(message_type)
|
|
||||||
|
|
||||||
def get_all_types(self) -> list[str]:
|
|
||||||
"""Get all registered message types for debugging/logging."""
|
|
||||||
return list(self._handlers.keys())
|
|
||||||
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
from fastapi import HTTPException
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from dependencies import get_current_user
|
|
||||||
from models import User
|
|
||||||
|
|
||||||
|
|
||||||
def extract_token_from_data(data: dict) -> str | None:
|
|
||||||
"""Extract authentication token from WebSocket message data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: WebSocket message data dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Token string or None if not present
|
|
||||||
"""
|
|
||||||
credentials = data.get("credentials")
|
|
||||||
if credentials and isinstance(credentials, dict):
|
|
||||||
return credentials.get("credentials")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_from_token(token: str, db: Session) -> User | None:
|
|
||||||
"""Get user from authentication token.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token: JWT token string
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User object or None if token is invalid
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Ensure session is in a usable state before querying
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
dummy_request = SimpleNamespace()
|
|
||||||
dummy_request.state = SimpleNamespace()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from fastapi.security import HTTPBearer
|
|
||||||
security = HTTPBearer()
|
|
||||||
# We need to create credentials manually
|
|
||||||
credentials = HTTPAuthorizationCredentials(
|
|
||||||
scheme="Bearer",
|
|
||||||
credentials=token
|
|
||||||
)
|
|
||||||
return get_current_user(dummy_request, credentials, db)
|
|
||||||
except HTTPException:
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None:
|
|
||||||
"""Authenticate user from WebSocket message data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: WebSocket message data dictionary
|
|
||||||
db: Database session
|
|
||||||
authRequired: If True, raises 401 on missing/invalid token
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User object (guaranteed not None if authRequired=True) or None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if authRequired=True and token is missing/invalid
|
|
||||||
"""
|
|
||||||
token = extract_token_from_data(data)
|
|
||||||
|
|
||||||
if authRequired:
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=401, detail="Missing credentials")
|
|
||||||
|
|
||||||
user = get_current_user_from_token(token, db)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
||||||
|
|
||||||
return user
|
|
||||||
else:
|
|
||||||
if token:
|
|
||||||
return get_current_user_from_token(token, db)
|
|
||||||
return None
|
|
||||||
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.fromchat.ru}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "8301:80"
|
||||||
|
restart: always
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Node.js
|
|
||||||
node_modules
|
|
||||||
npm-debug.log
|
|
||||||
.env
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.pytest_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
.ipynb_checkpoints
|
|
||||||
.venv
|
|
||||||
venv/
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Common
|
|
||||||
# Exclude editor and OS files, as well as test results
|
|
||||||
dist
|
|
||||||
dist-electron
|
|
||||||
build
|
|
||||||
coverage
|
|
||||||
test_results/
|
|
||||||
out
|
|
||||||
|
|
||||||
data
|
|
||||||
logs
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 1. Install pip dependencies
|
|
||||||
FROM python:3.12 AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
RUN python3 -m venv .venv
|
|
||||||
COPY backend/requirements.txt .
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
|
||||||
./.venv/bin/pip3 install -r requirements.txt
|
|
||||||
|
|
||||||
# 2. Runtime stage
|
|
||||||
FROM python:3.12-slim AS runtime
|
|
||||||
|
|
||||||
# 2.1. Non-root user
|
|
||||||
WORKDIR /app
|
|
||||||
RUN useradd -u 1000 app && \
|
|
||||||
chown -R app /app
|
|
||||||
|
|
||||||
# 2.2. Copy content and create dirs
|
|
||||||
COPY --chown=app backend .
|
|
||||||
COPY --from=builder --chown=app /app/.venv .venv
|
|
||||||
RUN mkdir -p /app/data /app/logs && \
|
|
||||||
chown -R app /app/data /app/logs && \
|
|
||||||
printf '#!/bin/sh\nexec /app/.venv/bin/python /app/admin_cli.py "$@"\n' > /usr/local/bin/admin-cli && \
|
|
||||||
chmod +x /usr/local/bin/admin-cli
|
|
||||||
|
|
||||||
USER app
|
|
||||||
|
|
||||||
# 3. Final command
|
|
||||||
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
fromchat.ru {
|
|
||||||
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 {
|
|
||||||
lb_policy first
|
|
||||||
header_up X-Real-IP {remote_host}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
header {
|
|
||||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
|
||||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
|
||||||
X-Frame-Options "DENY" # Prevent clickjacking
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
|
||||||
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
|
||||||
}
|
|
||||||
|
|
||||||
rate_limit {
|
|
||||||
zone global {
|
|
||||||
key {remote_ip}
|
|
||||||
window 1m
|
|
||||||
burst 20
|
|
||||||
events 500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_errors {
|
|
||||||
@errors {
|
|
||||||
expression {err.status_code} >= 400
|
|
||||||
}
|
|
||||||
|
|
||||||
handle @errors {
|
|
||||||
rewrite * /{err.status_code}
|
|
||||||
reverse_proxy https://http.cat {
|
|
||||||
header_up Host {upstream_hostport}
|
|
||||||
replace_status {err.status_code}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beta.fromchat.ru {
|
|
||||||
reverse_proxy 95.165.0.162:8301 {
|
|
||||||
header_up X-Real-IP {remote_host}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
header {
|
|
||||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
|
||||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
|
||||||
X-Frame-Options "DENY" # Prevent clickjacking
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
|
||||||
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
|
||||||
}
|
|
||||||
|
|
||||||
rate_limit {
|
|
||||||
zone global {
|
|
||||||
key {remote_ip}
|
|
||||||
window 1m
|
|
||||||
burst 20
|
|
||||||
events 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_errors {
|
|
||||||
@errors {
|
|
||||||
expression {err.status_code} >= 400
|
|
||||||
}
|
|
||||||
|
|
||||||
handle @errors {
|
|
||||||
rewrite * /{err.status_code}
|
|
||||||
reverse_proxy https://http.cat {
|
|
||||||
header_up Host {upstream_hostport}
|
|
||||||
replace_status {err.status_code}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
git.fromchat.ru {
|
|
||||||
reverse_proxy 172.18.0.1:3000 host.docker.internal:3000 172.17.0.1:3000 {
|
|
||||||
lb_policy first
|
|
||||||
header_up X-Real-IP {remote_host}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
header {
|
|
||||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
|
||||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
|
||||||
X-Frame-Options "DENY" # Prevent clickjacking
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
|
||||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
|
||||||
}
|
|
||||||
|
|
||||||
rate_limit {
|
|
||||||
zone global {
|
|
||||||
key {remote_ip}
|
|
||||||
window 1m
|
|
||||||
burst 20
|
|
||||||
events 500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
api.getgadgets.toolbox-io.ru {
|
|
||||||
reverse_proxy 95.165.0.162:8400
|
|
||||||
}
|
|
||||||
|
|
||||||
getgadgets.toolbox-io.ru {
|
|
||||||
reverse_proxy 95.165.0.162:8401
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#
|
|
||||||
# Custom Caddy built with:
|
|
||||||
# - Rate limit plugin
|
|
||||||
#
|
|
||||||
|
|
||||||
FROM caddy:2-builder AS builder
|
|
||||||
RUN xcaddy build \
|
|
||||||
--with github.com/mholt/caddy-ratelimit
|
|
||||||
|
|
||||||
FROM caddy:2
|
|
||||||
|
|
||||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
dockerfile: deployment/Dockerfile.backend
|
|
||||||
context: ..
|
|
||||||
environment:
|
|
||||||
PORT: 8300
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
|
||||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
|
||||||
volumes:
|
|
||||||
- data:/app/data
|
|
||||||
- logs:/app/logs
|
|
||||||
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: sync+restart
|
|
||||||
path: ../backend
|
|
||||||
target: /app
|
|
||||||
- action: rebuild
|
|
||||||
path: ../backend/requirements.txt
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
dockerfile: deployment/frontend/Dockerfile
|
|
||||||
context: ..
|
|
||||||
environment:
|
|
||||||
PORT: 8301
|
|
||||||
BACKEND_HOST: http://backend:8300
|
|
||||||
ports:
|
|
||||||
- "8301:8301"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: rebuild
|
|
||||||
path: ../frontend
|
|
||||||
- action: sync+restart
|
|
||||||
path: server.js
|
|
||||||
target: /server/server.js
|
|
||||||
- action: rebuild
|
|
||||||
path: package.json
|
|
||||||
|
|
||||||
caddy:
|
|
||||||
build:
|
|
||||||
context: ./caddy
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
volumes:
|
|
||||||
- certs:/root/site/certs
|
|
||||||
environment:
|
|
||||||
XDG_DATA_HOME: /root/site/certs
|
|
||||||
XDG_CONFIG_HOME: /root/site/certs
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
data:
|
|
||||||
name: fromchat-data
|
|
||||||
logs:
|
|
||||||
name: fromchat-logs
|
|
||||||
certs:
|
|
||||||
name: fromchat-certs
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=FromChat server
|
|
||||||
After=multi-user.target
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=3
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
ExecStart=/bin/docker compose up
|
|
||||||
ExecStop=/bin/docker compose down
|
|
||||||
WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
# Security settings
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ReadWritePaths=/var/log
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
StandardInput=tty-force
|
|
||||||
SyslogIdentifier=fromchat
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# 1. Build the frontend
|
|
||||||
FROM node:24 AS frontend
|
|
||||||
|
|
||||||
# 1.1. Install npm dependencies
|
|
||||||
WORKDIR /app
|
|
||||||
# Copy package.json and workspace package directory first (needed for workspace resolution)
|
|
||||||
COPY package.json .
|
|
||||||
COPY frontend/packages/ frontend/packages/
|
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
|
||||||
npm install --ignore-scripts
|
|
||||||
|
|
||||||
# 1.2. Copy remaining frontend code and build
|
|
||||||
COPY frontend frontend
|
|
||||||
RUN npm run frontend:build
|
|
||||||
|
|
||||||
|
|
||||||
# 2. Build the static file server
|
|
||||||
FROM node:24 AS server
|
|
||||||
|
|
||||||
# 2.1. Install npm dependencies
|
|
||||||
WORKDIR /server
|
|
||||||
COPY deployment/frontend/package.json .
|
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 2.2. Copy the code
|
|
||||||
COPY deployment/frontend/ .
|
|
||||||
|
|
||||||
# 2.3. Build
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
|
|
||||||
# 3. Put it all together
|
|
||||||
FROM node:24-slim
|
|
||||||
|
|
||||||
# 3.1. Non-root user
|
|
||||||
RUN useradd -u 1001 app && \
|
|
||||||
mkdir -p /app && \
|
|
||||||
chown -R app /app && \
|
|
||||||
mkdir /server && \
|
|
||||||
chown -R app /server
|
|
||||||
USER app
|
|
||||||
|
|
||||||
# 3.1. Frontend static files
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=frontend --chown=app /app/frontend/build/normal/dist .
|
|
||||||
|
|
||||||
# 3.2. Static file server
|
|
||||||
WORKDIR /server
|
|
||||||
COPY --from=server --chown=app /server .
|
|
||||||
|
|
||||||
# 4. Final command
|
|
||||||
ENV STATIC_FILE_PATH=/app
|
|
||||||
ENTRYPOINT ["npm", "run", "start:prod"]
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "ts-node server.ts",
|
|
||||||
"build": "tsc -b",
|
|
||||||
"start:prod": "node dist/server.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"http-proxy-middleware": "^3.0.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/node": "^20.10.0",
|
|
||||||
"typescript": "^5.3.0",
|
|
||||||
"ts-node": "^10.9.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const port = process.env.PORT || 3000;
|
|
||||||
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
|
||||||
const filePath = process.env.STATIC_FILE_PATH || ".";
|
|
||||||
|
|
||||||
// API proxy middleware
|
|
||||||
app.use('/api', createProxyMiddleware({
|
|
||||||
target: backendHost,
|
|
||||||
changeOrigin: true,
|
|
||||||
pathRewrite: { '^/api': '' },
|
|
||||||
ws: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve static files
|
|
||||||
app.use(express.static(resolve(filePath)));
|
|
||||||
|
|
||||||
// SPA routing - catch all handler for client-side routing
|
|
||||||
app.use((_req, res) => {
|
|
||||||
res.sendFile(resolve(filePath, 'index.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
|
||||||
console.log(`Server launched on http://localhost:${port}`);
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "commonjs",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./",
|
|
||||||
"declaration": true,
|
|
||||||
"sourceMap": true
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"server.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Vendored
-25
@@ -1,25 +0,0 @@
|
|||||||
export type Platform = "win32" | "darwin" | "linux"
|
|
||||||
|
|
||||||
export interface NotificationShowOptions {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
icon?: string;
|
|
||||||
tag?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectronNotifications {
|
|
||||||
requestPermission: () => Promise<NotificationPermission>;
|
|
||||||
show: (options: NotificationShowOptions) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectronInterface {
|
|
||||||
desktop: true,
|
|
||||||
platform: Platform,
|
|
||||||
notifications: ElectronNotifications
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
electronInterface: ElectronInterface
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
|
|
||||||
import path from "path";
|
|
||||||
import type { NotificationShowOptions } from '../electron.d.ts';
|
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
mainWindow = new BrowserWindow({
|
|
||||||
title: 'Main window',
|
|
||||||
minWidth: 800,
|
|
||||||
minHeight: 420,
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(import.meta.dirname, "preload.mjs")
|
|
||||||
},
|
|
||||||
titleBarStyle: "hidden",
|
|
||||||
trafficLightPosition: {
|
|
||||||
x: 16 - 4,
|
|
||||||
y: 16 - 4
|
|
||||||
},
|
|
||||||
titleBarOverlay: process.platform !== "darwin"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
|
||||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
|
|
||||||
} else {
|
|
||||||
mainWindow.loadFile('frontend/build/electron/dist/index.html');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle notification permission requests
|
|
||||||
ipcMain.handle('request-notification-permission', async () => {
|
|
||||||
if (Notification.isSupported()) {
|
|
||||||
return 'granted';
|
|
||||||
}
|
|
||||||
return 'denied';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle showing notifications
|
|
||||||
ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => {
|
|
||||||
if (Notification.isSupported()) {
|
|
||||||
try {
|
|
||||||
const notification = new Notification({
|
|
||||||
title: options.title,
|
|
||||||
body: options.body,
|
|
||||||
icon: options.icon,
|
|
||||||
silent: false,
|
|
||||||
urgency: 'normal'
|
|
||||||
});
|
|
||||||
|
|
||||||
notification.on('click', () => {
|
|
||||||
if (mainWindow) {
|
|
||||||
mainWindow.show();
|
|
||||||
mainWindow.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
notification.show();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating notification:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
|
||||||
import type { ElectronInterface, Platform } from "../electron";
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronInterface", {
|
|
||||||
desktop: true,
|
|
||||||
platform: process.platform as Platform,
|
|
||||||
notifications: {
|
|
||||||
requestPermission: () => ipcRenderer.invoke('request-notification-permission'),
|
|
||||||
show: (options) => ipcRenderer.invoke('show-notification', options)
|
|
||||||
}
|
|
||||||
} satisfies ElectronInterface);
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
|
||||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
|
||||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
packagerConfig: {
|
|
||||||
asar: true,
|
|
||||||
},
|
|
||||||
outDir: "frontend/build/electron/forge",
|
|
||||||
rebuildConfig: {},
|
|
||||||
makers: [
|
|
||||||
{
|
|
||||||
name: '@electron-forge/maker-zip',
|
|
||||||
config: {},
|
|
||||||
platforms: ['win32', 'darwin'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '@electron-forge/maker-deb',
|
|
||||||
config: {},
|
|
||||||
platforms: ['linux'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '@electron-forge/maker-rpm',
|
|
||||||
config: {},
|
|
||||||
platforms: ['linux'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
|
||||||
config: {},
|
|
||||||
},
|
|
||||||
// Fuses are used to enable/disable various Electron functionality
|
|
||||||
// at package time, before code signing the application
|
|
||||||
new FusesPlugin({
|
|
||||||
version: FuseVersion.V1,
|
|
||||||
[FuseV1Options.RunAsNode]: false,
|
|
||||||
[FuseV1Options.EnableCookieEncryption]: true,
|
|
||||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
|
||||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
|
||||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
|
||||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
} satisfies ForgeConfig;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
src/
|
|
||||||
tsconfig.json
|
|
||||||
node_modules/
|
|
||||||
package-lock.json
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
# Publishing FromChat Protocol
|
|
||||||
|
|
||||||
This guide explains how to publish the `@fromchat/protocol` package to npm or GitHub Packages.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. **npm account**: Create one at [npmjs.com](https://www.npmjs.com/signup)
|
|
||||||
2. **GitHub account**: For GitHub Packages
|
|
||||||
3. **Node.js**: Version 18 or higher
|
|
||||||
|
|
||||||
## Publishing to npm
|
|
||||||
|
|
||||||
### Important: Scoped Package Setup
|
|
||||||
|
|
||||||
The package uses the `@fromchat` scope. You have two options:
|
|
||||||
|
|
||||||
**Option A: Create an npm organization (Recommended)**
|
|
||||||
1. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create)
|
|
||||||
2. Create an organization named `fromchat`
|
|
||||||
3. Add yourself as a member
|
|
||||||
4. Then proceed with publishing below
|
|
||||||
|
|
||||||
**Option B: Use unscoped package name**
|
|
||||||
If you prefer not to create an organization, change the package name in `package.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "fromchat-protocol" // Remove the @fromchat/ scope
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Then update all imports in your codebase from `@fromchat/protocol` to `fromchat-protocol`.
|
|
||||||
|
|
||||||
### 1. Build the package
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend/packages/fromchat-protocol
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
This compiles TypeScript to JavaScript in the `dist/` directory.
|
|
||||||
|
|
||||||
### 2. Login to npm
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm login
|
|
||||||
```
|
|
||||||
|
|
||||||
Enter your npm username, password, and email.
|
|
||||||
|
|
||||||
### 3. Publish
|
|
||||||
|
|
||||||
**If using scoped package (`@fromchat/protocol`):**
|
|
||||||
```bash
|
|
||||||
npm publish --access public
|
|
||||||
```
|
|
||||||
|
|
||||||
**If using unscoped package (`fromchat-protocol`):**
|
|
||||||
```bash
|
|
||||||
npm publish
|
|
||||||
```
|
|
||||||
|
|
||||||
The `--access public` flag is required for scoped packages (packages starting with `@`).
|
|
||||||
|
|
||||||
### 4. Verify
|
|
||||||
|
|
||||||
Check your package at: `https://www.npmjs.com/package/@fromchat/protocol`
|
|
||||||
|
|
||||||
### 5. Update version for future releases
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Patch version (1.0.0 -> 1.0.1)
|
|
||||||
npm version patch
|
|
||||||
|
|
||||||
# Minor version (1.0.0 -> 1.1.0)
|
|
||||||
npm version minor
|
|
||||||
|
|
||||||
# Major version (1.0.0 -> 2.0.0)
|
|
||||||
npm version major
|
|
||||||
|
|
||||||
# Then publish
|
|
||||||
npm publish --access public
|
|
||||||
```
|
|
||||||
|
|
||||||
## Publishing to GitHub Packages
|
|
||||||
|
|
||||||
### 1. Create a GitHub Personal Access Token
|
|
||||||
|
|
||||||
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
|
|
||||||
2. Generate a new token with `write:packages` and `read:packages` permissions
|
|
||||||
3. Save the token securely
|
|
||||||
|
|
||||||
### 2. Configure npm to use GitHub Packages
|
|
||||||
|
|
||||||
Create or edit `~/.npmrc`:
|
|
||||||
|
|
||||||
```
|
|
||||||
@fromchat:registry=https://npm.pkg.github.com
|
|
||||||
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
Or add to `package.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://npm.pkg.github.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Update package.json
|
|
||||||
|
|
||||||
Update the repository URL to match your GitHub repository:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/YOUR_USERNAME/YOUR_REPO.git",
|
|
||||||
"directory": "frontend/packages/fromchat-protocol"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Build and publish
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend/packages/fromchat-protocol
|
|
||||||
npm run build
|
|
||||||
npm publish
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Install from GitHub Packages
|
|
||||||
|
|
||||||
Users can install your package with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @fromchat/protocol@npm:@fromchat/protocol
|
|
||||||
```
|
|
||||||
|
|
||||||
Or add to `.npmrc`:
|
|
||||||
|
|
||||||
```
|
|
||||||
@fromchat:registry=https://npm.pkg.github.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using the Published Package
|
|
||||||
|
|
||||||
### From npm
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @fromchat/protocol
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { FromChatProtocol } from "@fromchat/protocol";
|
|
||||||
```
|
|
||||||
|
|
||||||
### From GitHub Packages
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @fromchat/protocol@npm:@fromchat/protocol
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The package is built to `dist/` directory
|
|
||||||
- Source files in `src/` are excluded from the published package
|
|
||||||
- Only `dist/` and `README.md` are included in the published package
|
|
||||||
- The package uses ES modules (ESM) format
|
|
||||||
- TypeScript definitions are included in `dist/`
|
|
||||||
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# FromChat Protocol
|
|
||||||
|
|
||||||
Simple ECDH-based encryption protocol for direct messages.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The FromChat Protocol provides end-to-end encryption for direct messages using:
|
|
||||||
- **X25519** (ECDH) for key exchange
|
|
||||||
- **HKDF** for key derivation
|
|
||||||
- **AES-GCM** for symmetric encryption
|
|
||||||
|
|
||||||
This module is completely independent and can be used in any JavaScript/TypeScript project.
|
|
||||||
|
|
||||||
## Protocol Flow
|
|
||||||
|
|
||||||
### Encryption
|
|
||||||
|
|
||||||
1. Generate a random message key (mk) - 32 bytes
|
|
||||||
2. Generate a random salt (wkSalt) - 16 bytes
|
|
||||||
3. Derive shared secret from ECDH: `ecdhSharedSecret(myPrivateKey, theirPublicKey)`
|
|
||||||
4. Derive wrapping key: `deriveWrappingKey(sharedSecret, wkSalt, info)` using HKDF
|
|
||||||
5. Encrypt message with mk using AES-GCM → (iv, ciphertext)
|
|
||||||
6. Encrypt (wrap) mk with wrapping key using AES-GCM → (iv2, wrappedMk)
|
|
||||||
7. Send: `{ iv, ciphertext, salt, iv2, wrappedMk }`
|
|
||||||
|
|
||||||
### Decryption
|
|
||||||
|
|
||||||
1. Derive shared secret from ECDH
|
|
||||||
2. Derive wrapping key from shared secret using salt from message
|
|
||||||
3. Decrypt wrappedMk to get mk
|
|
||||||
4. Decrypt ciphertext with mk
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { FromChatProtocol } from "@fromchat/protocol";
|
|
||||||
|
|
||||||
// Initialize with your private key
|
|
||||||
const protocol = new FromChatProtocol(privateKey);
|
|
||||||
|
|
||||||
// Encrypt a message
|
|
||||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, "Hello!");
|
|
||||||
|
|
||||||
// Decrypt a message
|
|
||||||
const decrypted = await protocol.decryptMessage(senderPublicKey, encrypted);
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### `FromChatProtocol`
|
|
||||||
|
|
||||||
#### Constructor
|
|
||||||
- `constructor(privateKey: Uint8Array)` - Initialize protocol with your X25519 private key
|
|
||||||
|
|
||||||
#### Methods
|
|
||||||
- `encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage>` - Encrypt a message
|
|
||||||
- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string>` - Decrypt a message
|
|
||||||
|
|
||||||
### Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface EncryptedMessage {
|
|
||||||
iv: string; // Base64 encoded IV for message encryption
|
|
||||||
ciphertext: string; // Base64 encoded encrypted message
|
|
||||||
salt: string; // Base64 encoded salt for wrapping key derivation
|
|
||||||
iv2: string; // Base64 encoded IV for message key wrapping
|
|
||||||
wrappedMk: string; // Base64 encoded wrapped message key
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backup & Key Management
|
|
||||||
|
|
||||||
The protocol also includes utilities for backing up and restoring private keys:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
encryptBackupWithPassword,
|
|
||||||
decryptBackupWithPassword,
|
|
||||||
encodeBlob,
|
|
||||||
decodeBlob
|
|
||||||
} from "@fromchat/protocol";
|
|
||||||
|
|
||||||
// Create a backup of a private key
|
|
||||||
const bundle = { version: 1, privateKey: myPrivateKey };
|
|
||||||
const encrypted = await encryptBackupWithPassword("my-password", bundle);
|
|
||||||
const backupString = encodeBlob(encrypted); // Store this string
|
|
||||||
|
|
||||||
// Restore from backup
|
|
||||||
const encryptedBlob = decodeBlob(backupString);
|
|
||||||
const restored = await decryptBackupWithPassword("my-password", encryptedBlob);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
- Each message uses a fresh random message key
|
|
||||||
- The protocol does not provide forward secrecy
|
|
||||||
- Keys are derived using HKDF with SHA-256
|
|
||||||
- All encryption uses AES-GCM with 12-byte IVs
|
|
||||||
- Backup encryption uses PBKDF2 with 210,000 iterations
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@fromchat/protocol",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"module": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"require": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"prepublishOnly": "npm run build"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"encryption",
|
|
||||||
"ecdh",
|
|
||||||
"e2ee",
|
|
||||||
"end-to-end-encryption",
|
|
||||||
"x25519",
|
|
||||||
"aes-gcm",
|
|
||||||
"hkdf"
|
|
||||||
],
|
|
||||||
"author": "denis0001-dev",
|
|
||||||
"license": "GPL-3.0",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/Toolbox-io/FromChat.git",
|
|
||||||
"directory": "frontend/packages/fromchat-protocol"
|
|
||||||
},
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/Toolbox-io/FromChat/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/Toolbox-io/FromChat#readme",
|
|
||||||
"dependencies": {
|
|
||||||
"tweetnacl": "^1.0.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^25.0.2",
|
|
||||||
"typescript": "^5.0.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"README.md"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=24.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": ["ES2020", "DOM"],
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"sourceMap": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { PRODUCT_NAME } from "./core/config";
|
|
||||||
import { isElectron } from "./core/electron/electron";
|
|
||||||
|
|
||||||
export function ElectronTitleBar() {
|
|
||||||
return isElectron && (
|
|
||||||
<div id="electron-title-bar">
|
|
||||||
{window.electronInterface.platform == "darwin" && <div className="macos-padding"></div>}
|
|
||||||
<div id="window-title">{PRODUCT_NAME}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./user/auth";
|
|
||||||
import type { IceServersResponse } from "@/core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches ICE server configuration for WebRTC
|
|
||||||
*/
|
|
||||||
export async function iceServers(token: string): Promise<IceServersResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/webrtc/ice`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch ICE servers");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "../user/auth";
|
|
||||||
import { getCurrentKeys } from "../user/auth";
|
|
||||||
import { request } from "@/core/websocket";
|
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
|
||||||
import { fetchUserPublicKey } from "../crypto/identity";
|
|
||||||
import { fetchUsers, searchUsers } from "../user/search";
|
|
||||||
import { getOrInitProtocol } from "@/utils/crypto/fromchatInit";
|
|
||||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol";
|
|
||||||
|
|
||||||
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
|
||||||
const protocol = getOrInitProtocol();
|
|
||||||
const senderPublicKey = ub64(senderPublicKeyB64);
|
|
||||||
|
|
||||||
return await protocol.decryptMessage(senderPublicKey, envelope);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
|
||||||
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
|
|
||||||
if (beforeId) {
|
|
||||||
url += `&before_id=${beforeId}`;
|
|
||||||
}
|
|
||||||
const response = await globalThis.fetch(url, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!response.ok) return { messages: [], has_more: false };
|
|
||||||
const data = await response.json();
|
|
||||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
|
||||||
const protocol = getOrInitProtocol();
|
|
||||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
|
||||||
|
|
||||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext);
|
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
|
||||||
recipientId: recipientId,
|
|
||||||
...encrypted
|
|
||||||
};
|
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmSend",
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken
|
|
||||||
},
|
|
||||||
data: payload
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
|
||||||
// For files, we need to use the same message key for both the message and files
|
|
||||||
// So we'll do the encryption manually here to reuse the mk
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
const names: string[] = [];
|
|
||||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
|
||||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
// Encrypt file with same mk
|
|
||||||
const data = new Uint8Array(await f.arrayBuffer());
|
|
||||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
|
||||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
|
||||||
const serverName = f.name;
|
|
||||||
names.push(serverName);
|
|
||||||
form.append("files", new File([blob], serverName));
|
|
||||||
}
|
|
||||||
form.append("fileNames", JSON.stringify(names));
|
|
||||||
|
|
||||||
// Encrypt the plaintext JSON with the same mk
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson));
|
|
||||||
form.append("dm_payload", JSON.stringify({
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
} satisfies BaseDmEnvelope));
|
|
||||||
|
|
||||||
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, false),
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
|
||||||
const protocol = getOrInitProtocol();
|
|
||||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
|
||||||
|
|
||||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson);
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmEdit",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
...encrypted
|
|
||||||
}
|
|
||||||
} as DMEditRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
type: "dmDelete",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: { id, recipientId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversationResponse {
|
|
||||||
user: User;
|
|
||||||
lastMessage: DmEnvelope;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.conversations || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a DM as read
|
|
||||||
*/
|
|
||||||
export async function markRead(id: number, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
type: "dmMarkRead",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: { id }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export user functions for convenience
|
|
||||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./account";
|
|
||||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
|
||||||
import { getCurrentKeys } from "./account";
|
|
||||||
import { request } from "@/core/websocket";
|
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
|
||||||
import { fetchUserPublicKey } from "./crypto";
|
|
||||||
import { fetchUsers, searchUsers } from "./users";
|
|
||||||
|
|
||||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// Obtain the key
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
|
||||||
return new TextDecoder().decode(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const data = await response.json();
|
|
||||||
return data.messages || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export user functions for convenience
|
|
||||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
|
||||||
|
|
||||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// Encryption key
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
// Encrypt the message
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
};
|
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmSend",
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken
|
|
||||||
},
|
|
||||||
data: payload
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
const names: string[] = [];
|
|
||||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
|
||||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
// Encrypt file with same mk
|
|
||||||
const data = new Uint8Array(await f.arrayBuffer());
|
|
||||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
|
||||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
|
||||||
const serverName = f.name; // server uses provided name
|
|
||||||
names.push(serverName);
|
|
||||||
form.append("files", new File([blob], serverName));
|
|
||||||
}
|
|
||||||
form.append("fileNames", JSON.stringify(names));
|
|
||||||
|
|
||||||
// Merge files metadata into plaintext JSON and encrypt
|
|
||||||
let obj: DmEncryptedJSON;
|
|
||||||
try {
|
|
||||||
obj = JSON.parse(plaintextJson);
|
|
||||||
} catch {
|
|
||||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
|
||||||
form.append("dm_payload", JSON.stringify({
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
} satisfies BaseDmEnvelope));
|
|
||||||
|
|
||||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, false),
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmEdit",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext),
|
|
||||||
salt: b64(wkSalt)
|
|
||||||
}
|
|
||||||
} as DMEditRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
type: "dmDelete",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: { id, recipientId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DMConversationResponse {
|
|
||||||
user: User;
|
|
||||||
lastMessage: DmEnvelope;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.conversations || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./account";
|
|
||||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
|
||||||
import { getCurrentKeys } from "./account";
|
|
||||||
import { request } from "@/core/websocket";
|
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
|
||||||
import { fetchUserPublicKey } from "./crypto";
|
|
||||||
import { fetchUsers, searchUsers } from "./users";
|
|
||||||
|
|
||||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// Obtain the key
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
|
||||||
return new TextDecoder().decode(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const data = await response.json();
|
|
||||||
return data.messages || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export user functions for convenience
|
|
||||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
|
||||||
|
|
||||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// Encryption key
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
// Encrypt the message
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
};
|
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmSend",
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken
|
|
||||||
},
|
|
||||||
data: payload
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
const names: string[] = [];
|
|
||||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
|
||||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
// Encrypt file with same mk
|
|
||||||
const data = new Uint8Array(await f.arrayBuffer());
|
|
||||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
|
||||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
|
||||||
const serverName = f.name; // server uses provided name
|
|
||||||
names.push(serverName);
|
|
||||||
form.append("files", new File([blob], serverName));
|
|
||||||
}
|
|
||||||
form.append("fileNames", JSON.stringify(names));
|
|
||||||
|
|
||||||
// Merge files metadata into plaintext JSON and encrypt
|
|
||||||
let obj: DmEncryptedJSON;
|
|
||||||
try {
|
|
||||||
obj = JSON.parse(plaintextJson);
|
|
||||||
} catch {
|
|
||||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
|
||||||
form.append("dm_payload", JSON.stringify({
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
} satisfies BaseDmEnvelope));
|
|
||||||
|
|
||||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, false),
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmEdit",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext),
|
|
||||||
salt: b64(wkSalt)
|
|
||||||
}
|
|
||||||
} as DMEditRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
type: "dmDelete",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: { id, recipientId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DMConversationResponse {
|
|
||||||
user: User;
|
|
||||||
lastMessage: DmEnvelope;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.conversations || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./account";
|
|
||||||
import type { IceServersResponse } from "@/core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches ICE server configuration for WebRTC
|
|
||||||
*/
|
|
||||||
export async function getIceServers(token: string): Promise<IceServersResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/webrtc/ice`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch ICE servers");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import api from "@/core/api";
|
|
||||||
import { useUserStore } from "@/state/user";
|
|
||||||
import { MaterialIcon } from "@/utils/material";
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
verified: boolean;
|
|
||||||
userId?: number;
|
|
||||||
size?: "small" | "medium" | "large";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) {
|
|
||||||
const [isSimilarToVerified, setIsSimilarToVerified] = useState(false);
|
|
||||||
const { user } = useUserStore();
|
|
||||||
|
|
||||||
const className = `status-badge ${size}`;
|
|
||||||
|
|
||||||
// Check similarity for unverified users
|
|
||||||
useEffect(() => {
|
|
||||||
if (!verified && userId && user.authToken) {
|
|
||||||
api.user.profile.checkSimilarity(userId, user.authToken)
|
|
||||||
.then(result => {
|
|
||||||
setIsSimilarToVerified(result?.isSimilar || false);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error checking similarity:', error);
|
|
||||||
setIsSimilarToVerified(false);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setIsSimilarToVerified(false);
|
|
||||||
}
|
|
||||||
}, [verified, userId, user.authToken]);
|
|
||||||
|
|
||||||
if (verified) {
|
|
||||||
return (
|
|
||||||
<span className={`${className} verified`} title="Подтверждённый аккаунт">
|
|
||||||
<MaterialIcon name="verified--filled" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSimilarToVerified) {
|
|
||||||
return (
|
|
||||||
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
|
|
||||||
<MaterialIcon name="warning--filled" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't show anything if not verified and not similar
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Application configuration constants
|
|
||||||
* @description Contains all configuration values used throughout the application
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
export const BASE_DOMAIN = import.meta.env.VITE_API_BASE_URL || "fromchat.ru";
|
|
||||||
export const API_BASE_URL = `${location.host ? "" : `https://${BASE_DOMAIN}`}/api`;
|
|
||||||
export const API_WS_BASE_URL = `${location.host || BASE_DOMAIN}/api`;
|
|
||||||
export const PRODUCT_NAME = "FromChat";
|
|
||||||
export const MINIMUM_WIDTH = 800;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
@use "../../css/material" as *;
|
|
||||||
|
|
||||||
#electron-title-bar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.electron {
|
|
||||||
#electron-title-bar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 40px;
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
width: 100%;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
user-select: none;
|
|
||||||
z-index: 10;
|
|
||||||
transition: background-color 0.5s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.color-surface {
|
|
||||||
background-color: $color-dark-surface;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#window-title {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.platform-darwin .macos-padding {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Electron-specific code
|
|
||||||
* @description This module initializes Electron-specific functionality.
|
|
||||||
* @author denis0001-dev
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import "./electron.scss";
|
|
||||||
|
|
||||||
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface != undefined;
|
|
||||||
|
|
||||||
if (isElectron) {
|
|
||||||
console.log("Running in Electron");
|
|
||||||
document.documentElement.classList.add("electron", `platform-${window.electronInterface.platform}`);
|
|
||||||
} else {
|
|
||||||
console.log("Running in normal browser");
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import api from "@/core/api";
|
|
||||||
import { isElectron } from "@/core/electron/electron";
|
|
||||||
import { websocket } from "@/core/websocket";
|
|
||||||
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
|
|
||||||
import serviceWorker from "./service-worker?worker&url";
|
|
||||||
import logo from "@/images/logo.svg";
|
|
||||||
|
|
||||||
export interface PushSubscriptionData {
|
|
||||||
endpoint: string;
|
|
||||||
keys: {
|
|
||||||
p256dh: string;
|
|
||||||
auth: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationPayload {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
icon?: string;
|
|
||||||
image?: string;
|
|
||||||
tag?: string;
|
|
||||||
data?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global state
|
|
||||||
let isInitialized = false;
|
|
||||||
let registration: ServiceWorkerRegistration | null = null;
|
|
||||||
let subscription: PushSubscription | null = null;
|
|
||||||
let isElectronReceiverRunning = false;
|
|
||||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
||||||
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
|
||||||
const base64 = (base64String + padding)
|
|
||||||
.replace(/-/g, "+")
|
|
||||||
.replace(/_/g, "/");
|
|
||||||
|
|
||||||
const rawData = window.atob(base64);
|
|
||||||
const outputArray = new Uint8Array(rawData.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < rawData.length; ++i) {
|
|
||||||
outputArray[i] = rawData.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return outputArray;
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = "";
|
|
||||||
for (let i = 0; i < bytes.byteLength; i++) {
|
|
||||||
binary += String.fromCharCode(bytes[i]);
|
|
||||||
}
|
|
||||||
return window.btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function subscribeToWebPush(): Promise<PushSubscription | null> {
|
|
||||||
if (!registration) {
|
|
||||||
throw new Error("Service Worker not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
subscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlBase64ToUint8Array(
|
|
||||||
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
|
|
||||||
).slice().buffer
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Push subscription successful");
|
|
||||||
return subscription;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Push subscription failed:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendSubscriptionToServer(token: string): Promise<boolean> {
|
|
||||||
if (!subscription) {
|
|
||||||
throw new Error("No push subscription available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionData: PushSubscriptionData = {
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
keys: {
|
|
||||||
p256dh: arrayBufferToBase64(subscription.getKey("p256dh")!),
|
|
||||||
auth: arrayBufferToBase64(subscription.getKey("auth")!)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.push.subscription.subscribe(subscriptionData, token);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send subscription to server:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showMessageNotification(message: any): Promise<void> {
|
|
||||||
try {
|
|
||||||
await showNotification({
|
|
||||||
title: `New message from ${message.username}`,
|
|
||||||
body: message.content.length > 100
|
|
||||||
? message.content.substring(0, 100) + "..."
|
|
||||||
: message.content,
|
|
||||||
icon: message.profile_picture || logo,
|
|
||||||
tag: `message_${message.id}`,
|
|
||||||
data: {
|
|
||||||
type: "public_message",
|
|
||||||
message_id: message.id,
|
|
||||||
sender_id: message.user_id,
|
|
||||||
sender_username: message.username
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to show message notification:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
|
|
||||||
// Handle notifications for new messages
|
|
||||||
if (response.type === "newMessage" && response.data) {
|
|
||||||
const newResponse = response as NewMessageWebSocketMessage;
|
|
||||||
await showMessageNotification(newResponse.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public API functions
|
|
||||||
export async function initialize(): Promise<boolean> {
|
|
||||||
if (isInitialized) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isElectron) {
|
|
||||||
// For Electron, we just need to request permission
|
|
||||||
const permission = await window.electronInterface.notifications.requestPermission();
|
|
||||||
isInitialized = permission === "granted";
|
|
||||||
return isInitialized;
|
|
||||||
} else {
|
|
||||||
// For web browsers, initialize service worker and push manager
|
|
||||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
||||||
console.log("Push messaging is not supported");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
|
|
||||||
console.log("Service Worker registered successfully");
|
|
||||||
|
|
||||||
const permission = await Notification.requestPermission();
|
|
||||||
if (permission === "granted") {
|
|
||||||
await subscribeToWebPush();
|
|
||||||
isInitialized = true;
|
|
||||||
}
|
|
||||||
return isInitialized;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Service Worker registration failed:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to initialize notification service:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subscribe(token: string): Promise<boolean> {
|
|
||||||
if (!isInitialized) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isElectron) {
|
|
||||||
// In Electron, we don't need server-side subscription
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If subscription doesn't exist, try to get it from the push manager or create a new one
|
|
||||||
if (!subscription && registration) {
|
|
||||||
try {
|
|
||||||
// Try to get existing subscription first
|
|
||||||
subscription = await registration.pushManager.getSubscription();
|
|
||||||
// If no existing subscription, create a new one
|
|
||||||
if (!subscription) {
|
|
||||||
subscription = await subscribeToWebPush();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get or create subscription:", error);
|
|
||||||
subscription = await subscribeToWebPush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await sendSubscriptionToServer(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
|
|
||||||
if (isElectron) {
|
|
||||||
try {
|
|
||||||
return await window.electronInterface.notifications.show({
|
|
||||||
title: payload.title,
|
|
||||||
body: payload.body,
|
|
||||||
icon: payload.icon,
|
|
||||||
tag: payload.tag
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to show Electron notification:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For web browsers, notifications are handled by the service worker
|
|
||||||
// when push messages are received from the server
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unsubscribe(): Promise<boolean> {
|
|
||||||
if (isElectron) {
|
|
||||||
// In Electron, we don't need to unsubscribe from server
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await subscription.unsubscribe();
|
|
||||||
subscription = null;
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to unsubscribe:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSupported(): boolean {
|
|
||||||
if (isElectron) {
|
|
||||||
return true; // Electron always supports notifications
|
|
||||||
}
|
|
||||||
return "serviceWorker" in navigator && "PushManager" in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Electron-specific functions
|
|
||||||
export async function startElectronReceiver(): Promise<void> {
|
|
||||||
if (!isElectron || isElectronReceiverRunning) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isElectronReceiverRunning = true;
|
|
||||||
|
|
||||||
// Add our own message listener to the existing WebSocket
|
|
||||||
messageListener = (event: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const response: WebSocketMessage<any> = JSON.parse(event.data);
|
|
||||||
handleWebSocketMessage(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.addEventListener('message', messageListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopElectronReceiver(): void {
|
|
||||||
if (!isElectron) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isElectronReceiverRunning = false;
|
|
||||||
|
|
||||||
// Remove our message listener
|
|
||||||
if (messageListener) {
|
|
||||||
websocket.removeEventListener('message', messageListener);
|
|
||||||
messageListener = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
-49
@@ -3,58 +3,30 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "frontend/build/electron/core/main.js",
|
|
||||||
"description": "A 100% Open Source Messenger",
|
"description": "A 100% Open Source Messenger",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"authors": "denis0001-dev",
|
"authors": "denis0001-dev",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"backend:run": "bash ./scripts/backend:run.sh",
|
"frontend:dev": "vite",
|
||||||
"backend:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt",
|
"frontend:typecheck": "tsc --project tsconfig.json",
|
||||||
"backend:reinstall": "rm -rf .venv && npm run backend:dependencies",
|
"frontend:build": "npm run frontend:typecheck && vite build",
|
||||||
"backend:clean": "rm -rf backend/data",
|
"frontend:preview": "vite preview",
|
||||||
"frontend:dev": "dotenv -e deployment/.env -- vite frontend",
|
|
||||||
"frontend:typecheck": "tsc --project frontend",
|
|
||||||
"frontend:build": "npm run frontend:typecheck && dotenv -e deployment/.env -- vite build frontend",
|
|
||||||
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev",
|
|
||||||
"frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force --arch arm64,x64",
|
|
||||||
"frontend:preview": "vite preview frontend",
|
|
||||||
"frontend:dependencies": "npm install --ignore-scripts",
|
"frontend:dependencies": "npm install --ignore-scripts",
|
||||||
"frontend:clean": "rm -rf frontend/dist",
|
"frontend:clean": "rm -rf build",
|
||||||
"dev": "concurrently 'npm run frontend:dev' 'npm run backend:run'",
|
"build": "npm run frontend:build",
|
||||||
"dev:electron": "concurrently 'npm run frontend:electron:dev' 'npm run backend:run'",
|
"preview": "docker compose -f docker-compose.yml up --build --watch",
|
||||||
"build:electron": "npm run frontend:electron:build",
|
"preview:clean": "docker compose -f docker-compose.yml down -v --remove-orphans",
|
||||||
"build": "npm run frontend:build && npm run build:electron",
|
|
||||||
"preview": "cd deployment && docker compose up --build --watch",
|
|
||||||
"preview:clean": "cd deployment && docker compose down -v",
|
|
||||||
"clean": "npm run backend:clean && npm run frontend:clean && npm run preview:clean",
|
|
||||||
"install": "npm run backend:dependencies && if [[ ! -f deployment/.env ]]; then npm run generate:env; fi && npm run install:pussh",
|
|
||||||
"install:pussh": "bash ./scripts/install:pussh.sh",
|
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"generate:env": "bash ./scripts/generate:env.sh",
|
"install": "if [ ! -e .env ]; then cp .env.example .env; fi"
|
||||||
"deploy": "dotenv -e deployment/.env -- bash ./scripts/deploy.sh"
|
|
||||||
},
|
},
|
||||||
"files": [
|
|
||||||
"frontend/build/electron"
|
|
||||||
],
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-forge/cli": "^7.9.0",
|
|
||||||
"@electron-forge/maker-deb": "^7.9.0",
|
|
||||||
"@electron-forge/maker-dmg": "^7.9.0",
|
|
||||||
"@electron-forge/maker-rpm": "^7.9.0",
|
|
||||||
"@electron-forge/maker-squirrel": "^7.9.0",
|
|
||||||
"@electron-forge/maker-zip": "^7.9.0",
|
|
||||||
"@electron-forge/plugin-auto-unpack-natives": "^7.9.0",
|
|
||||||
"@electron-forge/plugin-fuses": "^7.9.0",
|
|
||||||
"@electron/fuses": "^1.0.0",
|
|
||||||
"@types/he": "^1.2.3",
|
"@types/he": "^1.2.3",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"baseline-browser-mapping": "^2.9.11",
|
|
||||||
"concurrently": "^9.2.1",
|
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"electron": "^39.2.7",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup-plugin-visualizer": "^6.0.4",
|
"rollup-plugin-visualizer": "^6.0.4",
|
||||||
@@ -62,18 +34,11 @@
|
|||||||
"svgo": "^4.0.0",
|
"svgo": "^4.0.0",
|
||||||
"terser": "^5.44.0",
|
"terser": "^5.44.0",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vite": "^7.1.6",
|
"vite": "^8.2.1",
|
||||||
"vite-plugin-electron": "^0.29.0",
|
|
||||||
"vite-plugin-electron-renderer": "^0.14.6",
|
|
||||||
"vite-plugin-html": "^3.2.2",
|
"vite-plugin-html": "^3.2.2",
|
||||||
"vite-plugin-sass-dts": "^1.3.34"
|
"vite-plugin-sass-dts": "^1.3.34"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
|
||||||
"frontend/packages/fromchat-protocol"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fromchat/protocol": "workspace:*",
|
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
@@ -86,8 +51,5 @@
|
|||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"use-immer": "^0.11.0",
|
"use-immer": "^0.11.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"forge": "frontend/forge.config.ts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
cd backend
|
|
||||||
dotenv -e ../deployment/.env -- \
|
|
||||||
../.venv/bin/uvicorn main:app \
|
|
||||||
--host 127.0.0.1 \
|
|
||||||
--port 8300 \
|
|
||||||
--reload \
|
|
||||||
--reload-exclude './alembic' \
|
|
||||||
--reload-exclude './alembic/*' \
|
|
||||||
--reload-exclude './alembic/versions/*' \
|
|
||||||
--access-log
|
|
||||||
@@ -1,612 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Complete deployment script: build and push to server
|
|
||||||
# Usage: ./scripts/deploy.sh [server_user@server_host] [deployment_path] [platform]
|
|
||||||
# Example: ./scripts/deploy.sh user@example.com /home/user/fromchat linux/arm64
|
|
||||||
|
|
||||||
# Colors
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
MAGENTA='\033[0;35m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
BOLD='\033[1m'
|
|
||||||
|
|
||||||
# Helper functions
|
|
||||||
info() { echo -e "${BLUE}ℹ${NC} $1"; }
|
|
||||||
success() { echo -e "${GREEN}✓${NC} $1"; }
|
|
||||||
warning() { echo -e "${YELLOW}⚠${NC} $1"; }
|
|
||||||
error() { echo -e "${RED}✗${NC} $1"; }
|
|
||||||
step() { echo -e "${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; }
|
|
||||||
substep() {
|
|
||||||
if [ "$2" = "-n" ]; then
|
|
||||||
echo -n -e " ${GREEN}•${NC} $1"
|
|
||||||
else
|
|
||||||
echo -e " ${GREEN}•${NC} $1"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo -e "${MAGENTA}${BOLD}🚀 Deployment${NC}\n"
|
|
||||||
|
|
||||||
# Read password with asterisks
|
|
||||||
read_password() {
|
|
||||||
local password=""
|
|
||||||
local char
|
|
||||||
local old_stty
|
|
||||||
|
|
||||||
# Save current terminal settings
|
|
||||||
old_stty=$(stty -g 2>/dev/null)
|
|
||||||
|
|
||||||
# Disable echo
|
|
||||||
stty -echo 2>/dev/null
|
|
||||||
|
|
||||||
# Read characters one by one
|
|
||||||
while IFS= read -rs -n 1 char; do
|
|
||||||
# Check for Enter key (empty means Enter was pressed)
|
|
||||||
if [ -z "$char" ]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
# Check for backspace/delete (ASCII 127)
|
|
||||||
if [ "$char" = $'\177' ] || [ "$char" = $'\b' ]; then
|
|
||||||
if [ ${#password} -gt 0 ]; then
|
|
||||||
password="${password%?}"
|
|
||||||
printf "\b \b" >&2
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
password+="$char"
|
|
||||||
printf "*" >&2
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Restore terminal settings
|
|
||||||
stty "$old_stty" 2>/dev/null
|
|
||||||
echo "" >&2
|
|
||||||
echo "$password"
|
|
||||||
}
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
DEPLOYMENT_DIR="$PROJECT_ROOT/deployment"
|
|
||||||
ENV_FILE="$DEPLOYMENT_DIR/.env"
|
|
||||||
|
|
||||||
# Load .env file if it exists
|
|
||||||
if [ -f "$ENV_FILE" ]; then
|
|
||||||
# Export variables from .env file (ignore comments and empty lines)
|
|
||||||
set -a
|
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
|
||||||
# Skip comments and empty lines
|
|
||||||
case "$line" in
|
|
||||||
\#*|'') continue ;;
|
|
||||||
*)
|
|
||||||
# Export the variable
|
|
||||||
export "$line" 2>/dev/null || true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done < "$ENV_FILE"
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Read server from environment variable (from .env), command line argument, or fallback
|
|
||||||
SERVER="${1:-${DEPLOYMENT_SERVER:-}}"
|
|
||||||
REPO_NAME="FromChat"
|
|
||||||
DEPLOY_PATH="~/actions-runner/_work/$REPO_NAME/$REPO_NAME"
|
|
||||||
PLATFORM="linux/arm64"
|
|
||||||
|
|
||||||
# Check if server is provided
|
|
||||||
if [ -z "$SERVER" ]; then
|
|
||||||
error "Server not specified. Usage: $0 [user@host] [deployment_path] [platform]"
|
|
||||||
echo " Or set DEPLOYMENT_SERVER in $ENV_FILE or as an environment variable"
|
|
||||||
echo ""
|
|
||||||
echo "Example:"
|
|
||||||
echo " $0 user@example.com /home/user/fromchat linux/arm64"
|
|
||||||
echo " Or add to $ENV_FILE: DEPLOYMENT_SERVER=user@example.com"
|
|
||||||
echo " Or: DEPLOYMENT_SERVER=user@example.com $0"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# SSH AUTHENTICATION
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
step "Authentication"
|
|
||||||
SSH_KEY_FILE="$HOME/.ssh/id_rsa"
|
|
||||||
SSH_KEY_PUB_FILE="$SSH_KEY_FILE.pub"
|
|
||||||
|
|
||||||
# Ensure ssh-agent is running
|
|
||||||
if [ -z "$SSH_AUTH_SOCK" ]; then
|
|
||||||
eval "$(ssh-agent -s)" > /dev/null 2>&1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if SSH key exists
|
|
||||||
if [ ! -f "$SSH_KEY_FILE" ]; then
|
|
||||||
error "SSH key not found at $SSH_KEY_FILE"
|
|
||||||
echo " Please generate an SSH key pair first:"
|
|
||||||
echo " ssh-keygen -t rsa -b 4096 -C 'your_email@example.com'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add SSH key to agent if not already loaded
|
|
||||||
KEY_LOADED=false
|
|
||||||
if ssh-add -l > /dev/null 2>&1; then
|
|
||||||
# Check if this specific key is loaded by trying to match the public key
|
|
||||||
KEY_FINGERPRINT=$(ssh-keygen -lf "$SSH_KEY_FILE" 2>/dev/null | awk '{print $2}')
|
|
||||||
if [ -n "$KEY_FINGERPRINT" ] && ssh-add -l 2>/dev/null | grep -q "$KEY_FINGERPRINT"; then
|
|
||||||
KEY_LOADED=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$KEY_LOADED" = false ]; then
|
|
||||||
substep "Adding SSH key to agent..."
|
|
||||||
if ! ssh-add "$SSH_KEY_FILE" 2>/dev/null; then
|
|
||||||
error "Failed to add SSH key to agent. Check your key passphrase."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if SSH key authentication already works
|
|
||||||
if ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SERVER" "echo 'SSH key works'" >/dev/null 2>&1; then
|
|
||||||
# SSH key already works, no need to copy
|
|
||||||
true
|
|
||||||
else
|
|
||||||
# Check if our public key is already on the server
|
|
||||||
KEY_CONTENT=$(cat "$SSH_KEY_PUB_FILE")
|
|
||||||
if ssh -o BatchMode=no -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SERVER" "
|
|
||||||
grep -q '$KEY_CONTENT' ~/.ssh/authorized_keys 2>/dev/null
|
|
||||||
" >/dev/null 2>&1; then
|
|
||||||
# Key exists but authentication failed - might be permissions issue
|
|
||||||
error "SSH key found on server but authentication failed. Check server SSH configuration."
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
# Key not on server, need to copy it
|
|
||||||
substep "SSH password: " -n
|
|
||||||
SSH_PASSWORD=$(read_password)
|
|
||||||
|
|
||||||
if [ -z "$SSH_PASSWORD" ]; then
|
|
||||||
error "No SSH password provided"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
substep "Copying SSH key to server..."
|
|
||||||
if command -v expect >/dev/null 2>&1; then
|
|
||||||
expect << EOF >/dev/null 2>&1
|
|
||||||
spawn ssh-copy-id -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i "$SSH_KEY_PUB_FILE" "$SERVER"
|
|
||||||
expect "password:"
|
|
||||||
send "$SSH_PASSWORD\r"
|
|
||||||
expect eof
|
|
||||||
EOF
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
true
|
|
||||||
else
|
|
||||||
error "Failed to copy SSH key to server"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
error "expect not available - cannot copy SSH key"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# SUDO AUTHENTICATION
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
SUDO_PASSWORD=""
|
|
||||||
# If SSH password was provided, try using it for sudo first
|
|
||||||
if [ -n "$SSH_PASSWORD" ]; then
|
|
||||||
if echo "$SSH_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then
|
|
||||||
SUDO_PASSWORD="$SSH_PASSWORD"
|
|
||||||
export SUDO_PASSWORD
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If we don't have a working sudo password yet, prompt for it
|
|
||||||
if [ -z "$SUDO_PASSWORD" ]; then
|
|
||||||
while true; do
|
|
||||||
substep "Sudo password: " -n
|
|
||||||
SUDO_PASSWORD=$(read_password)
|
|
||||||
|
|
||||||
if [ -z "$SUDO_PASSWORD" ]; then
|
|
||||||
warning "No password provided - assuming passwordless sudo"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if echo "$SUDO_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then
|
|
||||||
export SUDO_PASSWORD
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo -n " " && error "Invalid password, please try again"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# BUILD PHASE
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
echo -e "\n${MAGENTA}${BOLD}🔨 Building Docker images${NC}\n"
|
|
||||||
|
|
||||||
# Determine project name
|
|
||||||
if [ -n "$SERVER" ]; then
|
|
||||||
COMPOSE_DIR=$(ssh "$SERVER" "dirname $DEPLOY_PATH/deployment/docker-compose.yml" 2>/dev/null || echo "$DEPLOY_PATH/deployment")
|
|
||||||
PROJECT_NAME=$(ssh "$SERVER" "basename $COMPOSE_DIR" 2>/dev/null || echo "deployment")
|
|
||||||
else
|
|
||||||
PROJECT_NAME=$(basename "$DEPLOYMENT_DIR")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if Docker daemon is running
|
|
||||||
check_docker_daemon() {
|
|
||||||
docker info > /dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Start Docker Desktop
|
|
||||||
start_docker_desktop() {
|
|
||||||
substep "Starting Docker Desktop..."
|
|
||||||
if ! docker desktop start > /dev/null 2>&1; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait for Docker to be ready (max 60 seconds)
|
|
||||||
substep "Waiting for Docker to start..." -n
|
|
||||||
local max_wait=60
|
|
||||||
local waited=0
|
|
||||||
while [ $waited -lt $max_wait ]; do
|
|
||||||
if check_docker_daemon; then
|
|
||||||
echo ""
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
waited=$((waited + 2))
|
|
||||||
echo -n "."
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check buildx
|
|
||||||
if ! docker buildx version > /dev/null 2>&1; then
|
|
||||||
error "Docker buildx not available. Install Docker Desktop."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Docker daemon
|
|
||||||
if ! check_docker_daemon; then
|
|
||||||
warning "Docker daemon is not running"
|
|
||||||
if ! start_docker_desktop; then
|
|
||||||
error "Failed to start Docker Desktop. Please start it manually and try again."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Setup buildx builder
|
|
||||||
step "Setting up buildx builder"
|
|
||||||
BUILDER_NAME="fromchat-builder"
|
|
||||||
BUILDER_EXISTS=false
|
|
||||||
|
|
||||||
if docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
|
||||||
BUILDER_EXISTS=true
|
|
||||||
if ! docker buildx use "$BUILDER_NAME" > /dev/null 2>&1; then
|
|
||||||
substep "Recreating builder..."
|
|
||||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
|
||||||
BUILDER_EXISTS=false
|
|
||||||
elif ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
|
||||||
substep "Recreating builder (inspection failed)..."
|
|
||||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
|
||||||
BUILDER_EXISTS=false
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$BUILDER_EXISTS" = false ]; then
|
|
||||||
substep "Creating builder with persistent cache..."
|
|
||||||
docker buildx create \
|
|
||||||
--name "$BUILDER_NAME" \
|
|
||||||
--driver docker-container \
|
|
||||||
--driver-opt image=moby/buildkit:latest \
|
|
||||||
--use \
|
|
||||||
--bootstrap > /dev/null 2>&1
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker buildx use "$BUILDER_NAME" > /dev/null 2>&1
|
|
||||||
|
|
||||||
# Detect services
|
|
||||||
step "Detecting services"
|
|
||||||
cd "$DEPLOYMENT_DIR"
|
|
||||||
SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -z "$SERVICES" ]; then
|
|
||||||
error "No services found in docker-compose.yml"
|
|
||||||
fi
|
|
||||||
|
|
||||||
BUILT_IMAGES=()
|
|
||||||
|
|
||||||
for SERVICE in $SERVICES; do
|
|
||||||
HAS_BUILD=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
|
||||||
grep -A 30 "^[[:space:]]*${SERVICE}:" | \
|
|
||||||
grep -q "build:" && echo "yes" || echo "no")
|
|
||||||
|
|
||||||
if [ "$HAS_BUILD" != "yes" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
IMAGE_TAG="${PROJECT_NAME}-${SERVICE}:latest"
|
|
||||||
|
|
||||||
substep "Building ${CYAN}$SERVICE${NC} -> ${CYAN}$IMAGE_TAG${NC}..."
|
|
||||||
|
|
||||||
BUILD_OUTPUT=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
|
||||||
grep -A 15 "^[[:space:]]*${SERVICE}:" | \
|
|
||||||
grep -A 10 "build:")
|
|
||||||
|
|
||||||
DOCKERFILE_REL=$(echo "$BUILD_OUTPUT" | grep "dockerfile:" | \
|
|
||||||
sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \
|
|
||||||
tr -d '"' | tr -d "'" | xargs)
|
|
||||||
|
|
||||||
CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \
|
|
||||||
sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \
|
|
||||||
tr -d '"' | tr -d "'" | xargs)
|
|
||||||
|
|
||||||
if [ -z "$CONTEXT_REL" ]; then
|
|
||||||
CONTEXT_REL=".."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$CONTEXT_REL" == ".." ]]; then
|
|
||||||
BUILD_CONTEXT="$PROJECT_ROOT"
|
|
||||||
elif [[ "$CONTEXT_REL" == /* ]]; then
|
|
||||||
BUILD_CONTEXT="$CONTEXT_REL"
|
|
||||||
else
|
|
||||||
BUILD_CONTEXT="$DEPLOYMENT_DIR/$CONTEXT_REL"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$DOCKERFILE_REL" ]; then
|
|
||||||
if [[ "$DOCKERFILE_REL" == /* ]]; then
|
|
||||||
DOCKERFILE="$DOCKERFILE_REL"
|
|
||||||
else
|
|
||||||
if [[ "$CONTEXT_REL" == ".." ]] || [[ "$BUILD_CONTEXT" == "$PROJECT_ROOT" ]]; then
|
|
||||||
DOCKERFILE="$PROJECT_ROOT/$DOCKERFILE_REL"
|
|
||||||
else
|
|
||||||
DOCKERFILE="$BUILD_CONTEXT/$DOCKERFILE_REL"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ -f "$DEPLOYMENT_DIR/Dockerfile.$SERVICE" ]; then
|
|
||||||
DOCKERFILE="$DEPLOYMENT_DIR/Dockerfile.$SERVICE"
|
|
||||||
elif [ -f "$DEPLOYMENT_DIR/$SERVICE/Dockerfile" ]; then
|
|
||||||
DOCKERFILE="$DEPLOYMENT_DIR/$SERVICE/Dockerfile"
|
|
||||||
else
|
|
||||||
error "Could not determine Dockerfile for $SERVICE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if docker buildx build \
|
|
||||||
--platform "$PLATFORM" \
|
|
||||||
--file "$DOCKERFILE" \
|
|
||||||
--tag "$IMAGE_TAG" \
|
|
||||||
--load \
|
|
||||||
"$BUILD_CONTEXT"; then
|
|
||||||
echo -e " ${GREEN}✓${NC} Built ${CYAN}$SERVICE${NC}"
|
|
||||||
BUILT_IMAGES+=("$IMAGE_TAG")
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
error "Build failed for $SERVICE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
success "Build complete! ${#BUILT_IMAGES[@]} image(s) ready"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# DEPLOY PHASE
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
echo -e "\n${MAGENTA}${BOLD}🚀 Deploying to ${SERVER}${NC}\n"
|
|
||||||
|
|
||||||
|
|
||||||
# Check docker pussh
|
|
||||||
if ! docker pussh --help > /dev/null 2>&1; then
|
|
||||||
error "docker pussh plugin not installed"
|
|
||||||
echo " Install: npm run install:pussh"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Detect images based on docker-compose.yml (prefer explicit `image:` entries; fall back to built tags)
|
|
||||||
cd "$DEPLOYMENT_DIR"
|
|
||||||
COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null || true)
|
|
||||||
IMAGES=()
|
|
||||||
|
|
||||||
for S in $COMPOSE_SERVICES; do
|
|
||||||
# Try to read explicit image: field from the compose config for this service
|
|
||||||
IMAGE_FROM_COMPOSE=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
|
||||||
grep -A5 "^[[:space:]]*${S}:" | \
|
|
||||||
grep -m1 "image:" || true)
|
|
||||||
|
|
||||||
IMAGE_FROM_COMPOSE=$(echo "$IMAGE_FROM_COMPOSE" | sed 's/.*image:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs || true)
|
|
||||||
|
|
||||||
if [ -n "$IMAGE_FROM_COMPOSE" ]; then
|
|
||||||
IMAGES+=("$IMAGE_FROM_COMPOSE")
|
|
||||||
else
|
|
||||||
# If service has a build section (we built it above), use the tag pattern used during build
|
|
||||||
TAG="${PROJECT_NAME}-${S}:latest"
|
|
||||||
# Only include the tag if the image exists locally (avoid pushing unrelated images)
|
|
||||||
if docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${TAG}$"; then
|
|
||||||
IMAGES+=("$TAG")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Deduplicate while preserving order
|
|
||||||
if [ ${#IMAGES[@]} -gt 0 ]; then
|
|
||||||
IMAGES=($(printf "%s\n" "${IMAGES[@]}" | awk '!seen[$0]++'))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify that all built images are among the detected images to be pushed.
|
|
||||||
# This prevents accidentally pushing unrelated images.
|
|
||||||
BUILT_COUNT=${#BUILT_IMAGES[@]}
|
|
||||||
MATCHING_BUILT=0
|
|
||||||
MISSING_FROM_DETECTED=()
|
|
||||||
for BI in "${BUILT_IMAGES[@]}"; do
|
|
||||||
found=false
|
|
||||||
for DI in "${IMAGES[@]}"; do
|
|
||||||
if [ "$BI" = "$DI" ]; then
|
|
||||||
found=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$found" = true ]; then
|
|
||||||
MATCHING_BUILT=$((MATCHING_BUILT + 1))
|
|
||||||
else
|
|
||||||
MISSING_FROM_DETECTED+=("$BI")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Also list detected images that weren't built locally (these are likely external images)
|
|
||||||
NOT_BUILT_DETECTED=()
|
|
||||||
for DI in "${IMAGES[@]}"; do
|
|
||||||
built=false
|
|
||||||
for BI in "${BUILT_IMAGES[@]}"; do
|
|
||||||
if [ "$DI" = "$BI" ]; then
|
|
||||||
built=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$built" = false ]; then
|
|
||||||
NOT_BUILT_DETECTED+=("$DI")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$BUILT_COUNT" -ne "$MATCHING_BUILT" ]; then
|
|
||||||
error "Mismatch between built images (${BUILT_COUNT}) and detected built images (${MATCHING_BUILT})."
|
|
||||||
if [ ${#MISSING_FROM_DETECTED[@]} -gt 0 ]; then
|
|
||||||
echo " Built but not detected: ${MISSING_FROM_DETECTED[*]}"
|
|
||||||
fi
|
|
||||||
if [ ${#NOT_BUILT_DETECTED[@]} -gt 0 ]; then
|
|
||||||
echo " Detected but not built (external images): ${NOT_BUILT_DETECTED[*]}"
|
|
||||||
fi
|
|
||||||
echo "Aborting to avoid pushing incorrect images."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ${#IMAGES[@]} -eq 0 ]; then
|
|
||||||
error "No images found in docker-compose.yml or built locally for project ${PROJECT_NAME}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Pre-pull unregistry image if needed
|
|
||||||
UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry"
|
|
||||||
if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then
|
|
||||||
substep "Pulling unregistry image (one-time setup)..."
|
|
||||||
ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Transfer images
|
|
||||||
step "Transferring images"
|
|
||||||
PUSH_FAILED=0
|
|
||||||
for IMAGE in "${IMAGES[@]}"; do
|
|
||||||
substep "Pushing ${CYAN}$IMAGE${NC}..."
|
|
||||||
if docker pussh "$IMAGE" "$SERVER"; then
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
echo -e " ${RED}✗${NC} Failed to push ${CYAN}$IMAGE${NC}"
|
|
||||||
PUSH_FAILED=1
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ $PUSH_FAILED -eq 1 ]; then
|
|
||||||
error "Image transfer failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Transfer files
|
|
||||||
step "Transferring deployment files"
|
|
||||||
|
|
||||||
# Ensure destination directory exists with proper permissions
|
|
||||||
if [ -n "$SUDO_PASSWORD" ]; then
|
|
||||||
ssh "$SERVER" bash << REMOTE_SUDO_SCRIPT > /dev/null 2>&1
|
|
||||||
set -e
|
|
||||||
echo '$SUDO_PASSWORD' | sudo -S -p '' mkdir -p $DEPLOY_PATH/deployment 2>/dev/null || true
|
|
||||||
echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment 2>/dev/null || true
|
|
||||||
REMOTE_SUDO_SCRIPT
|
|
||||||
else
|
|
||||||
ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment && sudo chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Copy deployment directory excluding gitignored files
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
substep "Copying deployment directory..."
|
|
||||||
|
|
||||||
# Generate exclude file for rsync using git ls-files to list ignored files
|
|
||||||
EXCLUDE_FILE="/tmp/fromchat-rsync-exclude-$$"
|
|
||||||
RSYNC_ERROR="/tmp/fromchat-rsync-error-$$"
|
|
||||||
|
|
||||||
# Get ignored files in deployment directory and convert to rsync exclude patterns
|
|
||||||
git ls-files --others --ignored --exclude-standard deployment/ 2>/dev/null | \
|
|
||||||
sed 's|^deployment/||' > "$EXCLUDE_FILE" || true
|
|
||||||
|
|
||||||
# Use rsync with native --exclude-from option
|
|
||||||
if rsync -avz --delete --exclude-from="$EXCLUDE_FILE" \
|
|
||||||
"$DEPLOYMENT_DIR/" \
|
|
||||||
"$SERVER:$DEPLOY_PATH/deployment/" > "$RSYNC_ERROR" 2>&1; then
|
|
||||||
rm -f "$EXCLUDE_FILE" "$RSYNC_ERROR"
|
|
||||||
else
|
|
||||||
echo -e " ${RED}✗${NC} Rsync failed. Error output:"
|
|
||||||
cat "$RSYNC_ERROR" | sed 's/^/ /'
|
|
||||||
rm -f "$EXCLUDE_FILE" "$RSYNC_ERROR"
|
|
||||||
echo -n " " && error "Failed to copy deployment directory"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Copy .env.prod to .env on server (bypassing gitignore)
|
|
||||||
if [ -f "$DEPLOYMENT_DIR/.env.prod" ]; then
|
|
||||||
substep "Copying .env.prod to .env..."
|
|
||||||
if ! scp "$DEPLOYMENT_DIR/.env.prod" "$SERVER:$DEPLOY_PATH/deployment/.env" > /dev/null 2>&1; then
|
|
||||||
warning "Failed to copy .env.prod to .env"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warning ".env.prod not found in deployment directory"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Deploy on server
|
|
||||||
step "Deploying on server"
|
|
||||||
ssh "$SERVER" SUDO_PASSWORD="$SUDO_PASSWORD" DEPLOY_PATH="$DEPLOY_PATH" bash << 'REMOTE_SCRIPT'
|
|
||||||
set -e
|
|
||||||
|
|
||||||
REMOTE_SUDO_PASS="${SUDO_PASSWORD:-}"
|
|
||||||
REMOTE_DEPLOY_PATH="${DEPLOY_PATH:-}"
|
|
||||||
export SUDO_PROMPT=""
|
|
||||||
|
|
||||||
sudo_cmd() {
|
|
||||||
if [ -n "$REMOTE_SUDO_PASS" ]; then
|
|
||||||
echo "$REMOTE_SUDO_PASS" | sudo -S -p '' "$@" 2>/dev/null
|
|
||||||
else
|
|
||||||
sudo "$@" 2>/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ -z "$REMOTE_DEPLOY_PATH" ]; then
|
|
||||||
echo "❌ DEPLOY_PATH is not set"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$REMOTE_DEPLOY_PATH/deployment"
|
|
||||||
cd "$REMOTE_DEPLOY_PATH/deployment"
|
|
||||||
|
|
||||||
if [ ! -f "$REMOTE_DEPLOY_PATH/deployment/.env" ]; then
|
|
||||||
echo "⚠️ Warning: .env file not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if systemctl is-active --quiet fromchat; then
|
|
||||||
sudo_cmd systemctl stop fromchat
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker compose down > /dev/null 2>&1 || true
|
|
||||||
|
|
||||||
sudo_cmd cp -f "$REMOTE_DEPLOY_PATH/deployment/fromchat.service" /etc/systemd/system/fromchat.service
|
|
||||||
sudo_cmd systemctl daemon-reload
|
|
||||||
sudo_cmd systemctl restart fromchat
|
|
||||||
|
|
||||||
sleep 3
|
|
||||||
if ! systemctl is-active --quiet fromchat; then
|
|
||||||
echo "❌ Service failed to start"
|
|
||||||
sudo_cmd journalctl --no-pager -xeu fromchat -n 30
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
REMOTE_SCRIPT
|
|
||||||
|
|
||||||
echo
|
|
||||||
success "Deployment complete!"
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo > deployment/.env
|
|
||||||
|
|
||||||
./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env
|
|
||||||
|
|
||||||
cat >> deployment/.env <<EOF
|
|
||||||
JWT_SECRET="$(openssl rand -base64 32)"
|
|
||||||
TURN_USERNAME=<set>
|
|
||||||
TURN_SECRET=<set>
|
|
||||||
DEPLOYMENT_SERVER=<set>
|
|
||||||
FIREBASE_CERT=<set>
|
|
||||||
RELEASES_TOKEN=<set>
|
|
||||||
EOF
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Don't use set -e here - we want to continue if Homebrew installation fails
|
|
||||||
|
|
||||||
# Install docker-pussh plugin for Docker CLI
|
|
||||||
# This script installs the unregistry docker-pussh plugin
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
echo "📦 Installing docker-pussh plugin..."
|
|
||||||
|
|
||||||
# Check if Docker is installed
|
|
||||||
if ! command -v docker > /dev/null 2>&1; then
|
|
||||||
echo "⚠️ Docker is not installed. Skipping docker-pussh installation."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create docker plugins directory if it doesn't exist
|
|
||||||
PLUGIN_DIR="$HOME/.docker/cli-plugins"
|
|
||||||
mkdir -p "$PLUGIN_DIR"
|
|
||||||
|
|
||||||
# Check if already installed
|
|
||||||
if [ -f "$PLUGIN_DIR/docker-pussh" ] && docker pussh --help > /dev/null 2>&1; then
|
|
||||||
echo " ✓ docker-pussh is already installed"
|
|
||||||
docker pussh --version 2>/dev/null || true
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try installing via Homebrew first
|
|
||||||
if command -v brew > /dev/null 2>&1; then
|
|
||||||
echo " Attempting installation via Homebrew..."
|
|
||||||
if brew install psviderski/tap/docker-pussh 2>/dev/null; then
|
|
||||||
# Create symlink to use as Docker CLI plugin
|
|
||||||
BREW_PREFIX=$(brew --prefix 2>/dev/null || echo "/opt/homebrew")
|
|
||||||
if [ -f "$BREW_PREFIX/bin/docker-pussh" ]; then
|
|
||||||
mkdir -p "$PLUGIN_DIR"
|
|
||||||
ln -sf "$BREW_PREFIX/bin/docker-pussh" "$PLUGIN_DIR/docker-pussh" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Verify installation
|
|
||||||
if docker pussh --help > /dev/null 2>&1; then
|
|
||||||
echo " ✓ docker-pussh installed successfully via Homebrew"
|
|
||||||
docker pussh --version 2>/dev/null || true
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo " ⚠️ Homebrew installation failed or incomplete, trying direct download..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback: Download and install docker-pussh directly (using latest from main branch)
|
|
||||||
echo " Downloading docker-pussh from unregistry..."
|
|
||||||
if curl -sSL https://raw.githubusercontent.com/psviderski/unregistry/main/docker-pussh \
|
|
||||||
-o "$PLUGIN_DIR/docker-pussh" 2>/dev/null; then
|
|
||||||
chmod +x "$PLUGIN_DIR/docker-pussh"
|
|
||||||
|
|
||||||
# Verify installation
|
|
||||||
if docker pussh --help > /dev/null 2>&1; then
|
|
||||||
echo " ✓ docker-pussh installed successfully"
|
|
||||||
docker pussh --version 2>/dev/null || true
|
|
||||||
else
|
|
||||||
echo " ⚠️ Installation completed but plugin verification failed"
|
|
||||||
echo " You may need to restart your terminal or Docker Desktop"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " ⚠️ Failed to download docker-pussh"
|
|
||||||
echo " You can install it manually:"
|
|
||||||
echo ""
|
|
||||||
echo " Via Homebrew:"
|
|
||||||
echo " brew install psviderski/tap/docker-pussh"
|
|
||||||
echo " mkdir -p ~/.docker/cli-plugins"
|
|
||||||
echo " ln -sf \$(brew --prefix)/bin/docker-pussh ~/.docker/cli-plugins/docker-pussh"
|
|
||||||
echo ""
|
|
||||||
echo " Or via direct download:"
|
|
||||||
echo " mkdir -p ~/.docker/cli-plugins"
|
|
||||||
echo " curl -sSL https://raw.githubusercontent.com/psviderski/unregistry/main/docker-pussh \\"
|
|
||||||
echo " -o ~/.docker/cli-plugins/docker-pussh"
|
|
||||||
echo " chmod +x ~/.docker/cli-plugins/docker-pussh"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Transfer unregistry image from local machine to server
|
|
||||||
# Usage: ./scripts/transfer:unregistry.sh [server_user@server_host]
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SERVER="${1:-${DEPLOY_SERVER:-}}"
|
|
||||||
|
|
||||||
if [ -z "$SERVER" ]; then
|
|
||||||
echo "❌ Server not specified. Usage: $0 [user@host]"
|
|
||||||
echo " Or set DEPLOY_SERVER environment variable"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📦 Transferring unregistry image to $SERVER..."
|
|
||||||
|
|
||||||
# Pull the image locally if not already present
|
|
||||||
if ! docker images ghcr.io/psviderski/unregistry:0.3.1 --format "{{.Repository}}:{{.Tag}}" | grep -q "unregistry:0.3.1"; then
|
|
||||||
echo " Pulling unregistry:0.3.1 locally..."
|
|
||||||
docker pull ghcr.io/psviderski/unregistry:0.3.1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Save and transfer the image
|
|
||||||
echo " Saving and transferring image to server..."
|
|
||||||
docker save ghcr.io/psviderski/unregistry:0.3.1 | ssh "$SERVER" "docker load"
|
|
||||||
|
|
||||||
echo " ✓ Unregistry image transferred successfully"
|
|
||||||
echo ""
|
|
||||||
echo " Now you can run on the server:"
|
|
||||||
echo " docker run -d \\"
|
|
||||||
echo " --name unregistry \\"
|
|
||||||
echo " -p 5000:5000 \\"
|
|
||||||
echo " -v /run/containerd/containerd.sock:/run/containerd/containerd.sock \\"
|
|
||||||
echo " --restart unless-stopped \\"
|
|
||||||
echo " ghcr.io/psviderski/unregistry:0.3.1"
|
|
||||||
|
|
||||||
|
|
||||||
@@ -4,10 +4,11 @@
|
|||||||
<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="./src/images/logo.svg" />
|
<link rel="icon" href="./images/logo.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="src/main.tsx" type="module"></script>
|
<script src="main/main.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { ElectronTitleBar } from "./Electron";
|
|
||||||
import { useUserStore } from "./state/user";
|
import { useUserStore } from "./state/user";
|
||||||
import { lazy, useEffect, useRef, useState } from "react";
|
import { lazy, useEffect, useRef, useState } from "react";
|
||||||
import { parseProfileLink } from "./core/profileLinks";
|
import { parseProfileLink } from "./core/profileLinks";
|
||||||
@@ -15,6 +14,8 @@ import { delay } from "./utils/utils";
|
|||||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||||
const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
|
const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
|
||||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||||
|
const PrivacyPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.PrivacyPage })));
|
||||||
|
const TermsPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.TermsPage })));
|
||||||
|
|
||||||
const routeConfig: RouteObject[] = [
|
const routeConfig: RouteObject[] = [
|
||||||
{ path: "/", element: <HomePage /> },
|
{ path: "/", element: <HomePage /> },
|
||||||
@@ -22,6 +23,8 @@ const routeConfig: RouteObject[] = [
|
|||||||
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
|
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
|
||||||
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
|
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
|
||||||
{ path: "/download-app", element: <DownloadAppPage /> },
|
{ path: "/download-app", element: <DownloadAppPage /> },
|
||||||
|
{ path: "/privacy", element: <PrivacyPage /> },
|
||||||
|
{ path: "/terms", element: <TermsPage /> },
|
||||||
{
|
{
|
||||||
path: "/chat",
|
path: "/chat",
|
||||||
element: (
|
element: (
|
||||||
@@ -129,7 +132,6 @@ export default function App() {
|
|||||||
|
|
||||||
return authReady && (
|
return authReady && (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ElectronTitleBar />
|
|
||||||
<AlertDialogProvider />
|
<AlertDialogProvider />
|
||||||
<div id="main-wrapper">
|
<div id="main-wrapper">
|
||||||
<AnimatedRoutes />
|
<AnimatedRoutes />
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MaterialIcon } from "@/utils/material";
|
||||||
|
import { avatarGradientFromUserId } from "@/core/avatarGradient";
|
||||||
|
import styles from "@/pages/chat/css/deleted-user-avatar.module.scss";
|
||||||
|
|
||||||
|
interface DeletedUserAvatarProps {
|
||||||
|
userId: number;
|
||||||
|
className?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeletedUserAvatar({ userId, className, iconClassName }: DeletedUserAvatarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className ?? styles.deletedUserAvatar}
|
||||||
|
style={{ background: avatarGradientFromUserId(userId) }}
|
||||||
|
>
|
||||||
|
<MaterialIcon
|
||||||
|
name="account_circle_off--outlined"
|
||||||
|
className={iconClassName ?? styles.deletedUserAvatarIcon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,10 +71,10 @@ export async function checkAuth(token: string): Promise<CheckAuthResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs in a user with username and password
|
* Logs in a user with username and password (step-based auth).
|
||||||
*/
|
*/
|
||||||
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
||||||
const res = await fetch(`${API_BASE_URL}/login`, {
|
const res = await fetch(`${API_BASE_URL}/auth/steps/password`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: getAuthHeaders(null, true),
|
headers: getAuthHeaders(null, true),
|
||||||
body: JSON.stringify(request)
|
body: JSON.stringify(request)
|
||||||
@@ -83,17 +83,28 @@ export async function login(request: LoginRequest): Promise<LoginResponse> {
|
|||||||
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
||||||
throw new Error(error.detail || "Login failed");
|
throw new Error(error.detail || "Login failed");
|
||||||
}
|
}
|
||||||
return await res.json();
|
const data = await res.json();
|
||||||
|
if (data.status === "needs_register") {
|
||||||
|
throw new Error("Account not found");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a new user
|
* Registers a new user (step-based auth; Yandex proof required when enabled on server).
|
||||||
*/
|
*/
|
||||||
export async function register(request: RegisterRequest): Promise<LoginResponse> {
|
export async function register(request: RegisterRequest & { registration_proof?: string }): Promise<LoginResponse> {
|
||||||
const res = await fetch(`${API_BASE_URL}/register`, {
|
const res = await fetch(`${API_BASE_URL}/auth/steps/register/confirm`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: getAuthHeaders(null, true),
|
headers: getAuthHeaders(null, true),
|
||||||
body: JSON.stringify(request)
|
body: JSON.stringify({
|
||||||
|
display_name: request.display_name,
|
||||||
|
username: request.username,
|
||||||
|
password: request.password,
|
||||||
|
confirm_password: request.confirm_password,
|
||||||
|
bio: request.bio,
|
||||||
|
registration_proof: request.registration_proof,
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
||||||
@@ -142,7 +153,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
|
|||||||
const pair = generateX25519KeyPair();
|
const pair = generateX25519KeyPair();
|
||||||
currentPublicKey = pair.publicKey;
|
currentPublicKey = pair.publicKey;
|
||||||
currentPrivateKey = pair.privateKey;
|
currentPrivateKey = pair.privateKey;
|
||||||
await uploadPublicKey(currentPublicKey, token);
|
await uploadPublicKey(pair.publicKey, token);
|
||||||
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||||
await uploadBackupBlob(encodeBlob(newBlob), token);
|
await uploadBackupBlob(encodeBlob(newBlob), token);
|
||||||
}
|
}
|
||||||
@@ -159,7 +170,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
|
|||||||
const pair = generateX25519KeyPair();
|
const pair = generateX25519KeyPair();
|
||||||
currentPublicKey = pair.publicKey;
|
currentPublicKey = pair.publicKey;
|
||||||
currentPrivateKey = pair.privateKey;
|
currentPrivateKey = pair.privateKey;
|
||||||
await uploadPublicKey(currentPublicKey, token);
|
await uploadPublicKey(pair.publicKey, token);
|
||||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||||
|
|
||||||
@@ -170,45 +170,6 @@ export async function verifyUser(userId: number, token: string): Promise<{verifi
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory cache for user similarity results
|
|
||||||
* Key: userId, Value: similarity result
|
|
||||||
*/
|
|
||||||
const similarityCache = new Map<number, {isSimilar: boolean, similarTo?: string} | null>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a user is similar to any verified user
|
|
||||||
* Results are cached in memory to avoid redundant API calls
|
|
||||||
*/
|
|
||||||
export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> {
|
|
||||||
// Check cache first
|
|
||||||
if (similarityCache.has(userId)) {
|
|
||||||
return similarityCache.get(userId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
let result: {isSimilar: boolean, similarTo?: string} | null = null;
|
|
||||||
if (response.ok) {
|
|
||||||
result = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result (even if null/error)
|
|
||||||
similarityCache.set(userId, result);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking user similarity:', error);
|
|
||||||
const result: null = null;
|
|
||||||
// Cache null result to avoid retrying on errors
|
|
||||||
similarityCache.set(userId, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suspends a user account (admin only)
|
* Suspends a user account (admin only)
|
||||||
*/
|
*/
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import { getAuthHeaders } from "../user/auth";
|
||||||
|
import { getCurrentKeys } from "../user/auth";
|
||||||
|
import { request } from "@/core/websocket";
|
||||||
|
import type { DmEnvelope, User } from "@/core/types";
|
||||||
|
import { ub64 } from "@/utils/utils";
|
||||||
|
import { fetchUserPublicKey } from "../crypto/identity";
|
||||||
|
import { searchUsers } from "../user/search";
|
||||||
|
import { deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||||
|
import tweetnacl from "tweetnacl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap a MEK using the appropriate wrapping key for the current user
|
||||||
|
*/
|
||||||
|
export async function unwrapMek(wrappedMekB64: string, envelope: DmEnvelope, userId?: number): Promise<Uint8Array> {
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
|
// Determine context based on whether we're sender or recipient
|
||||||
|
const currentUserId = userId || parseInt(localStorage.getItem('userId') || '0');
|
||||||
|
const isRecipient = envelope.recipientId === currentUserId;
|
||||||
|
const context = isRecipient ? "recipient_wrap_key" : "sender_wrap_key";
|
||||||
|
|
||||||
|
// Derive wrapping key from our public key
|
||||||
|
const salt = new Uint8Array(16).fill(0); // 16 zero bytes salt
|
||||||
|
const wrappingKeyRaw = await deriveWrappingKey(keys.publicKey, salt, new TextEncoder().encode(context));
|
||||||
|
const wrappingKey = await importAesGcmKey(wrappingKeyRaw);
|
||||||
|
|
||||||
|
// Unwrap the MEK using AES-256-GCM
|
||||||
|
const wrappedMekBytes = ub64(wrappedMekB64);
|
||||||
|
const mekNonce = wrappedMekBytes.slice(0, 12);
|
||||||
|
const mekCiphertext = wrappedMekBytes.slice(12);
|
||||||
|
|
||||||
|
return await aesGcmDecrypt(wrappingKey, mekNonce, mekCiphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Use the wrapped MEK provided for this user
|
||||||
|
const wrappedMekB64 = envelope.wrapped_mek_b64;
|
||||||
|
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
|
||||||
|
|
||||||
|
// Unwrap the MEK using shared logic
|
||||||
|
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
|
||||||
|
|
||||||
|
// Decrypt the message using the unwrapped MEK
|
||||||
|
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
|
||||||
|
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
|
||||||
|
const messageKey = await importAesGcmKey(mek);
|
||||||
|
const messageNonce = ub64(envelope.iv_b64 || "");
|
||||||
|
const messageCiphertext = ub64(envelope.ciphertext_b64);
|
||||||
|
|
||||||
|
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
|
||||||
|
const result = new TextDecoder().decode(plaintext);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to decrypt DM envelope:", error);
|
||||||
|
console.error("Error details:", {
|
||||||
|
envelope: envelope,
|
||||||
|
userId: userId,
|
||||||
|
localStorageUserId: localStorage.getItem('userId')
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||||
|
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
|
||||||
|
if (beforeId) {
|
||||||
|
url += `&before_id=${beforeId}`;
|
||||||
|
}
|
||||||
|
const response = await globalThis.fetch(url, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
if (!response.ok) return { messages: [], has_more: false };
|
||||||
|
const data = await response.json();
|
||||||
|
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the transport public key from the server
|
||||||
|
*/
|
||||||
|
async function getTransportPublicKey(): Promise<string> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch transport key: HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.public_key_b64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt message using transport key (client-side only)
|
||||||
|
*/
|
||||||
|
function encryptWithTransportKey(plaintext: string, transportPublicKeyB64: string): { client_public_key_b64: string; nonce_b64: string; ciphertext_b64: string } {
|
||||||
|
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||||
|
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||||
|
const transportPublicKeyBytes = new Uint8Array(
|
||||||
|
atob(transportPublicKeyB64)
|
||||||
|
.split("")
|
||||||
|
.map((c: string) => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
|
||||||
|
const nonce = tweetnacl.randomBytes(24);
|
||||||
|
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
client_public_key_b64: btoa(String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])),
|
||||||
|
nonce_b64: btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[])),
|
||||||
|
ciphertext_b64: btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number, attachments?: Array<{name:string,path:string,wrapped_mek_b64?:string,nonce_b64?:string}>): Promise<void> {
|
||||||
|
// Get keys
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
|
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||||
|
|
||||||
|
// Client-side transport encryption only
|
||||||
|
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||||
|
|
||||||
|
// Get sender's public key (from current keys)
|
||||||
|
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||||
|
|
||||||
|
// Send to server (server will handle envelope encryption)
|
||||||
|
const bodyPayload: any = {
|
||||||
|
recipient_id: recipientId,
|
||||||
|
client_public_key_b64,
|
||||||
|
transport_nonce_b64: nonce_b64,
|
||||||
|
transport_ciphertext_b64: ciphertext_b64,
|
||||||
|
sender_public_key_b64: senderPublicKeyB64,
|
||||||
|
recipient_public_key_b64: recipientPublicKeyB64,
|
||||||
|
reply_to_id: replyToId
|
||||||
|
};
|
||||||
|
if (attachments && attachments.length > 0) bodyPayload["files"] = attachments;
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(authToken, true)
|
||||||
|
},
|
||||||
|
body: JSON.stringify(bodyPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function sendWithFiles(
|
||||||
|
recipientId: number,
|
||||||
|
recipientPublicKeyB64: string,
|
||||||
|
files: File[],
|
||||||
|
plaintext: string,
|
||||||
|
authToken: string,
|
||||||
|
replyToId?: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
throw new Error("No files provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get transport key for encryption (shared across message + files)
|
||||||
|
const transportKeyResponse = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||||
|
if (!transportKeyResponse.ok) {
|
||||||
|
throw new Error("Failed to get transport key");
|
||||||
|
}
|
||||||
|
const transportKeyData = await transportKeyResponse.json();
|
||||||
|
const transportPublicKeyB64 = transportKeyData.public_key_b64;
|
||||||
|
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
|
const transportPublicKey = ub64(transportPublicKeyB64);
|
||||||
|
|
||||||
|
// One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob).
|
||||||
|
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||||
|
const messagePlaintextBytes = new TextEncoder().encode(plaintext || "");
|
||||||
|
const messageNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||||
|
const messageCiphertext = tweetnacl.box(
|
||||||
|
messagePlaintextBytes,
|
||||||
|
messageNonce,
|
||||||
|
transportPublicKey,
|
||||||
|
ephemeralKeypair.secretKey
|
||||||
|
);
|
||||||
|
const client_public_key_b64 = btoa(
|
||||||
|
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||||
|
);
|
||||||
|
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageNonce) as number[]));
|
||||||
|
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageCiphertext) as number[]));
|
||||||
|
|
||||||
|
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||||
|
|
||||||
|
// Base64 encode helper (chunked)
|
||||||
|
const uint8ToB64 = (uint8: Uint8Array): string => {
|
||||||
|
const CHUNK = 0x8000;
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < uint8.length; i += CHUNK) {
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(uint8.subarray(i, i + CHUNK)) as number[]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transport-encrypt files; server will envelope-encrypt them with the SAME MEK as the message.
|
||||||
|
const transport_files: Array<{ encrypted_file_data_b64: string; filename: string; file_size: number }> = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const fileData = await file.arrayBuffer();
|
||||||
|
const transportNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||||
|
const transportEncrypted = tweetnacl.box(
|
||||||
|
new Uint8Array(fileData),
|
||||||
|
transportNonce,
|
||||||
|
transportPublicKey,
|
||||||
|
ephemeralKeypair.secretKey
|
||||||
|
);
|
||||||
|
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
|
||||||
|
transportEncryptedWithNonce.set(transportNonce);
|
||||||
|
transportEncryptedWithNonce.set(transportEncrypted, transportNonce.length);
|
||||||
|
|
||||||
|
transport_files.push({
|
||||||
|
encrypted_file_data_b64: uint8ToB64(transportEncryptedWithNonce),
|
||||||
|
filename: file.name,
|
||||||
|
file_size: file.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
recipient_id: recipientId,
|
||||||
|
client_public_key_b64,
|
||||||
|
transport_nonce_b64: nonce_b64,
|
||||||
|
transport_ciphertext_b64: ciphertext_b64,
|
||||||
|
sender_public_key_b64: senderPublicKeyB64,
|
||||||
|
recipient_public_key_b64: recipientPublicKeyB64,
|
||||||
|
reply_to_id: replyToId,
|
||||||
|
transport_files
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(authToken, true)
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||||
|
await request({
|
||||||
|
type: "dmDelete",
|
||||||
|
credentials: { scheme: "Bearer", credentials: authToken },
|
||||||
|
data: { id, recipientId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationResponse {
|
||||||
|
user: User;
|
||||||
|
lastMessage: DmEnvelope;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.conversations || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a DM as read
|
||||||
|
*/
|
||||||
|
export async function markRead(id: number, authToken: string): Promise<void> {
|
||||||
|
await request({
|
||||||
|
type: "dmMarkRead",
|
||||||
|
credentials: { scheme: "Bearer", credentials: authToken },
|
||||||
|
data: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editMessage(
|
||||||
|
messageId: number,
|
||||||
|
recipientPublicKeyB64: string,
|
||||||
|
plaintext: string,
|
||||||
|
authToken: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Get keys
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
|
// Get transport key for initial encryption
|
||||||
|
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||||
|
|
||||||
|
// Client-side transport encryption (same as sending)
|
||||||
|
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||||
|
|
||||||
|
// Get sender's public key
|
||||||
|
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||||
|
|
||||||
|
// Send transport-encrypted data to the edit endpoint (it will handle envelope encryption)
|
||||||
|
const editResponse = await fetch(`${API_BASE_URL}/dm/edit/${messageId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(authToken, true)
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_public_key_b64,
|
||||||
|
transport_nonce_b64: nonce_b64,
|
||||||
|
transport_ciphertext_b64: ciphertext_b64,
|
||||||
|
sender_public_key_b64: senderPublicKeyB64,
|
||||||
|
recipient_public_key_b64: recipientPublicKeyB64
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!editResponse.ok) throw new Error(`Failed to edit DM: HTTP ${editResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export user functions for convenience
|
||||||
|
export { searchUsers, fetchUserPublicKey };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user