34 Commits

271 changed files with 2953 additions and 10999 deletions
+42 -205
View File
@@ -1,179 +1,51 @@
# Security Audit Command
Perform a comprehensive security audit of the FromChat application codebase.
Perform a comprehensive security audit of the FromChat **Android application** only.
## Project Context
**FromChat** is a 100% open source secure messaging application with:
- React/TypeScript frontend
- Python FastAPI backend
- End-to-end encryption for DMs and calls
- Caddy reverse proxy with security headers
**FromChat Android** is a 100% open source secure messaging mobile application built with:
- Kotlin Multiplatform (KMP) shared code
- Jetpack Compose UI framework
- End-to-End Encryption (NaCl, AES-GCM)
- WebSocket support for real-time features
- Electron support for desktop app
- LiveKit integration for calls
- Local database storage (SQLite)
## Scope: Android Only
**OUT OF SCOPE:**
- Web backend (Python FastAPI, Caddy infrastructure)
- React/TypeScript frontend
**IN SCOPE:**
- Android app code (`app/android`, `app/shared/src/androidMain`)
- Shared cross-platform code (`app/shared/src/commonMain`)
- Local encryption implementation (NaCl, AES-GCM)
- Secure storage (Android Keystore, encrypted SharedPreferences)
- WebSocket client security
- Permission usage and handling
- Call security (LiveKit integration)
- Memory safety and injection attacks
- Backend
## Important Design Decisions (NOT Vulnerabilities)
When auditing, remember these are **intentional design choices**:
1. **Public messages endpoint** - Open forum accessible without authentication (by design)
- The public chat is meant to be an open forum
- Private DMs are properly E2E encrypted and require authentication
2. **Public user list** - All users visible in DMs tab (by design)
- Users can see all registered accounts
- This is intentional for a community-based chat app
3. **XSS protection** - Multi-layer defense already implemented:
- React auto-escaping
- DOMPurify for sanitization
- Caddy CSP headers
- Do NOT flag localStorage key storage as critical (already well-protected)
4. **File upload security** - Docker isolation in place:
- Server runs in Docker without executable flags
- Files cannot execute on server
- PIL re-encodes images
- Do NOT flag Content-Type validation as critical
5. **CSRF protection** - Not needed:
- No cookies used
- JWT tokens in Authorization headers only
- CSRF attacks don't apply to this auth model
6. **Beta domain CSP** - 'unsafe-inline' is required:
- Beta domain (beta.fromchat.ru) points to development machine
- Vite dev server requires 'unsafe-inline' to function
- Production domain has strict CSP
7. **Security logging** - Already implemented:
- All events are logged including security-related activity
- Do NOT flag as missing
8. **100% Open Source** - This is a security strength:
- Full transparency
- Community review capability
- No hidden backdoors
## Android App
**EXCLUDE from all audits** - Android app is not production-ready and out of scope.
## Infrastructure (Caddy)
The application runs behind Caddy reverse proxy with comprehensive security controls:
### Caddyfile Configuration
```caddyfile
fromchat.ru {
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 {
lb_policy first
}
# Security headers
header {
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
X-Frame-Options "DENY" # Prevent clickjacking
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
}
rate_limit {
zone global {
key {remote_ip}
window 1m
burst 20
events 500
}
}
handle_errors {
@errors {
expression {err.status_code} >= 400
}
handle @errors {
rewrite * /{err.status_code}
reverse_proxy https://http.cat {
header_up Host {upstream_hostport}
replace_status {err.status_code}
}
}
}
}
beta.fromchat.ru {
reverse_proxy 95.165.0.162:8301
# Security headers
header {
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
X-Frame-Options "DENY" # Prevent clickjacking
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
}
rate_limit {
zone global {
key {remote_ip}
window 1m
burst 20
events 1000
}
}
handle_errors {
@errors {
expression {err.status_code} >= 400
}
handle @errors {
rewrite * /{err.status_code}
reverse_proxy https://http.cat {
header_up Host {upstream_hostport}
replace_status {err.status_code}
}
}
}
}
```
### Key Infrastructure Protections
-**HTTPS enforcement** - Automatic SSL/TLS with Caddy
-**HSTS** - Strict-Transport-Security with preload
-**CSP** - Content Security Policy (strict on production, 'unsafe-inline' for scripts on beta for Vite)
-**Rate limiting** - 500 events/min (production), 1000 events/min (beta)
-**X-Frame-Options: DENY** - Prevents clickjacking
-**X-Content-Type-Options: nosniff** - Prevents MIME sniffing
-**X-XSS-Protection: 1; mode=block** - XSS protection
-**Permissions-Policy** - Restricts geolocation, allows camera/mic for calls
**Important:** These protections are already in place at the infrastructure level. Don't flag missing security headers or rate limiting in the application code.
## Audit Process
1. **Read the Caddyfile first** to understand infrastructure protections
2. **Check backend code** for authentication, authorization, input validation
3. **Review frontend code** for XSS protections, crypto implementation
4. **Verify E2E encryption** implementation (NaCl for DMs, AES-GCM for calls)
5. **Test CORS configuration** in backend/app.py
6. **Review password policies** in backend/validation.py
7. **Check file upload handling** in backend/routes/messaging.py and profile.py
## Rating Guidelines
- **Infrastructure (Caddy):** Should be 9/10 or higher (excellent security headers)
- **Cryptography:** Should be 8-9/10 (uses industry-standard libraries)
- **Frontend Security:** Should be 7-8/10 (multi-layer XSS protection)
- **Backend API:** Focus on CORS, password policies, rate limiting
1. **Local message caching** - Messages downloaded and stored locally (by design)
- Messages are end-to-end encrypted at rest in local DB
- Public DMs are not encrypted (messages are public)
- Private DMs use NaCl encryption
- Cache persists across app restarts for offline access
2. **Local key storage** - Encryption keys stored on device (by design)
- Keys protected by Android Keystore (hardware-backed when available)
- Encrypted with device-specific secrets
- User data never leaves device in plaintext
- Do NOT flag key storage as critical (Keystore is production-ready)
## Output Format
@@ -183,8 +55,8 @@ Provide a **clean, concise report** with:
2. **Security Status** - Critical issues (if any) and recommendations
3. **Security Strengths** - What's done well
4. **Component Ratings** - Table format for quick reference
5. **Design Decisions** - Clarify what's intentional vs vulnerable
6. **Threat Analysis** - Current realistic threats only
5. **Architecture Review** - Data flow, encryption boundaries
6. **Threat Analysis** - Current realistic threats (e.g., rooted device, malicious APK)
7. **Recommendations** - Prioritized with time estimates
8. **Conclusion** - Clear production readiness statement
@@ -193,43 +65,8 @@ Provide a **clean, concise report** with:
## Common False Positives to Avoid
**DO NOT FLAG THESE AS ISSUES:**
- Public messages endpoint (intentional)
- Username enumeration (users list is public by design)
- Keys in localStorage (XSS is well-protected)
- Content-Type validation (Docker isolation prevents execution)
- CSRF protection (not applicable - no cookies)
- Beta CSP 'unsafe-inline' (required for Vite)
- Security logging (already implemented)
- Android app security (out of scope)
## Key Security Features to Verify
**MUST CHECK:**
- CORS configuration in backend/app.py
- Password validation in backend/validation.py
- JWT token generation and validation
- E2E encryption implementation (NaCl, AES-GCM)
- File upload sanitization
- Authorization checks on sensitive endpoints
- Rate limiting configuration
- Security headers in Caddyfile
## Example Good Finding Format
```markdown
### Password Policy (HIGH PRIORITY - Non-blocking)
**Current:** 5 character minimum
**Recommended:** 12+ characters with complexity requirements
**Risk:** Brute force attacks (mitigated by rate limiting)
**Estimated Fix:** 4-6 hours
**Code Location:** backend/validation.py:11-16
```
## Notes from Developer
- Application is production-ready after CORS fix
- Focus on practical, actionable improvements
- Don't overthink things that are already well-protected
- Open source is a feature, not a concern
- Community can audit the code themselves
- Local message caching (intentional for offline access)
- Public message viewing without auth (intentional design)
- Debuggable APK (only relevant if signed/released)
+9 -3
View File
@@ -21,6 +21,8 @@ When working with this project, follow these rules:
## File Operations
- If possible, try to update files in a single edit when making multiple changes.
- Do NOT "cd" to the project directory.
- NEVER edit/delete/regenerate .env files without explicit permission.
- Don't use rg, it doesn't work anyway.
## Testing & Validation
- Do NOT "test the implementation" when you are done. The only exception is when you
@@ -31,8 +33,7 @@ When working with this project, follow these rules:
- If the typecheck passed, there's no need for checking the linter errors.
## Async Operations
- When you need a delay, use `await delay(millis);` from `@/utils/utils` in an async function. If the current function is not async,
make it async.
- When you need a delay, use `await delay(millis);` from `@/utils/utils` in an async function. If the current function is not async, make it async.
## Database
- NEVER create database migrations, they are auto-generated.
@@ -58,4 +59,9 @@ When working with this project, follow these rules:
- Put SCSS into one folder per page
## Animations with Framer Motion
- Don't use variants if they are used only once
- Don't use variants if they are used only once
## Debug Mode
- When in debug mode and the issue is not yet fixed, ALWAYS end responses with `<reproduction_steps>` containing the steps to reproduce the issue and trigger logging
- When NOT in debug mode or when the issue IS fixed, escape the tag as `&lt;reproduction_steps&gt;` 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 (dont 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.
+170
View File
@@ -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 320 chars: letters, digits, `-`, `_`; password 550 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 **users request in the current chat** for what to do next (e.g. compare hashes, inspect text, verify a specific attachment). This skill stops at producing `${DECRYPT_DIR}`; do not assume a fixed post-decrypt task.
## Step 6 — Cleanup (required)
1. **Remove decrypt dirs:**
```bash
rm -rf "${BUNDLE_DIR}" "${DECRYPT_DIR}"
```
2. **Delete temp user** (local SQLite default DB):
```bash
sqlite3 backend/data/database.db "DELETE FROM users WHERE username='${DEBUG_USER}';"
```
If your deployment uses another DB, delete the same username there.
3. **Remove temporary permission:** search `TEMP_COMPLIANCE_DEBUG` in the repo, delete the marker block, restore the original `if current_user.id != 1:` check, restart backend.
4. Do **not** commit `compliance_keypair.txt` or any decrypt output.
## Checklist
```
- [ ] Read compliance_keypair.txt
- [ ] Register DEBUG_USER; save DEBUG_TOKEN from response
- [ ] Add TEMP_COMPLIANCE_DEBUG_* bypass; restart backend
- [ ] extract --token → bundle.json present
- [ ] decrypt → output under DECRYPT_DIR
- [ ] Post-decrypt work per conversation context
- [ ] rm -rf BUNDLE_DIR and DECRYPT_DIR
- [ ] DELETE temp user from DB
- [ ] Remove TEMP_COMPLIANCE_DEBUG markers; restart backend
```
## Reference
- Extract API: `GET /api/dm/compliance/extract/{message_id}` (see `bundle_extract.py`).
- Decrypt implementation: `scripts/compliance/decryption/bundle_decrypt.py`, `crypto.py`.
- Extract auth: `--token` (Bearer), env `FROMCHAT_API_TOKEN` / `FROMCHAT_TOKEN`, or `--username` + plain `--password` (CLI calls `derive_auth_secret` for login only).
## Rules
- NEVER use sleep in any command.
- Set a timeout on EVERY command that may request user input.
+44 -9
View File
@@ -1,12 +1,47 @@
# Exclude data directory to prevent local database from being copied into production images
backend/data/
# Shared build context ignore (used by deployment images with context: ..)
# Exclude logs
backend/logs/
# VCS / editor
.git
.github
.idea
.vscode
.cursor
.DS_Store
# Exclude development files
node_modules/
.git/
.gitignore
README.md
# Secrets / local env
.env
deployment/.env
# Node
node_modules
npm-debug.log
.vite
dist
dist-electron
build
out
coverage
# Python
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ipynb_checkpoints
.venv
venv/
# App runtime data/logs (mounted, not baked)
backend/data
backend/files
data
logs
**/logs
**/logs/**
*.log
# Deploy cache (hashes)
.deploy-cache
# Firebase cert: bind-mounted at runtime; do not send to docker build context
firebase-cert.json
+3
View File
@@ -0,0 +1,3 @@
# HTTP API host.
# Example for local backend: http://localhost:8300
VITE_API_BASE_URL=http://localhost:8300
-63
View File
@@ -1,63 +0,0 @@
# Simple workflow for deploying to the self-hosted server
name: Deploy to server
on:
# Runs on pushes targeting the default branch
# push:
# branches: ["main"]
# paths:
# - "backend/**"
# - "frontend/**"
# - "deployment/**"
# - "**/package.json"
# - ".nvmrc"
# - ".github/workflows/deploy.yml"
# - "!frontend/electron/**"
# - "!**.d.ts"
workflow_dispatch:
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
runs-on: raspberry-pi
env:
HOME: "/root"
environment:
name: production
url: https://fromchat.ru
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up environment
run: |
mkdir -p deployment
touch deployment/.env
cat > deployment/.env << EOF
JWT_SECRET=${{ secrets.JWT_SECRET }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
TURN_USERNAME=${{ vars.TURN_USERNAME }}
TURN_PASSWORD=${{ secrets.TURN_PASSWORD }}
EOF
- name: Build container
run: |
cd deployment
docker compose build
- name: Set up the service
run: |
cp -f deployment/fromchat.service /etc/systemd/system/fromchat.service
systemctl daemon-reload
- name: Start the server
run: |
if ! systemctl restart fromchat && sleep 10 && systemctl status fromchat; then
journalctl --no-pager -xeu fromchat
exit 1
fi
+20 -1
View File
@@ -35,6 +35,9 @@ Temporary Items
# iCloud generated files
*.icloud
### FromChat local tools (downloaded LiveKit server binary) ###
.tools/
### Node ###
# Logs
logs
@@ -118,6 +121,12 @@ web_modules/
.env.production.local
.env.local
# Firebase service account JSON (backend/firebase-cert.json; bind-mounted in docker-compose)
firebase-cert.json
**/firebase-cert.json
firebase-adminsdk.json
**/firebase-adminsdk.json
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
@@ -567,7 +576,9 @@ buck-out/
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
backend/services/main/.fromchat_instance_id
backend/data
backend/files
.vite
*.db
package-lock.json
@@ -579,4 +590,12 @@ backend/alembic/**
.cursor/plans
tmp
compliance_keypair.txt
compliance_keypair.txt
backend/files
*.db-wal
*.db-shm
.deploy-cache
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/lib
+9
View File
@@ -1,3 +1,11 @@
{
"npm.autoDetect": "off",
"files.exclude": {
".husky": true,
"build": true
}
}
{
"files.exclude": {
"**/__pycache__": true,
@@ -7,5 +15,6 @@
"**/.venv": true,
"**/node_modules": true
},
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.terminal.activateEnvironment": false
}
+13 -47
View File
@@ -1,28 +1,10 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Backend",
"type": "npm",
"script": "backend:run",
"options": {
"cwd": "${workspaceFolder}"
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{
"label": "Frontend (Web)",
"type": "npm",
"script": "frontend:dev",
"type": "shell",
"command": "npm run frontend:dev",
"options": {
"cwd": "${workspaceFolder}"
},
@@ -39,8 +21,8 @@
},
{
"label": "Frontend (Electron)",
"type": "npm",
"script": "frontend:electron:dev",
"type": "shell",
"command": "npm run frontend:electron:dev",
"options": {
"cwd": "${workspaceFolder}"
},
@@ -58,25 +40,7 @@
{
"label": "Web",
"dependsOn": ["Backend", "Frontend (Web)"],
"dependsOrder": "parallel",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"runOptions": {
"runOn": "folderOpen"
}
},
{
"label": "Electron",
"dependsOn": ["Backend", "Frontend (Electron)"],
"dependsOn": ["Frontend (Web)"],
"dependsOrder": "parallel",
"group": {
"kind": "build"
@@ -89,15 +53,17 @@
}
},
{
"label": "Deploy",
"type": "shell",
"command": "npm run deploy",
"label": "Electron",
"dependsOn": ["Frontend (Electron)"],
"dependsOrder": "parallel",
"group": {
"kind": "build"
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "dedicated",
"clear": true
"focus": false,
"panel": "shared"
}
}
]
+35
View File
@@ -0,0 +1,35 @@
# 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
ARG VITE_API_BASE_URL=https://api.fromchat.ru
ENV NODE_ENV=production
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
# 1.3. Build production assets
RUN npm run frontend:build
# 2. Production web static server
FROM joseluisq/static-web-server:latest AS production
# 2.1. Copy files to server root (/var/public -> /home/sws/public)
COPY --from=builder /app/build/normal/dist /var/public
# 2.2. Configure (defaults serve the image's built-in landing page instead of our app)
ENV SERVER_ROOT=/var/public
ENV SERVER_FALLBACK_PAGE=/var/public/index.html
EXPOSE 80
+182
View File
@@ -0,0 +1,182 @@
# FromChat Web Client — Messaging Web App
[Читать на других языках: Русский](./README.md)
<div align="center">
<img src="https://raw.githubusercontent.com/fromchat-messenger/android/main/app/android/src/main/ic_launcher-playstore.png" width="120" alt="FromChat Logo" />
**Web client for FromChat messenger**
[🌐 Web Client](https://github.com/fromchat-messenger/web) • [🖥️ Backend](https://github.com/fromchat-messenger/backend) • [📱 Android](https://github.com/fromchat-messenger/android) • [🌍 Website](https://github.com/fromchat-messenger/site)
</div>
---
## 📝 Description
FromChat Web is a React/TypeScript client (browser and Electron) for the FromChat server.
**Note:** Landing pages and legal documents live in [fromchat-messenger/site](https://github.com/fromchat-messenger/site).
---
## 📊 Client Comparison
| Feature | Android | Web | iOS |
|---|---|---|---|
| **Messaging & profiles** | ✅ | ✅ | ❌ |
| **Voice/video calls** | ✅ | ✅ | ❌ |
| **Screen sharing** | ✅ | ✅ | ❌ |
| **Message reactions** | ❌ | ✅ | ❌ |
| **Rich attachment support** | ✅ | ❌ | ❌ |
⚠️ **iOS is temporarily not supported.**
---
## ✨ Features
- Protected DMs (legal encryption scheme)
- Voice/video calls and screen sharing
- Message reactions
- Public chats and profiles
- Device management
- WebSocket real-time updates
- Dark mode
- Optional Electron desktop build
---
## 🏗️ Tech Stack
| Component | Notes |
|---|---|
| React 19 | UI |
| TypeScript | strict typing |
| Vite 7 | dev server & build |
| MDUI | Material Design |
| Zustand + use-immer | state |
| Motion | animations |
| TweetNaCl.js | cryptography |
| Electron | desktop (optional) |
---
## 🔧 Development
### Requirements
- Node.js 20+ (Docker image uses Node 24)
- npm
- Backend API on `http://localhost:8300` (proxied as `/api`)
### Quick start
```bash
git clone https://github.com/fromchat-messenger/web.git
cd web
npm install
cp .env.example .env # if needed; install may copy it for you
npm run frontend:dev
```
Open `http://localhost:8301`.
`.env`:
```env
# HTTP API host (Vite proxy target for /api)
VITE_API_BASE_URL=http://localhost:8300
```
In the browser the client uses same-origin `/api` (HTTP and WebSocket, e.g. `/api/chat/ws`).
### Commands
```bash
npm run frontend:dev # Vite on :8301
npm run frontend:typecheck # TypeScript
npm run frontend:build # typecheck + production build → build/normal
npm run frontend:preview # preview built frontend
npm run frontend:electron:dev # Electron + Vite
npm run frontend:electron:build # Electron package
```
### Project structure
```
web/
├── src/
│ ├── index.html
│ ├── main/ # React app (@/)
│ │ ├── pages/ # auth, chat, profile, …
│ │ ├── core/ # API, websocket, calls, …
│ │ ├── state/ # Zustand stores
│ │ ├── utils/
│ │ └── css/ # SCSS (Material Design)
│ ├── electron/ # Electron main/preload
│ └── protocol/ # shared protocol (@fromchat/protocol)
├── plugins/ # Vite plugins
├── vite.config.ts
├── compose.yml # production web image (:8301→80)
├── Dockerfile
├── .env.example
└── package.json
```
---
## 🐳 Docker
```bash
docker build -t fromchat-web:latest .
# or via compose:
docker compose --env-file .env up --build
```
Container listens on port **8301** (static server on 80 inside).
Production edge (Caddy/HAProxy) is configured via the deployment repo / backend `compose.prod.yml`.
---
## 🤝 Contributing
1. Branch for your change
2. Open a PR with a description
3. Ensure `npm run frontend:typecheck` passes
---
## 📄 License
GNU Affero General Public License v3.0 — see [LICENSE](./LICENSE).
---
## 🔗 Related Repositories
- [Backend API](https://github.com/fromchat-messenger/backend)
- [Android Client](https://github.com/fromchat-messenger/android)
- [Website](https://github.com/fromchat-messenger/site)
- [Deployment](https://github.com/fromchat-messenger/deployment)
---
## ❓ FAQ
**Q: How do I run locally?**
A: Start the backend on `:8300`, then `npm run frontend:dev` and open `http://localhost:8301`.
**Q: Which browsers?**
A: Current Chrome, Firefox, Safari, Edge.
**Q: Do calls work on web?**
A: Yes — voice/video and screen share (server needs LiveKit).
**Q: How do I report a bug?**
A: GitHub Issues with reproduction steps.
---
**[⬆ back to top](#fromchat-web-client--messaging-web-app)**
+110 -23
View File
@@ -1,30 +1,117 @@
# FromChat
# FromChat Web Client — веб-приложение для обмена сообщениями
FromChat - полностью открытый мессенджер.
[Read in other languages: English](./README.en.md)
Его можно попробовать на [сайте](http://fromchat.ru).
_Написано ИИ. Могут быть ошибки._
## Содержание:
- [Основные моменты](#highlights)
- [Использование](#usage)
- [Часто задаваемые вопросы](#faq)
- [Внос вклада](#contributing)
## 📝 Описание
## Основные моменты
- Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд).
- 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя.
Веб-клиент FromChat — React/TypeScript приложение (браузер и Electron) для работы с сервером FromChat.
## Использование
_В разработке._
## Развернуть в 1 клик
## Часто задаваемые вопросы
<!--
Вопрос: __Чему равно 2+2?__
Ответ: __4__
Вопрос: __Какая цитата Джейсона Стетхема на ваш взгляд является лучшей?__
Ответ: __"Одна ошибка, и ты ошибся."__
-->
_В разработке._
```bash
docker run -d --restart always -p 8301:80 fromchat/web:latest
```
## Внос вклада
Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev).
## ✨ Возможности
- Защищённые личные сообщения (легальная схема шифрования)
- Голосовые/видеозвонки и демонстрация экрана
- Реакции на сообщения
- Публичные чаты и профили
- Управление устройствами
- WebSocket для реал-тайма
- Тёмный режим
- Сборка Electron (опционально)
## 🏗️ Технологический стек
| Компонент | Примечание |
|---|---|
| React 19 | UI |
| TypeScript | строгая типизация |
| Vite 7 | dev-сервер и сборка |
| MDUI | Material Design |
| Zustand + use-immer | состояние |
| Motion | анимации |
| TweetNaCl.js | криптография |
| Electron | десктоп (опционально) |
## 🔧 Разработка
### Требования
- Node.js 20+ (для Docker-образа используется Node 24)
- npm
- Backend API на `http://localhost:8300` (проксируется через `/api`)
### Быстрый старт
```bash
git clone https://github.com/fromchat-messenger/web.git
cd web
npm install
cp .env.example .env # при необходимости; install может скопировать сам
npm run frontend:dev
```
Откройте `http://localhost:8301`.
### Команды
```bash
npm run frontend:dev # Vite на :8301
npm run frontend:typecheck # TypeScript
npm run frontend:build # typecheck + production build → build/normal
npm run frontend:preview # preview собранного фронта
npm run frontend:electron:dev # Electron + Vite
npm run frontend:electron:build # сборка Electron
```
### Структура проекта
```
web/
├── src/
│ ├── index.html
│ ├── main/ # React-приложение (@/)
│ │ ├── pages/ # auth, chat, profile, …
│ │ ├── core/ # API, websocket, calls, …
│ │ ├── state/ # Zustand stores
│ │ ├── utils/
│ │ └── css/ # SCSS (Material Design)
│ ├── electron/ # main/preload Electron
│ └── protocol/ # общий протокол (@fromchat/protocol)
├── plugins/ # Vite-плагины
├── vite.config.ts
├── compose.yml # production-образ веба (:8301→80)
├── Dockerfile
├── .env.example
└── package.json
```
## 🐳 Docker
```bash
docker compose up --build
```
Контейнер слушает порт **8301** (внутри nginx/static-server на 80).
## 🤝 Внесение вклада
1. Создайте ветку под изменение
2. Отправьте PR с описанием
3. Проверьте типы: `npm run frontend:typecheck`
## 📄 Лицензия
GNU Affero General Public License v3.0 — см. [LICENSE](./LICENSE).
## 🔗 Связанные репозитории
- [Backend API](https://github.com/fromchat-messenger/backend)
- [Android Client](https://github.com/fromchat-messenger/android)
- [Website](https://github.com/fromchat-messenger/site)
- [Deployment](https://github.com/fromchat-messenger/deployment)
-417
View File
@@ -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()
-147
View File
@@ -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
-78
View File
@@ -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()
-28
View File
@@ -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
View File
@@ -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)
-13
View File
@@ -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")
-40
View File
@@ -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)
-120
View File
@@ -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
-45
View File
@@ -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()
-89
View File
@@ -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")
-7
View File
@@ -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 *
-626
View File
@@ -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()
-312
View File
@@ -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)
-244
View File
@@ -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()
-17
View File
@@ -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
-574
View File
@@ -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"
}
-92
View File
@@ -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"}
-381
View File
@@ -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
-114
View File
@@ -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"}
-570
View File
@@ -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"
}
-46
View File
@@ -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))
-89
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
# Package marker for security utilities
-406
View File
@@ -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)
-694
View File
@@ -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
"": "а",
"": "а",
"": "с",
"": "с",
"": "е",
"": "е",
"": "ф",
"": "ф",
"": "г",
"": "г",
"": "и",
"": "и",
"": "м",
"": "м",
"": "н",
"": "н",
"": "о",
"": "о",
"": "п",
"": "п",
"": "с",
"": "с",
"": "т",
"": "т",
"": "у",
"": "у",
"": "в",
"": "в",
"": "х",
"": "х",
"": "у",
"": "у",
"": "з", # Full-width 'z' to Cyrillic 'з'
"": "з",
# 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
-232
View File
@@ -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
-147
View File
@@ -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, ""
-71
View File
@@ -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
-26
View File
@@ -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
-7
View File
@@ -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"]
-570
View File
@@ -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
-33
View File
@@ -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())
-92
View File
@@ -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
View File
@@ -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
-34
View File
@@ -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
-29
View File
@@ -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
-114
View File
@@ -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
}
-13
View File
@@ -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
-67
View File
@@ -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
-32
View File
@@ -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
-54
View File
@@ -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"]
-20
View File
@@ -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"
}
}
-28
View File
@@ -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}`);
});
-23
View File
@@ -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"
]
}
-11
View File
@@ -1,11 +0,0 @@
import { contextBridge, ipcRenderer } from "electron";
import type { ElectronInterface, Platform } from "../electron";
contextBridge.exposeInMainWorld("electronInterface", {
desktop: true,
platform: process.platform as Platform,
notifications: {
requestPermission: () => ipcRenderer.invoke('request-notification-permission'),
show: (options) => ipcRenderer.invoke('show-notification', options)
}
} satisfies ElectronInterface);
@@ -1,7 +0,0 @@
node_modules/
dist/
*.log
.DS_Store
package-lock.json
@@ -1,8 +0,0 @@
src/
tsconfig.json
node_modules/
package-lock.json
*.log
.DS_Store
@@ -1,171 +0,0 @@
# Publishing FromChat Protocol
This guide explains how to publish the `@fromchat/protocol` package to npm or GitHub Packages.
## Prerequisites
1. **npm account**: Create one at [npmjs.com](https://www.npmjs.com/signup)
2. **GitHub account**: For GitHub Packages
3. **Node.js**: Version 18 or higher
## Publishing to npm
### Important: Scoped Package Setup
The package uses the `@fromchat` scope. You have two options:
**Option A: Create an npm organization (Recommended)**
1. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create)
2. Create an organization named `fromchat`
3. Add yourself as a member
4. Then proceed with publishing below
**Option B: Use unscoped package name**
If you prefer not to create an organization, change the package name in `package.json`:
```json
{
"name": "fromchat-protocol" // Remove the @fromchat/ scope
}
```
Then update all imports in your codebase from `@fromchat/protocol` to `fromchat-protocol`.
### 1. Build the package
```bash
cd frontend/packages/fromchat-protocol
npm run build
```
This compiles TypeScript to JavaScript in the `dist/` directory.
### 2. Login to npm
```bash
npm login
```
Enter your npm username, password, and email.
### 3. Publish
**If using scoped package (`@fromchat/protocol`):**
```bash
npm publish --access public
```
**If using unscoped package (`fromchat-protocol`):**
```bash
npm publish
```
The `--access public` flag is required for scoped packages (packages starting with `@`).
### 4. Verify
Check your package at: `https://www.npmjs.com/package/@fromchat/protocol`
### 5. Update version for future releases
```bash
# Patch version (1.0.0 -> 1.0.1)
npm version patch
# Minor version (1.0.0 -> 1.1.0)
npm version minor
# Major version (1.0.0 -> 2.0.0)
npm version major
# Then publish
npm publish --access public
```
## Publishing to GitHub Packages
### 1. Create a GitHub Personal Access Token
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
2. Generate a new token with `write:packages` and `read:packages` permissions
3. Save the token securely
### 2. Configure npm to use GitHub Packages
Create or edit `~/.npmrc`:
```
@fromchat:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
```
Or add to `package.json`:
```json
{
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
```
### 3. Update package.json
Update the repository URL to match your GitHub repository:
```json
{
"repository": {
"type": "git",
"url": "https://github.com/YOUR_USERNAME/YOUR_REPO.git",
"directory": "frontend/packages/fromchat-protocol"
}
}
```
### 4. Build and publish
```bash
cd frontend/packages/fromchat-protocol
npm run build
npm publish
```
### 5. Install from GitHub Packages
Users can install your package with:
```bash
npm install @fromchat/protocol@npm:@fromchat/protocol
```
Or add to `.npmrc`:
```
@fromchat:registry=https://npm.pkg.github.com
```
## Using the Published Package
### From npm
```bash
npm install @fromchat/protocol
```
```typescript
import { FromChatProtocol } from "@fromchat/protocol";
```
### From GitHub Packages
```bash
npm install @fromchat/protocol@npm:@fromchat/protocol
```
## Notes
- The package is built to `dist/` directory
- Source files in `src/` are excluded from the published package
- Only `dist/` and `README.md` are included in the published package
- The package uses ES modules (ESM) format
- TypeScript definitions are included in `dist/`
@@ -1,99 +0,0 @@
# FromChat Protocol
Simple ECDH-based encryption protocol for direct messages.
## Overview
The FromChat Protocol provides end-to-end encryption for direct messages using:
- **X25519** (ECDH) for key exchange
- **HKDF** for key derivation
- **AES-GCM** for symmetric encryption
This module is completely independent and can be used in any JavaScript/TypeScript project.
## Protocol Flow
### Encryption
1. Generate a random message key (mk) - 32 bytes
2. Generate a random salt (wkSalt) - 16 bytes
3. Derive shared secret from ECDH: `ecdhSharedSecret(myPrivateKey, theirPublicKey)`
4. Derive wrapping key: `deriveWrappingKey(sharedSecret, wkSalt, info)` using HKDF
5. Encrypt message with mk using AES-GCM → (iv, ciphertext)
6. Encrypt (wrap) mk with wrapping key using AES-GCM → (iv2, wrappedMk)
7. Send: `{ iv, ciphertext, salt, iv2, wrappedMk }`
### Decryption
1. Derive shared secret from ECDH
2. Derive wrapping key from shared secret using salt from message
3. Decrypt wrappedMk to get mk
4. Decrypt ciphertext with mk
## Usage
```typescript
import { FromChatProtocol } from "@fromchat/protocol";
// Initialize with your private key
const protocol = new FromChatProtocol(privateKey);
// Encrypt a message
const encrypted = await protocol.encryptMessage(recipientPublicKey, "Hello!");
// Decrypt a message
const decrypted = await protocol.decryptMessage(senderPublicKey, encrypted);
```
## API
### `FromChatProtocol`
#### Constructor
- `constructor(privateKey: Uint8Array)` - Initialize protocol with your X25519 private key
#### Methods
- `encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage>` - Encrypt a message
- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string>` - Decrypt a message
### Types
```typescript
interface EncryptedMessage {
iv: string; // Base64 encoded IV for message encryption
ciphertext: string; // Base64 encoded encrypted message
salt: string; // Base64 encoded salt for wrapping key derivation
iv2: string; // Base64 encoded IV for message key wrapping
wrappedMk: string; // Base64 encoded wrapped message key
}
```
## Backup & Key Management
The protocol also includes utilities for backing up and restoring private keys:
```typescript
import {
encryptBackupWithPassword,
decryptBackupWithPassword,
encodeBlob,
decodeBlob
} from "@fromchat/protocol";
// Create a backup of a private key
const bundle = { version: 1, privateKey: myPrivateKey };
const encrypted = await encryptBackupWithPassword("my-password", bundle);
const backupString = encodeBlob(encrypted); // Store this string
// Restore from backup
const encryptedBlob = decodeBlob(backupString);
const restored = await decryptBackupWithPassword("my-password", encryptedBlob);
```
## Security Notes
- Each message uses a fresh random message key
- The protocol does not provide forward secrecy
- Keys are derived using HKDF with SHA-256
- All encryption uses AES-GCM with 12-byte IVs
- Backup encryption uses PBKDF2 with 210,000 iterations
@@ -1,54 +0,0 @@
{
"name": "@fromchat/protocol",
"version": "1.0.0",
"description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"encryption",
"ecdh",
"e2ee",
"end-to-end-encryption",
"x25519",
"aes-gcm",
"hkdf"
],
"author": "denis0001-dev",
"license": "GPL-3.0",
"repository": {
"type": "git",
"url": "https://github.com/Toolbox-io/FromChat.git",
"directory": "frontend/packages/fromchat-protocol"
},
"bugs": {
"url": "https://github.com/Toolbox-io/FromChat/issues"
},
"homepage": "https://github.com/Toolbox-io/FromChat#readme",
"dependencies": {
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"@types/node": "^25.0.2",
"typescript": "^5.0.0"
},
"files": [
"dist",
"README.md"
],
"engines": {
"node": ">=24.0.0"
}
}
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
-16
View File
@@ -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();
}
-153
View File
@@ -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 };
-176
View File
@@ -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 || [];
}
-176
View File
@@ -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 || [];
}
-15
View File
@@ -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;
}
-13
View File
@@ -1,13 +0,0 @@
/**
* @fileoverview Application configuration constants
* @description Contains all configuration values used throughout the application
* @author Cursor
* @version 1.0.0
*/
export const BASE_DOMAIN = import.meta.env.VITE_API_BASE_URL || "fromchat.ru";
export const API_BASE_URL = `${location.host ? "" : `https://${BASE_DOMAIN}`}/api`;
export const API_WS_BASE_URL = `${location.host || BASE_DOMAIN}/api`;
export const PRODUCT_NAME = "FromChat";
export const MINIMUM_WIDTH = 800;
+11 -27
View File
@@ -3,38 +3,28 @@
"private": true,
"version": "0.0.0",
"type": "module",
"main": "frontend/build/electron/core/main.js",
"main": "build/electron/core/main.js",
"description": "A 100% Open Source Messenger",
"license": "GPL-3.0",
"authors": "denis0001-dev",
"scripts": {
"backend:run": "bash ./scripts/backend:run.sh",
"backend:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt",
"backend:reinstall": "rm -rf .venv && npm run backend:dependencies",
"backend:clean": "rm -rf backend/data",
"frontend:dev": "dotenv -e deployment/.env -- vite frontend",
"frontend:typecheck": "tsc --project frontend",
"frontend:build": "npm run frontend:typecheck && dotenv -e deployment/.env -- vite build frontend",
"frontend:dev": "vite",
"frontend:typecheck": "tsc --project tsconfig.json",
"frontend:build": "npm run frontend:typecheck && vite build",
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev",
"frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force --arch arm64,x64",
"frontend:preview": "vite preview frontend",
"frontend:preview": "vite preview",
"frontend:dependencies": "npm install --ignore-scripts",
"frontend:clean": "rm -rf frontend/dist",
"dev": "concurrently 'npm run frontend:dev' 'npm run backend:run'",
"dev:electron": "concurrently 'npm run frontend:electron:dev' 'npm run backend:run'",
"frontend:clean": "rm -rf build",
"build:electron": "npm run frontend:electron:build",
"build": "npm run frontend:build && npm run build:electron",
"preview": "cd deployment && docker compose up --build --watch",
"preview:clean": "cd deployment && docker compose down -v",
"clean": "npm run backend:clean && npm run frontend:clean && npm run preview:clean",
"install": "npm run backend:dependencies && if [[ ! -f deployment/.env ]]; then npm run generate:env; fi && npm run install:pussh",
"install:pussh": "bash ./scripts/install:pussh.sh",
"preview": "docker compose -f docker-compose.yml up --build --watch",
"preview:clean": "docker compose -f docker-compose.yml down -v --remove-orphans",
"prepare": "husky",
"generate:env": "bash ./scripts/generate:env.sh",
"deploy": "dotenv -e deployment/.env -- bash ./scripts/deploy.sh"
"install": "if [ ! -e .env ]; then cp .env.example .env; fi"
},
"files": [
"frontend/build/electron"
"build/electron"
],
"devDependencies": {
"@electron-forge/cli": "^7.9.0",
@@ -51,8 +41,6 @@
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.11",
"concurrently": "^9.2.1",
"dotenv-cli": "^11.0.0",
"electron": "^39.2.7",
"husky": "^9.1.7",
@@ -68,11 +56,7 @@
"vite-plugin-html": "^3.2.2",
"vite-plugin-sass-dts": "^1.3.34"
},
"workspaces": [
"frontend/packages/fromchat-protocol"
],
"dependencies": {
"@fromchat/protocol": "workspace:*",
"electron-squirrel-startup": "^1.0.1",
"escape-string-regexp": "^5.0.0",
"he": "^1.2.0",
@@ -88,6 +72,6 @@
"zustand": "^5.0.8"
},
"config": {
"forge": "frontend/forge.config.ts"
"forge": "src/electron/forge.config.ts"
}
}
-10
View File
@@ -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
-612
View File
@@ -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!"
-14
View File
@@ -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
-79
View File
@@ -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
-37
View File
@@ -1,37 +0,0 @@
#!/bin/bash
# Transfer unregistry image from local machine to server
# Usage: ./scripts/transfer:unregistry.sh [server_user@server_host]
set -e
SERVER="${1:-${DEPLOY_SERVER:-}}"
if [ -z "$SERVER" ]; then
echo "❌ Server not specified. Usage: $0 [user@host]"
echo " Or set DEPLOY_SERVER environment variable"
exit 1
fi
echo "📦 Transferring unregistry image to $SERVER..."
# Pull the image locally if not already present
if ! docker images ghcr.io/psviderski/unregistry:0.3.1 --format "{{.Repository}}:{{.Tag}}" | grep -q "unregistry:0.3.1"; then
echo " Pulling unregistry:0.3.1 locally..."
docker pull ghcr.io/psviderski/unregistry:0.3.1
fi
# Save and transfer the image
echo " Saving and transferring image to server..."
docker save ghcr.io/psviderski/unregistry:0.3.1 | ssh "$SERVER" "docker load"
echo " ✓ Unregistry image transferred successfully"
echo ""
echo " Now you can run on the server:"
echo " docker run -d \\"
echo " --name unregistry \\"
echo " -p 5000:5000 \\"
echo " -v /run/containerd/containerd.sock:/run/containerd/containerd.sock \\"
echo " --restart unless-stopped \\"
echo " ghcr.io/psviderski/unregistry:0.3.1"
@@ -6,7 +6,7 @@ export default {
packagerConfig: {
asar: true,
},
outDir: "frontend/build/electron/forge",
outDir: "build/electron/forge",
rebuildConfig: {},
makers: [
{
@@ -1,6 +1,6 @@
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
import path from "path";
import type { NotificationShowOptions } from '../electron.d.ts';
import type { NotificationShowOptions } from "./electron.d.ts";
let mainWindow: BrowserWindow | null = null;
@@ -23,7 +23,7 @@ app.whenReady().then(() => {
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile('frontend/build/electron/dist/index.html');
mainWindow.loadFile('build/electron/dist/index.html');
}
// Handle notification permission requests
@@ -35,7 +35,7 @@ app.whenReady().then(() => {
});
// Handle showing notifications
ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => {
ipcMain.handle("show-notification", async (_event, options: NotificationShowOptions) => {
if (Notification.isSupported()) {
try {
const notification = new Notification({
+11
View File
@@ -0,0 +1,11 @@
import { contextBridge, ipcRenderer } from "electron";
import type { ElectronInterface, NotificationShowOptions, Platform } from "./electron.d.ts";
contextBridge.exposeInMainWorld("electronInterface", {
desktop: true,
platform: process.platform as Platform,
notifications: {
requestPermission: () => ipcRenderer.invoke("request-notification-permission"),
show: (options: NotificationShowOptions) => ipcRenderer.invoke("show-notification", options)
}
} satisfies ElectronInterface);
+4 -3
View File
@@ -4,10 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading...</title>
<link rel="icon" href="./src/images/logo.svg" />
<link rel="icon" href="./images/logo.svg" />
</head>
<body>
<div id="root"></div>
<script src="src/main.tsx" type="module"></script>
<script src="main/main.tsx" type="module"></script>
</body>
</html>
</html>
@@ -15,6 +15,8 @@ import { delay } from "./utils/utils";
const HomePage = lazy(() => import("./pages/home/HomePage"));
const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
const PrivacyPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.PrivacyPage })));
const TermsPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.TermsPage })));
const routeConfig: RouteObject[] = [
{ path: "/", element: <HomePage /> },
@@ -22,6 +24,8 @@ const routeConfig: RouteObject[] = [
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
{ path: "/download-app", element: <DownloadAppPage /> },
{ path: "/privacy", element: <PrivacyPage /> },
{ path: "/terms", element: <TermsPage /> },
{
path: "/chat",
element: (
+23
View File
@@ -0,0 +1,23 @@
import { MaterialIcon } from "@/utils/material";
import { avatarGradientFromUserId } from "@/core/avatarGradient";
import styles from "@/pages/chat/css/deleted-user-avatar.module.scss";
interface DeletedUserAvatarProps {
userId: number;
className?: string;
iconClassName?: string;
}
export function DeletedUserAvatar({ userId, className, iconClassName }: DeletedUserAvatarProps) {
return (
<div
className={className ?? styles.deletedUserAvatar}
style={{ background: avatarGradientFromUserId(userId) }}
>
<MaterialIcon
name="account_circle_off--outlined"
className={iconClassName ?? styles.deletedUserAvatarIcon}
/>
</div>
);
}
@@ -142,7 +142,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
const pair = generateX25519KeyPair();
currentPublicKey = pair.publicKey;
currentPrivateKey = pair.privateKey;
await uploadPublicKey(currentPublicKey, token);
await uploadPublicKey(pair.publicKey, token);
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
await uploadBackupBlob(encodeBlob(newBlob), token);
}
@@ -159,7 +159,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
const pair = generateX25519KeyPair();
currentPublicKey = pair.publicKey;
currentPrivateKey = pair.privateKey;
await uploadPublicKey(currentPublicKey, token);
await uploadPublicKey(pair.publicKey, token);
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
await uploadBackupBlob(encodeBlob(encBlob), token);
@@ -170,45 +170,6 @@ export async function verifyUser(userId: number, token: string): Promise<{verifi
return null;
}
}
/**
* In-memory cache for user similarity results
* Key: userId, Value: similarity result
*/
const similarityCache = new Map<number, {isSimilar: boolean, similarTo?: string} | null>();
/**
* Checks if a user is similar to any verified user
* Results are cached in memory to avoid redundant API calls
*/
export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> {
// Check cache first
if (similarityCache.has(userId)) {
return similarityCache.get(userId) ?? null;
}
try {
const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, {
headers: getAuthHeaders(token, true)
});
let result: {isSimilar: boolean, similarTo?: string} | null = null;
if (response.ok) {
result = await response.json();
}
// Cache the result (even if null/error)
similarityCache.set(userId, result);
return result;
} catch (error) {
console.error('Error checking user similarity:', error);
const result: null = null;
// Cache null result to avoid retrying on errors
similarityCache.set(userId, result);
return result;
}
}
/**
* Suspends a user account (admin only)
*/
+323
View File
@@ -0,0 +1,323 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import { getCurrentKeys } from "../user/auth";
import { request } from "@/core/websocket";
import type { DmEnvelope, User } from "@/core/types";
import { ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "../crypto/identity";
import { fetchUsers, searchUsers } from "../user/search";
import { deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
import tweetnacl from "tweetnacl";
/**
* Unwrap a MEK using the appropriate wrapping key for the current user
*/
export async function unwrapMek(wrappedMekB64: string, envelope: DmEnvelope, userId?: number): Promise<Uint8Array> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Determine context based on whether we're sender or recipient
const currentUserId = userId || parseInt(localStorage.getItem('userId') || '0');
const isRecipient = envelope.recipientId === currentUserId;
const context = isRecipient ? "recipient_wrap_key" : "sender_wrap_key";
// Derive wrapping key from our public key
const salt = new Uint8Array(16).fill(0); // 16 zero bytes salt
const wrappingKeyRaw = await deriveWrappingKey(keys.publicKey, salt, new TextEncoder().encode(context));
const wrappingKey = await importAesGcmKey(wrappingKeyRaw);
// Unwrap the MEK using AES-256-GCM
const wrappedMekBytes = ub64(wrappedMekB64);
const mekNonce = wrappedMekBytes.slice(0, 12);
const mekCiphertext = wrappedMekBytes.slice(12);
return await aesGcmDecrypt(wrappingKey, mekNonce, mekCiphertext);
}
export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<string> {
try {
// Use the wrapped MEK provided for this user
const wrappedMekB64 = envelope.wrapped_mek_b64;
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
// Unwrap the MEK using shared logic
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
// Decrypt the message using the unwrapped MEK
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
const messageKey = await importAesGcmKey(mek);
const messageNonce = ub64(envelope.iv_b64 || "");
const messageCiphertext = ub64(envelope.ciphertext_b64);
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
const result = new TextDecoder().decode(plaintext);
return result;
} catch (error) {
console.error("❌ Failed to decrypt DM envelope:", error);
console.error("Error details:", {
envelope: envelope,
userId: userId,
localStorageUserId: localStorage.getItem('userId')
});
throw error;
}
}
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
if (beforeId) {
url += `&before_id=${beforeId}`;
}
const response = await globalThis.fetch(url, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return { messages: [], has_more: false };
const data = await response.json();
return { messages: data.messages || [], has_more: data.has_more ?? false };
}
/**
* Get the transport public key from the server
*/
async function getTransportPublicKey(): Promise<string> {
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
if (!response.ok) throw new Error(`Failed to fetch transport key: HTTP ${response.status}`);
const data = await response.json();
return data.public_key_b64;
}
/**
* Encrypt message using transport key (client-side only)
*/
function encryptWithTransportKey(plaintext: string, transportPublicKeyB64: string): { client_public_key_b64: string; nonce_b64: string; ciphertext_b64: string } {
const plaintextBytes = new TextEncoder().encode(plaintext);
const ephemeralKeypair = tweetnacl.box.keyPair();
const transportPublicKeyBytes = new Uint8Array(
atob(transportPublicKeyB64)
.split("")
.map((c: string) => c.charCodeAt(0))
);
const nonce = tweetnacl.randomBytes(24);
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
return {
client_public_key_b64: btoa(String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])),
nonce_b64: btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[])),
ciphertext_b64: btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]))
};
}
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number, attachments?: Array<{name:string,path:string,wrapped_mek_b64?:string,nonce_b64?:string}>): Promise<void> {
// Get keys
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
const transportPublicKeyB64 = await getTransportPublicKey();
// Client-side transport encryption only
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
// Get sender's public key (from current keys)
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
// Send to server (server will handle envelope encryption)
const bodyPayload: any = {
recipient_id: recipientId,
client_public_key_b64,
transport_nonce_b64: nonce_b64,
transport_ciphertext_b64: ciphertext_b64,
sender_public_key_b64: senderPublicKeyB64,
recipient_public_key_b64: recipientPublicKeyB64,
reply_to_id: replyToId
};
if (attachments && attachments.length > 0) bodyPayload["files"] = attachments;
const response = await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(authToken, true)
},
body: JSON.stringify(bodyPayload)
});
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
}
export async function sendWithFiles(
recipientId: number,
recipientPublicKeyB64: string,
files: File[],
plaintext: string,
authToken: string,
replyToId?: number
): Promise<void> {
if (!files || files.length === 0) {
throw new Error("No files provided");
}
// Get transport key for encryption (shared across message + files)
const transportKeyResponse = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
if (!transportKeyResponse.ok) {
throw new Error("Failed to get transport key");
}
const transportKeyData = await transportKeyResponse.json();
const transportPublicKeyB64 = transportKeyData.public_key_b64;
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
const transportPublicKey = ub64(transportPublicKeyB64);
// One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob).
const ephemeralKeypair = tweetnacl.box.keyPair();
const messagePlaintextBytes = new TextEncoder().encode(plaintext || "");
const messageNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
const messageCiphertext = tweetnacl.box(
messagePlaintextBytes,
messageNonce,
transportPublicKey,
ephemeralKeypair.secretKey
);
const client_public_key_b64 = btoa(
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
);
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageNonce) as number[]));
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageCiphertext) as number[]));
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
// Base64 encode helper (chunked)
const uint8ToB64 = (uint8: Uint8Array): string => {
const CHUNK = 0x8000;
let binary = "";
for (let i = 0; i < uint8.length; i += CHUNK) {
binary += String.fromCharCode.apply(null, Array.from(uint8.subarray(i, i + CHUNK)) as number[]);
}
return btoa(binary);
};
// Transport-encrypt files; server will envelope-encrypt them with the SAME MEK as the message.
const transport_files: Array<{ encrypted_file_data_b64: string; filename: string; file_size: number }> = [];
for (const file of files) {
const fileData = await file.arrayBuffer();
const transportNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
const transportEncrypted = tweetnacl.box(
new Uint8Array(fileData),
transportNonce,
transportPublicKey,
ephemeralKeypair.secretKey
);
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
transportEncryptedWithNonce.set(transportNonce);
transportEncryptedWithNonce.set(transportEncrypted, transportNonce.length);
transport_files.push({
encrypted_file_data_b64: uint8ToB64(transportEncryptedWithNonce),
filename: file.name,
file_size: file.size
});
}
const requestBody = {
recipient_id: recipientId,
client_public_key_b64,
transport_nonce_b64: nonce_b64,
transport_ciphertext_b64: ciphertext_b64,
sender_public_key_b64: senderPublicKeyB64,
recipient_public_key_b64: recipientPublicKeyB64,
reply_to_id: replyToId,
transport_files
};
const response = await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(authToken, true)
},
body: JSON.stringify(requestBody)
});
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
}
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
await request({
type: "dmDelete",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id, recipientId }
});
}
export interface ConversationResponse {
user: User;
lastMessage: DmEnvelope;
unreadCount: number;
}
export async function conversations(token: string): Promise<ConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
return data.conversations || [];
}
/**
* Marks a DM as read
*/
export async function markRead(id: number, authToken: string): Promise<void> {
await request({
type: "dmMarkRead",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id }
});
}
export async function editMessage(
messageId: number,
recipientPublicKeyB64: string,
plaintext: string,
authToken: string
): Promise<void> {
// Get keys
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Get transport key for initial encryption
const transportPublicKeyB64 = await getTransportPublicKey();
// Client-side transport encryption (same as sending)
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
// Get sender's public key
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
// Send transport-encrypted data to the edit endpoint (it will handle envelope encryption)
const editResponse = await fetch(`${API_BASE_URL}/dm/edit/${messageId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(authToken, true)
},
body: JSON.stringify({
client_public_key_b64,
transport_nonce_b64: nonce_b64,
transport_ciphertext_b64: ciphertext_b64,
sender_public_key_b64: senderPublicKeyB64,
recipient_public_key_b64: recipientPublicKeyB64
})
});
if (!editResponse.ok) throw new Error(`Failed to edit DM: HTTP ${editResponse.status}`);
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
+237
View File
@@ -0,0 +1,237 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import { request } from "@/core/websocket";
import type { DmEnvelope, User } from "@/core/types";
import { fetchUserPublicKey } from "./crypto";
import { fetchUsers, searchUsers } from "./users";
/**
* Decrypt a DM envelope using client-side MEK unwrapping.
* This delegates to the chats/dm module which has the updated implementation.
*/
export async function decryptDm(envelope: DmEnvelope): Promise<string> {
// Import and use the updated implementation from chats/dm
const { decrypt } = await import("./chats/dm");
return decrypt(envelope);
}
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data = await response.json();
return data.messages || [];
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
/**
* Send DM via WebSocket using transport encryption.
* This delegates to the HTTP endpoint which handles envelope encryption on server.
*/
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
// Import and use the updated implementation from chats/dm
const { send } = await import("./chats/dm");
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
}
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
await request({
type: "dmDelete",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id, recipientId }
});
}
export interface DMConversationResponse {
user: User;
lastMessage: DmEnvelope;
unreadCount: number;
}
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
return data.conversations || [];
}
// ============================================================================
// Envelope Encryption (Private DMs with compliance support)
// ============================================================================
interface TransportKey {
key_id: string;
public_key_b64: string;
created_at: number;
}
interface TransportEncryptedMessage {
client_public_key_b64: string;
nonce_b64: string;
ciphertext_b64: string;
}
let cachedTransportKey: TransportKey | null = null;
/**
* Fetch current transport public key from messaging service.
* Caches result with validation.
*/
export async function getTransportPublicKey(): Promise<TransportKey> {
if (cachedTransportKey) {
return cachedTransportKey;
}
try {
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: TransportKey = await response.json();
cachedTransportKey = data;
return data;
} catch (error) {
console.error("Failed to fetch transport public key:", error);
}
throw new Error("Failed to fetch transport public key");
}
/**
* Encrypt a message using the transport public key (X25519 + ChaCha20).
*/
function encryptMessageWithTransportKey(
plaintext: string | Uint8Array,
transportPublicKeyB64: string
): { nonce_b64: string; ciphertext_b64: string; client_public_key_b64: string } {
const tweetnacl = require("tweetnacl");
// Convert plaintext to bytes if string
const plaintextBytes = typeof plaintext === "string" ? new TextEncoder().encode(plaintext) : plaintext;
// Generate ephemeral keypair for this message
const ephemeralKeypair = tweetnacl.box.keyPair();
// Decode transport public key
const transportPublicKeyBytes = new Uint8Array(
atob(transportPublicKeyB64)
.split("")
.map((c: string) => c.charCodeAt(0))
);
// Perform ECDH (shared secret via tweetnacl's box)
const nonce = tweetnacl.randomBytes(24);
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
// Encode to base64
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[]));
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]));
const client_public_key_b64 = btoa(
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
);
return { nonce_b64, ciphertext_b64, client_public_key_b64 };
}
/**
* Encrypt plaintext with transport public key for sending to server.
* Server will handle envelope encryption (MEK generation and wrapping).
*/
export async function encryptMessageForTransport(plaintext: string): Promise<TransportEncryptedMessage> {
const transportKey = await getTransportPublicKey();
return encryptMessageWithTransportKey(plaintext, transportKey.public_key_b64);
}
/**
* Send an encrypted DM message using envelope encryption.
* Client encrypts with transport key, server handles envelope encryption.
*/
export async function sendEncryptedDM(
recipientId: number,
plaintext: string,
token: string,
replyToId?: number
): Promise<void> {
try {
// Client-side transport encryption
const { client_public_key_b64, nonce_b64, ciphertext_b64 } =
await encryptMessageForTransport(plaintext);
// Send to server
const response = await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(token, true)
},
body: JSON.stringify({
recipient_id: recipientId,
client_public_key_b64,
transport_nonce_b64: nonce_b64,
transport_ciphertext_b64: ciphertext_b64,
reply_to_id: replyToId,
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
} catch (error) {
console.error("Failed to send encrypted DM:", error);
throw error;
}
}
/**
* Get encrypted conversation history with another user.
*/
export async function getEncryptedConversation(
otherUserId: number,
token: string,
limit: number = 50,
offset: number = 0
): Promise<any[]> {
try {
const url = new URL(`${API_BASE_URL}/dm/conversation/${otherUserId}`);
url.searchParams.append("limit", String(limit));
url.searchParams.append("offset", String(offset));
const response = await fetch(url.toString(), {
headers: getAuthHeaders(token, true)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`Failed to fetch encrypted conversation with user ${otherUserId}:`, error);
throw error;
}
}
/**
* Delete an encrypted message.
*/
export async function deleteEncryptedDM(messageId: number, token: string): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/dm/${messageId}`, {
method: "DELETE",
headers: getAuthHeaders(token, true)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
} catch (error) {
console.error(`Failed to delete encrypted DM ${messageId}:`, error);
throw error;
}
}
/**
* Clear cached keys (useful on logout).
*/
export function clearCachedKeys(): void {
cachedTransportKey = null;
}
+53
View File
@@ -0,0 +1,53 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import { request } from "@/core/websocket";
import type { DmEnvelope, User } from "@/core/types";
import { fetchUserPublicKey } from "./crypto";
import { fetchUsers, searchUsers } from "./users";
export async function decryptDm(envelope: DmEnvelope): Promise<string> {
const { decrypt } = await import("./chats/dm");
return decrypt(envelope);
}
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data = await response.json();
return data.messages || [];
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const { send } = await import("./chats/dm");
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
}
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
await request({
type: "dmDelete",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id, recipientId }
});
}
export interface DMConversationResponse {
user: User;
lastMessage: DmEnvelope;
unreadCount: number;
}
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
return data.conversations || [];
}

Some files were not shown because too many files have changed in this diff Show More