68 Commits

287 changed files with 9916 additions and 15598 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 (don’t persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
- Applies especially to: local storage/cache layers, list previews, and any code that “cleans” strings before show or read.
+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 3–20 chars: letters, digits, `-`, `_`; password 5–50 chars, no spaces):
```bash
DEBUG_USER="decryptdbg$(openssl rand -hex 3)"
DEBUG_PASS="$(openssl rand -base64 12 | tr -d '/+=' | head -c 16)"
echo "user=$DEBUG_USER pass=$DEBUG_PASS"
```
Register and save **`token`** from the JSON response (not the derived login secret):
```bash
REGISTER_JSON=$(curl -sS -X POST "http://localhost:8301/api/register" \
-H "Content-Type: application/json" \
-d "{\"username\":\"${DEBUG_USER}\",\"display_name\":\"DecryptDebug\",\"password\":\"${DEBUG_PASS}\",\"confirm_password\":\"${DEBUG_PASS}\"}")
echo "$REGISTER_JSON"
DEBUG_TOKEN=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' <<< "$REGISTER_JSON")
echo "token saved (length ${#DEBUG_TOKEN})"
```
Alternatively export for the CLI: `export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"`
Save `user.id` from the same JSON if needed for debugging.
## Step 2 — Temporary compliance extract permission (marker blocks)
`GET /api/dm/compliance/extract/{message_id}` is restricted to **user id 1** in:
`backend/services/main/routes/envelope_messaging.py` (function `extract_message_for_compliance`).
Add a **temporary** bypass between searchable markers (replace `DEBUG_USERNAME` with the user from step 1):
```python
# TEMP_COMPLIANCE_DEBUG_START — remove after decrypt debug run
_TEMP_COMPLIANCE_DEBUG_USERNAMES = {"DEBUG_USERNAME"}
# TEMP_COMPLIANCE_DEBUG_END
# Security check: only user ID 1 can access this
if current_user.id != 1 and current_user.username not in _TEMP_COMPLIANCE_DEBUG_USERNAMES:
```
Backend will auto-reload after this change, you don't need to do anything.
To remove later: search the repo for `TEMP_COMPLIANCE_DEBUG` and delete the marker block + revert the `if` to only `current_user.id != 1`.
## Step 3 — Extract bundle (online)
From Web repo root, set variables and run (user supplies `MESSAGE_ID`):
```bash
MESSAGE_ID=<message_id>
RANDOM_FOLDER="run_$(openssl rand -hex 4)"
BUNDLE_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}"
DECRYPT_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}_dec"
mkdir -p /tmp/debug_decrypt
cd /path/to/Web
python scripts/compliance/decryption/main.py extract \
--server localhost:8301 \
--http \
--token "${DEBUG_TOKEN}" \
--message-ids "${MESSAGE_ID}" \
--out-dir "${BUNDLE_DIR}"
```
Equivalent using env (no `--token` flag):
```bash
export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"
python scripts/compliance/decryption/main.py extract \
--server localhost:8301 \
--http \
--message-ids "${MESSAGE_ID}" \
--out-dir "${BUNDLE_DIR}"
```
Confirm `${BUNDLE_DIR}/bundle.json` exists.
**Auth flags:** use `--token` (Bearer from register/login), or `--username` + `--password` (CLI derives the login secret). Do not pass both.
## Step 4 — Decrypt bundle (offline)
Still from Web repo root (`compliance_keypair.txt` must resolve):
```bash
python scripts/compliance/decryption/main.py decrypt \
--bundle-dir "${BUNDLE_DIR}" \
--output-dir "${DECRYPT_DIR}"
```
Outputs:
- `${DECRYPT_DIR}/messages/<message_id>/message.decrypted.txt` — message plaintext
- `${DECRYPT_DIR}/messages/<message_id>/files/` — decrypted attachments
- `${DECRYPT_DIR}/index.html` — HTML report
## Step 5 — Use decrypted output (conversation-driven)
Follow the **user’s request in the current chat** for what to do next (e.g. compare hashes, inspect text, verify a specific attachment). This skill stops at producing `${DECRYPT_DIR}`; do not assume a fixed post-decrypt task.
## Step 6 — Cleanup (required)
1. **Remove decrypt dirs:**
```bash
rm -rf "${BUNDLE_DIR}" "${DECRYPT_DIR}"
```
2. **Delete temp user** (local SQLite default DB):
```bash
sqlite3 backend/data/database.db "DELETE FROM users WHERE username='${DEBUG_USER}';"
```
If your deployment uses another DB, delete the same username there.
3. **Remove temporary permission:** search `TEMP_COMPLIANCE_DEBUG` in the repo, delete the marker block, restore the original `if current_user.id != 1:` check, restart backend.
4. Do **not** commit `compliance_keypair.txt` or any decrypt output.
## Checklist
```
- [ ] Read compliance_keypair.txt
- [ ] Register DEBUG_USER; save DEBUG_TOKEN from response
- [ ] Add TEMP_COMPLIANCE_DEBUG_* bypass; restart backend
- [ ] extract --token → bundle.json present
- [ ] decrypt → output under DECRYPT_DIR
- [ ] Post-decrypt work per conversation context
- [ ] rm -rf BUNDLE_DIR and DECRYPT_DIR
- [ ] DELETE temp user from DB
- [ ] Remove TEMP_COMPLIANCE_DEBUG markers; restart backend
```
## Reference
- Extract API: `GET /api/dm/compliance/extract/{message_id}` (see `bundle_extract.py`).
- Decrypt implementation: `scripts/compliance/decryption/bundle_decrypt.py`, `crypto.py`.
- Extract auth: `--token` (Bearer), env `FROMCHAT_API_TOKEN` / `FROMCHAT_TOKEN`, or `--username` + plain `--password` (CLI calls `derive_auth_secret` for login only).
## Rules
- NEVER use sleep in any command.
- Set a timeout on EVERY command that may request user input.
+47
View File
@@ -0,0 +1,47 @@
# Shared build context ignore (used by deployment images with context: ..)
# VCS / editor
.git
.github
.idea
.vscode
.cursor
.DS_Store
# 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
+24 -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
@@ -575,4 +586,16 @@ backend/alembic/**
!backend/alembic/env.py
!backend/alembic/script.py.mako
!frontend/src/css/lib
**/*.module.scss.d.ts
**/*.module.scss.d.ts
.cursor/plans
tmp
compliance_keypair.txt
backend/files
*.db-wal
*.db-shm
.deploy-cache
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/lib
+11 -1
View File
@@ -1,3 +1,11 @@
{
"npm.autoDetect": "off",
"files.exclude": {
".husky": true,
"build": true
}
}
{
"files.exclude": {
"**/__pycache__": true,
@@ -6,5 +14,7 @@
"**/.husky/_": true,
"**/.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"
}
}
]
+39
View File
@@ -0,0 +1,39 @@
# 1. Frontend production build
FROM node:24 AS builder
WORKDIR /app
# 1.1. Install dependencies
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install --ignore-scripts
# 1.2. Copy sources and configure
COPY . .
WORKDIR /app
ARG NODE_ENV=production
# Overridable at build time. Must be a real http(s) URL — never a compose ${...} stub.
ARG VITE_API_BASE_URL=https://api.fromchat.ru
# Fail the build if the arg is not a real URL (e.g. unexpanded Compose ${...}).
RUN if ! printf "%s" "${VITE_API_BASE_URL}" | grep -Eq '^https?://'; then \
echo "ERROR: VITE_API_BASE_URL must be an http(s) URL, got: ${VITE_API_BASE_URL}" >&2; \
exit 1; \
fi && \
export NODE_ENV=production VITE_API_BASE_URL && \
npm run frontend:build
# 2. Production web static server
FROM joseluisq/static-web-server:latest AS production
# 2.1. Copy files to server root (/var/public -> /home/sws/public)
COPY --from=builder /app/build/normal/dist /var/public
# 2.2. Configure (defaults serve the image's built-in landing page instead of our app)
ENV SERVER_ROOT=/var/public
ENV SERVER_FALLBACK_PAGE=/var/public/index.html
EXPOSE 80
+65 -78
View File
@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+182
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"}
-167
View File
@@ -1,167 +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
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 separate process 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.username == OWNER_USERNAME).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):
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)
-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)
-104
View File
@@ -1,104 +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
security = HTTPBearer()
# Зависимость для получения сессии БД
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
payload = verify_token(token)
if not 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:
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:
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()
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:
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:
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 *
-611
View File
@@ -1,611 +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:
# 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()
-351
View File
@@ -1,351 +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 SignalPreKeyBundle(Base):
__tablename__ = "signal_prekey_bundle"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
bundle_json = Column(Text, nullable=False) # JSON string of PreKeyBundleData (identity, signed prekey, registration ID)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class SignalPreKey(Base):
__tablename__ = "signal_prekey"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
prekey_id = Column(Integer, nullable=False) # The prekey ID from the client
public_key = Column(Text, nullable=False) # Base64 encoded public key
used = Column(Boolean, default=False, nullable=False, index=True) # Whether this prekey has been used
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (UniqueConstraint('user_id', 'prekey_id', name='_user_prekey_uc'),)
class SignalSession(Base):
__tablename__ = "signal_session"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
recipient_id = Column(Integer, nullable=False, index=True) # The other user in the session
device_id = Column(Integer, default=1, nullable=False) # Device ID (always 1 for now)
encrypted_session_data = Column(Text, nullable=False) # Encrypted session record (JSON with salt, iv, ciphertext)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
__table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_uc'),)
class SentMessagePlaintext(Base):
"""Stores encrypted plaintexts of sent messages for history display"""
__tablename__ = "sent_message_plaintext"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
message_id = Column(Integer, nullable=False, index=True) # DM envelope ID
recipient_id = Column(Integer, nullable=False, index=True) # The recipient of the message
encrypted_data = Column(Text, nullable=False) # Encrypted plaintext (JSON with salt, iv, ciphertext)
created_at = Column(DateTime, default=datetime.now, index=True)
__table_args__ = (UniqueConstraint('user_id', 'message_id', name='_user_message_uc'),)
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 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)
-152
View File
@@ -1,152 +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
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")
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
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
if not subscription:
continue
await self._send_notification_to_user(
db, user.id,
f"New message from {message.author.username}",
message.content[:100] + ("..." if len(message.content) > 100 else ""),
message.author.profile_picture,
{
"type": "public_message",
"message_id": message.id,
"sender_id": message.user_id,
"sender_username": message.author.username
}
)
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:
await self._send_notification_to_user(
db, dm_envelope.recipient_id,
f"New message from {sender.username}",
"You have a new direct message",
sender.profile_picture,
{
"type": "dm",
"dm_id": dm_envelope.id,
"sender_id": sender.id,
"sender_username": sender.username
}
)
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}")
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()
-16
View File
@@ -1,16 +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
-983
View File
@@ -1,983 +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, SignalSession, SentMessagePlaintext
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 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": user.username == OWNER_USERNAME,
"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": current_user.username == OWNER_USERNAME
}
@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
# If owner not yet registered, only allow the owner to register
if not owner_exists and username != OWNER_USERNAME:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Регистрация временно закрыта до регистрации владельца"
)
# 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="Пароли не совпадают"
)
# After owner exists, disallow registering the reserved owner username via public registration
if owner_exists and username == OWNER_USERNAME:
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 current_user.username != OWNER_USERNAME:
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 user.username == OWNER_USERNAME:
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.post("/crypto/signal/prekey-bundle")
@rate_limit_per_ip("10/minute")
def upload_prekey_bundle(
request: Request,
payload: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload Signal Protocol prekey bundle for the current user"""
from models import SignalPreKeyBundle, SignalPreKey
import json
bundle = payload.get("bundle")
if not bundle:
raise HTTPException(status_code=400, detail="bundle required")
# Validate bundle structure
if not isinstance(bundle, dict):
raise HTTPException(status_code=400, detail="bundle must be a JSON object")
# Validate required fields
required_fields = ["registrationId", "identityKey", "signedPreKey"]
for field in required_fields:
if field not in bundle:
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]:
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
# Store bundle (identity key, signed prekey, registration ID) - without the one-time prekey
bundle_without_prekey = {
"registrationId": bundle["registrationId"],
"identityKey": bundle["identityKey"],
"signedPreKey": bundle["signedPreKey"]
}
bundle_json = json.dumps(bundle_without_prekey)
if len(bundle_json) > 50000: # 50KB limit
raise HTTPException(status_code=400, detail="Bundle too large")
# Store or update the bundle
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
if row:
row.bundle_json = bundle_json
row.updated_at = datetime.now()
else:
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
db.add(row)
# Store the one-time prekey if provided
if "preKey" in bundle and bundle["preKey"]:
prekey = bundle["preKey"]
if isinstance(prekey, dict) and "keyId" in prekey and "publicKey" in prekey:
# Check if this prekey already exists
existing = db.query(SignalPreKey).filter(
SignalPreKey.user_id == current_user.id,
SignalPreKey.prekey_id == prekey["keyId"]
).first()
if existing:
# Update existing prekey (mark as unused if it was used)
existing.public_key = prekey["publicKey"]
existing.used = False
existing.created_at = datetime.now()
else:
# Add new prekey
new_prekey = SignalPreKey(
user_id=current_user.id,
prekey_id=prekey["keyId"],
public_key=prekey["publicKey"],
used=False
)
db.add(new_prekey)
db.commit()
return {"status": "ok"}
@router.post("/crypto/signal/prekeys/bulk")
@rate_limit_per_ip("10/minute")
def upload_prekeys_bulk(
request: Request,
payload: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload multiple Signal Protocol prekeys in one request"""
from models import SignalPreKeyBundle, SignalPreKey
import json
base_bundle = payload.get("baseBundle")
prekeys = payload.get("prekeys", [])
if not base_bundle:
raise HTTPException(status_code=400, detail="baseBundle required")
if not isinstance(prekeys, list):
raise HTTPException(status_code=400, detail="prekeys must be an array")
# Validate base bundle structure
if not isinstance(base_bundle, dict):
raise HTTPException(status_code=400, detail="baseBundle must be a JSON object")
# Validate required fields
required_fields = ["registrationId", "identityKey", "signedPreKey"]
for field in required_fields:
if field not in base_bundle:
raise HTTPException(status_code=400, detail=f"Missing required field in baseBundle: {field}")
if not isinstance(base_bundle["signedPreKey"], dict) or "keyId" not in base_bundle["signedPreKey"]:
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
# Store or update the base bundle (identity key, signed prekey, registration ID)
bundle_without_prekey = {
"registrationId": base_bundle["registrationId"],
"identityKey": base_bundle["identityKey"],
"signedPreKey": base_bundle["signedPreKey"]
}
bundle_json = json.dumps(bundle_without_prekey)
if len(bundle_json) > 50000: # 50KB limit
raise HTTPException(status_code=400, detail="Bundle too large")
# Store or update the bundle
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
if row:
row.bundle_json = bundle_json
row.updated_at = datetime.now()
else:
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
db.add(row)
# Store all prekeys
for prekey in prekeys:
if not isinstance(prekey, dict) or "keyId" not in prekey or "publicKey" not in prekey:
continue # Skip invalid prekeys
# Check if this prekey already exists
existing = db.query(SignalPreKey).filter(
SignalPreKey.user_id == current_user.id,
SignalPreKey.prekey_id == prekey["keyId"]
).first()
if existing:
# Update existing prekey (mark as unused if it was used)
existing.public_key = prekey["publicKey"]
existing.used = False
existing.created_at = datetime.now()
else:
# Add new prekey
new_prekey = SignalPreKey(
user_id=current_user.id,
prekey_id=prekey["keyId"],
public_key=prekey["publicKey"],
used=False
)
db.add(new_prekey)
db.commit()
return {"status": "ok", "uploaded": len(prekeys)}
@router.get("/crypto/signal/prekey-bundle")
def get_prekey_bundle(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get Signal Protocol prekey bundle for the current user"""
from models import SignalPreKeyBundle
import json
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
if not row:
raise HTTPException(status_code=404, detail="Prekey bundle not found")
try:
bundle = json.loads(row.bundle_json)
return {"bundle": bundle}
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Invalid bundle data")
@router.get("/crypto/signal/prekey-bundle/of/{user_id}")
@rate_limit_per_ip("100/minute")
def get_prekey_bundle_of(
request: Request,
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get Signal Protocol prekey bundle for another user with prekey rotation"""
from models import SignalPreKeyBundle, SignalPreKey
import json
# Get the base bundle (identity key, signed prekey, registration ID)
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first()
if not row:
raise HTTPException(status_code=404, detail="Prekey bundle not found")
try:
bundle = json.loads(row.bundle_json)
# Find an unused prekey for this user
unused_prekey = db.query(SignalPreKey).filter(
SignalPreKey.user_id == user_id,
SignalPreKey.used == False
).order_by(SignalPreKey.created_at.asc()).first()
if unused_prekey:
# Mark this prekey as used (atomic operation)
unused_prekey.used = True
db.commit()
# Add the prekey to the bundle
bundle["preKey"] = {
"keyId": unused_prekey.prekey_id,
"publicKey": unused_prekey.public_key
}
else:
# No unused prekeys available - return bundle without prekey
# The client will need to establish a session using the signed prekey only
pass
return {"bundle": bundle}
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Invalid bundle data")
@router.post("/crypto/signal/sessions")
@rate_limit_per_ip("100/minute")
def upload_signal_sessions(
request: Request,
payload: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload encrypted Signal Protocol sessions for the current user"""
import json
from datetime import datetime
sessions = payload.get("sessions")
if not isinstance(sessions, list):
raise HTTPException(status_code=400, detail="sessions must be a list")
uploaded_count = 0
for session_data in sessions:
if not isinstance(session_data, dict):
continue
recipient_id = session_data.get("recipientId")
device_id = session_data.get("deviceId", 1)
encrypted_data = session_data.get("encryptedData")
if not recipient_id or not encrypted_data:
continue
try:
# Validate encrypted_data is valid JSON
json.loads(encrypted_data)
except (json.JSONDecodeError, TypeError):
continue
# Store or update session
existing = db.query(SignalSession).filter(
SignalSession.user_id == current_user.id,
SignalSession.recipient_id == recipient_id,
SignalSession.device_id == device_id
).first()
if existing:
existing.encrypted_session_data = encrypted_data
existing.updated_at = datetime.now()
else:
new_session = SignalSession(
user_id=current_user.id,
recipient_id=recipient_id,
device_id=device_id,
encrypted_session_data=encrypted_data
)
db.add(new_session)
uploaded_count += 1
db.commit()
return {"status": "ok", "uploaded_count": uploaded_count}
@router.get("/crypto/signal/sessions")
@rate_limit_per_ip("60/minute")
def get_signal_sessions(
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all encrypted Signal Protocol sessions for the current user"""
sessions = db.query(SignalSession).filter(
SignalSession.user_id == current_user.id
).all()
return {
"sessions": [
{
"recipientId": s.recipient_id,
"deviceId": s.device_id,
"encryptedData": s.encrypted_session_data,
"updatedAt": s.updated_at.isoformat()
}
for s in sessions
]
}
@router.post("/crypto/signal/message-plaintexts")
@rate_limit_per_ip("100/minute")
def upload_message_plaintexts(
request: Request,
payload: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload encrypted plaintexts of sent messages"""
import json
from datetime import datetime
messages = payload.get("messages")
if not isinstance(messages, list):
raise HTTPException(status_code=400, detail="messages must be a list")
uploaded_count = 0
for msg_data in messages:
if not isinstance(msg_data, dict):
continue
message_id = msg_data.get("messageId")
recipient_id = msg_data.get("recipientId")
encrypted_data = msg_data.get("encryptedData")
if not message_id or not recipient_id or not encrypted_data:
continue
try:
# Validate encrypted_data is valid JSON
json.loads(encrypted_data)
except (json.JSONDecodeError, TypeError):
continue
# Store or update plaintext
existing = db.query(SentMessagePlaintext).filter(
SentMessagePlaintext.user_id == current_user.id,
SentMessagePlaintext.message_id == message_id
).first()
if existing:
existing.encrypted_data = encrypted_data
else:
new_plaintext = SentMessagePlaintext(
user_id=current_user.id,
message_id=message_id,
recipient_id=recipient_id,
encrypted_data=encrypted_data
)
db.add(new_plaintext)
uploaded_count += 1
db.commit()
return {"status": "ok", "uploaded_count": uploaded_count}
@router.get("/crypto/signal/message-plaintexts")
@rate_limit_per_ip("60/minute")
def get_message_plaintexts(
request: Request,
recipient_id: int | None = None, # Optional filter by recipient
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get encrypted plaintexts of sent messages for the current user"""
query = db.query(SentMessagePlaintext).filter(
SentMessagePlaintext.user_id == current_user.id
)
if recipient_id is not None:
query = query.filter(SentMessagePlaintext.recipient_id == recipient_id)
plaintexts = query.all()
return {
"messages": [
{
"messageId": p.message_id,
"recipientId": p.recipient_id,
"encryptedData": p.encrypted_data,
"createdAt": p.created_at.isoformat()
}
for p in plaintexts
]
}
@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 current_user.username == OWNER_USERNAME or current_user.id == 1:
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"}
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)
-655
View File
@@ -1,655 +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),
)
# Map for normalizing homoglyphs (similar-looking characters)
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
# Also includes Greek, full-width, and other Unicode variants
_LEET_MAP = {
# Numbers to letters
"0": "о",
"1": "и",
"3": "е",
"4": "а",
# Latin to Cyrillic (lowercase)
"a": "а",
"c": "с",
"e": "е",
"f": "ф",
"g": "г",
"i": "и",
"m": "м",
"n": "н",
"o": "о",
"p": "п",
"s": "с",
"t": "т",
"u": "у",
"v": "в",
"x": "х",
"y": "у",
"z": "з", # English 'z' to Cyrillic 'з'
# Latin to Cyrillic (uppercase)
"A": "а",
"C": "с",
"E": "е",
"F": "ф",
"G": "г",
"I": "и",
"M": "м",
"N": "н",
"O": "о",
"P": "п",
"S": "с",
"T": "т",
"U": "у",
"V": "в",
"X": "х",
"Y": "у",
"Z": "з", # English 'Z' to Cyrillic 'з'
# Greek letters that look like Cyrillic/Latin
"α": "а", # Greek alpha
"Α": "а",
"ο": "о", # Greek omicron
"Ο": "о",
"ρ": "р", # Greek rho (looks like Cyrillic р)
"Ρ": "р",
"υ": "у", # Greek upsilon
"Υ": "у",
"χ": "х", # Greek chi
"Χ": "х",
"ε": "е", # Greek epsilon
"Ε": "е",
"ι": "и", # Greek iota
"Ι": "и",
"ν": "н", # Greek nu
"Ν": "н",
"μ": "м", # Greek mu
"Μ": "м",
"π": "п", # Greek pi
"Π": "п",
"τ": "т", # Greek tau
"Τ": "т",
"γ": "г", # Greek gamma
"Γ": "г",
"σ": "с", # Greek sigma
"Σ": "с",
"φ": "ф", # Greek phi
"Φ": "ф",
# Full-width Latin characters
"a": "а",
"A": "а",
"c": "с",
"C": "с",
"e": "е",
"E": "е",
"f": "ф",
"F": "ф",
"g": "г",
"G": "г",
"i": "и",
"I": "и",
"m": "м",
"M": "м",
"n": "н",
"N": "н",
"o": "о",
"O": "о",
"p": "п",
"P": "п",
"s": "с",
"S": "с",
"t": "т",
"T": "т",
"u": "у",
"U": "у",
"v": "в",
"V": "в",
"x": "х",
"X": "х",
"y": "у",
"Y": "у",
"z": "з", # Full-width 'z' to Cyrillic 'з'
"Z": "з",
# Cyrillic to canonical Cyrillic (identity mappings)
"а": "а",
"с": "с",
"е": "е",
"ё": "е",
"ф": "ф",
"г": "г",
"и": "и",
"м": "м",
"н": "н",
"о": "о",
"п": "п",
"т": "т",
"у": "у",
"ү": "у", # Cyrillic capital U (U+04AE)
"Ү": "у", # Cyrillic capital U (U+04AE)
"в": "в",
"х": "х",
"р": "р",
"з": "з", # Cyrillic 'з'
"д": "д", # Cyrillic 'д'
"б": "б", # Cyrillic 'б'
"л": "л", # Cyrillic 'л'
"я": "я", # Cyrillic 'я'
"н": "н", # Already mapped, but explicit
# Special characters
"@": "а",
}
_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 _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 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 "хU★уй" -> "хууй"
# Only do subsequence matching for words of length 4 or more to avoid false positives
# Use stricter span limits for shorter words to prevent false matches in long legitimate words
if len(word_lower) >= 4:
word_chars = list(word_lower)
text_chars = list(normalized_lower)
# Stricter ratio for shorter words, more lenient for longer words
if len(word_lower) <= 5:
max_span_ratio = 1.5 # Very strict for short words
else:
max_span_ratio = 2.0 # 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 phrase patterns first
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"]
-572
View File
@@ -1,572 +0,0 @@
from datetime import datetime
import json
import logging
import time
from typing import Any
from fastapi import HTTPException, WebSocket
from sqlalchemy.orm import Session
from websocket.registry import WebSocketHandlerRegistry
from routes.messaging import (
MessaggingSocketManager,
_send_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."""
from types import SimpleNamespace
message_id = data["message_id"]
request: EditMessageRequest = EditMessageRequest.model_validate(data)
# Create a dummy request object for the HTTP endpoint function
dummy_request = SimpleNamespace()
response = await edit_message(dummy_request, message_id, 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
-48
View File
@@ -1,48 +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}
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
volumes:
data:
name: fromchat-data
logs:
name: fromchat-logs
-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
-52
View File
@@ -1,52 +0,0 @@
# 1. Build the frontend
FROM node:24 AS frontend
# 1.1. Install npm dependencies
WORKDIR /app
COPY package.json .
RUN --mount=type=cache,target=/root/.npm \
npm install --ignore-scripts
# 1.2. 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);
-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();
}
-435
View File
@@ -1,435 +0,0 @@
import { API_BASE_URL } from "@/core/config";
import api from "@/core/api";
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64 } from "@/utils/utils";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { useUserStore } from "@/state/user";
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
export async function decrypt(envelope: DmEnvelope, senderId: number): Promise<string> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
if (!envelope.ciphertext) {
throw new Error("DM envelope missing ciphertext");
}
const signalService = new SignalProtocolService(user.id.toString());
// Remove padding (backward compatible with old messages)
// Check if ciphertext is base64 (padded) or already JSON (unpadded)
let ciphertextStr: string = envelope.ciphertext;
// Check if it's base64 (padded messages are base64)
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
if (isBase64) {
// Try to remove padding
try {
const unpadded = removePadding(envelope.ciphertext);
// Verify it's valid JSON before using it
JSON.parse(unpadded);
ciphertextStr = unpadded;
} catch {
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
try {
JSON.parse(envelope.ciphertext);
ciphertextStr = envelope.ciphertext;
} catch {
// If both fail, throw an error
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
}
}
} else {
// Not base64, assume it's already JSON (unpadded message)
ciphertextStr = envelope.ciphertext;
}
// Parse Signal Protocol message
let signalCiphertext: { type: number; body: string };
try {
signalCiphertext = JSON.parse(ciphertextStr);
} catch (error) {
throw new Error(`Failed to parse ciphertext as JSON: ${error instanceof Error ? error.message : String(error)}. Ciphertext length: ${ciphertextStr.length}, first 100 chars: ${ciphertextStr.substring(0, 100)}`);
}
if (!signalCiphertext || typeof signalCiphertext !== "object") {
throw new Error("Invalid Signal Protocol message format: not an object");
}
if (typeof signalCiphertext.type !== "number") {
throw new Error("Invalid Signal Protocol message format: type is not a number");
}
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
}
// Check if body contains non-printable characters (corrupted binary data from old encryption)
// This must be checked first, before any base64 validation
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
if (hasNonPrintable) {
// This is a corrupted message from before the base64 conversion fix
// It cannot be decrypted - the body contains raw binary data instead of base64
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
return "_This message is corrupted and cannot be displayed._";
}
// Check if body contains Unicode escape sequences (from JSON.stringify escaping)
// If so, we need to unescape them to get the actual base64 string
let bodyToDecode = signalCiphertext.body;
// Check for literal backslash-u sequences (before JSON parsing, these would be "\\u")
// After JSON parsing, Unicode escapes are converted to actual characters, so we check for
// the pattern that indicates it might have been escaped
if (bodyToDecode.includes("\\u") || bodyToDecode.match(/\\u[0-9a-fA-F]{4}/)) {
// Try to unescape Unicode sequences by wrapping in JSON quotes
try {
bodyToDecode = JSON.parse(`"${bodyToDecode.replace(/\\/g, "\\\\")}"`);
} catch {
// If unescaping fails, use the original
bodyToDecode = signalCiphertext.body;
}
}
// Validate that body is valid base64 before attempting decryption
// Check if it's a valid base64 string (only contains base64 characters and padding)
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(bodyToDecode)) {
// Log for debugging - this should help identify the issue
console.error("Invalid base64 in body:", {
bodyType: typeof signalCiphertext.body,
bodyLength: signalCiphertext.body.length,
unescapedLength: bodyToDecode.length,
first50: signalCiphertext.body.substring(0, 50),
unescapedFirst50: bodyToDecode.substring(0, 50),
envelopeId: envelope.id
});
throw new Error(`Invalid base64 format in ciphertext body`);
}
// Use the unescaped body for decryption
signalCiphertext.body = bodyToDecode;
try {
// Try to decode a small portion to validate base64
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
} catch (error) {
// Log for debugging
console.error("Base64 decode failed:", {
bodyLength: signalCiphertext.body.length,
first50: signalCiphertext.body.substring(0, 50),
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
envelopeId: envelope.id,
error: error instanceof Error ? error.message : String(error)
});
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
}
try {
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
return plaintext;
} catch (error) {
// If decryption fails, check if it's a session issue
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("No session exists") || errorMessage.includes("No record for device")) {
console.warn(`Session missing for sender ${senderId} (envelope ID: ${envelope.id}). This may happen after page reload if the session was not properly restored.`);
}
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: api.user.auth.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, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
let hasSession = false;
try {
hasSession = await signalService.hasSession(recipientId);
} catch (error) {
console.warn("Failed to check session, will attempt to establish new one:", error);
}
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
await signalService.processPreKeyBundle(recipientId, bundle);
} catch (error) {
// Re-throw PrekeyExhaustedError as-is for proper handling
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
throw error;
}
// Log other errors for debugging
console.error("Failed to establish session:", {
recipientId,
error: error instanceof Error ? error.message : String(error)
});
// Re-throw other errors
throw error;
}
}
// Encrypt with Signal Protocol
let ciphertext: { type: number; body: string };
try {
ciphertext = await signalService.encryptMessage(recipientId, plaintext);
} catch (error) {
console.error("Failed to encrypt message:", {
recipientId,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
// Verify the body is valid base64 before stringifying
if (ciphertext.body && typeof ciphertext.body === "string") {
try {
// Test that body is valid base64
atob(ciphertext.body.substring(0, Math.min(4, ciphertext.body.length)));
// Verify the entire body is valid base64
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(ciphertext.body)) {
console.error("Invalid base64 characters in encrypted body:", {
bodyLength: ciphertext.body.length,
first100: ciphertext.body.substring(0, 100),
last100: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 100))
});
throw new Error("Encrypted body contains invalid base64 characters");
}
} catch (error) {
throw new Error(`Encrypted body is not valid base64: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Stringify the ciphertext - JSON.stringify should not escape base64 strings
const ciphertextJson = JSON.stringify(ciphertext);
// Verify the stringified JSON doesn't have escaped characters in the body field
const parsed = JSON.parse(ciphertextJson);
if (parsed.body !== ciphertext.body) {
console.error("Body was modified during JSON stringification:", {
original: ciphertext.body.substring(0, 50),
stringified: parsed.body.substring(0, 50),
originalLength: ciphertext.body.length,
stringifiedLength: parsed.body.length
});
throw new Error("Body was incorrectly escaped during JSON stringification");
}
// Add padding to obfuscate message size (anti-censorship)
const paddedCiphertext = addPadding(ciphertextJson);
const payload: SendDMRequest = {
recipientId: recipientId,
iv: "", // Not used for Signal Protocol
ciphertext: paddedCiphertext, // Padded Signal Protocol message
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: "" // Not used for Signal Protocol
};
if (replyToId) payload.replyToId = replyToId;
await request({
type: "dmSend",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: payload
});
// Note: We'll cache the message when we receive the dmNew confirmation via WebSocket
// which contains the actual message ID
}
export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
await signalService.processPreKeyBundle(recipientId, bundle);
} catch (error) {
// Re-throw PrekeyExhaustedError as-is for proper handling
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
throw error;
}
// Re-throw other errors
throw error;
}
}
// Generate master key for file encryption
const mk = randomBytes(32);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
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: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope));
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: api.user.auth.getAuthHeaders(token, false),
body: form
});
}
export async function edit(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
await signalService.processPreKeyBundle(recipientId, bundle);
} catch (error) {
// Re-throw PrekeyExhaustedError as-is for proper handling
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
throw error;
}
// Re-throw other errors
throw error;
}
}
// Generate fresh master key for the edited message
const mk = randomBytes(32);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
// Encrypt the message content with the master key
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
await request({
type: "dmEdit",
credentials: { scheme: "Bearer", credentials: authToken },
data: {
id,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
salt: "" // Not used for Signal Protocol
}
} 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: api.user.auth.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 } from "@/core/api/users";
export { fetchUserPublicKey } from "@/core/api/crypto/identity";
@@ -1,69 +0,0 @@
/**
* API functions for managing encrypted message plaintexts on the server
*/
import { API_BASE_URL } from "@/core/config";
import api from "@/core/api";
export interface MessagePlaintextData {
messageId: number;
recipientId: number;
encryptedData: string;
}
export interface MessagePlaintextResponse {
messageId: number;
recipientId: number;
encryptedData: string;
createdAt: string;
}
/**
* Upload encrypted message plaintexts to the server
*/
export async function uploadMessagePlaintexts(
messages: MessagePlaintextData[],
token: string
): Promise<void> {
const response = await fetch(`${API_BASE_URL}/crypto/signal/message-plaintexts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...api.user.auth.getAuthHeaders(token, false)
},
body: JSON.stringify({ messages })
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to upload message plaintexts" }));
throw new Error(error.detail || "Failed to upload message plaintexts");
}
}
/**
* Fetch encrypted message plaintexts from the server
*/
export async function fetchMessagePlaintexts(
token: string,
recipientId?: number
): Promise<MessagePlaintextResponse[]> {
let url = `${API_BASE_URL}/crypto/signal/message-plaintexts`;
if (recipientId !== undefined) {
const separator = url.includes("?") ? "&" : "?";
url = `${url}${separator}recipient_id=${recipientId}`;
}
const response = await fetch(url, {
method: "GET",
headers: api.user.auth.getAuthHeaders(token, false)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to fetch message plaintexts" }));
throw new Error(error.detail || "Failed to fetch message plaintexts");
}
const data = await response.json();
return data.messages || [];
}
-89
View File
@@ -1,89 +0,0 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
/**
* Uploads Signal Protocol prekey bundle for the current user
* This uploads the base bundle (identity, signed prekey) and one prekey
*/
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
const payload = { bundle };
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Failed to upload prekey bundle");
}
/**
* Uploads all available prekeys to the server for rotation in a single request
*/
export async function uploadAllPreKeys(
baseBundle: Omit<PreKeyBundleData, "preKey">,
prekeys: Array<{ keyId: number; publicKey: string }>,
token: string
): Promise<void> {
const headers = getAuthHeaders(token, true);
const payload = {
baseBundle,
prekeys
};
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekeys/bulk`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) {
throw new Error(`Failed to upload prekeys: ${res.statusText}`);
}
}
/**
* Custom error for prekey exhaustion
*/
export class PrekeyExhaustedError extends Error {
constructor(public readonly recipientId: number) {
super("Recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys.");
this.name = "PrekeyExhaustedError";
}
}
/**
* Fetches Signal Protocol prekey bundle for another user
* @throws {PrekeyExhaustedError} If the recipient has no unused prekeys available
*/
export async function fetchPreKeyBundle(userId: number, token: string): Promise<PreKeyBundleData> {
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
method: "GET",
headers
});
if (!res.ok) {
if (res.status === 404) {
throw new Error("Recipient has not set up encryption. They need to log in to initialize their encryption keys.");
}
throw new Error("Failed to fetch prekey bundle");
}
const data = await res.json();
const bundle = data.bundle;
// Check if bundle exists but has no prekey (all prekeys exhausted)
if (!bundle) {
throw new PrekeyExhaustedError(userId);
}
// If bundle exists but has no preKey field, it means all prekeys are exhausted
// The backend returns bundle without preKey when no unused prekeys are available
if (!bundle.preKey) {
throw new PrekeyExhaustedError(userId);
}
return bundle;
}
-69
View File
@@ -1,69 +0,0 @@
/**
* API functions for managing Signal Protocol sessions on the server
*/
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
export interface SessionData {
recipientId: number;
deviceId: number;
encryptedData: string; // JSON string of encrypted session
}
/**
* Upload encrypted Signal Protocol sessions to the server
*/
export async function uploadSessions(sessions: SessionData[], token: string): Promise<void> {
const headers = getAuthHeaders(token, true);
const payload = {
sessions
};
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) {
throw new Error(`Failed to upload sessions: ${res.statusText}`);
}
}
/**
* Fetch all encrypted Signal Protocol sessions from the server
*/
export async function fetchSessions(token: string): Promise<SessionData[]> {
console.log("[Session API] Fetching sessions from server...");
console.log("[Session API] URL:", `${API_BASE_URL}/crypto/signal/sessions`);
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
method: "GET",
headers
});
console.log("[Session API] Response status:", res.status, res.statusText);
if (!res.ok) {
const errorText = await res.text().catch(() => "Unknown error");
console.error("[Session API] Failed to fetch sessions:", {
status: res.status,
statusText: res.statusText,
errorText
});
throw new Error(`Failed to fetch sessions: ${res.status} ${res.statusText}`);
}
const data = await res.json();
console.log("[Session API] Response data:", {
hasSessions: !!data.sessions,
sessionCount: data.sessions?.length || 0
});
return data.sessions || [];
}
-16
View File
@@ -1,16 +0,0 @@
// Re-export from dmApi.ts which has Signal Protocol support
export {
decryptDm,
fetchDMHistory,
sendDMViaWebSocket,
sendDmWithFiles,
editDmEnvelope,
deleteDmEnvelope,
fetchDMConversations,
fetchUsers,
searchUsers,
fetchUserPublicKey
} from "./dmApi";
export type { DMConversationResponse } from "./dmApi";
-315
View File
@@ -1,315 +0,0 @@
import { API_BASE_URL } from "@/core/config";
import api from "@/core/api";
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64 } from "@/utils/utils";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { useUserStore } from "@/state/user";
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
if (!envelope.ciphertext) {
throw new Error("DM envelope missing ciphertext");
}
const signalService = new SignalProtocolService(user.id.toString());
// Remove padding (backward compatible with old messages)
// Check if ciphertext is base64 (padded messages are base64)
let ciphertextStr: string = envelope.ciphertext;
// Check if it's base64 (padded messages are base64)
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
if (isBase64) {
// Try to remove padding
try {
const unpadded = removePadding(envelope.ciphertext);
// Verify it's valid JSON before using it
JSON.parse(unpadded);
ciphertextStr = unpadded;
} catch {
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
try {
JSON.parse(envelope.ciphertext);
ciphertextStr = envelope.ciphertext;
} catch {
// If both fail, throw an error
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
}
}
} else {
// Not base64, assume it's already JSON (unpadded message)
ciphertextStr = envelope.ciphertext;
}
// Parse Signal Protocol message
let signalCiphertext: { type: number; body: string };
try {
signalCiphertext = JSON.parse(ciphertextStr);
} catch (error) {
throw new Error(`Failed to parse Signal Protocol message: ${error instanceof Error ? error.message : String(error)}`);
}
if (!signalCiphertext || typeof signalCiphertext !== "object") {
throw new Error("Invalid Signal Protocol message format: not an object");
}
if (typeof signalCiphertext.type !== "number") {
throw new Error("Invalid Signal Protocol message format: type is not a number");
}
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
}
// Validate that body is valid base64 before attempting decryption
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(signalCiphertext.body)) {
// Check if body contains non-printable characters (corrupted binary data)
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
if (hasNonPrintable) {
// This is a corrupted message from before the base64 conversion fix
// It cannot be decrypted - the body contains raw binary data instead of base64
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
return "_This message is corrupted and cannot be displayed._";
}
console.error("Invalid base64 in body:", {
bodyType: typeof signalCiphertext.body,
bodyLength: signalCiphertext.body.length,
first50: signalCiphertext.body.substring(0, 50),
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
envelopeId: envelope.id
});
throw new Error(`Invalid base64 format in ciphertext body`);
}
try {
// Try to decode a small portion to validate base64
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
} catch (error) {
console.error("Base64 decode failed:", {
bodyLength: signalCiphertext.body.length,
first50: signalCiphertext.body.substring(0, 50),
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
envelopeId: envelope.id,
error: error instanceof Error ? error.message : String(error)
});
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
}
try {
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
return plaintext;
} catch (error) {
throw new Error(`Failed to decrypt DM: ${error instanceof Error ? error.message : String(error)}`);
}
}
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: api.user.auth.getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data = await response.json();
return data.messages || [];
}
export async function sendDMViaWebSocket(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
// Fetch prekey bundle from server
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
if (!bundle) {
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Encrypt with Signal Protocol
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
// Add padding to obfuscate message size (anti-censorship)
const paddedCiphertext = addPadding(JSON.stringify(ciphertext));
const payload: SendDMRequest = {
recipientId: recipientId,
iv: "", // Not used for Signal Protocol
ciphertext: paddedCiphertext, // Padded Signal Protocol message
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: "" // Not used for Signal Protocol
};
if (replyToId) payload.replyToId = replyToId;
await request({
type: "dmSend",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: payload
});
}
export async function sendDmWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
if (!bundle) {
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Generate master key for file encryption
const mk = randomBytes(32);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
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: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope));
await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: api.user.auth.getAuthHeaders(token, false),
body: form
});
}
export async function editDmEnvelope(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
if (!bundle) {
throw new Error("No Signal Protocol prekey bundle available for recipient");
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Generate fresh master key for the edited message
const mk = randomBytes(32);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
// Encrypt the message content with the master key
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
await request({
type: "dmEdit",
credentials: { scheme: "Bearer", credentials: authToken },
data: {
id,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
salt: "" // Not used for Signal Protocol
}
} 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;
}
// Re-export for convenience
export { fetchUsers, searchUsers } from "./users";
export { fetchUserPublicKey } from "./crypto/identity";
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: api.user.auth.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();
}
-135
View File
@@ -1,135 +0,0 @@
import { randomBytes } from "@/utils/crypto/kdf";
import { b64, ub64 } from "@/utils/utils";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { useUserStore } from "@/state/user";
import { fetchPreKeyBundle } from "@/core/api/crypto";
import { getAuthToken } from "@/core/api/account";
export interface CallSessionKey {
key: Uint8Array;
hash: string; // For emoji display
}
/**
* Generates a new call session key for end-to-end encryption
* @returns Promise that resolves to a session key with its hash for display
*/
export async function generateCallSessionKey(): Promise<CallSessionKey> {
// Generate session key material
const sessionKeyMaterial = randomBytes(32);
// Generate hash for emoji display (first 4 bytes of SHA-256 hash)
const hashBuffer = await crypto.subtle.digest("SHA-256", sessionKeyMaterial.buffer as ArrayBuffer);
const hash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
return {
key: sessionKeyMaterial,
hash
};
}
/**
* Rotate a session key by generating a completely new key
* This provides forward secrecy for long-running calls
*/
export async function rotateCallSessionKey(): Promise<CallSessionKey> {
// Generate new session key material (completely independent of current key)
const newSessionKeyMaterial = randomBytes(32);
// Generate new hash for emoji display
const hashBuffer = await crypto.subtle.digest("SHA-256", newSessionKeyMaterial.buffer as ArrayBuffer);
const newHash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
return {
key: newSessionKeyMaterial,
hash: newHash
};
}
/**
* Generate 4 emojis representing the call session key
*/
export function generateCallEmojis(sessionKeyHash: string): string[] {
// Convert hash to numbers and map to emoji ranges
const hashBytes = new Uint8Array(ub64(sessionKeyHash));
const emojis: string[] = [];
// Different emoji categories for variety
const emojiSets = [
["🎵", "🎶", "🎤", "🎧", "🎼", "🎹", "🥁", "🎺", "🎸", "🎻"], // Music
["🔥", "💫", "⭐", "✨", "🌟", "💥", "⚡", "🌈", "🎆", "🎇"], // Energy
["🚀", "🛸", "🛰️", "🌌", "🔭", "⚙️", "🔧", "⚡", "💡", "🔬"], // Tech/Space
["🎭", "🎪", "🎨", "🎬", "📷", "🎥", "📺", "🎮", "🕹️", "🎯"] // Entertainment
];
for (let i = 0; i < 4; i++) {
const set = emojiSets[i % emojiSets.length];
const index = hashBytes[i % hashBytes.length] % set.length;
emojis.push(set[index]);
}
return emojis;
}
/**
* Encrypts a call session key using Signal Protocol
* @param recipientId - The recipient's user ID
* @param sessionKey - The session key to encrypt
* @returns Promise that resolves to encrypted session key data
*/
export async function encryptCallSessionKey(recipientId: number, sessionKey: Uint8Array): Promise<{ type: number; body: string }> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Ensure we have a session with the recipient
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
// Fetch prekey bundle and establish session
const token = getAuthToken();
if (!token) {
throw new Error("No auth token");
}
const bundle = await fetchPreKeyBundle(recipientId, token);
if (!bundle) {
throw new Error("No prekey bundle available for recipient");
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Encrypt the session key using Signal Protocol
const sessionKeyString = b64(sessionKey);
const encrypted = await signalService.encryptMessage(recipientId, sessionKeyString);
return encrypted;
}
/**
* Decrypts a call session key using Signal Protocol
* @param senderId - The sender's user ID
* @param encryptedKey - The encrypted session key data
* @returns Promise that resolves to the decrypted session key
*/
export async function decryptCallSessionKey(senderId: number, encryptedKey: { type: number; body: string }): Promise<Uint8Array> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Decrypt using Signal Protocol
const decryptedString = await signalService.decryptMessage(senderId, encryptedKey);
// Convert back to Uint8Array
const sessionKey = new Uint8Array(
atob(decryptedString).split("").map(c => c.charCodeAt(0))
);
return sessionKey;
}
@@ -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;
-208
View File
@@ -1,208 +0,0 @@
import { AuthContainer } from "./Auth";
import { useState, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "motion/react";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { LoginForm } from "./LoginForm";
import { RegisterForm } from "./RegisterForm";
import type { Variants, Transition } from "motion/react";
import styles from "./auth.module.scss";
import { useUserStore } from "@/state/user";
const slideVariants: Variants = {
enter: (direction: number) => ({
x: direction > 0 ? 300 : -300,
opacity: 0
}),
center: {
x: 0,
opacity: 1
},
exit: (direction: number) => ({
x: direction > 0 ? -300 : 300,
opacity: 0
})
};
const slideTransition: Transition = {
x: {
type: "spring",
stiffness: 300,
damping: 30
},
opacity: { duration: 0.2 }
};
export default function AuthPage() {
const [searchParams] = useSearchParams();
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
const navigate = useNavigate();
const { user } = useUserStore();
const [direction, setDirection] = useState(0);
const prevMode = useRef(searchParams.get("mode") || "login");
const containerRef = useRef<HTMLDivElement>(null);
const loginFormRef = useRef<HTMLDivElement>(null);
const registerFormRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number | "auto">("auto");
const currentMode = searchParams.get("mode") || "login";
const enteringElementRef = useRef<"login" | "register" | null>(null);
const [effectActivated, setEffectActivated] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
useLayoutEffect(() => {
if (prevMode.current !== currentMode) {
const previousMode = prevMode.current;
// Measure the exiting form's height BEFORE changing anything
// This works whether it's relative or absolute
const exitingComponent = previousMode === "login" ? loginFormRef.current : registerFormRef.current;
let measuredHeight: number | null = null;
if (exitingComponent) {
const height = exitingComponent.scrollHeight;
if (height > 0) {
measuredHeight = height;
}
}
// Update mode and direction first
prevMode.current = currentMode;
setDirection(currentMode === "register" ? 1 : -1);
enteringElementRef.current = currentMode as "login" | "register";
// Set height and transition state together
if (measuredHeight !== null) {
setContainerHeight(measuredHeight);
}
setIsTransitioning(true);
}
}, [currentMode]);
const measureActiveHeight = useCallback(() => {
const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current;
if (activeComponent) {
const height = activeComponent.scrollHeight;
if (height > 0) {
setContainerHeight(height);
}
}
}, [currentMode, loginFormRef, registerFormRef]);
useLayoutEffect(() => {
if (!effectActivated) {
setEffectActivated(true);
return;
}
// Always measure, but prioritize the entering element during transitions
// Use double requestAnimationFrame to ensure DOM is fully updated and layout is complete
let rafId2: number | null = null;
const rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
measureActiveHeight();
});
});
return () => {
cancelAnimationFrame(rafId1);
if (rafId2 !== null) {
cancelAnimationFrame(rafId2);
}
};
}, [currentMode]);
// Now we can do conditional returns after all hooks are called
if (navigateDownloadApp) return navigateDownloadApp;
if (user.authToken && user.currentUser) {
return <Navigate to="/chat" replace />;
}
function switchMode(newMode: "login" | "register") {
navigate(`/auth?mode=${newMode}`, { replace: true });
}
function handleAnimationComplete(
currentMode: "login" | "register",
mode: "login" | "register",
enteringElementRef: RefObject<"login" | "register" | null>,
formRef: React.RefObject<HTMLDivElement | null>,
setContainerHeight: (height: number) => void
) {
return () => {
if (currentMode === mode && enteringElementRef.current === mode) {
enteringElementRef.current = null;
setIsTransitioning(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (formRef.current && currentMode === mode) {
const height = formRef.current.scrollHeight;
if (height > 0) {
setContainerHeight(height);
}
}
});
});
}
}
}
return (
<AuthContainer>
<div
ref={containerRef}
style={{
position: "relative",
width: "100%",
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
transition: "height 0.3s ease"
}}
onAnimationEnd={() => {
setContainerHeight("auto");
}}
>
<AnimatePresence mode="sync" custom={direction}>
{currentMode === "login" ? (
<motion.div
key="login"
ref={loginFormRef}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={slideTransition}
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
className={styles.formWrapper}
style={{
position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
}}
>
<LoginForm onSwitchMode={() => switchMode("register")} />
</motion.div>
) : (
<motion.div
key="register"
ref={registerFormRef}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={slideTransition}
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
className={styles.formWrapper}
style={{
position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
}}
>
<RegisterForm onSwitchMode={() => switchMode("login")} />
</motion.div>
)}
</AnimatePresence>
</div>
</AuthContainer>
)
}
-226
View File
@@ -1,226 +0,0 @@
import type { StateCreator } from "zustand";
import type { Message, User } from "@/core/types";
import { MessagePanel } from "../ui/right/panels/MessagePanel";
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel";
import type { ChatState, ChatTabs, ActiveDM } from "./types";
import { useUserStore } from "@/state/user";
export interface ChatStateSlice {
chat: ChatState;
addMessage: (message: Message) => void;
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
removeMessage: (messageId: number) => void;
setCurrentChat: (chat: string) => void;
setActiveTab: (tab: ChatTabs) => void;
setDmUsers: (users: User[]) => void;
setActiveDm: (dm: ActiveDM | null) => void;
clearMessages: () => void;
setActivePanel: (panel: MessagePanel | null) => void;
setPendingPanel: (panel: MessagePanel | null) => void;
applyPendingPanel: () => void;
switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
}
export const createChatState: StateCreator<
ChatStateSlice & { user: { authToken: string | null } },
[],
[],
ChatStateSlice
> = (set, get) => ({
chat: {
messages: [],
currentChat: "Общий чат",
activeTab: "chats",
dmUsers: [],
activeDm: null,
isSwitching: false,
setIsSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isSwitching: value
}
})),
activePanel: null,
publicChatPanel: null,
dmPanel: null,
pendingPanel: null,
profileDialog: null,
call: {
isActive: false,
status: "ended",
startTime: null,
isMuted: false,
remoteUserId: null,
remoteUsername: null,
isInitiator: false,
isMinimized: false,
sessionKeyHash: null,
encryptionEmojis: [],
isVideoEnabled: false,
isRemoteVideoEnabled: false,
isSharingScreen: false,
isRemoteScreenSharing: false
},
onlineStatuses: new Map(),
typingUsers: new Map(),
dmTypingUsers: new Map()
},
addMessage: (message: Message) => set((state) => {
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
if (messageExists) {
return state;
}
return {
chat: {
...state.chat,
messages: [...state.chat.messages, message]
}
};
}),
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
chat: {
...state.chat,
messages: state.chat.messages.map(msg =>
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
)
}
})),
removeMessage: (messageId: number) => set((state) => ({
chat: {
...state.chat,
messages: state.chat.messages.filter(msg => msg.id !== messageId)
}
})),
clearMessages: () => set((state) => ({
chat: {
...state.chat,
messages: []
}
})),
setCurrentChat: (chat: string) => set((state) => ({
chat: {
...state.chat,
currentChat: chat
}
})),
setActiveTab: (tab: ChatTabs) => set((state) => ({
chat: {
...state.chat,
activeTab: tab
}
})),
setDmUsers: (users: User[]) => set((state) => ({
chat: {
...state.chat,
dmUsers: users
}
})),
setActiveDm: (dm: ActiveDM | null) => set((state) => ({
chat: {
...state.chat,
activeDm: dm
}
})),
setActivePanel: (panel: MessagePanel | null) => {
const state = get();
if (state.chat.activePanel && state.chat.activePanel !== panel) {
state.chat.activePanel.deactivate();
}
return set((state) => ({
chat: {
...state.chat,
activePanel: panel
}
}));
},
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
chat: {
...state.chat,
pendingPanel: panel
}
})),
applyPendingPanel: () => {
const state = get();
if (state.chat.activePanel) {
state.chat.activePanel.deactivate();
}
return set((state) => ({
chat: {
...state.chat,
activePanel: state.chat.pendingPanel || state.chat.activePanel,
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
? (state.chat.pendingPanel as PublicChatPanel)
: state.chat.publicChatPanel,
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
? (state.chat.pendingPanel as DMPanel)
: state.chat.dmPanel,
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
pendingPanel: null
}
}));
},
switchToPublicChat: async (chatName: string) => {
const { chat } = get();
const { user } = useUserStore();
if (!user.authToken) return;
chat.setIsSwitching(true);
let publicChatPanel = chat.publicChatPanel;
if (!publicChatPanel) {
publicChatPanel = new PublicChatPanel(chatName, user);
} else {
publicChatPanel.setChatName(chatName);
publicChatPanel.setAuthToken(user.authToken);
publicChatPanel.clearMessages();
}
await publicChatPanel.activate();
set((state) => ({
chat: {
...state.chat,
pendingPanel: publicChatPanel,
activeTab: "chats"
}
}));
},
switchToDM: async (dmData: DMPanelData) => {
const { chat } = get();
const { user } = useUserStore();
if (!user.authToken) return;
chat.setIsSwitching(true);
let dmPanel = chat.dmPanel;
if (!dmPanel) {
dmPanel = new DMPanel(user);
} else {
dmPanel.setAuthToken(user.authToken);
dmPanel.clearMessages();
}
dmPanel.setDMData(dmData);
await dmPanel.activate();
set((state) => ({
chat: {
...state.chat,
pendingPanel: dmPanel,
activeDm: {
userId: dmData.userId,
username: dmData.username,
publicKey: dmData.publicKey
},
activeTab: "chats"
}
}));
}
});
-226
View File
@@ -1,226 +0,0 @@
import type { StateCreator } from "zustand";
import type { Message, User } from "@/core/types";
import { MessagePanel } from "../ui/right/panels/MessagePanel";
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel";
import type { ChatState, ChatTabs, ActiveDM } from "./types";
import { useUserStore } from "@/state/user";
export interface ChatStateSlice {
chat: ChatState;
addMessage: (message: Message) => void;
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
removeMessage: (messageId: number) => void;
setCurrentChat: (chat: string) => void;
setActiveTab: (tab: ChatTabs) => void;
setDmUsers: (users: User[]) => void;
setActiveDm: (dm: ActiveDM | null) => void;
clearMessages: () => void;
setActivePanel: (panel: MessagePanel | null) => void;
setPendingPanel: (panel: MessagePanel | null) => void;
applyPendingPanel: () => void;
switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
}
export const createChatState: StateCreator<
ChatStateSlice & { user: { authToken: string | null } },
[],
[],
ChatStateSlice
> = (set, get) => ({
chat: {
messages: [],
currentChat: "Общий чат",
activeTab: "chats",
dmUsers: [],
activeDm: null,
isSwitching: false,
setIsSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isSwitching: value
}
})),
activePanel: null,
publicChatPanel: null,
dmPanel: null,
pendingPanel: null,
profileDialog: null,
call: {
isActive: false,
status: "ended",
startTime: null,
isMuted: false,
remoteUserId: null,
remoteUsername: null,
isInitiator: false,
isMinimized: false,
sessionKeyHash: null,
encryptionEmojis: [],
isVideoEnabled: false,
isRemoteVideoEnabled: false,
isSharingScreen: false,
isRemoteScreenSharing: false
},
onlineStatuses: new Map(),
typingUsers: new Map(),
dmTypingUsers: new Map()
},
addMessage: (message: Message) => set((state) => {
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
if (messageExists) {
return state;
}
return {
chat: {
...state.chat,
messages: [...state.chat.messages, message]
}
};
}),
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
chat: {
...state.chat,
messages: state.chat.messages.map(msg =>
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
)
}
})),
removeMessage: (messageId: number) => set((state) => ({
chat: {
...state.chat,
messages: state.chat.messages.filter(msg => msg.id !== messageId)
}
})),
clearMessages: () => set((state) => ({
chat: {
...state.chat,
messages: []
}
})),
setCurrentChat: (chat: string) => set((state) => ({
chat: {
...state.chat,
currentChat: chat
}
})),
setActiveTab: (tab: ChatTabs) => set((state) => ({
chat: {
...state.chat,
activeTab: tab
}
})),
setDmUsers: (users: User[]) => set((state) => ({
chat: {
...state.chat,
dmUsers: users
}
})),
setActiveDm: (dm: ActiveDM | null) => set((state) => ({
chat: {
...state.chat,
activeDm: dm
}
})),
setActivePanel: (panel: MessagePanel | null) => {
const state = get();
if (state.chat.activePanel && state.chat.activePanel !== panel) {
state.chat.activePanel.deactivate();
}
return set((state) => ({
chat: {
...state.chat,
activePanel: panel
}
}));
},
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
chat: {
...state.chat,
pendingPanel: panel
}
})),
applyPendingPanel: () => {
const state = get();
if (state.chat.activePanel) {
state.chat.activePanel.deactivate();
}
return set((state) => ({
chat: {
...state.chat,
activePanel: state.chat.pendingPanel || state.chat.activePanel,
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
? (state.chat.pendingPanel as PublicChatPanel)
: state.chat.publicChatPanel,
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
? (state.chat.pendingPanel as DMPanel)
: state.chat.dmPanel,
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
pendingPanel: null
}
}));
},
switchToPublicChat: async (chatName: string) => {
const { chat } = get();
const { user } = useUserStore();
if (!user.authToken) return;
chat.setIsSwitching(true);
let publicChatPanel = chat.publicChatPanel;
if (!publicChatPanel) {
publicChatPanel = new PublicChatPanel(chatName, user);
} else {
publicChatPanel.setChatName(chatName);
publicChatPanel.setAuthToken(user.authToken);
publicChatPanel.clearMessages();
}
await publicChatPanel.activate();
set((state) => ({
chat: {
...state.chat,
pendingPanel: publicChatPanel,
activeTab: "chats"
}
}));
},
switchToDM: async (dmData: DMPanelData) => {
const { chat } = get();
const { user } = useUserStore();
if (!user.authToken) return;
chat.setIsSwitching(true);
let dmPanel = chat.dmPanel;
if (!dmPanel) {
dmPanel = new DMPanel(user);
} else {
dmPanel.setAuthToken(user.authToken);
dmPanel.clearMessages();
}
dmPanel.setDMData(dmData);
await dmPanel.activate();
set((state) => ({
chat: {
...state.chat,
pendingPanel: dmPanel,
activeDm: {
userId: dmData.userId,
username: dmData.username,
publicKey: dmData.publicKey
},
activeTab: "chats"
}
}));
}
});
-73
View File
@@ -1,73 +0,0 @@
import type { Message, User } from "@/core/types";
import { MessagePanel } from "../ui/right/panels/MessagePanel";
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
import { DMPanel } from "../ui/right/panels/DMPanel";
export type ChatTabs = "chats" | "channels" | "contacts";
export type CallStatus = "calling" | "connecting" | "active" | "ended";
export interface ProfileDialogData {
userId?: number;
username?: string;
display_name?: string;
profilePicture?: string;
bio?: string;
memberSince?: string;
online?: boolean;
isOwnProfile: boolean;
verified?: boolean;
suspended?: boolean;
suspension_reason?: string | null;
deleted?: boolean;
}
export interface ActiveDM {
userId: number;
username: string;
publicKey: string | null;
}
export interface CallState {
isActive: boolean;
status: CallStatus;
startTime: number | null;
isMuted: boolean;
remoteUserId: number | null;
remoteUsername: string | null;
isInitiator: boolean;
isMinimized: boolean;
sessionKeyHash: string | null;
encryptionEmojis: string[];
isVideoEnabled: boolean;
isRemoteVideoEnabled: boolean;
isSharingScreen: boolean;
isRemoteScreenSharing: boolean;
}
export interface ChatState {
messages: Message[];
currentChat: string;
activeTab: ChatTabs;
dmUsers: User[];
activeDm: ActiveDM | null;
isSwitching: boolean;
setIsSwitching: (value: boolean) => void;
activePanel: MessagePanel | null;
publicChatPanel: PublicChatPanel | null;
dmPanel: DMPanel | null;
pendingPanel?: MessagePanel | null;
call: CallState;
profileDialog: ProfileDialogData | null;
onlineStatuses: Map<number, {online: boolean, lastSeen: string}>;
typingUsers: Map<number, string>; // userId -> username
dmTypingUsers: Map<number, boolean>;
}
export interface UserState {
currentUser: User | null;
authToken: string | null;
isSuspended: boolean;
suspensionReason: string | null;
}
@@ -1,716 +0,0 @@
import { MessagePanel } from "./MessagePanel";
import api from "@/core/api";
import { decryptDm, sendDMViaWebSocket, sendDmWithFiles } from "@/core/api/dm";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types";
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
export interface DMPanelData {
userId: number;
username: string;
publicKey: string;
profilePicture?: string;
online: boolean;
}
export class DMPanel extends MessagePanel {
public dmData: DMPanelData | null = null;
private messagesLoaded: boolean = false;
private signalService: SignalProtocolService | null = null;
constructor(
user: UserState
) {
super("dm", user);
// Initialize Signal Protocol service if user is available
if (user.currentUser?.id) {
this.signalService = new SignalProtocolService(user.currentUser.id.toString());
}
}
isDm(): boolean {
return true;
}
getRecipientId(): number | null {
return this.dmData?.userId || null;
}
async activate(): Promise<void> {
// Don't load messages immediately during activation to prevent animation freeze
// Messages will be loaded after the animation completes
// Subscribe to recipient's online status
if (this.dmData?.userId) {
onlineStatusManager.subscribe(this.dmData.userId);
}
}
deactivate(): void {
// Unsubscribe from recipient's online status
if (this.dmData?.userId) {
onlineStatusManager.unsubscribe(this.dmData.userId);
}
}
clearMessages(): void {
super.clearMessages();
this.messagesLoaded = false;
this.processedMessageIds.clear();
this.failedDecryptionIds.clear();
}
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[], plaintextOverride?: string) {
// Check if this is a message sent by the current user
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
let plaintext: string;
if (isSentByUs) {
// Can't decrypt our own sent messages in Signal Protocol
// The plaintext should be passed in from loadMessages (fetched from server)
if (plaintextOverride) {
plaintext = plaintextOverride;
} else {
// Try to fetch from server as fallback
try {
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData!.userId);
const cached = plaintexts.get(env.id);
if (cached) {
plaintext = cached;
} else {
// Not on server - skip this message
throw new Error("Cannot decrypt own sent message - plaintext not available on server");
}
} catch (error) {
throw new Error("Cannot decrypt own sent message - plaintext must be fetched from server first");
}
}
} else {
// Decrypt incoming messages
plaintext = await decryptDm(env, env.senderId);
}
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
let content = plaintext;
let reply_to_id: number | undefined = undefined;
try {
const obj = JSON.parse(plaintext) as DmEncryptedJSON;
if (obj && obj.type === "text" && obj.data) {
content = obj.data.content;
reply_to_id = Number(obj.data.reply_to_id) || undefined;
}
} catch {}
const dmMsg: Message = {
id: env.id,
user_id: env.senderId,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
reactions: env.reactions || [],
runtimeData: {
dmEnvelope: env
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
return dmMsg;
}
async loadMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
this.setLoading(true);
try {
// Wait for session restoration to complete (if in progress)
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
await waitForSessionRestore();
console.log(`[DMPanel] Session restoration complete, proceeding with message load for user ${this.dmData.userId}`);
// Ensure Signal Protocol session is established before fetching messages
if (!this.signalService && this.currentUser.currentUser?.id) {
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
}
if (this.signalService) {
const hasSession = await this.signalService.hasSession(this.dmData.userId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during history load.`);
} catch (error) {
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during history load:`, error);
// Continue loading history, but decryption will likely fail for new messages
}
} else {
console.log(`[DMPanel] Signal Protocol session exists for user ${this.dmData.userId}`);
}
}
// Fetch encrypted plaintexts from server for sent messages
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
const limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
for (const env of messages) {
try {
// Mark as processed to prevent duplicates
if (env.id) {
this.processedMessageIds.add(env.id);
}
// For sent messages, use plaintext from server
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
let dmMsg: Message;
if (isSentByUs) {
const cachedPlaintext = plaintexts.get(env.id);
if (!cachedPlaintext) {
// Not on server - skip this message
continue;
}
// Parse the plaintext as if it came from parseTextPayload
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
let content = cachedPlaintext;
let reply_to_id: number | undefined = undefined;
try {
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
if (obj && obj.type === "text" && obj.data) {
content = obj.data.content;
reply_to_id = Number(obj.data.reply_to_id) || undefined;
}
} catch {}
dmMsg = {
id: env.id,
user_id: env.senderId,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
reactions: env.reactions || [],
runtimeData: {
dmEnvelope: env
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
} else {
dmMsg = await this.parseTextPayload(env, decryptedMessages);
}
decryptedMessages.push(dmMsg);
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (error) {
// Log warning with deduplication to avoid console spam
if (env.id && !this.failedDecryptionIds.has(env.id)) {
this.failedDecryptionIds.add(env.id);
console.warn(`Failed to decrypt DM ${env.id}:`, error instanceof Error ? error.message : String(error));
}
// Remove from processed set if decryption failed
if (env.id) {
this.processedMessageIds.delete(env.id);
}
}
}
// Only clear and replace if we actually decrypted something
if (decryptedMessages.length > 0) {
this.clearMessages();
decryptedMessages.forEach(msg => this.addMessage(msg));
} else {
console.warn("[DMPanel] No messages decrypted; keeping existing messages to avoid empty state after reload.");
}
this.setHasMoreMessages(has_more);
// Update last read ID
if (maxIncomingId > 0) {
this.setLastReadId(this.dmData.userId, maxIncomingId);
}
this.messagesLoaded = true;
} catch (error) {
console.error("Failed to load DM history:", error);
} finally {
this.setLoading(false);
}
}
async loadMoreMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !this.state.hasMoreMessages || this.state.isLoadingMore) return;
const messages = this.getMessages();
if (messages.length === 0) return;
const oldestMessage = messages[0];
const oldestEnvelope = oldestMessage.runtimeData?.dmEnvelope;
if (!oldestEnvelope) return;
this.setLoadingMore(true);
try {
// Ensure Signal Protocol session is established before fetching messages
if (!this.signalService && this.currentUser.currentUser?.id) {
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
}
if (this.signalService) {
const hasSession = await this.signalService.hasSession(this.dmData.userId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during more history load.`);
} catch (error) {
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during more history load:`, error);
// Continue loading history, but decryption will likely fail for new messages
}
}
}
// Fetch encrypted plaintexts from server for sent messages
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
const limit = this.calculateMessageLimit();
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
this.dmData.userId,
this.currentUser.authToken,
limit,
oldestEnvelope.id
);
if (newEnvelopes && newEnvelopes.length > 0) {
const decryptedMessages: Message[] = [];
for (const env of newEnvelopes) {
try {
// Mark as processed to prevent duplicates
if (env.id) {
this.processedMessageIds.add(env.id);
}
// For sent messages, use plaintext from server
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
let dmMsg: Message;
if (isSentByUs) {
const cachedPlaintext = plaintexts.get(env.id);
if (!cachedPlaintext) {
// Not on server - skip this message
continue;
}
// Parse the plaintext as if it came from parseTextPayload
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
let content = cachedPlaintext;
let reply_to_id: number | undefined = undefined;
try {
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
if (obj && obj.type === "text" && obj.data) {
content = obj.data.content;
reply_to_id = Number(obj.data.reply_to_id) || undefined;
}
} catch {}
dmMsg = {
id: env.id,
user_id: env.senderId,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
reactions: env.reactions || [],
runtimeData: {
dmEnvelope: env
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
} else {
dmMsg = await this.parseTextPayload(env, decryptedMessages);
}
decryptedMessages.push(dmMsg);
} catch (error) {
// Silently skip messages that can't be decrypted
}
}
// Prepend older messages (they come in reverse chronological order)
this.updateState({
messages: [...decryptedMessages.reverse(), ...messages]
});
}
this.setHasMoreMessages(has_more);
} catch (error) {
console.error("Failed to load more DM messages:", error);
} finally {
this.setLoadingMore(false);
}
}
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
const payload: DmEncryptedJSON = {
type: "text",
data: {
content: content.trim(),
reply_to_id: replyToId ?? undefined
}
}
const json = JSON.stringify(payload);
try {
if (files.length === 0) {
await sendDMViaWebSocket(
this.dmData.userId,
json,
this.currentUser.authToken
);
} else {
await sendDmWithFiles(
this.dmData.userId,
json,
files,
this.currentUser.authToken
);
}
} catch (error) {
console.error("Failed to send DM:", error);
// Check if it's a prekey exhaustion error
const { PrekeyExhaustedError } = await import("@/core/api/crypto/prekeys");
if (error instanceof PrekeyExhaustedError) {
const { alert } = await import("@/core/components/AlertDialog");
await alert("Cannot Send Message: The recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys. This ensures maximum privacy and security.");
}
}
}
// Set DM conversation data
setDMData(dmData: DMPanelData): void {
this.dmData = dmData;
this.messagesLoaded = false;
this.updateState({
id: `dm-${dmData.userId}`,
title: dmData.username,
profilePicture: dmData.profilePicture,
online: dmData.online
});
}
// Track processed message IDs to prevent duplicates
private processedMessageIds: Set<number> = new Set();
private failedDecryptionIds: Set<number> = new Set(); // Track messages that failed decryption to avoid spam
// Handle incoming WebSocket DM messages
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
// Only process actual DM messages, not typing indicators or other events
if (response.type === "dmNew" && this.dmData) {
const envelope = response.data;
// Validate envelope has required fields
if (!envelope || !envelope.ciphertext || !envelope.senderId || !envelope.id) {
console.warn("Invalid DM envelope received, skipping");
return;
}
// Skip if we've already processed this message
if (this.processedMessageIds.has(envelope.id)) {
return;
}
// If this is for the active DM conversation
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
try {
// Mark as processed before attempting decryption
this.processedMessageIds.add(envelope.id);
// Check if this is a confirmation of a message we sent
const isOurMessage = envelope.senderId === this.currentUser.currentUser?.id;
let dmMsg: Message;
if (isOurMessage) {
// For sent messages, fetch plaintext from server
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
const cachedPlaintext = plaintexts.get(envelope.id);
if (cachedPlaintext) {
// Parse the plaintext and create message
dmMsg = await this.parseTextPayload(envelope, this.getMessages(), cachedPlaintext);
} else {
// Plaintext not available yet - this might be a new message confirmation
// Try to get it from temp message content
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
let tempMsgContent: string | null = null;
for (const tempMsg of tempMessages) {
if (tempMsg.runtimeData?.sendingState?.retryData?.content) {
tempMsgContent = tempMsg.content;
break;
}
}
if (tempMsgContent) {
dmMsg = await this.parseTextPayload(envelope, this.getMessages(), tempMsgContent);
} else {
// Can't display without plaintext - skip
console.warn(`Cannot display sent message ${envelope.id} - plaintext not available`);
return;
}
}
} else {
// Incoming message - decrypt normally
dmMsg = await this.parseTextPayload(envelope, this.getMessages());
}
if (isOurMessage) {
// This is our message being confirmed, find the temp message and replace it
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
let tempMsgContent: string | null = null;
for (const tempMsg of tempMessages) {
if ((tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content ||
tempMsg.content === dmMsg.content) && tempMsg.runtimeData?.sendingState?.tempId) {
tempMsgContent = tempMsg.content; // Get plaintext from temp message
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId, dmMsg);
// Upload the plaintext to server (encrypted) so we can display it in history
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
if (tempMsgContent) {
await uploadMessagePlaintext(this.dmData.userId, envelope.id, tempMsgContent);
}
return;
}
}
// If we didn't find a temp message, try to upload from dmMsg content
// (this might happen if the page was reloaded)
if (!tempMsgContent && dmMsg.content) {
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
await uploadMessagePlaintext(this.dmData.userId, envelope.id, dmMsg.content);
}
// Add the message to the chat
this.addMessage(dmMsg);
return;
}
// Incoming message - add to chat
this.addMessage(dmMsg);
this.addMessage(dmMsg);
// Update last read if it's from the other user
if (envelope.senderId === this.dmData.userId) {
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
}
} catch (error) {
// Only log each failed message once to avoid console spam
if (envelope.id && !this.failedDecryptionIds.has(envelope.id)) {
this.failedDecryptionIds.add(envelope.id);
console.warn(`Failed to decrypt DM ${envelope.id}:`, error instanceof Error ? error.message : String(error));
}
// Remove from processed set so we can retry if needed
if (envelope.id) {
this.processedMessageIds.delete(envelope.id);
}
}
}
}
if (response.type === "dmEdited" && this.dmData) {
const { id, senderId, recipientId, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
try {
// Decrypt new content in-place
const plaintext = await decryptDm(
{
id,
senderId,
recipientId,
iv,
ciphertext,
salt,
iv2,
wrappedMk,
timestamp: new Date().toISOString()
},
senderId
);
let content = plaintext;
let files: Message["files"] | undefined = undefined;
try {
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
if (obj.type === "text" && obj.data) {
content = obj.data.content;
files = obj.data.files;
}
} catch {}
const updates: Partial<Message> = { content, is_edited: true, files };
this.updateMessage(id, updates);
} catch (e) {
this.updateMessage(id, { is_edited: true });
}
}
if (response.type === "dmDeleted" && this.dmData) {
const { id } = response.data;
this.removeMessage(id);
}
if (response.type === "dmReactionUpdate" && this.dmData) {
const { dm_envelope_id, reactions } = response.data;
this.updateMessageReactions(dm_envelope_id, reactions);
}
};
// Reset for DM switching
reset(): void {
// Unsubscribe from current recipient's status before switching
if (this.dmData?.userId) {
onlineStatusManager.unsubscribe(this.dmData.userId);
}
this.dmData = null;
this.messagesLoaded = false;
this.clearMessages();
this.failedDecryptionIds.clear(); // Clear failed decryption tracking
this.updateState({
id: "dm",
title: "Select a user",
profilePicture: undefined,
online: false
});
}
// Update auth token
setAuthToken(authToken: string): void {
this.currentUser.authToken = authToken;
}
// Get DM user ID for call functionality
getDMUserId(): number | null {
return this.dmData?.userId || null;
}
// Get DM username for call functionality
getDMUsername(): string | null {
return this.dmData?.username || null;
}
// Handle typing in DM
handleTyping(): void {
if (this.dmData?.userId) {
typingManager.sendDmTyping(this.dmData.userId);
}
}
// Helper functions for localStorage
private getLastReadId(userId: number): number {
try {
const v = localStorage.getItem(`dmLastRead:${userId}`);
return v ? Number(v) : 0;
} catch {
return 0;
}
}
private setLastReadId(userId: number, id: number): void {
try {
localStorage.setItem(`dmLastRead:${userId}`, String(id));
} catch {}
}
async handleDeleteMessage(messageId: number): Promise<void> {
if (!this.currentUser.authToken || !this.dmData) return;
// Remove message immediately from UI
this.deleteMessageImmediately(messageId);
// Fire and forget server deletion; UI already updated
await api.chats.dm.deleteMessage(messageId, this.dmData.userId, this.currentUser.authToken);
}
async handleEditMessage(messageId: number, content: string): Promise<void> {
if (!this.currentUser.authToken || !this.dmData) return;
const msg = this.getMessages().find(m => m.id === messageId);
// Build encrypted JSON preserving files and reply_to if present
const payload: EncryptedMessageJson = {
type: "text",
data: {
content: content,
files: msg?.files,
reply_to_id: msg?.reply_to?.id ?? undefined
}
};
api.chats.dm.edit(messageId, this.dmData.userId, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
console.error("Failed to edit DM:", e);
});
}
async getProfile(): Promise<ProfileDialogData | null> {
if (!this.dmData || !this.currentUser.authToken) return null;
try {
const userProfile = await api.user.profile.fetchById(this.currentUser.authToken, this.dmData.userId);
if (!userProfile) return null;
return {
userId: userProfile.id,
username: userProfile.username,
display_name: userProfile.display_name,
profilePicture: userProfile.profile_picture,
bio: userProfile.bio,
memberSince: userProfile.created_at,
online: userProfile.online,
isOwnProfile: false
};
} catch (error) {
console.error("Failed to fetch user profile:", error);
return null;
}
}
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
const messages = this.getMessages();
const messageIndex = messages.findIndex(msg =>
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
);
if (messageIndex !== -1) {
const updatedMessage = { ...messages[messageIndex] };
updatedMessage.reactions = reactions;
this.updateMessage(updatedMessage.id, { reactions: reactions });
}
}
}
@@ -1,28 +0,0 @@
import { MaterialButton } from "@/utils/material";
import styles from "./download-app.module.scss";
export default function DownloadAppPage() {
return (
<div className={styles.downloadAppScreen}>
<div>
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
<p>
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
вам нужно скачать приложение мессенджера.
</p>
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
<MaterialButton>Скачать на GitHub</MaterialButton>
</a>
<p>
Если возникнут сложности или есть вопросы, нажмите кнопку!
</p>
<a href="https://t.me/denis0001-dev">
<MaterialButton>Написать в поддержку</MaterialButton>
</a>
</div>
</div>
)
}
@@ -1,9 +0,0 @@
.downloadAppScreen {
display: flex;
justify-content: center;
align-items: center;
min-width: 100vw;
min-height: 100vh;
padding: 32px;
}
-282
View File
@@ -1,282 +0,0 @@
import { useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user";
import styles from "./home.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon } from "@/utils/material";
function GitHubLink({ children }: { children: React.ReactNode }) {
return (
<a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a>
);
}
function SupportLink({ children }: { children: React.ReactNode }) {
return (
<a href="https://t.me/denis0001-dev" target="_blank">{children}</a>
);
}
export default function HomePage() {
const navigate = useNavigate();
const { user } = useUserStore();
const { isMobile } = useDownloadAppScreen();
const isLoggedIn = user.authToken && user.currentUser;
function handleGetStarted() {
if (isMobile) {
navigate("/download-app");
} else if (isLoggedIn) {
navigate("/chat");
} else {
navigate("/login");
}
}
const openBtn = (
<MaterialButton variant="filled" onClick={handleGetStarted}>
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
</MaterialButton>
);
return (
<div className={styles.homepage}>
<header className={styles.homepageHeader}>
<div className={styles.container}>
<div className={styles.headerContent}>
<div className={styles.logo}>
<h1>FromChat</h1>
<span className={styles.tagline}>100% открытый мессенджер</span>
</div>
<nav className={styles.headerNav}>
<GitHubLink>
<MaterialButton variant="text">GitHub</MaterialButton>
</GitHubLink>
<SupportLink>
<MaterialButton variant="text">Поддержка</MaterialButton>
</SupportLink>
{openBtn}
</nav>
</div>
</div>
</header>
<main>
<section className={styles.hero}>
<div className={styles.container}>
<div className={styles.heroContent}>
<h2 className={styles.heroTitle}>
Безопасный мессенджер с открытым исходным кодом
</h2>
<p className={styles.heroDescription}>
FromChat — это полностью открытый мессенджер с end-to-end шифрованием,
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
</p>
<div className={styles.heroActions}>
{openBtn}
{!isMobile && (
<MaterialButton
variant="outlined"
onClick={() => navigate("/register")}>
Зарегистрироваться
</MaterialButton>
)}
</div>
</div>
<div className={styles.heroVisual}>
<div className={styles.chatPreview}>
<div className={styles.chatWindow}>
<div className={styles.chatHeader}>
<div className={styles.chatTitle}>Общий чат</div>
<div className={styles.onlineIndicator}>●</div>
</div>
<div className={styles.chatMessages}>
<div className={`${styles.message} ${styles.received}`}>
<div className={styles.messageAvatar}>А</div>
<div className={styles.messageContent}>
<div className={styles.messageText}>Привет! Как дела?</div>
<div className={styles.messageTime}>14:30</div>
</div>
</div>
<div className={`${styles.message} ${styles.sent}`}>
<div className={styles.messageContent}>
<div className={styles.messageText}>Всё отлично! А у тебя как?</div>
<div className={styles.messageTime}>14:32</div>
</div>
</div>
<div className={`${styles.message} ${styles.received}`}>
<div className={styles.messageAvatar}>Б</div>
<div className={styles.messageContent}>
<div className={styles.messageText}>Отправляю файл 📎</div>
<div className={styles.messageTime}>14:35</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section className={styles.features}>
<div className={styles.container}>
<h3 className={styles.sectionTitle}>Возможности</h3>
<div className={styles.featuresGrid}>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="security" />
</div>
<h4>End-to-End Шифрование</h4>
<p>
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
Только вы и получатель можете прочитать сообщения.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="code" />
</div>
<h4>100% открытый код</h4>
<p>
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
внести изменения или развернуть свой сервер.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="attach_file" />
</div>
<h4>Обмен Файлами</h4>
<p>
Отправляйте файлы до 4 ГБ. Файлы в личных сообщениях шифруются.
В общем чате шифрования нет, так как ваши сообщения могут читать все пользователи FromChat.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="notifications" />
</div>
<h4>Уведомления</h4>
<p>
Получайте push-уведомления в браузере и настольном приложении.
Никогда не пропустите важное сообщение.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="edit" />
</div>
<h4>Редактирование</h4>
<p>
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
для лучшего контекста общения.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="computer" />
</div>
<h4>Кроссплатформенность</h4>
<p>
Работает в браузере и как настольное приложение для Windows,
macOS и Linux. Единый интерфейс везде.
</p>
</div>
</div>
</div>
</section>
<section className={styles.download}>
<div className={styles.container}>
<div className={styles.downloadContent}>
<h3>Скачайте приложение</h3>
<p>
Для лучшего опыта используйте настольное приложение с поддержкой
уведомлений и автономной работы.
</p>
<div className={styles.downloadButtons}>
{!isMobile ? (
<>
<a
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
target="_blank"
rel="noopener noreferrer"
>
<MaterialButton variant="filled">
<MaterialIcon name="download" slot="icon" />
Скачать для ПК
</MaterialButton>
</a>
<MaterialButton variant="outlined" onClick={() => navigate("/login")}>
<MaterialIcon name="language" slot="icon" />
Веб-версия
</MaterialButton>
</>
) : (
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
Скачать приложение
</MaterialButton>
)}
</div>
</div>
</div>
</section>
<section className={styles.cta}>
<div className={styles.container}>
<div className={styles.ctaContent}>
<h3>Готовы начать общение?</h3>
<p>
Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами.
</p>
<div className={styles.ctaActions}>
{isMobile ? (
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
Скачать приложение
</MaterialButton>
) : (
<>
<MaterialButton
variant="filled"
onClick={() => navigate("/register")}>
Создать аккаунт
</MaterialButton>
<MaterialButton
variant="outlined"
onClick={() => navigate("/login")}>
Войти
</MaterialButton>
</>
)}
</div>
</div>
</div>
</section>
</main>
<footer className={styles.homepageFooter}>
<div className={styles.container}>
<div className={styles.footerContent}>
<div className={styles.footerSection}>
<h4>Ссылки</h4>
<GitHubLink>GitHub</GitHubLink>
<SupportLink>Поддержка</SupportLink>
</div>
<div className={styles.footerSection}>
<h4>Лицензия</h4>
<p>GPL-3.0</p>
</div>
</div>
<div className={styles.footerBottom}>
<p>&copy; 2025 FromChat. Сделано программистом denis0001-dev с ❤️ для свободы общения.</p>
</div>
</div>
</footer>
</div>
);
}
-658
View File
@@ -1,658 +0,0 @@
@use "../../css/material" as *;
.homepage {
min-height: 100vh;
background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 50%, $color-dark-primary-container 100%);
color: $color-dark-on-background;
font-family: 'Montserrat', sans-serif;
position: relative;
&::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba($color-dark-primary, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba($color-dark-tertiary, 0.3) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.2) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
// Cascaded styles for all child elements
* {
position: relative;
z-index: 1;
}
// Container styles
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
// Header styles
.homepageHeader {
padding: 1rem 0;
background: rgba($color-dark-surface-container, 0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba($color-dark-primary, 0.3);
box-shadow: 0 4px 20px rgba($color-dark-primary, 0.1);
position: sticky;
top: 0;
z-index: 1000;
transition: all 0.3s ease;
.headerContent {
display: flex;
justify-content: space-between;
align-items: center;
.logo {
h1 {
font-size: 2rem;
font-weight: 700;
margin: 0;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.tagline {
font-size: 0.9rem;
}
}
.headerNav {
display: flex;
align-items: center;
gap: 10px;
a {
display: flex;
}
}
}
}
// Hero section
.hero {
padding: 4rem 0;
display: flex;
align-items: center;
min-height: 80vh;
margin-top: 0;
.heroContent {
flex: 1;
max-width: 600px;
.heroTitle {
font-size: 3.5rem;
font-weight: 800;
line-height: 1.1;
margin-bottom: 1.5rem;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary, $color-dark-secondary);
background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 30px rgba($color-dark-primary, 0.5);
animation: neonGlow 3s ease-in-out infinite alternate;
}
.heroDescription {
font-size: 1.25rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
}
.heroActions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
}
.heroVisual {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
.chatPreview {
perspective: 1000px;
.chatWindow {
background: rgba($color-dark-surface-container, 0.95);
border-radius: 20px;
padding: 1.5rem;
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.5),
0 0 20px rgba($color-dark-primary, 0.3),
inset 0 1px 0 rgba($color-dark-primary, 0.2);
transform: rotateY(-5deg) rotateX(5deg);
color: $color-dark-on-surface;
max-width: 400px;
width: 100%;
border: 1px solid rgba($color-dark-primary, 0.3);
.chatHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid rgba($color-dark-primary, 0.3);
margin-bottom: 1rem;
.chatTitle {
font-weight: 600;
font-size: 1.1rem;
}
.onlineIndicator {
color: $color-dark-primary;
font-size: 0.8rem;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.8);
animation: pulse 2s ease-in-out infinite;
}
}
.chatMessages {
display: flex;
flex-direction: column;
gap: 1rem;
.message {
display: flex;
gap: 0.75rem;
align-items: flex-start;
&.sent {
flex-direction: row-reverse;
.messageContent {
background: linear-gradient(135deg, $color-dark-primary, $color-dark-primary-container);
color: $color-dark-on-primary;
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
}
}
&.received {
.messageContent {
background: rgba($color-dark-surface-variant, 0.8);
color: $color-dark-on-surface-variant;
border: 1px solid rgba($color-dark-outline-variant, 0.3);
}
}
.messageAvatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, $color-dark-primary, $color-dark-tertiary);
color: $color-dark-on-primary;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.8rem;
flex-shrink: 0;
box-shadow: 0 0 10px rgba($color-dark-primary, 0.4);
}
.messageContent {
max-width: 70%;
padding: 0.75rem 1rem;
border-radius: 18px;
position: relative;
.messageText {
font-size: 0.9rem;
line-height: 1.4;
}
.messageTime {
font-size: 0.75rem;
opacity: 0.7;
margin-top: 0.25rem;
}
}
}
}
}
}
}
}
// Features section
.features {
padding: 6rem 0;
background: rgba($color-dark-surface-container, 0.3);
backdrop-filter: blur(20px);
border-top: 1px solid rgba($color-dark-primary, 0.2);
border-bottom: 1px solid rgba($color-dark-primary, 0.2);
.sectionTitle {
text-align: center;
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 3rem;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.featuresGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
.featureCard {
background: rgba($color-dark-surface-container, 0.6);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 2rem;
border: 1px solid rgba($color-dark-primary, 0.3);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba($color-dark-primary, 0.1), rgba($color-dark-tertiary, 0.1));
opacity: 0;
transition: opacity 0.3s ease;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
&:hover {
transform: translateY(-5px);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.3),
0 0 30px rgba($color-dark-primary, 0.2);
border-color: rgba($color-dark-primary, 0.5);
&::before {
opacity: 1;
}
}
.featureIcon {
width: 60px;
height: 60px;
background: linear-gradient(135deg, rgba($color-dark-primary, 0.3), rgba($color-dark-tertiary, 0.3));
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
border: 1px solid rgba($color-dark-primary, 0.4);
box-shadow: 0 0 15px rgba($color-dark-primary, 0.2);
user-select: none;
mdui-icon {
font-size: 1.5rem;
color: $color-dark-primary;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.8);
}
}
h4 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
color: $color-dark-on-surface;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.3);
}
p {
line-height: 1.6;
opacity: 0.9;
color: $color-dark-on-surface-variant;
}
}
}
}
// Download section
.download {
padding: 6rem 0;
.downloadContent {
text-align: center;
max-width: 600px;
margin: 0 auto;
h3 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
p {
font-size: 1.25rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
}
.downloadButtons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
}
}
// CTA section
.cta {
padding: 6rem 0;
background: rgba($color-dark-surface-container, 0.3);
backdrop-filter: blur(20px);
border-top: 1px solid rgba($color-dark-primary, 0.2);
.ctaContent {
text-align: center;
max-width: 600px;
margin: 0 auto;
h3 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
p {
font-size: 1.25rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
}
.ctaActions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
}
}
// Footer
.homepageFooter {
background: rgba($color-dark-surface-container, 0.8);
backdrop-filter: blur(20px);
padding: 3rem 0 1rem;
border-top: 1px solid rgba($color-dark-primary, 0.3);
box-shadow: 0 -4px 20px rgba($color-dark-primary, 0.1);
.footerContent {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
.footerSection {
h4 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1rem;
color: $color-dark-on-surface;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.3);
}
p {
opacity: 0.8;
line-height: 1.6;
color: $color-dark-on-surface-variant;
}
a {
color: $color-dark-on-surface;
text-decoration: none;
opacity: 0.8;
display: block;
margin-bottom: 0.5rem;
transition: all 0.3s ease;
padding: 0.25rem 0;
border-radius: 4px;
&:hover {
opacity: 1;
color: $color-dark-primary;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.5);
background: rgba($color-dark-primary, 0.1);
}
}
}
}
.footerBottom {
text-align: center;
padding-top: 2rem;
border-top: 1px solid rgba($color-dark-primary, 0.3);
p {
opacity: 0.7;
margin: 0;
color: $color-dark-on-surface-variant;
}
}
}
}
// Keyframes for animations
@keyframes neonGlow {
0% {
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5), 0 0 40px rgba($color-dark-primary, 0.3);
}
100% {
text-shadow: 0 0 30px rgba($color-dark-primary, 0.8), 0 0 60px rgba($color-dark-primary, 0.5);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
// Responsive Design
@media (min-width: 1024px) {
.homepage {
.homepageHeader {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 2rem);
max-width: 1200px;
border-radius: 20px;
border: 1px solid rgba($color-dark-primary, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 20px rgba($color-dark-primary, 0.2);
backdrop-filter: blur(30px);
background: rgba($color-dark-surface-container, 0.9);
}
.hero {
margin-top: 6rem;
}
}
}
@media (max-width: 1023px) {
.homepage {
.homepageHeader {
position: sticky;
top: 0;
border-radius: 0;
margin: 0;
width: 100%;
transform: none;
}
}
}
@media (max-width: 768px) {
.homepage {
.container {
padding: 0 1rem;
}
.homepageHeader {
.headerContent {
flex-direction: column;
gap: 1rem;
}
}
.hero {
flex-direction: column;
text-align: center;
padding: 2rem 0;
.heroContent {
.heroTitle {
font-size: 2.5rem;
}
.heroDescription {
font-size: 1.1rem;
}
}
.heroVisual {
padding: 1rem;
.chatPreview {
.chatWindow {
transform: none;
max-width: 100%;
}
}
}
}
.features {
.featuresGrid {
grid-template-columns: 1fr;
.featureCard {
padding: 1.5rem;
}
}
.sectionTitle {
font-size: 2rem;
}
}
.download {
.downloadContent {
h3 {
font-size: 2rem;
}
.downloadButtons {
flex-direction: column;
align-items: center;
}
}
}
.cta {
.ctaContent {
h3 {
font-size: 2rem;
}
.ctaActions {
flex-direction: column;
align-items: center;
}
}
}
.homepageFooter {
.footerContent {
grid-template-columns: 1fr;
text-align: center;
}
}
}
}
@media (max-width: 480px) {
.homepage {
.hero {
.heroContent {
.heroTitle {
font-size: 2rem;
}
.heroDescription {
font-size: 1rem;
}
}
}
.features {
.sectionTitle {
font-size: 1.75rem;
}
}
.download {
.downloadContent {
h3 {
font-size: 1.75rem;
}
}
}
.cta {
.ctaContent {
h3 {
font-size: 1.75rem;
}
}
}
}
}
-135
View File
@@ -1,135 +0,0 @@
/**
* Message cache for storing sent message plaintexts
* Since Signal Protocol doesn't allow decrypting your own sent messages,
* we store the plaintext locally and optionally sync to server
*/
const DB_NAME = "message_cache_db";
const DB_VERSION = 1;
const STORE_NAME = "sent_messages";
let dbPromise: Promise<IDBDatabase> | null = null;
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
// Key: [userId, messageId], Value: { plaintext, timestamp }
const store = db.createObjectStore(STORE_NAME, { keyPath: ["userId", "messageId"] });
store.createIndex("userId", "userId", { unique: false });
store.createIndex("messageId", "messageId", { unique: false });
}
};
});
return dbPromise;
}
async function getStore(mode: IDBTransactionMode = "readonly"): Promise<IDBObjectStore> {
const db = await openDB();
const tx = db.transaction([STORE_NAME], mode);
return tx.objectStore(STORE_NAME);
}
interface CachedMessage {
userId: number;
messageId: number;
plaintext: string;
timestamp: string;
}
/**
* Store a sent message's plaintext in the cache
*/
export async function cacheSentMessage(userId: number, messageId: number, plaintext: string): Promise<void> {
try {
const store = await getStore("readwrite");
await new Promise<void>((resolve, reject) => {
const request = store.put({
userId,
messageId,
plaintext,
timestamp: new Date().toISOString()
});
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (error) {
console.warn("Failed to cache sent message:", error);
}
}
/**
* Retrieve a sent message's plaintext from the cache
*/
export async function getCachedMessage(userId: number, messageId: number): Promise<string | null> {
try {
const store = await getStore();
const result = await new Promise<CachedMessage | undefined>((resolve, reject) => {
const request = store.get([userId, messageId]);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return result?.plaintext || null;
} catch (error) {
console.warn("Failed to get cached message:", error);
return null;
}
}
/**
* Get all cached messages for a user
*/
export async function getAllCachedMessages(userId: number): Promise<Map<number, string>> {
const cache = new Map<number, string>();
try {
const store = await getStore();
const index = store.index("userId");
const result = await new Promise<CachedMessage[]>((resolve, reject) => {
const request = index.getAll(userId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
result.forEach(msg => {
cache.set(msg.messageId, msg.plaintext);
});
} catch (error) {
console.warn("Failed to get all cached messages:", error);
}
return cache;
}
/**
* Clear cached messages for a user (e.g., on logout)
*/
export async function clearCachedMessages(userId: number): Promise<void> {
try {
const store = await getStore("readwrite");
const index = store.index("userId");
await new Promise<void>((resolve, reject) => {
const request = index.openCursor(IDBKeyRange.only(userId));
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
cursor.delete();
cursor.continue();
} else {
resolve();
}
};
request.onerror = () => reject(request.error);
});
} catch (error) {
console.warn("Failed to clear cached messages:", error);
}
}
@@ -1,23 +0,0 @@
/**
* Functions for encrypting/decrypting sent message plaintexts
* Uses password-derived key (same as session encryption)
*/
import { encryptSessionWithPassword, decryptSessionWithPassword, encodeSessionBlob, decodeSessionBlob } from "./sessionEncryption";
/**
* Encrypt message plaintext using password-derived key
*/
export async function encryptMessagePlaintext(password: string | null, userId: string, plaintext: string): Promise<string> {
const encrypted = await encryptSessionWithPassword(password, userId, plaintext);
return encodeSessionBlob(encrypted);
}
/**
* Decrypt message plaintext using password-derived key
*/
export async function decryptMessagePlaintext(password: string | null, userId: string, encryptedData: string): Promise<string> {
const blob = decodeSessionBlob(encryptedData);
return await decryptSessionWithPassword(password, userId, blob);
}
@@ -1,129 +0,0 @@
/**
* Service for syncing sent message plaintexts with the server
* Plaintexts are encrypted with password-derived key and stored on server
*/
import { encryptMessagePlaintext, decryptMessagePlaintext } from "./messagePlaintextEncryption";
import { uploadMessagePlaintexts, fetchMessagePlaintexts } from "@/core/api/crypto/messagePlaintexts";
// Global state for message plaintext sync
let syncPassword: string | null = null;
let syncToken: string | null = null;
let syncUserId: string | null = null;
/**
* Initialize message plaintext sync - stores password and userId for encryption
* Called after login when password is available
*/
export function initializeMessagePlaintextSync(userId: string, password: string, token: string): void {
console.log("Initializing message plaintext sync for user", userId);
syncUserId = userId;
syncPassword = password;
syncToken = token;
}
/**
* Clear message plaintext sync - called on logout
*/
export function clearMessagePlaintextSync(): void {
console.log("Clearing message plaintext sync state");
syncUserId = null;
syncPassword = null;
syncToken = null;
}
/**
* Upload a sent message's plaintext to the server (encrypted)
* Called when a message is sent and confirmed
*/
export async function uploadMessagePlaintext(
messageId: number,
recipientId: number,
plaintext: string
): Promise<void> {
if (!syncPassword || !syncToken || !syncUserId) {
console.warn("Message plaintext sync not initialized (missing password/token/userId)");
return; // Not initialized yet
}
try {
console.log(`Encrypting plaintext for message ${messageId}...`);
// Use stored key if available, otherwise use password to derive it
const encryptedData = await encryptMessagePlaintext(syncPassword, syncUserId, plaintext);
console.log(`Uploading plaintext for message ${messageId} to server...`);
await uploadMessagePlaintexts([
{
messageId,
recipientId,
encryptedData
}
], syncToken);
console.log(`Successfully uploaded plaintext for message ${messageId} to server`);
} catch (error) {
console.error("Failed to upload message plaintext to server:", error);
// Don't throw - message is already sent, plaintext upload failure shouldn't break anything
}
}
/**
* Fetch and decrypt message plaintexts from the server
* Called when loading message history
*/
export async function fetchMessagePlaintextsForRecipient(
recipientId: number
): Promise<Map<number, string>> {
const plaintexts = new Map<number, string>();
// Try to get token and userId from global state if sync isn't initialized
let token = syncToken;
let userId = syncUserId;
let password = syncPassword;
if (!token || !userId) {
// Fallback: try to get from user store
try {
const { useUserStore } = await import("@/state/user");
const userState = useUserStore.getState().user;
if (userState.authToken && userState.currentUser?.id) {
token = userState.authToken;
userId = userState.currentUser.id.toString();
console.log(`[MessagePlaintextSync] Using token/userId from user store (sync not initialized)`);
} else {
console.warn("Message plaintext sync not initialized (missing token/userId)");
return plaintexts; // Return empty map if not initialized
}
} catch (error) {
console.warn("Message plaintext sync not initialized (missing token/userId)");
return plaintexts; // Return empty map if not initialized
}
}
// Password can be null - decryptMessagePlaintext will use stored session key if password is null
try {
console.log(`Fetching encrypted plaintexts for recipient ${recipientId}...`);
const encryptedMessages = await fetchMessagePlaintexts(token, recipientId);
console.log(`Found ${encryptedMessages.length} encrypted plaintexts, decrypting...`);
for (const msg of encryptedMessages) {
try {
// Use stored key if available, otherwise use password to derive it
const plaintext = await decryptMessagePlaintext(password, userId, msg.encryptedData);
plaintexts.set(msg.messageId, plaintext);
} catch (error) {
console.warn(`Failed to decrypt plaintext for message ${msg.messageId}:`, error);
// Continue with other messages
}
}
console.log(`Decrypted ${plaintexts.size}/${encryptedMessages.length} plaintexts`);
} catch (error) {
console.error("Failed to fetch message plaintexts from server:", error);
// Don't throw - allow history loading to continue even if plaintext fetch fails
}
return plaintexts;
}
-76
View File
@@ -1,76 +0,0 @@
import { randomBytes } from "./kdf";
/**
* Padding sizes that look like normal HTTP/WebSocket traffic
* These sizes are common in real web traffic to avoid fingerprinting
*/
const PADDING_BUCKETS = [64, 128, 256, 512, 1024, 2048, 4096];
/**
* Adds padding to a message to make it resistant to size-based fingerprinting
* Pads to the nearest bucket size to make all messages look similar
* @param data - The data to pad
* @returns Padded data with padding length prefix
*/
export function addPadding(data: string): string {
const dataBytes = new TextEncoder().encode(data);
const dataSize = dataBytes.length;
// Find the smallest bucket that fits the data
let targetSize = PADDING_BUCKETS[PADDING_BUCKETS.length - 1];
for (const bucket of PADDING_BUCKETS) {
if (bucket >= dataSize + 4) { // +4 for padding length header
targetSize = bucket;
break;
}
}
// Calculate padding needed (subtract data size and 4-byte length header)
const paddingSize = targetSize - dataSize - 4;
const padding = randomBytes(Math.max(0, paddingSize));
// Create padded message: [4-byte length][data][random padding]
const lengthBytes = new Uint8Array(4);
const view = new DataView(lengthBytes.buffer);
view.setUint32(0, dataSize, true); // Little-endian
const padded = new Uint8Array(4 + dataSize + padding.length);
padded.set(lengthBytes, 0);
padded.set(dataBytes, 4);
padded.set(padding, 4 + dataSize);
// Return as base64 for easy transmission
// Use chunked approach to avoid "Maximum call stack size exceeded" for large arrays
// Convert Uint8Array to base64 in chunks
const chunkSize = 8192;
let binary = '';
for (let i = 0; i < padded.length; i += chunkSize) {
const chunk = padded.slice(i, i + chunkSize);
binary += String.fromCharCode.apply(null, Array.from(chunk));
}
return btoa(binary);
}
/**
* Removes padding from a message
* @param paddedData - The padded data (base64)
* @returns Original unpadded data
*/
export function removePadding(paddedData: string): string {
try {
const padded = Uint8Array.from(atob(paddedData), c => c.charCodeAt(0));
// Read length from first 4 bytes
const view = new DataView(padded.buffer);
const dataSize = view.getUint32(0, true); // Little-endian
// Extract original data
const data = padded.slice(4, 4 + dataSize);
return new TextDecoder().decode(data);
} catch (error) {
// If padding removal fails, assume it's an old message without padding
return paddedData;
}
}
@@ -1,96 +0,0 @@
/**
* Functions for encrypting/decrypting Signal Protocol session data
* Uses a stable key derived from password (stored in localStorage as a hash)
*/
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric";
import { randomBytes } from "./kdf";
import { b64, ub64 } from "../utils";
import { deriveSessionKey, exportKey, importKey, storeSessionKey, getStoredSessionKey } from "./sessionKeyStorage";
export interface EncryptedSessionData {
salt: Uint8Array; // Random salt (kept for backward compatibility, not used for key derivation)
iv: Uint8Array; // AES-GCM IV
ciphertext: Uint8Array; // encrypted session record (string)
}
/**
* Encrypt session record using stored session key (derived from password)
* If password is provided and key is not stored, derive and store it
* If password is not provided, use stored key (for page refresh scenarios)
*/
export async function encryptSessionWithPassword(password: string | null, userId: string, sessionRecord: string): Promise<EncryptedSessionData> {
// Derive or get the stored session key
let sessionKey: CryptoKey;
const storedKeyString = getStoredSessionKey(userId);
if (storedKeyString) {
// Use stored key (works even without password on page refresh)
sessionKey = await importKey(storedKeyString);
} else if (password) {
// Derive new key and store it (as a "hash" - it's actually the derived key)
sessionKey = await deriveSessionKey(password, userId);
const keyString = await exportKey(sessionKey);
storeSessionKey(userId, keyString);
} else {
throw new Error("Cannot encrypt session: no stored key and no password provided");
}
// Generate random salt for backward compatibility (not used for key derivation)
const salt = randomBytes(16);
const sessionBytes = new TextEncoder().encode(sessionRecord);
// Encrypt using the stable key (aesGcmEncrypt generates its own IV)
const { iv, ciphertext } = await aesGcmEncrypt(sessionKey, sessionBytes);
return { salt, iv, ciphertext };
}
/**
* Decrypt session record using stored session key
* If password is provided and key is not stored, derive and store it
* If password is not provided, use stored key (for page refresh scenarios)
*/
export async function decryptSessionWithPassword(password: string | null, userId: string, blob: EncryptedSessionData): Promise<string> {
// Get or derive the session key
let sessionKey: CryptoKey;
const storedKeyString = getStoredSessionKey(userId);
if (storedKeyString) {
// Use stored key (works even without password on page refresh)
sessionKey = await importKey(storedKeyString);
} else if (password) {
// Derive key from password and store it
sessionKey = await deriveSessionKey(password, userId);
const keyString = await exportKey(sessionKey);
storeSessionKey(userId, keyString);
} else {
throw new Error("Cannot decrypt session: no stored key and no password provided");
}
const plaintext = await aesGcmDecrypt(sessionKey, blob.iv, blob.ciphertext);
return new TextDecoder().decode(plaintext);
}
/**
* Encode encrypted session data to JSON string for storage
*/
export function encodeSessionBlob(blob: EncryptedSessionData): string {
return JSON.stringify({
salt: b64(blob.salt),
iv: b64(blob.iv),
ciphertext: b64(blob.ciphertext)
});
}
/**
* Decode encrypted session data from JSON string
*/
export function decodeSessionBlob(json: string): EncryptedSessionData {
const obj = JSON.parse(json);
return {
salt: ub64(obj.salt),
iv: ub64(obj.iv),
ciphertext: ub64(obj.ciphertext)
};
}
@@ -1,89 +0,0 @@
/**
* Functions for deriving and storing a stable key from password
* This key is used to encrypt/decrypt sessions on the server
*/
import { importPassword, deriveKEK } from "./kdf";
import { b64, ub64 } from "../utils";
const SESSION_KEY_SALT_PREFIX = "fromchat.session-key:";
/**
* Derive a stable key from password using user ID as salt
* User ID never changes, so this key will always be the same for a given password
* This key can be stored in localStorage and used to encrypt/decrypt sessions
*/
export async function deriveSessionKey(password: string, userId: string): Promise<CryptoKey> {
const salt = new TextEncoder().encode(`${SESSION_KEY_SALT_PREFIX}${userId}`);
const pw = await importPassword(password);
// Make the key extractable so we can store it in localStorage
return await deriveKEK(pw, salt, 210_000, true);
}
/**
* Export a CryptoKey to a base64 string for storage
*/
export async function exportKey(key: CryptoKey): Promise<string> {
const exported = await crypto.subtle.exportKey("raw", key);
return b64(new Uint8Array(exported));
}
/**
* Import a base64 string back to a CryptoKey
*/
export async function importKey(keyString: string): Promise<CryptoKey> {
const keyBytes = ub64(keyString);
// Ensure we have a proper ArrayBuffer (not SharedArrayBuffer)
// Create a new ArrayBuffer copy to avoid SharedArrayBuffer issues
const keyArray = new Uint8Array(keyBytes);
const keyBuffer = keyArray.buffer;
return await crypto.subtle.importKey(
"raw",
keyBuffer,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
/**
* Store the session key in localStorage
*/
export function storeSessionKey(userId: string, keyString: string): void {
try {
localStorage.setItem(`sessionKey:${userId}`, keyString);
console.log(`[SessionKeyStorage] ✅ Stored session key for user ${userId} (length: ${keyString.length})`);
} catch (error) {
console.error("[SessionKeyStorage] ❌ Failed to store session key:", error);
}
}
/**
* Retrieve the session key from localStorage
*/
export function getStoredSessionKey(userId: string): string | null {
try {
const key = localStorage.getItem(`sessionKey:${userId}`);
if (key) {
console.log(`[SessionKeyStorage] ✅ Retrieved stored session key for user ${userId} (length: ${key.length})`);
} else {
console.log(`[SessionKeyStorage] ⚠️ No stored session key found for user ${userId}`);
}
return key;
} catch (error) {
console.error("[SessionKeyStorage] ❌ Failed to get session key:", error);
return null;
}
}
/**
* Clear the session key from localStorage
*/
export function clearSessionKey(userId: string): void {
try {
localStorage.removeItem(`sessionKey:${userId}`);
} catch (error) {
console.error("Failed to clear session key:", error);
}
}
@@ -1,64 +0,0 @@
/**
* Global state to track session restoration progress
* Used to ensure messages aren't loaded before sessions are restored
*/
let isRestoring = false;
let restorePromise: Promise<void> | null = null;
let restoreComplete = false;
/**
* Mark that session restoration has started
*/
export function setRestoringSessions(promise: Promise<void>): void {
isRestoring = true;
restoreComplete = false;
restorePromise = promise;
promise.finally(() => {
isRestoring = false;
restoreComplete = true;
});
}
/**
* Wait for session restoration to complete (if in progress)
*/
export async function waitForSessionRestore(): Promise<void> {
if (!isRestoring && restoreComplete) {
console.log("[SessionRestoreState] Session restoration already completed");
return; // Already completed
}
if (isRestoring && restorePromise) {
console.log("[SessionRestoreState] Waiting for session restoration to complete...");
await restorePromise;
console.log("[SessionRestoreState] Session restoration completed");
} else if (!restoreComplete) {
// No restoration in progress and not completed - mark as complete to avoid blocking
console.log("[SessionRestoreState] No session restoration in progress, marking as complete");
restoreComplete = true;
}
}
/**
* Check if session restoration is in progress
*/
export function isSessionRestoreInProgress(): boolean {
return isRestoring;
}
/**
* Check if session restoration has completed
*/
export function hasSessionRestoreCompleted(): boolean {
return restoreComplete;
}
/**
* Reset the restore state (e.g., on logout)
*/
export function resetSessionRestoreState(): void {
isRestoring = false;
restorePromise = null;
restoreComplete = false;
}
-281
View File
@@ -1,281 +0,0 @@
/**
* Service for syncing Signal Protocol sessions with the server
* Sessions are encrypted with password-derived key and stored on server
*/
import { SignalProtocolStorage, setSessionSyncCallback, setRestoring } from "./signalStorage";
import { encryptSessionWithPassword, decryptSessionWithPassword, encodeSessionBlob, decodeSessionBlob } from "./sessionEncryption";
import { uploadSessions, fetchSessions, type SessionData } from "@/core/api/crypto/sessions";
// Global state for session sync
let syncPassword: string | null = null;
let syncToken: string | null = null;
let syncUserId: string | null = null;
/**
* Initialize session sync - sets up automatic upload of sessions when they're created
* Called after login when password is available
*/
export function initializeSessionSync(userId: string, password: string, token: string): void {
console.log("Initializing session sync for user", userId);
syncUserId = userId;
syncPassword = password;
syncToken = token;
// Set up callback to upload sessions when they're stored
setSessionSyncCallback(async (address: string, record: string) => {
console.log(`Session sync callback invoked for address: ${address}`);
if (!syncPassword || !syncToken || !syncUserId) {
console.warn("Session sync not initialized (missing password/token/userId)");
return; // Not initialized yet
}
try {
const parts = address.split(".");
const recipientId = parseInt(parts[0], 10);
const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1;
if (isNaN(recipientId)) {
console.warn(`Invalid address format: ${address}`);
return;
}
console.log(`Encrypting session for recipient ${recipientId}...`);
const encryptedBlob = await encryptSessionWithPassword(syncPassword, syncUserId, record);
const encryptedData = encodeSessionBlob(encryptedBlob);
console.log(`Uploading session for recipient ${recipientId} to server...`);
await uploadSessions([
{
recipientId,
deviceId,
encryptedData
}
], syncToken);
console.log(`Successfully uploaded session for recipient ${recipientId} to server`);
} catch (error) {
console.error("Failed to sync session to server:", error);
// Don't throw - session is already stored in IndexedDB, sync failure shouldn't break anything
}
});
console.log("Session sync callback set successfully");
}
/**
* Clear session sync - called on logout
*/
export function clearSessionSync(): void {
syncUserId = null;
syncPassword = null;
syncToken = null;
setSessionSyncCallback(null);
}
/**
* Restore all sessions from server and populate IndexedDB
* Called after login when password is available
*/
export async function restoreSessionsFromServer(
userId: string,
password: string,
token: string
): Promise<void> {
try {
console.log("[Session Sync] Restoring sessions from server...");
console.log("[Session Sync] Making API request to fetch sessions...");
// Fetch encrypted sessions from server
const encryptedSessions = await fetchSessions(token);
console.log(`[Session Sync] API response received: ${encryptedSessions.length} sessions found`);
if (encryptedSessions.length === 0) {
console.log("[Session Sync] No sessions to restore from server");
return; // No sessions to restore
}
console.log(`[Session Sync] Found ${encryptedSessions.length} sessions on server, restoring...`);
const storage = new SignalProtocolStorage(userId);
// Set restoring flag to prevent sync callback from re-uploading restored sessions
setRestoring(true);
let restoredCount = 0;
let failedCount = 0;
try {
// Decrypt and restore each session
for (const sessionData of encryptedSessions) {
try {
const address = `${sessionData.recipientId}.${sessionData.deviceId}`;
// Always restore from server to ensure we have a valid session
// Local sessions might be corrupted, so we restore from server on every reload
// The server has the authoritative copy encrypted with password-derived key
try {
const existingSession = await storage.loadSession(address);
if (existingSession) {
console.log(`[Session Sync] Local session exists for recipient ${sessionData.recipientId}, but restoring from server to ensure validity`);
}
} catch (error) {
console.log(`[Session Sync] Local session for recipient ${sessionData.recipientId} failed to load, restoring from server`);
}
// Always restore from server (don't skip)
const encryptedBlob = decodeSessionBlob(sessionData.encryptedData);
// Use stored key if available, otherwise use password to derive it
const sessionRecord = await decryptSessionWithPassword(password, userId, encryptedBlob);
// Store in IndexedDB (sync callback won't fire because isRestoring is true)
await storage.storeSession(address, sessionRecord);
restoredCount++;
console.log(`[Session Sync ✅] Restored session for recipient ${sessionData.recipientId} from server`);
} catch (error) {
failedCount++;
console.warn(`Failed to restore session for recipient ${sessionData.recipientId}:`, error);
// Continue with other sessions
}
}
} finally {
// Always clear the restoring flag
setRestoring(false);
}
console.log(`[Session Sync] Restored ${restoredCount}/${encryptedSessions.length} sessions from server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
} catch (error) {
console.error("[Session Sync] Failed to restore sessions from server:", error);
console.error("[Session Sync] Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
// Don't throw - allow login to continue even if session restore fails
}
}
/**
* Upload all sessions to server
* Called after login/registration to backup all current sessions
*/
export async function uploadAllSessionsToServer(
userId: string,
password: string,
token: string
): Promise<void> {
try {
console.log("Uploading sessions to server...");
const storage = new SignalProtocolStorage(userId);
// Get all sessions from IndexedDB
const sessions = await storage.getAllSessions();
if (sessions.length === 0) {
console.log("No sessions in IndexedDB to upload");
return; // No sessions to upload
}
console.log(`Found ${sessions.length} sessions in IndexedDB, uploading...`);
// Encrypt and prepare sessions for upload
const sessionData: SessionData[] = [];
let failedCount = 0;
for (const { address, record } of sessions) {
try {
// Parse address to get recipientId and deviceId
const parts = address.split(".");
const recipientId = parseInt(parts[0], 10);
const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1;
if (isNaN(recipientId)) {
console.warn(`Invalid address format: ${address}`);
failedCount++;
continue;
}
// Encrypt session record (use stored key if available)
const encryptedBlob = await encryptSessionWithPassword(password, userId, record);
const encryptedData = encodeSessionBlob(encryptedBlob);
sessionData.push({
recipientId,
deviceId,
encryptedData
});
} catch (error) {
failedCount++;
console.warn(`Failed to encrypt session ${address}:`, error);
// Continue with other sessions
}
}
if (sessionData.length > 0) {
await uploadSessions(sessionData, token);
console.log(`Uploaded ${sessionData.length} sessions to server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
} else if (failedCount > 0) {
console.warn(`Failed to upload all ${sessions.length} sessions to server`);
}
} catch (error) {
console.error("Failed to upload sessions to server:", error);
// Don't throw - allow login to continue even if upload fails
}
}
/**
* Store a session in IndexedDB and upload to server
* This should be called instead of direct storage.storeSession when password is available
*/
export async function storeSessionWithSync(
userId: string,
address: string,
record: string,
password: string,
token: string
): Promise<void> {
const storage = new SignalProtocolStorage(userId);
// Store in IndexedDB first (for immediate use)
await storage.storeSession(address, record);
// Parse address to get recipientId and deviceId
const parts = address.split(".");
const recipientId = parseInt(parts[0], 10);
const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1;
if (isNaN(recipientId)) {
console.warn(`Invalid address format: ${address}`);
return;
}
// Encrypt and upload to server (use stored key if available)
try {
const encryptedBlob = await encryptSessionWithPassword(password, userId, record);
const encryptedData = encodeSessionBlob(encryptedBlob);
await uploadSessions([
{
recipientId,
deviceId,
encryptedData
}
], token);
} catch (error) {
console.error("Failed to upload session to server:", error);
// Don't throw - session is still stored in IndexedDB
}
}
/**
* Remove a session from IndexedDB
* Note: We don't remove from server immediately because we need password for encryption.
* Stale sessions on server will be overwritten on next login when we upload all current sessions.
*/
export async function removeSessionLocal(
userId: string,
address: string
): Promise<void> {
const storage = new SignalProtocolStorage(userId);
await storage.removeSession(address);
}
-509
View File
@@ -1,509 +0,0 @@
/**
* Signal Protocol service wrapper
* Provides high-level API for encrypting/decrypting messages using Signal Protocol
*/
import {
SessionBuilder,
SessionCipher,
KeyHelper,
SignalProtocolAddress,
type DeviceType,
type KeyPairType
} from "@privacyresearch/libsignal-protocol-typescript";
import { SignalProtocolStorage } from "./signalStorage";
import { b64, ub64 } from "../utils";
import api from "@/core/api";
// Helper to ensure we get a proper ArrayBuffer (not SharedArrayBuffer)
function toArrayBuffer(buffer: ArrayBuffer | SharedArrayBuffer): ArrayBuffer {
if (buffer instanceof ArrayBuffer) return buffer;
// Convert SharedArrayBuffer to ArrayBuffer by copying
const view = new Uint8Array(buffer);
const copy = new Uint8Array(view.length);
copy.set(view);
return copy.buffer;
}
export interface PreKeyBundleData {
registrationId: number;
identityKey: string; // base64
signedPreKey: {
keyId: number;
publicKey: string; // base64
signature: string; // base64
};
preKey?: {
keyId: number;
publicKey: string; // base64
};
}
export class SignalProtocolService {
private storage: SignalProtocolStorage;
// Prekey configuration constants
private static readonly PREKEY_COUNT = 20;
private static readonly PREKEY_REGEN_THRESHOLD = 5; // Regenerate when fewer than this many prekeys are left
private static readonly PREKEY_REGEN_COUNT = 10; // Number of prekeys to regenerate
private static readonly SIGNED_PREKEY_ID = 1;
private static readonly BATCH_SIZE = 10;
constructor(userId: string) {
this.storage = new SignalProtocolStorage(userId);
}
/**
* Initialize Signal Protocol for this user
* Generates identity keys, registration ID, and prekeys if they don't exist
*/
async initialize(): Promise<void> {
// Check if already initialized
const existingIdentity = await this.storage.getIdentityKeyPair();
if (existingIdentity) {
return; // Already initialized
}
// Generate identity key pair
const identityKeyPair = await KeyHelper.generateIdentityKeyPair();
await this.storage.saveIdentityKeyPair(identityKeyPair);
// Generate registration ID
const registrationId = KeyHelper.generateRegistrationId();
await this.storage.saveLocalRegistrationId(registrationId);
// Generate signed prekey
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, SignalProtocolService.SIGNED_PREKEY_ID);
// Store both the key pair and its signature
await this.storage.storeSignedPreKey(
SignalProtocolService.SIGNED_PREKEY_ID,
signedPreKey.keyPair,
new Uint8Array(signedPreKey.signature)
);
// Generate prekeys (one-time keys for establishing new sessions)
// Each new conversation consumes one prekey when the first message is sent
// Generation is non-blocking (yields to event loop), so this doesn't freeze the UI
for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
const preKey = await KeyHelper.generatePreKey(i);
await this.storage.storePreKey(i, preKey.keyPair);
// Yield to event loop every batchSize keys to prevent UI freezing
if (i % SignalProtocolService.BATCH_SIZE === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
/**
* Ensure signed prekey exists and is valid, regenerating if necessary
*/
private async ensureSignedPreKey(identityKeyPair: KeyPairType): Promise<{ keyPair: KeyPairType; signature: Uint8Array }> {
let signature = await this.storage.loadSignedPreKeySignature(SignalProtocolService.SIGNED_PREKEY_ID);
let signedPreKey = await this.storage.loadSignedPreKey(SignalProtocolService.SIGNED_PREKEY_ID);
if (!signedPreKey || !signature) {
// Signed prekey or signature missing - regenerate both to ensure consistency
const signedPreKeyWithSig = await KeyHelper.generateSignedPreKey(identityKeyPair, SignalProtocolService.SIGNED_PREKEY_ID);
await this.storage.storeSignedPreKey(
SignalProtocolService.SIGNED_PREKEY_ID,
signedPreKeyWithSig.keyPair,
new Uint8Array(signedPreKeyWithSig.signature)
);
signedPreKey = signedPreKeyWithSig.keyPair;
signature = new Uint8Array(signedPreKeyWithSig.signature);
}
return { keyPair: signedPreKey, signature };
}
/**
* Find an available prekey, regenerating if necessary
*/
private async findOrRegeneratePreKey(): Promise<{ keyPair: KeyPairType; keyId: number }> {
// Find the first available prekey
let preKey: KeyPairType | undefined;
let preKeyId = 0;
let availableCount = 0;
for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
const candidate = await this.storage.loadPreKey(i);
if (candidate) {
availableCount++;
if (!preKey) {
preKey = candidate;
preKeyId = i;
}
}
}
// If we're running low on prekeys, regenerate more proactively
if (availableCount < SignalProtocolService.PREKEY_REGEN_THRESHOLD) {
console.warn(`Low on prekeys (${availableCount} remaining), regenerating...`);
// Find the next available ID to regenerate from
let nextId = SignalProtocolService.PREKEY_COUNT + 1;
for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
const existing = await this.storage.loadPreKey(i);
if (!existing) {
nextId = i;
break;
}
}
// Regenerate prekeys starting from nextId
for (let i = 0; i < SignalProtocolService.PREKEY_REGEN_COUNT; i++) {
const keyId = nextId + i;
const existing = await this.storage.loadPreKey(keyId);
if (!existing) {
const newPreKey = await KeyHelper.generatePreKey(keyId);
await this.storage.storePreKey(keyId, newPreKey.keyPair);
if (!preKey) {
preKey = newPreKey.keyPair;
preKeyId = keyId;
}
}
}
}
// Emergency fallback if still no prekey
if (!preKey) {
console.error("No prekeys available, emergency regeneration...");
const newPreKey = await KeyHelper.generatePreKey(1);
await this.storage.storePreKey(1, newPreKey.keyPair);
preKey = newPreKey.keyPair;
preKeyId = 1;
}
return { keyPair: preKey, keyId: preKeyId };
}
/**
* Get prekey bundle for this user to share with others
*/
async getPreKeyBundle(): Promise<PreKeyBundleData> {
const identityKeyPair = await this.storage.getIdentityKeyPair();
if (!identityKeyPair) {
throw new Error("Signal Protocol not initialized");
}
const registrationId = await this.storage.getLocalRegistrationId();
if (!registrationId) {
throw new Error("Registration ID not found");
}
const { keyPair: signedPreKey, signature } = await this.ensureSignedPreKey(identityKeyPair);
const { keyPair: preKey, keyId: preKeyId } = await this.findOrRegeneratePreKey();
return {
registrationId: registrationId,
identityKey: b64(new Uint8Array(identityKeyPair.pubKey)),
signedPreKey: {
keyId: SignalProtocolService.SIGNED_PREKEY_ID,
publicKey: b64(new Uint8Array(signedPreKey.pubKey)),
signature: b64(signature)
},
preKey: {
keyId: preKeyId,
publicKey: b64(new Uint8Array(preKey.pubKey))
}
};
}
/**
* Get all available prekeys for uploading to the server
*/
async getAllPreKeys(): Promise<Array<{ keyId: number; publicKey: string }>> {
const prekeys: Array<{ keyId: number; publicKey: string }> = [];
// Check all possible prekey IDs (including regenerated ones beyond initial count)
// We check up to PREKEY_COUNT + PREKEY_REGEN_COUNT to include regenerated prekeys
const maxPreKeyId = SignalProtocolService.PREKEY_COUNT + SignalProtocolService.PREKEY_REGEN_COUNT;
for (let i = 1; i <= maxPreKeyId; i++) {
const prekey = await this.storage.loadPreKey(i);
if (prekey) {
prekeys.push({
keyId: i,
publicKey: b64(new Uint8Array(prekey.pubKey))
});
}
}
return prekeys;
}
/**
* Get the base bundle (without prekey) for uploading all prekeys
*/
async getBaseBundle(): Promise<Omit<PreKeyBundleData, "preKey">> {
const identityKeyPair = await this.storage.getIdentityKeyPair();
if (!identityKeyPair) {
throw new Error("Signal Protocol not initialized");
}
const registrationId = await this.storage.getLocalRegistrationId();
if (!registrationId) {
throw new Error("Registration ID not found");
}
const { keyPair: signedPreKey, signature } = await this.ensureSignedPreKey(identityKeyPair);
return {
registrationId: registrationId,
identityKey: b64(new Uint8Array(identityKeyPair.pubKey)),
signedPreKey: {
keyId: SignalProtocolService.SIGNED_PREKEY_ID,
publicKey: b64(new Uint8Array(signedPreKey.pubKey)),
signature: b64(signature)
}
};
}
/**
* Process a prekey bundle from another user and establish a session
*/
async processPreKeyBundle(recipientId: number, bundle: PreKeyBundleData): Promise<void> {
const address = new SignalProtocolAddress(recipientId.toString(), 1);
const identityKeyBuf = ub64(bundle.identityKey);
const signedPreKeyPubBuf = ub64(bundle.signedPreKey.publicKey);
const signedPreKeySigBuf = ub64(bundle.signedPreKey.signature);
const deviceBundle: DeviceType = {
identityKey: toArrayBuffer(identityKeyBuf.buffer.slice(identityKeyBuf.byteOffset, identityKeyBuf.byteOffset + identityKeyBuf.byteLength)),
signedPreKey: {
keyId: bundle.signedPreKey.keyId,
publicKey: toArrayBuffer(signedPreKeyPubBuf.buffer.slice(signedPreKeyPubBuf.byteOffset, signedPreKeyPubBuf.byteOffset + signedPreKeyPubBuf.byteLength)),
signature: toArrayBuffer(signedPreKeySigBuf.buffer.slice(signedPreKeySigBuf.byteOffset, signedPreKeySigBuf.byteOffset + signedPreKeySigBuf.byteLength))
},
preKey: bundle.preKey ? {
keyId: bundle.preKey.keyId,
publicKey: (() => {
const preKeyBuf = ub64(bundle.preKey!.publicKey);
return toArrayBuffer(preKeyBuf.buffer.slice(preKeyBuf.byteOffset, preKeyBuf.byteOffset + preKeyBuf.byteLength));
})()
} : undefined,
registrationId: bundle.registrationId
};
const sessionBuilder = new SessionBuilder(this.storage, address);
await sessionBuilder.processPreKey(deviceBundle);
}
/**
* Encrypt a message for a recipient
*/
async encryptMessage(recipientId: number, plaintext: string): Promise<{ type: number; body: string }> {
try {
const address = new SignalProtocolAddress(recipientId.toString(), 1);
const sessionCipher = new SessionCipher(this.storage, address);
const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer);
const encryptResult = await sessionCipher.encrypt(plaintextBuffer);
const { type, body } = encryptResult;
if (!body) {
throw new Error("Encryption failed: no body in ciphertext");
}
// The library returns body as ArrayBuffer or Uint8Array, we need to convert it to base64 string
// Always convert to Uint8Array first, then to base64, regardless of input type
let bodyArray: Uint8Array;
const bodyAny = body as any;
if (typeof body === "string") {
// String input - check if it's already base64
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (base64Regex.test(body)) {
// Already base64, use as-is
bodyArray = ub64(body);
} else {
// String contains binary data, convert to Uint8Array
bodyArray = new Uint8Array([...body].map(c => c.charCodeAt(0)));
}
} else if (bodyAny instanceof Uint8Array) {
bodyArray = bodyAny;
} else if (bodyAny instanceof ArrayBuffer) {
bodyArray = new Uint8Array(bodyAny);
} else {
// Try to convert unknown type
if (bodyAny.buffer && bodyAny.buffer instanceof ArrayBuffer) {
bodyArray = new Uint8Array(bodyAny.buffer, bodyAny.byteOffset || 0, bodyAny.byteLength || bodyAny.buffer.byteLength);
} else {
bodyArray = new Uint8Array(bodyAny as ArrayBuffer);
}
}
// Convert to base64
const bodyBase64 = b64(bodyArray);
// Final validation - ensure the result is valid base64
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(bodyBase64)) {
throw new Error(`Failed to convert body to base64: result contains invalid characters. Length: ${bodyBase64.length}`);
}
// Test that it can be decoded
try {
atob(bodyBase64.substring(0, Math.min(4, bodyBase64.length)));
} catch (error) {
throw new Error(`Failed to convert body to base64: ${error instanceof Error ? error.message : String(error)}`);
}
return { type, body: bodyBase64 };
} catch (error) {
// Log detailed error information for debugging
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Signal Protocol encryption failed:", {
recipientId,
plaintextLength: plaintext.length,
error: errorMessage
});
throw new Error(`Failed to encrypt message: ${errorMessage}`);
}
}
/**
* Decrypt a message from a sender
*/
async decryptMessage(senderId: number, ciphertext: { type: number; body: string }): Promise<string> {
if (!ciphertext.body || typeof ciphertext.body !== "string") {
throw new Error("Invalid ciphertext: body is missing or not a string");
}
const address = new SignalProtocolAddress(senderId.toString(), 1);
const sessionCipher = new SessionCipher(this.storage, address);
// Handle both PreKeyWhisperMessage (type 3) and WhisperMessage (type 1)
// ciphertext.body is a base64 string from the Signal Protocol library
let bodyBuffer: ArrayBuffer;
try {
const { buffer, byteOffset, byteLength } = ub64(ciphertext.body);
bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength));
} catch (error) {
throw new Error(`Failed to decode ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
}
let plaintextBytes: ArrayBuffer;
try {
if (ciphertext.type === 3) {
// PreKeyWhisperMessage - this will consume a prekey
// Count available prekeys before decryption
const prekeysBefore = await this.countAvailablePrekeys();
plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer);
// Check if a prekey was consumed (removed by the library)
const prekeysAfter = await this.countAvailablePrekeys();
if (prekeysBefore > prekeysAfter) {
// A prekey was consumed - refresh the bundle in the background
// This ensures new users can still message you while you're offline
this.refreshPreKeyBundle().catch(err =>
console.warn("Failed to refresh prekey bundle after consumption:", err)
);
}
} else {
// WhisperMessage - uses existing session, no prekey consumed
plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer);
}
} catch (error) {
// Log detailed error information for debugging
const errorMessage = error instanceof Error ? error.message : String(error);
// Handle different types of decryption errors
if (errorMessage.includes("Bad MAC")) {
console.warn(`Bad MAC error detected for sender ${senderId} (type ${ciphertext.type}). Session may be out of sync.`);
// For both types, remove the session so next message can re-establish it
try {
await this.storage.removeSession(address.toString());
console.warn(`Removed corrupted session for sender ${senderId}. Sender needs to send a new message to re-establish session.`);
} catch (resetError) {
console.error("Failed to remove session:", resetError);
}
} else if (
errorMessage.includes("Tried to decrypt on a sending chain") ||
errorMessage.includes("No record for device") ||
errorMessage.includes("Message key not found") ||
errorMessage.includes("counter was repeated") ||
errorMessage.includes("key was not filled")
) {
// These errors indicate the session state is corrupted, missing, or out of sync
// Remove the session so it can be re-established
console.warn(`Session state error for sender ${senderId}: ${errorMessage}. Removing session.`);
try {
await this.storage.removeSession(address.toString());
console.warn(`Removed corrupted session for sender ${senderId}. Sender needs to send a new message to re-establish session.`);
} catch (resetError) {
console.error("Failed to remove session:", resetError);
}
}
console.error("Signal Protocol decryption failed:", {
senderId,
type: ciphertext.type,
bodyLength: ciphertext.body.length,
bodyFirst50: ciphertext.body.substring(0, 50),
bodyLast50: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 50)),
bodyIsBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(ciphertext.body),
error: errorMessage
});
throw new Error(`Failed to decrypt message: ${errorMessage}`);
}
return new TextDecoder().decode(plaintextBytes);
}
/**
* Count available prekeys
*/
private async countAvailablePrekeys(): Promise<number> {
let count = 0;
const maxPreKeyId = SignalProtocolService.PREKEY_COUNT + SignalProtocolService.PREKEY_REGEN_COUNT;
for (let i = 1; i <= maxPreKeyId; i++) {
const prekey = await this.storage.loadPreKey(i);
if (prekey) {
count++;
}
}
return count;
}
/**
* Refresh prekey bundle after a prekey was consumed
* This ensures new users can still message you while you're offline
* Uploads all available prekeys to the server for rotation
*/
private async refreshPreKeyBundle(): Promise<void> {
try {
const token = api.user.auth.getAuthToken();
if (!token) {
console.warn("No auth token, cannot refresh prekey bundle");
return;
}
const baseBundle = await this.getBaseBundle();
const prekeys = await this.getAllPreKeys();
// Upload all prekeys in the background
api.crypto.prekeys.uploadAllPreKeys(baseBundle, prekeys, token).catch(err =>
console.warn("Failed to upload all prekeys:", err)
);
} catch (error) {
console.error("Failed to refresh prekey bundle:", error);
}
}
/**
* Check if a session exists for a recipient
*/
async hasSession(recipientId: number): Promise<boolean> {
const address = new SignalProtocolAddress(recipientId.toString(), 1);
const sessionCipher = new SessionCipher(this.storage, address);
return await sessionCipher.hasOpenSession();
}
}
@@ -1,142 +0,0 @@
/**
* Centralized Signal Protocol initialization
* Used in login, register, and token restoration
*/
import { SignalProtocolService } from "./signalProtocol";
import { uploadAllPreKeys } from "@/core/api/crypto/prekeys";
import { initializeSessionSync, restoreSessionsFromServer, uploadAllSessionsToServer } from "./sessionSync";
import { initializeMessagePlaintextSync } from "./messagePlaintextSync";
import { deriveSessionKey, exportKey, storeSessionKey } from "./sessionKeyStorage";
export interface SignalProtocolInitOptions {
userId: string;
password: string;
token: string;
restoreSessions?: boolean;
uploadSessions?: boolean;
}
/**
* Initialize Signal Protocol with all necessary setup
* This function handles:
* - Signal Protocol service initialization
* - Session key derivation and storage
* - Session sync initialization
* - Message plaintext sync initialization
* - Prekey bundle upload
* - Session restoration from server (optional)
* - Plaintext restoration from server (optional)
* - Session upload to server (optional)
* - Plaintext upload to server (optional)
*/
export async function initializeSignalProtocol({
userId,
password,
token,
restoreSessions = false,
uploadSessions = false
}: SignalProtocolInitOptions): Promise<void> {
console.log("========================================");
console.log("[Signal Protocol Init] 🚀 STARTING SIGNAL PROTOCOL INITIALIZATION");
console.log("[Signal Protocol Init] User ID:", userId);
console.log("[Signal Protocol Init] Has password:", !!password);
console.log("[Signal Protocol Init] Has token:", !!token);
console.log("========================================");
try {
// Step 1: Derive and store session key
console.log("[Signal Protocol Init] Step 1: Deriving session key...");
const sessionKey = await deriveSessionKey(password, userId);
const keyString = await exportKey(sessionKey);
storeSessionKey(userId, keyString);
console.log("[Signal Protocol Init] Step 1: ✅ Session key derived and stored");
// Step 2: Initialize Signal Protocol service
console.log("[Signal Protocol Init] Step 2: Initializing Signal Protocol service...");
const signalService = new SignalProtocolService(userId);
await signalService.initialize();
console.log("[Signal Protocol Init] Step 2: ✅ Signal Protocol service initialized");
// Step 3: Initialize session sync
console.log("[Signal Protocol Init] Step 3: Initializing session sync...");
initializeSessionSync(userId, password, token);
console.log("[Signal Protocol Init] Step 3: ✅ Session sync initialized");
// Step 4: Initialize message plaintext sync
console.log("[Signal Protocol Init] Step 4: Initializing message plaintext sync...");
initializeMessagePlaintextSync(userId, password, token);
console.log("[Signal Protocol Init] Step 4: ✅ Message plaintext sync initialized");
// Step 5: Restore sessions from server (if requested)
if (restoreSessions) {
console.log("========================================");
console.log("[Signal Protocol Init] Step 5: ⚠️ RESTORING SESSIONS FROM SERVER");
console.log("========================================");
const { setRestoringSessions } = await import("./sessionRestoreState");
const restorePromise = restoreSessionsFromServer(userId, password, token);
setRestoringSessions(restorePromise);
try {
await restorePromise;
console.log("========================================");
console.log("[Signal Protocol Init] Step 5: ✅ SESSIONS RESTORED FROM SERVER");
console.log("========================================");
} catch (error) {
console.error("========================================");
console.error("[Signal Protocol Init] Step 5: ❌ SESSION RESTORATION FAILED");
console.error("[Signal Protocol Init] Error:", error);
console.error("========================================");
// Continue even if restoration fails
}
} else {
// Mark restore as complete if we're not restoring (to avoid blocking message loading)
const { setRestoringSessions } = await import("./sessionRestoreState");
setRestoringSessions(Promise.resolve());
}
// Step 6: Upload prekey bundle
console.log("[Signal Protocol Init] Step 6: Uploading prekey bundle...");
const bundle = await signalService.getPreKeyBundle();
const { uploadPreKeyBundle } = await import("@/core/api/crypto/prekeys");
await uploadPreKeyBundle(bundle, token);
console.log("[Signal Protocol Init] Step 6: ✅ Prekey bundle uploaded");
// Step 7: Upload all prekeys
console.log("[Signal Protocol Init] Step 7: Uploading all prekeys...");
const baseBundle = await signalService.getBaseBundle();
const prekeys = await signalService.getAllPreKeys();
await uploadAllPreKeys(baseBundle, prekeys, token);
console.log(`[Signal Protocol Init] Step 7: ✅ Uploaded ${prekeys.length} prekeys to server`);
// Step 8: Upload all sessions to server (if requested)
if (uploadSessions) {
console.log("[Signal Protocol Init] Step 8: Uploading all sessions to server...");
try {
await uploadAllSessionsToServer(userId, password, token);
console.log("[Signal Protocol Init] Step 8: ✅ Sessions uploaded to server");
} catch (error) {
console.error("[Signal Protocol Init] Step 8: ❌ Failed to upload sessions:", error);
// Continue even if upload fails
}
}
// Step 9: Restore message plaintexts from server (if requested)
// Note: Message plaintext restoration is handled per-conversation when needed
// No bulk restoration needed here
// Step 10: Upload all message plaintexts to server (if requested)
// Note: Message plaintext upload is handled automatically when messages are sent
// No bulk upload needed here
console.log("========================================");
console.log("[Signal Protocol Init] ✅ ALL SIGNAL PROTOCOL INITIALIZATION COMPLETED");
console.log("========================================");
} catch (error) {
console.error("========================================");
console.error("[Signal Protocol Init] ❌ SIGNAL PROTOCOL INITIALIZATION FAILED");
console.error("[Signal Protocol Init] Error:", error);
console.error("========================================");
throw error;
}
}
-481
View File
@@ -1,481 +0,0 @@
/**
* IndexedDB storage implementation for Signal Protocol
* Stores identity keys, prekeys, signed prekeys, and session states
*/
import type { StorageType, KeyPairType, Direction } from "@privacyresearch/libsignal-protocol-typescript";
const DB_NAME = "signal_protocol_db";
const DB_VERSION = 1;
interface SignalDB {
identityKeys: IDBObjectStore;
preKeys: IDBObjectStore;
signedPreKeys: IDBObjectStore;
sessions: IDBObjectStore;
registrationId: IDBObjectStore;
}
let dbPromise: Promise<IDBDatabase> | null = null;
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Identity keys store: key = userId, value = { publicKey, privateKey }
if (!db.objectStoreNames.contains("identityKeys")) {
db.createObjectStore("identityKeys", { keyPath: "userId" });
}
// Prekeys store: key = userId + preKeyId, value = { userId, preKeyId, publicKey, privateKey }
if (!db.objectStoreNames.contains("preKeys")) {
const preKeysStore = db.createObjectStore("preKeys", { keyPath: ["userId", "preKeyId"] });
preKeysStore.createIndex("userId", "userId", { unique: false });
}
// Signed prekeys store: key = userId, value = { userId, keyId, publicKey, privateKey, signature }
if (!db.objectStoreNames.contains("signedPreKeys")) {
db.createObjectStore("signedPreKeys", { keyPath: "userId" });
}
// Sessions store: key = userId + recipientId, value = { userId, recipientId, deviceId, record }
// Note: recipientId is stored in the deviceId field for backward compatibility
// The actual deviceId is always 1 for now
if (!db.objectStoreNames.contains("sessions")) {
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["userId", "deviceId"] });
sessionsStore.createIndex("userId", "userId", { unique: false });
}
// Registration ID store: key = userId, value = { userId, registrationId }
if (!db.objectStoreNames.contains("registrationId")) {
db.createObjectStore("registrationId", { keyPath: "userId" });
}
};
});
return dbPromise;
}
async function getStore(storeName: keyof SignalDB, mode: IDBTransactionMode = "readonly"): Promise<IDBObjectStore> {
const db = await openDB();
const tx = db.transaction([storeName], mode);
return tx.objectStore(storeName);
}
// Helper to convert Uint8Array to ArrayBuffer
function toArrayBuffer(u8: Uint8Array | ArrayBuffer | ArrayBufferLike): ArrayBuffer {
if (u8 instanceof ArrayBuffer) return u8;
// Check if SharedArrayBuffer is available (requires COOP/COEP headers)
const SharedArrayBufferConstructor = typeof SharedArrayBuffer !== "undefined" ? SharedArrayBuffer : null;
if (SharedArrayBufferConstructor && u8 instanceof SharedArrayBufferConstructor) {
// Convert SharedArrayBuffer to ArrayBuffer by copying
const view = new Uint8Array(u8);
const copy = new Uint8Array(view.length);
copy.set(view);
// copy.buffer is always ArrayBuffer for a newly created Uint8Array
return copy.buffer as ArrayBuffer;
}
// Uint8Array case - buffer might be SharedArrayBuffer, so copy it
if (u8 instanceof Uint8Array) {
const buffer = u8.buffer;
if (SharedArrayBufferConstructor && buffer instanceof SharedArrayBufferConstructor) {
const copy = new Uint8Array(u8.length);
copy.set(u8);
// copy.buffer is always ArrayBuffer for a newly created Uint8Array
return copy.buffer as ArrayBuffer;
}
const sliced = buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
// Ensure we return ArrayBuffer, not SharedArrayBuffer
if (SharedArrayBufferConstructor && sliced instanceof SharedArrayBufferConstructor) {
const copy = new Uint8Array(sliced);
// copy.buffer is always ArrayBuffer for a newly created Uint8Array
return copy.buffer as unknown as ArrayBuffer;
}
// TypeScript doesn't know that slice() returns ArrayBuffer when buffer is ArrayBuffer
// But we've already checked it's not SharedArrayBuffer, so it must be ArrayBuffer
return sliced as unknown as ArrayBuffer;
}
// Fallback: treat as ArrayBuffer
return u8 as unknown as ArrayBuffer;
}
// Helper to convert ArrayBuffer to Uint8Array
function toUint8Array(ab: ArrayBuffer | Uint8Array): Uint8Array {
if (ab instanceof Uint8Array) return ab;
return new Uint8Array(ab);
}
// Global session sync callback - set by sessionSync service
let sessionSyncCallback: ((address: string, record: string) => Promise<void>) | null = null;
// Flag to prevent sync callback during restoration (to avoid re-uploading restored sessions)
let isRestoring = false;
export function setSessionSyncCallback(callback: ((address: string, record: string) => Promise<void>) | null): void {
sessionSyncCallback = callback;
}
export function setRestoring(restoring: boolean): void {
isRestoring = restoring;
}
export class SignalProtocolStorage implements StorageType {
private userId: string;
constructor(userId: string) {
this.userId = userId;
}
// Identity Key Management
async getIdentityKeyPair(): Promise<KeyPairType | undefined> {
const store = await getStore("identityKeys");
const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
const request = store.get(this.userId);
request.onsuccess = () => {
const data = request.result;
if (!data) {
resolve(undefined);
return;
}
resolve({
pubKey: toArrayBuffer(data.publicKey),
privKey: toArrayBuffer(data.privateKey)
});
};
request.onerror = () => reject(request.error);
});
return result;
}
async getLocalRegistrationId(): Promise<number | undefined> {
const store = await getStore("registrationId");
const result = await new Promise<{ registrationId: number } | undefined>((resolve, reject) => {
const request = store.get(this.userId);
request.onsuccess = () => {
const data = request.result;
resolve(data ? { registrationId: data.registrationId } : undefined);
};
request.onerror = () => reject(request.error);
});
return result?.registrationId;
}
async isTrustedIdentity(identifier: string, identityKey: ArrayBuffer, direction: Direction): Promise<boolean> {
// For now, always trust (can be enhanced with key verification)
// In production, you'd check against previously stored identity keys
return true;
}
async saveIdentity(encodedAddress: string, publicKey: ArrayBuffer, nonblockingApproval?: boolean): Promise<boolean> {
// Store other users' identity keys if needed
// For now, we trust all identities
return true;
}
// Helper methods for initialization (not part of StorageType interface)
async saveIdentityKeyPair(keyPair: KeyPairType): Promise<void> {
const store = await getStore("identityKeys", "readwrite");
await new Promise<void>((resolve, reject) => {
const request = store.put({
userId: this.userId,
publicKey: toUint8Array(keyPair.pubKey),
privateKey: toUint8Array(keyPair.privKey)
});
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async saveLocalRegistrationId(registrationId: number): Promise<void> {
const store = await getStore("registrationId", "readwrite");
await new Promise<void>((resolve, reject) => {
const request = store.put({
userId: this.userId,
registrationId: registrationId
});
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// PreKey Management
async loadPreKey(encodedAddress: string | number): Promise<KeyPairType | undefined> {
const preKeyId = typeof encodedAddress === "number" ? encodedAddress : parseInt(encodedAddress, 10);
const store = await getStore("preKeys");
const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
const request = store.get([this.userId, preKeyId]);
request.onsuccess = () => {
const data = request.result;
if (!data) {
resolve(undefined);
return;
}
resolve({
pubKey: toArrayBuffer(data.publicKey),
privKey: toArrayBuffer(data.privateKey)
});
};
request.onerror = () => reject(request.error);
});
return result;
}
async storePreKey(keyId: number | string, keyPair: KeyPairType): Promise<void> {
const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
const store = await getStore("preKeys", "readwrite");
await new Promise<void>((resolve, reject) => {
const request = store.put({
userId: this.userId,
preKeyId: preKeyId,
publicKey: toUint8Array(keyPair.pubKey),
privateKey: toUint8Array(keyPair.privKey)
});
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async removePreKey(keyId: number | string): Promise<void> {
const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
const store = await getStore("preKeys", "readwrite");
await new Promise<void>((resolve, reject) => {
const request = store.delete([this.userId, preKeyId]);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Signed PreKey Management
async loadSignedPreKey(keyId: number | string): Promise<KeyPairType | undefined> {
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
const store = await getStore("signedPreKeys");
const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
const request = store.get(this.userId);
request.onsuccess = () => {
const data = request.result;
if (!data || data.keyId !== signedPreKeyId) {
resolve(undefined);
return;
}
resolve({
pubKey: toArrayBuffer(data.publicKey),
privKey: toArrayBuffer(data.privateKey)
});
};
request.onerror = () => reject(request.error);
});
return result;
}
async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType, signature?: Uint8Array): Promise<void> {
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
const store = await getStore("signedPreKeys", "readwrite");
await new Promise<void>((resolve, reject) => {
interface SignedPreKeyData {
userId: string;
keyId: number;
publicKey: Uint8Array;
privateKey: Uint8Array;
signature?: Uint8Array;
}
const data: SignedPreKeyData = {
userId: this.userId,
keyId: signedPreKeyId,
publicKey: toUint8Array(keyPair.pubKey),
privateKey: toUint8Array(keyPair.privKey)
};
if (signature) {
data.signature = toUint8Array(signature);
}
const request = store.put(data);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async loadSignedPreKeySignature(keyId: number | string): Promise<Uint8Array | undefined> {
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
const store = await getStore("signedPreKeys");
const result = await new Promise<{ signature?: Uint8Array } | undefined>((resolve, reject) => {
const request = store.get(this.userId);
request.onsuccess = () => {
const data = request.result;
if (!data || data.keyId !== signedPreKeyId) {
resolve(undefined);
return;
}
resolve(data.signature ? { signature: toUint8Array(data.signature) } : undefined);
};
request.onerror = () => reject(request.error);
});
return result?.signature;
}
async removeSignedPreKey(keyId: number | string): Promise<void> {
const store = await getStore("signedPreKeys", "readwrite");
await new Promise<void>((resolve, reject) => {
const request = store.delete(this.userId);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Session Management
async loadSession(encodedAddress: string): Promise<string | undefined> {
// encodedAddress format: "recipientId.deviceId" (from Signal Protocol)
// recipientId is the other user's ID, deviceId is always 1 for now
const parts = encodedAddress.split(".");
const recipientId = parts[0]; // First part is the recipient's user ID
// Load using recipientId as the key (stored in deviceId field for backward compatibility)
// Ensure we search with string to match how we stored it
const store = await getStore("sessions");
const result = await new Promise<string | undefined>((resolve, reject) => {
const request = store.get([this.userId, String(recipientId)]);
request.onsuccess = () => {
const data = request.result;
if (data && data.record && typeof data.record === "string" && data.record.length > 0) {
console.log(`[SignalStorage] ✅ Loaded session for recipient ${recipientId} (address: ${encodedAddress})`);
resolve(data.record);
} else {
// Try with number if string didn't work (backward compatibility)
if (!data && !isNaN(Number(recipientId))) {
const numRequest = store.get([this.userId, Number(recipientId)]);
numRequest.onsuccess = () => {
const numData = numRequest.result;
if (numData && numData.record && typeof numData.record === "string" && numData.record.length > 0) {
console.log(`[SignalStorage] ✅ Loaded session for recipient ${recipientId} (address: ${encodedAddress}, using number key)`);
resolve(numData.record);
} else {
console.warn(`[SignalStorage] ⚠️ Session record missing or invalid for recipient ${recipientId} (address: ${encodedAddress}) - checked both string and number keys`);
resolve(undefined);
}
};
numRequest.onerror = () => {
console.warn(`[SignalStorage] ⚠️ Session record missing for recipient ${recipientId} (address: ${encodedAddress}) - IndexedDB error`);
resolve(undefined);
};
} else {
console.warn(`[SignalStorage] ⚠️ Session record missing or invalid for recipient ${recipientId} (address: ${encodedAddress}) - no data found`);
resolve(undefined);
}
}
};
request.onerror = () => {
console.error(`Failed to load session for recipient ${recipientId}:`, request.error);
reject(request.error);
};
});
return result;
}
async storeSession(encodedAddress: string, record: string): Promise<void> {
// encodedAddress format: "recipientId.deviceId" (from Signal Protocol)
// recipientId is the other user's ID, deviceId is always 1 for now
const parts = encodedAddress.split(".");
const recipientId = parts[0]; // First part is the recipient's user ID
// Validate record
if (!record || typeof record !== "string" || record.length === 0) {
console.warn(`[SignalStorage] Invalid session record for address ${encodedAddress}`);
return;
}
// Store with recipientId as the key (using deviceId field for backward compatibility)
// Ensure recipientId is stored as string to match how we load it
const store = await getStore("sessions", "readwrite");
await new Promise<void>((resolve, reject) => {
const request = store.put({
userId: this.userId,
deviceId: String(recipientId), // Store recipientId as string in deviceId field
record: record
});
request.onsuccess = () => {
console.log(`[SignalStorage] ✅ Stored session for recipient ${recipientId} (address: ${encodedAddress}, record length: ${record.length})`);
resolve();
// If session sync callback is set and we're not restoring, upload to server in background (non-blocking)
// Do this AFTER resolve() to ensure storage completes even if sync fails
if (sessionSyncCallback && !isRestoring) {
// Use setTimeout to make it truly async and non-blocking
setTimeout(() => {
sessionSyncCallback!(encodedAddress, record).then(() => {
console.log(`Session synced to server for ${encodedAddress}`);
}).catch(err => {
console.error(`Failed to sync session to server for ${encodedAddress}:`, err);
});
}, 0);
}
};
request.onerror = () => reject(request.error);
});
}
async removeSession(encodedAddress: string): Promise<void> {
// encodedAddress format: "recipientId.deviceId" (from Signal Protocol)
// recipientId is the other user's ID, deviceId is always 1 for now
const parts = encodedAddress.split(".");
const recipientId = parts[0]; // First part is the recipient's user ID
// Remove using recipientId as the key (stored in deviceId field for backward compatibility)
const store = await getStore("sessions", "readwrite");
await new Promise<void>((resolve, reject) => {
const request = store.delete([this.userId, recipientId]);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Get all sessions for this user
* Returns array of { address, record } where address is "recipientId.deviceId"
* Note: In IndexedDB, deviceId field actually stores the recipientId from the Signal Protocol address
*/
async getAllSessions(): Promise<Array<{ address: string; record: string }>> {
const store = await getStore("sessions");
const sessions: Array<{ address: string; record: string }> = [];
return new Promise((resolve, reject) => {
const request = store.index("userId").openCursor(IDBKeyRange.only(this.userId));
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
const data = cursor.value;
// In Signal Protocol, address format is "recipientId.deviceId"
// We stored it with recipientId in the deviceId field (for backward compatibility)
// The actual deviceId is always 1 for now
const recipientId = data.deviceId; // This is actually the recipientId from the address
const deviceId = 1; // Always 1 for now
const address = `${recipientId}.${deviceId}`;
// Validate that record exists and is a string
if (data.record && typeof data.record === "string" && data.record.length > 0) {
sessions.push({ address, record: data.record });
} else {
console.warn(`Invalid session record for recipient ${recipientId}:`, data);
}
cursor.continue();
} else {
resolve(sessions);
}
};
request.onerror = () => reject(request.error);
});
}
}
+14 -26
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": "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,9 +41,8 @@
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.1",
"dotenv-cli": "^10.0.0",
"electron": "^38.1.2",
"dotenv-cli": "^11.0.0",
"electron": "^39.2.7",
"husky": "^9.1.7",
"postcss": "^8.5.6",
"rollup-plugin-visualizer": "^6.0.4",
@@ -68,12 +57,11 @@
"vite-plugin-sass-dts": "^1.3.34"
},
"dependencies": {
"@privacyresearch/libsignal-protocol-typescript": "^0.0.16",
"electron-squirrel-startup": "^1.0.1",
"escape-string-regexp": "^5.0.0",
"he": "^1.2.0",
"idb": "^8.0.3",
"marked": "^16.3.0",
"marked": "^17.0.1",
"mdui": "^2.1.4",
"motion": "^12.23.24",
"react": "^19.1.1",
@@ -84,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
-478
View File
@@ -1,478 +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"
# Ensure ssh-agent is running
if [ -z "$SSH_AUTH_SOCK" ]; then
eval "$(ssh-agent -s)" > /dev/null 2>&1
fi
# Add SSH key to agent if not already loaded
if [ -f "$SSH_KEY_FILE" ]; then
# Check if key is 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..."
ssh-add "$SSH_KEY_FILE" 2>/dev/null || true
fi
else
warning "SSH key not found at $SSH_KEY_FILE"
fi
# Test SSH connection once to cache the key (this will prompt for passphrase if needed)
ssh -o ConnectTimeout=5 "$SERVER" "echo" > /dev/null 2>&1 || true
# ============================================================================
# SUDO AUTHENTICATION
# ============================================================================
SUDO_PASSWORD=""
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
# ============================================================================
# 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"
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
IMAGES=($(docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${PROJECT_NAME}-" || true))
if [ ${#IMAGES[@]} -eq 0 ]; then
error "No ${PROJECT_NAME} images found"
fi
# Pre-pull unregistry image if needed
UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry:0.3.1"
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!"
-12
View File
@@ -1,12 +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>
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({

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