120 Commits

269 changed files with 27466 additions and 6674 deletions
+40 -4
View File
@@ -1,4 +1,40 @@
View git diff between the branch i specified and HEAD. If no branch is specified, # Code Cleanup Command
default to main. Identify code that needs to be cleaned up, like debug logs,
unused variables etc. Think twice before removing or adding code, because you ## Overview
mustn't alter the behavior. Analyze git diff between the specified branch and HEAD (defaults to main if no branch specified) and clean up code quality issues without altering functionality.
## Process
1. **Get diff**: Run `git diff <branch>..HEAD` to see changes
2. **Identify issues**: Look for code quality problems in the diff
3. **Clean up**: Remove only the identified issues
4. **Verify**: Ensure no behavioral changes
## What to Clean Up
- **Debug artifacts**: `console.log()`, `debugger`, `print()` statements
- **Unused code**: Variables, imports, functions, parameters
- **Commented code**: Dead code blocks, TODO comments (unless active)
- **Formatting**: Inconsistent spacing, trailing whitespace
- **Temporary code**: Test values, hardcoded strings meant to be dynamic
- **Redundant code**: Duplicate logic, unnecessary intermediate variables
## What NOT to Touch
- **Functional logic**: Don't change how features work
- **API interfaces**: Keep method signatures intact
- **Configuration**: Don't modify settings or constants
- **Comments**: Keep documentation and explanatory comments
- **Error handling**: Don't remove try-catch blocks or validation
## Safety Rules
- ✅ Only modify code that appears in the git diff
- ✅ Preserve all existing functionality
- ✅ Maintain code readability and structure
- ❌ Don't refactor or optimize beyond cleanup
- ❌ Don't add new features or improvements
- ❌ Don't change variable names or function signatures
## Example
```bash
# If user specifies: "/clean-up main"
git diff main
# Clean only the issues found in this diff
```
+1
View File
@@ -0,0 +1 @@
Run the command "npm run frontend:typecheck" and fix all errors listed in the command if there's any.
+235
View File
@@ -0,0 +1,235 @@
# Security Audit Command
Perform a comprehensive security audit of the FromChat application codebase.
## 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
- WebSocket support for real-time features
- Electron support for desktop app
## 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
## Output Format
Provide a **clean, concise report** with:
1. **Executive Summary** - Overall rating and production readiness
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
7. **Recommendations** - Prioritized with time estimates
8. **Conclusion** - Clear production readiness statement
**Keep it under 500 lines** - focus on actionable findings, not verbose explanations.
## 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
+19
View File
@@ -0,0 +1,19 @@
---
alwaysApply: true
---
When using the browser, use this information to work better:
## Login credentials
Username: test
Password: 11111
## Server URL
http://localhost:8301
## Rules
- Do NOT start the dev server yourself, it's started automatically.
If the URL doesn't work, stop and ask me to turn on the dev server.
- Don't wait, you are slow enough to keep up with the browser.
+1 -2
View File
@@ -1,6 +1,5 @@
--- ---
description: Documentation rules alwaysApply: true
alwaysApply: false
--- ---
When documenting this project, follow these rules: When documenting this project, follow these rules:
+10 -1
View File
@@ -16,6 +16,7 @@ When working with this project, follow these rules:
- Use double quotes ("") for strings consistently. - Use double quotes ("") for strings consistently.
- Prefer functional components over class components in React. - Prefer functional components over class components in React.
- Use TypeScript strictly - avoid `any` types unless absolutely necessary. - Use TypeScript strictly - avoid `any` types unless absolutely necessary.
- DO NOT leave placeholders - ask me when it would be better or implement it fully.
## File Operations ## File Operations
- If possible, try to update files in a single edit when making multiple changes. - If possible, try to update files in a single edit when making multiple changes.
@@ -30,7 +31,7 @@ When working with this project, follow these rules:
- If the typecheck passed, there's no need for checking the linter errors. - If the typecheck passed, there's no need for checking the linter errors.
## Async Operations ## Async Operations
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async, - When you need a delay, use `await delay(millis);` from `@/utils/utils` in an async function. If the current function is not async,
make it async. make it async.
## Database ## Database
@@ -50,3 +51,11 @@ When working with this project, follow these rules:
- Batch tool calls when possible to reduce latency - Batch tool calls when possible to reduce latency
- Use semantic search before grep when looking for concepts - Use semantic search before grep when looking for concepts
- Use TODOs for complex multi-step tasks to track progress - Use TODOs for complex multi-step tasks to track progress
## Styling
- Use SCSS modules
- Use nested styles
- Put SCSS into one folder per page
## Animations with Framer Motion
- Don't use variants if they are used only once
+7 -2
View File
@@ -3,6 +3,11 @@ alwaysApply: true
--- ---
When you work with UI: When you work with UI:
1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML. 1. Use MDUI components through the wrapper: `@/utils/material`. If the component you want to use is missing in that wrapper,
add it. Do NOT remove anything.
3. The supporting text slot for MDUI lists is "description". 3. The supporting text slot for MDUI lists is "description".
4. When working with lists/sets in states, use the "useImmer" hook. 4. When working with lists/sets in states, use the "useImmer" hook.
5. Do NOT use inline styles in React components if they are static, instead write them in CSS.
Find the appropriate file to put the styles in, or create a new one.
6. In SCSS, for Material Design colors use `$color-dark-<color-name>` variables. For all colors, refer
to `frontend/src/css/_material.scss`.
+12
View File
@@ -0,0 +1,12 @@
# Exclude data directory to prevent local database from being copied into production images
backend/data/
# Exclude logs
backend/logs/
# Exclude development files
node_modules/
.git/
.gitignore
README.md
*.log
+15 -13
View File
@@ -3,17 +3,17 @@ name: Deploy to server
on: on:
# Runs on pushes targeting the default branch # Runs on pushes targeting the default branch
push: # push:
branches: ["main"] # branches: ["main"]
paths: # paths:
- "backend/**" # - "backend/**"
- "frontend/**" # - "frontend/**"
- "deployment/**" # - "deployment/**"
- "**/package.json" # - "**/package.json"
- ".nvmrc" # - ".nvmrc"
- ".github/workflows/deploy.yml" # - ".github/workflows/deploy.yml"
- "!frontend/electron/**" # - "!frontend/electron/**"
- "!**.d.ts" # - "!**.d.ts"
workflow_dispatch: workflow_dispatch:
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
@@ -27,7 +27,7 @@ permissions:
jobs: jobs:
deploy: deploy:
runs-on: self-hosted runs-on: raspberry-pi
env: env:
HOME: "/root" HOME: "/root"
environment: environment:
@@ -44,6 +44,8 @@ jobs:
JWT_SECRET=${{ secrets.JWT_SECRET }} JWT_SECRET=${{ secrets.JWT_SECRET }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }} VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }} VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
TURN_USERNAME=${{ vars.TURN_USERNAME }}
TURN_PASSWORD=${{ secrets.TURN_PASSWORD }}
EOF EOF
- name: Build container - name: Build container
run: | run: |
@@ -58,4 +60,4 @@ jobs:
if ! systemctl restart fromchat && sleep 10 && systemctl status fromchat; then if ! systemctl restart fromchat && sleep 10 && systemctl status fromchat; then
journalctl --no-pager -xeu fromchat journalctl --no-pager -xeu fromchat
exit 1 exit 1
fi fi
+4 -1
View File
@@ -112,6 +112,7 @@ web_modules/
# dotenv environment variable files # dotenv environment variable files
.env .env
.env.prod
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
@@ -573,4 +574,6 @@ package-lock.json
backend/alembic/** backend/alembic/**
!backend/alembic/env.py !backend/alembic/env.py
!backend/alembic/script.py.mako !backend/alembic/script.py.mako
!frontend/src/css/lib !frontend/src/css/lib
**/*.module.scss.d.ts
.cursor/plans
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
# Post-push hook: opens deploy command in system's native terminal
# Cross-platform support: macOS, Linux, Windows, WSL
# Get the project root directory
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
cd "$PROJECT_ROOT" || exit 1
# Command to run in terminal (deploy.sh will load .env from project root)
COMMAND="npm run -s deploy"
# Detect OS and open appropriate terminal
detect_and_open_terminal() {
# Detect WSL
if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -f /proc/version ] && grep -qi microsoft /proc/version 2>/dev/null; then
# WSL detected - try to open Windows Terminal, fallback to Linux terminals
if command -v wt.exe >/dev/null 2>&1; then
# Windows Terminal (preferred for WSL)
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
elif command -v wsl.exe >/dev/null 2>&1; then
# Fallback: use wsl.exe to open cmd
WINDOWS_PATH=$(wslpath -w "$PROJECT_ROOT" 2>/dev/null || echo "$PROJECT_ROOT")
cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\""
else
# Fallback to Linux terminal
open_linux_terminal
fi
# macOS
elif [ "$(uname)" = "Darwin" ]; then
# macOS - use .command file with open command
# Clean up old script files and create a new one
rm -f /tmp/post-push-deploy-*.command 2>/dev/null
SCRIPT_FILE=$(mktemp /tmp/post-push-deploy-XXXXXX.command 2>/dev/null)
if [ -z "$SCRIPT_FILE" ] || [ ! -f "$SCRIPT_FILE" ]; then
# Fallback if mktemp fails
SCRIPT_FILE="/tmp/post-push-deploy-$$.command"
fi
{
echo "#!/bin/bash"
echo "clear"
echo "cd '$PROJECT_ROOT'"
echo "export PS1=''"
echo "set +x"
# Export DEPLOYMENT_SERVER if it was set in the hook environment
if [ -n "$DEPLOYMENT_SERVER_VALUE" ]; then
echo "export DEPLOYMENT_SERVER='$DEPLOYMENT_SERVER_VALUE'"
fi
echo "$COMMAND"
echo "echo ''"
echo "echo 'Press Enter to close...'"
echo "read -r"
echo "osascript -e 'tell application \"Terminal\" to close front window' &"
} > "$SCRIPT_FILE"
chmod +x "$SCRIPT_FILE"
# Use open command to launch .command file - opens only one Terminal window
open "$SCRIPT_FILE"
# Windows (Git Bash or similar)
elif [ -n "${MSYSTEM:-}" ] || [ -n "${MINGW64:-}" ] || [ -n "${MINGW32:-}" ]; then
# Git Bash on Windows
if command -v wt.exe >/dev/null 2>&1; then
# Windows Terminal
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
elif command -v cmd.exe >/dev/null 2>&1; then
# Command Prompt - convert path to Windows format
WINDOWS_PATH=$(echo "$PROJECT_ROOT" | sed 's|^/\([a-z]\)|\1:|' | sed 's|/|\\|g' | sed 's|\\|\\\\|g')
cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\""
else
# Fallback
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
start "Deploy" bash -c "cd '$ESCAPED_PATH' && $COMMAND; exec bash"
fi
# Linux
else
open_linux_terminal
fi
}
open_linux_terminal() {
# Escape path for use in shell commands
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
# Try different Linux terminal emulators
if command -v gnome-terminal >/dev/null 2>&1; then
gnome-terminal -- bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
elif command -v x-terminal-emulator >/dev/null 2>&1; then
x-terminal-emulator -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
elif command -v konsole >/dev/null 2>&1; then
konsole -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
elif command -v xterm >/dev/null 2>&1; then
xterm -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
elif command -v alacritty >/dev/null 2>&1; then
alacritty -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
elif command -v kitty >/dev/null 2>&1; then
kitty bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
elif command -v tilix >/dev/null 2>&1; then
tilix -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
else
# Last resort: try to find any terminal
TERMINAL=$(command -v x-terminal-emulator gnome-terminal konsole xterm alacritty kitty tilix 2>/dev/null | head -1)
if [ -n "$TERMINAL" ]; then
"$TERMINAL" -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
else
echo "Could not find a terminal emulator. Please run manually: cd '$PROJECT_ROOT' && $COMMAND"
fi
fi
}
# Run in background so git push doesn't wait
# Add a small delay to ensure git push completes first
(sleep 0.5 && detect_and_open_terminal) &
+6 -4
View File
@@ -1,9 +1,11 @@
{ {
"files.exclude": { "files.exclude": {
"**/__pycache__": true, "**/__pycache__": true,
"**/package-lock.json": true "**/package-lock.json": true,
"**/*.module.scss.d.ts": true,
"**/.husky/_": true,
"**/.venv": true,
"**/node_modules": true
}, },
"github-actions.workflows.pinned.workflows": [], "python.terminal.activateEnvironment": false
"github-actions.workflows.pinned.workflows.ignore": true,
"github-actions.workflows.pinned.workflows.ignoreContextAccess": true
} }
+16 -1
View File
@@ -33,7 +33,7 @@
"panel": "shared" "panel": "shared"
}, },
"group": { "group": {
"kind": "build", "kind": "build"
}, },
"isBackground": true "isBackground": true
}, },
@@ -69,6 +69,9 @@
"reveal": "always", "reveal": "always",
"focus": false, "focus": false,
"panel": "shared" "panel": "shared"
},
"runOptions": {
"runOn": "folderOpen"
} }
}, },
{ {
@@ -84,6 +87,18 @@
"focus": false, "focus": false,
"panel": "shared" "panel": "shared"
} }
},
{
"label": "Deploy",
"type": "shell",
"command": "npm run deploy",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "dedicated",
"clear": true
}
} }
] ]
} }
+1 -1
View File
@@ -2,7 +2,7 @@
FromChat - полностью открытый мессенджер. FromChat - полностью открытый мессенджер.
Демо версию можно попробовать на [сайте](http://95.165.0.162:8301). Его можно попробовать на [сайте](http://fromchat.ru).
## Содержание: ## Содержание:
- [Основные моменты](#highlights) - [Основные моменты](#highlights)
+417
View File
@@ -0,0 +1,417 @@
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import os
import shlex
import sys
from getpass import getpass
from typing import Iterable, List, Optional, Tuple
import readline
import httpx
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
class CLIError(Exception):
"""Generic CLI error with a human-readable message."""
def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
return hmac.new(salt, ikm, hashlib.sha256).digest()
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
blocks: list[bytes] = []
previous = b""
counter = 1
while len(b"".join(blocks)) < length:
previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest()
blocks.append(previous)
counter += 1
return b"".join(blocks)[:length]
def derive_auth_secret(username: str, password: str) -> str:
salt = f"fromchat.user:{username}".encode("utf-8")
prk = _hkdf_extract(salt, password.encode("utf-8"))
okm = _hkdf_expand(prk, b"auth-secret", 32)
return base64.b64encode(okm).decode("utf-8")
def _read_single_key() -> str:
try: # Windows
import msvcrt # type: ignore
ch = msvcrt.getch()
return ch.decode("utf-8", errors="ignore").lower()
except ImportError:
import termios
import tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return ch.lower()
class AdminCLI:
def __init__(self, api_url: str) -> None:
self.console = Console()
self.api_url = api_url.rstrip("/")
self.client = httpx.Client(base_url=self.api_url, timeout=30.0)
self.username: Optional[str] = None
self.token: Optional[str] = None
# --------------------------- HTTP helpers --------------------------- #
def _auth_headers(self) -> dict:
headers: dict = {}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
def _request(self, method: str, path: str, *, auth: bool = True, **kwargs) -> httpx.Response:
rel_path = path.lstrip("/")
headers = kwargs.pop("headers", {})
if auth:
headers.update(self._auth_headers())
response = self.client.request(method, rel_path, headers=headers, **kwargs)
if response.status_code >= 400:
detail = ""
try:
payload = response.json()
if isinstance(payload, dict):
detail = payload.get("detail") or payload.get("message") or ""
except Exception:
detail = response.text
message = f"{response.status_code} {response.reason_phrase}"
if detail:
message = f"{message}: {detail}"
raise CLIError(message.strip())
return response
# --------------------------- CLI primitives ------------------------- #
def _require_auth(self) -> None:
if not self.token:
raise CLIError("You must login before running this command.")
def _resolve_user(self, identifier: str) -> dict:
self._require_auth()
if identifier.isdigit():
response = self._request("GET", f"user/id/{identifier}")
else:
response = self._request("GET", f"user/{identifier.replace('@', '')}")
return response.json()
def _confirm(self, prompt: str) -> bool:
self.console.print(f"[bold yellow]{prompt}[/] [green](y)[/] / [red](n)[/]: ", end="")
choice = _read_single_key()
self.console.print("") # move to next line
return choice == "y"
def _render_user(self, user: dict) -> None:
table = Table(show_header=False)
table.add_row("ID", str(user.get("id")))
table.add_row("Username", user.get("username", ""))
table.add_row("Display name", user.get("display_name", ""))
table.add_row("Verified", "" if user.get("verified") else "")
if user.get("suspended"):
table.add_row("Suspended", f"🚫 ({user.get('suspension_reason') or 'no reason'})")
else:
table.add_row("Suspended", "✅ Active")
self.console.print(table)
# --------------------------- Commands ------------------------------- #
def cmd_login(self, args: List[str]) -> None:
if args:
username = args[0]
else:
username = self.console.input("[bold cyan]Username[/]: ").strip()
if not username:
raise CLIError("Username is required.")
password = getpass("Password: ")
derived_password = derive_auth_secret(username, password)
payload = {"username": username, "password": derived_password}
response = self._request("POST", "login", json=payload, auth=False)
body = response.json()
token = body.get("token")
if not token:
raise CLIError("Authentication succeeded but token was not returned.")
self.token = token
self.username = username
self.console.print("[bold green]Login successful.[/]")
def cmd_suspend(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: suspend <user_id|username>")
identifier = args[0]
user = self._resolve_user(identifier)
self.console.print(Panel.fit("[bold red]Suspend user[/]", style="red"))
self._render_user(user)
reason = self.console.input("[bold yellow]Reason (press Enter to leave empty)[/]: ").strip()
if not self._confirm(f"Confirm suspension of {user.get('username')}?"):
self.console.print("[yellow]Suspension cancelled.[/]")
return
payload = {"reason": reason}
self._request("POST", f"user/{user['id']}/suspend", json=payload)
log_reason = reason or "no reason provided"
self.console.print(f"[bold red]User {user['username']} suspended ({log_reason}).[/]")
def cmd_unsuspend(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: unsuspend <user_id|username>")
identifier = args[0]
user = self._resolve_user(identifier)
self.console.print(Panel.fit("[bold green]Unsuspend user[/]", style="green"))
self._render_user(user)
if not self._confirm(f"Unsuspend {user.get('username')}?"):
self.console.print("[yellow]Unsuspension cancelled.[/]")
return
self._request("POST", f"user/{user['id']}/unsuspend")
self.console.print(f"[bold green]User {user['username']} unsuspended.[/]")
def cmd_block_word(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: block-word <word or phrase> [additional words...]")
self._require_auth()
words = args
response = self._request("POST", "moderation/blocklist", json={"words": words})
data = response.json()
added = data.get("added", [])
current = data.get("words", [])
if added:
self.console.print(f"[bold green]Added {len(added)} entr{'y' if len(added)==1 else 'ies'} to blocklist.[/]")
else:
self.console.print("[yellow]No new words added.[/]")
self.console.print(f"Blocklist size: {len(current)}")
def cmd_list_users(self) -> None:
self._require_auth()
payload = self._request("GET", "user/list").json()
users = payload.get("users", [])
table = Table(title="Users", show_lines=False)
table.add_column("ID")
table.add_column("Username")
table.add_column("Display name")
table.add_column("Suspended")
for user in users:
table.add_row(
str(user.get("id")),
user.get("username", ""),
user.get("display_name", ""),
"🚫" if user.get("suspended") else "",
)
self.console.print(table)
def cmd_user(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: user <user_id|username>")
user = self._resolve_user(args[0])
self._render_user(user)
def cmd_delete(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: delete <user_id|username>")
user = self._resolve_user(args[0])
self.console.print(Panel.fit("[bold red]Delete user[/]", style="red"))
self._render_user(user)
if not self._confirm(f"Permanently delete {user.get('username')}?"):
self.console.print("[yellow]Deletion cancelled.[/]")
return
self._request("POST", f"user/{user['id']}/delete")
self.console.print(f"[bold red]User {user['username']} deleted.[/]")
def cmd_unblock_word(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: unblock-word <word or phrase> [additional words...]")
self._require_auth()
response = self._request("DELETE", "moderation/blocklist", json={"words": args})
data = response.json()
removed = data.get("removed", [])
current = data.get("words", [])
if removed:
self.console.print(f"[bold green]Removed {len(removed)} entr{'y' if len(removed)==1 else 'ies'} from blocklist.[/]")
else:
self.console.print("[yellow]No matching words removed.[/]")
self.console.print(f"Blocklist size: {len(current)}")
def cmd_verify(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: verify <user_id|username>")
user = self._resolve_user(args[0])
if user.get("verified"):
self.console.print(f"[yellow]{user['username']} is already verified.[/]")
return
self._request("POST", f"user/{user['id']}/verify")
self.console.print(f"[bold green]{user['username']} marked as verified.[/]")
def cmd_unverify(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: unverify <user_id|username>")
user = self._resolve_user(args[0])
if not user.get("verified"):
self.console.print(f"[yellow]{user['username']} is already unverified.[/]")
return
self._request("POST", f"user/{user['id']}/verify")
self.console.print(f"[bold green]{user['username']} is now unverified.[/]")
def cmd_list_blocklist(self) -> None:
self._require_auth()
response = self._request("GET", "moderation/blocklist")
words = response.json().get("words", [])
if not words:
self.console.print("[cyan]Blocklist is empty.[/]")
return
table = Table(title="Blocked Words", show_lines=True)
table.add_column("Word / Phrase")
for entry in words:
table.add_row(entry)
self.console.print(table)
def cmd_unblock_ip(self, args: List[str]) -> None:
if not args:
raise CLIError("Usage: unblock-ip <ip_address>")
self._require_auth()
ip = args[0].strip()
if not ip:
raise CLIError("IP address cannot be empty")
response = self._request("POST", "moderation/unblock-ip", json={"ip": ip})
data = response.json()
message = data.get("message", "IP unblocked")
self.console.print(f"[bold green]{message}[/]")
def cmd_clear_all_rate_limits(self) -> None:
"""Clear all rate limit entries. Use with caution."""
self._require_auth()
if not self._confirm("Clear ALL rate limit entries? This affects all IPs."):
self.console.print("[yellow]Operation cancelled.[/]")
return
response = self._request("POST", "moderation/clear-all-rate-limits")
data = response.json()
message = data.get("message", "Rate limits cleared")
self.console.print(f"[bold green]{message}[/]")
def cmd_help(self) -> None:
cmds = {
"login [username]": "Authenticate as owner/admin.",
"suspend <user>": "Suspend account (alias: ban).",
"unsuspend <user>": "Unsuspend account (alias: unban).",
"delete <user>": "Permanently delete the user account.",
"verify <user>": "Mark user as verified.",
"unverify <user>": "Remove verification flag.",
"block-word <words>": "Add words/phrases to chat filter.",
"unblock-word <words>": "Remove words/phrases from filter.",
"blocklist": "Show current blocklist.",
"unblock-ip <ip>": "Unblock an IP address from rate limiting.",
"clear-all-rate-limits": "Clear all rate limit entries (use with caution).",
"list": "List all users.",
"user <user>": "Show detailed user information.",
"whoami": "Display current session context.",
"help": "Show this help panel.",
"exit": "Quit the CLI.",
}
table = Table(title="Available Commands")
table.add_column("Command", style="cyan")
table.add_column("Description", style="white")
for cmd, desc in cmds.items():
table.add_row(cmd, desc)
self.console.print(table)
def cmd_whoami(self) -> None:
if not self.token:
self.console.print("[yellow]Not authenticated.[/]")
return
self.console.print(f"[green]Logged in as[/] [bold]{self.username}[/] ({self.api_url})")
# --------------------------- Main loop ------------------------------ #
def run(self) -> None:
self.console.print(Panel.fit("[bold magenta]FromChat Admin CLI[/]", style="magenta"))
while True:
prompt_identity = self.username or "guest"
try:
prompt_str = f"\033[36m{prompt_identity}\033[0m \033[1m>\033[0m "
raw = input(prompt_str).strip()
except (KeyboardInterrupt, EOFError):
self.console.print("\n[red]Exiting...[/]")
break
if not raw:
continue
try:
parts = shlex.split(raw)
except ValueError as exc:
self.console.print(f"[red]Parse error:[/] {exc}")
continue
command = parts[0].lstrip("/").lower()
args = parts[1:]
if command in {"exit", "quit"}:
self.console.print("[red]Goodbye.[/]")
break
try:
if command == "login":
self.cmd_login(args)
elif command in {"suspend", "ban"}:
self.cmd_suspend(args)
elif command in {"unsuspend", "unban"}:
self.cmd_unsuspend(args)
elif command == "block-word":
self.cmd_block_word(args)
elif command == "unblock-word":
self.cmd_unblock_word(args)
elif command == "blocklist":
self.cmd_list_blocklist()
elif command == "unblock-ip":
self.cmd_unblock_ip(args)
elif command == "clear-all-rate-limits":
self.cmd_clear_all_rate_limits()
elif command == "verify":
self.cmd_verify(args)
elif command == "unverify":
self.cmd_unverify(args)
elif command in {"delete", "remove"}:
self.cmd_delete(args)
elif command == "list":
self.cmd_list_users()
elif command == "user":
self.cmd_user(args)
elif command == "help":
self.cmd_help()
elif command == "whoami":
self.cmd_whoami()
else:
self.console.print("[yellow]Unknown command. Type /help for a list of commands.[/]")
except CLIError as err:
self.console.print(f"[red]Error:[/] {err}")
except httpx.RequestError as err:
self.console.print(f"[red]Network error:[/] {err}")
self.client.close()
def main(argv: Optional[Iterable[str]] = None) -> None:
parser = argparse.ArgumentParser(description="FromChat Emergency Admin CLI")
parser.add_argument(
"--api-url",
default=os.getenv("FC_ADMIN_API_URL", "http://127.0.0.1:8300"),
help="Base API URL for the FromChat backend (default: %(default)s).",
)
args = parser.parse_args(list(argv) if argv is not None else None)
cli = AdminCLI(args.api_url)
cli.run()
if __name__ == "__main__":
main()
+2 -2
View File
@@ -5,7 +5,7 @@
# this is typically a path given in POSIX (e.g. forward slashes) # 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 # format, relative to the token %(here)s which refers to the location of this
# ini file # ini file
script_location = %(here)s/alembic script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # 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 # Uncomment the line below if you want the files to be prepended with date and time
@@ -84,7 +84,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only. # 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 # other means of configuring database URLs may be customized within the env.py
# file. # file.
sqlalchemy.url = sqlite:///./data/database.db # sqlalchemy.url is set dynamically from DATABASE_URL environment variable
[post_write_hooks] [post_write_hooks]
+1 -1
View File
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
# add your model's MetaData object here # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
from models import Base from backend.shared.models import Base
target_metadata = Base.metadata target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
+172 -29
View File
@@ -1,51 +1,194 @@
from fastapi import FastAPI import asyncio
import time
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import subprocess import subprocess
import sys import sys
import os import os
import httpx
import logging
# Gateway doesn't need direct model access - it's a stateless proxy
# Gateway doesn't need constants - it's a stateless proxy
from backend.shared.utils import get_client_ip
# Gateway doesn't need database access - it's a stateless proxy
from backend.logging_config import access_logger # noqa: F401 - ensure loggers configured
from backend.security.audit import log_access
from backend.security.rate_limit import limiter
from slowapi.middleware import SlowAPIMiddleware
# Service URL mapping for routing
SERVICE_URLS = {
"account": os.getenv("ACCOUNT_SERVICE_URL", "http://account_service:8302"),
"profile": os.getenv("PROFILE_SERVICE_URL", "http://profile_service:8303"),
"devices": os.getenv("DEVICE_SERVICE_URL", "http://device_service:8304"),
"messaging": os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305"),
"push": os.getenv("PUSH_SERVICE_URL", "http://push_service:8306"),
"webrtc": os.getenv("WEBRTC_SERVICE_URL", "http://webrtc_service:8307"),
"moderation": os.getenv("MODERATION_SERVICE_URL", "http://moderation_service:8308"),
}
logger = logging.getLogger("uvicorn.error")
from routes import account, messaging, profile, push
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup - run migration in separate process to avoid logging interference # Gateway is a stateless proxy - no database operations or background tasks needed
try: logger.info("Gateway proxy service initialized - routing to microservices")
print("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__))
# No capture_output - let it stream to terminal in real-time
# No text=True - let it use the terminal's encoding
)
except Exception as e:
print(f"Failed to run database migrations: {e}")
raise
yield yield
logger.info("Gateway proxy service shutting down.")
# Shutdown (if needed in the future)
# logger.info("Application shutdown")
# Инициализация FastAPI # Инициализация FastAPI
app = FastAPI(title="FromChat", lifespan=lifespan) app = FastAPI(title="FromChat", lifespan=lifespan)
# Add rate limiting middleware
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
@app.middleware("http")
async def access_logging_middleware(request: Request, call_next):
# Log incoming request and Authorization header presence for debugging auth issues
try:
auth_header = request.headers.get("authorization")
if auth_header:
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
else:
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
except Exception:
pass
start = time.perf_counter()
try:
response = await call_next(request)
except Exception as exc:
duration = time.perf_counter() - start
user = getattr(getattr(request, "state", None), "current_user", None)
log_access(
"http_error",
method=request.method,
path=request.url.path,
status="error",
user=getattr(user, "username", None),
ip=get_client_ip(request),
duration=f"{duration:.3f}s",
error=str(exc),
)
raise
else:
duration = time.perf_counter() - start
user = getattr(getattr(request, "state", None), "current_user", None)
log_access(
"http_request",
method=request.method,
path=request.url.path,
status=response.status_code,
user=getattr(user, "username", None),
ip=get_client_ip(request),
duration=f"{duration:.3f}s",
)
return response
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # В продакшене замените на нужные домены 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_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Routes # Common API endpoints - route to appropriate services (defined first for priority)
app.include_router(account.router) @app.api_route("/login", methods=["POST"])
app.include_router(messaging.router) async def login(request: Request):
app.include_router(profile.router) """Login endpoint - routes to account service."""
app.include_router(push.router, prefix="/push") return await _proxy_to_service("account", "login", request)
@app.api_route("/register", methods=["POST"])
async def register(request: Request):
"""Register endpoint - routes to account service."""
return await _proxy_to_service("account", "register", request)
# API routes - route to appropriate microservices
@app.api_route("/account/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_account(path: str, request: Request):
"""Proxy account service requests."""
return await _proxy_to_service("account", path, request)
@app.api_route("/profile/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_profile(path: str, request: Request):
"""Proxy profile service requests."""
return await _proxy_to_service("profile", path, request)
@app.api_route("/devices/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_devices(path: str, request: Request):
"""Proxy device service requests."""
return await _proxy_to_service("devices", path, request)
@app.api_route("/messaging/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_messaging(path: str, request: Request):
"""Proxy messaging service requests."""
return await _proxy_to_service("messaging", path, request)
@app.api_route("/push/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_push(path: str, request: Request):
"""Proxy push service requests."""
return await _proxy_to_service("push", path, request)
@app.api_route("/webrtc/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_webrtc(path: str, request: Request):
"""Proxy WebRTC service requests."""
return await _proxy_to_service("webrtc", path, request)
@app.api_route("/moderation/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_moderation(path: str, request: Request):
"""Proxy moderation service requests."""
return await _proxy_to_service("moderation", path, request)
async def _proxy_to_service(service: str, path: str, request: Request):
"""Helper function to proxy requests to microservices."""
from fastapi.responses import Response
service_url = SERVICE_URLS[service]
target_url = f"{service_url}/{service}/{path}"
# Get request body
body = await request.body()
# Prepare headers (remove host header)
headers = dict(request.headers)
headers.pop("host", None)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
params=request.query_params,
)
# Return response with the same status code and content
content = response.content
return Response(
content=content,
status_code=response.status_code,
headers={"content-type": response.headers.get("content-type", "application/json")}
)
except httpx.RequestError as exc:
logging.error(f"Error communicating with {service} service: {exc}")
raise HTTPException(status_code=503, detail=f"Service {service} unavailable")
# Routes are handled by the catch-all proxy above
+4 -2
View File
@@ -1,9 +1,11 @@
import os import os
DATABASE_URL = "sqlite:///./data/database.db" DATABASE_URL = "sqlite:///./data/database.db"
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24 # 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" OWNER_USERNAME = "denis0001-dev"
JWT_SECRET_KEY = os.getenv("JWT_SECRET") JWT_SECRET_KEY = os.getenv("JWT_SECRET")
+31 -1
View File
@@ -6,5 +6,35 @@ from constants import DATABASE_URL
# Ensure data directory exists # Ensure data directory exists
os.makedirs("data", exist_ok=True) os.makedirs("data", exist_ok=True)
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) 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) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+87 -5
View File
@@ -1,11 +1,14 @@
from fastapi import Depends, HTTPException, status from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from utils import * from utils import verify_token
from models import * from models import User, DeviceSession
from db import SessionLocal from db import SessionLocal
import logging
security = HTTPBearer() security = HTTPBearer()
logger = logging.getLogger("uvicorn.error")
# Зависимость для получения сессии БД # Зависимость для получения сессии БД
def get_db(): def get_db():
@@ -17,12 +20,22 @@ def get_db():
# Зависимость для получения текущего пользователя # Зависимость для получения текущего пользователя
def get_current_user( def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db) db: Session = Depends(get_db),
) -> User: ) -> User:
token = credentials.credentials token = credentials.credentials
payload = verify_token(token) try:
payload = verify_token(token)
except Exception as e:
logger.warning("get_current_user: token verification error: %s", str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
if not payload: if not payload:
logger.info("get_current_user: verify_token returned empty payload")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token", detail="Invalid or expired token",
@@ -30,9 +43,78 @@ def get_current_user(
) )
user = db.query(User).filter(User.id == payload["user_id"]).first() user = db.query(User).filter(User.id == payload["user_id"]).first()
if not user: if not user:
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found", detail="User not found",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if user.id == 1 and user.suspended:
user.suspended = False
user.suspension_reason = None
db.commit()
db.refresh(user)
# Validate device session from JWT
session_id = payload.get("session_id")
if not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid session",
headers={"WWW-Authenticate": "Bearer"},
)
device_session = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id)
.first()
)
if not device_session or device_session.revoked:
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session revoked or not found",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if session has been inactive for too long (sliding expiration)
from constants import TOKEN_INACTIVITY_EXPIRE_HOURS
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
if device_session.last_seen < inactivity_threshold:
# Session expired due to inactivity - revoke it
device_session.revoked = True
db.commit()
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to inactivity",
headers={"WWW-Authenticate": "Bearer"},
)
# Touch last_seen on valid session (sliding expiration - extends token life)
device_session.last_seen = datetime.now()
db.commit()
# Check if user is suspended
if user.suspended:
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account suspended",
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
)
# Check if user is deleted
if user.deleted:
logger.info("get_current_user: account deleted for user_id=%s", user.id)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account deleted",
)
request.state.current_user = user
request.state.session_id = session_id
return user return user
+89
View File
@@ -0,0 +1,89 @@
import logging
import os
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from threading import RLock
from typing import Dict
LOGS_DIR = Path(__file__).resolve().parent / "logs"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
class HumanReadableFileHandler(RotatingFileHandler):
def __init__(self, filename: Path, level: int) -> None:
super().__init__(filename, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", delay=True)
self.level = level
self._lock = RLock()
self._last_date: str | None = None
self._previous_entry: str | None = None
def emit(self, record: logging.LogRecord) -> None:
try:
message = record.getMessage().strip()
if not message:
return
timestamp = datetime.fromtimestamp(record.created)
date_str = timestamp.strftime("%d.%m.%Y")
time_str = timestamp.strftime("%H:%M:%S")
lines = [line.rstrip() for line in message.splitlines() if line.strip()]
with self._lock:
if self.stream is None:
self.stream = self._open()
if self._last_date != date_str:
if self._last_date is not None:
self.stream.write("\n")
separator = "-" * 11
self.stream.write(f"\n\n{separator}\n{date_str}\n{separator}\n\n")
self._last_date = date_str
entry_lines: list[str] = []
if lines:
entry_lines.append(f"{time_str} {lines[0]}")
for line in lines[1:]:
if line.startswith("|"):
entry_lines.append(f" {line}")
else:
entry_lines.append(f"{line}")
else:
entry_lines.append(time_str)
entry_text = "\n".join(entry_lines)
if entry_text == self._previous_entry:
return
self.stream.write(entry_text + "\n")
self._previous_entry = entry_text
self.flush()
except Exception:
self.handleError(record)
_HANDLED_FILES: Dict[str, Path] = {}
def _configure_logger(name: str, filename: str, level: int = logging.INFO) -> logging.Logger:
logger = logging.getLogger(name)
target_path = LOGS_DIR / filename
if _HANDLED_FILES.get(name) == target_path:
return logger
logger.handlers.clear()
handler = HumanReadableFileHandler(target_path, level)
handler.setLevel(level)
logger.addHandler(handler)
logger.setLevel(level)
logger.propagate = False
_HANDLED_FILES[name] = target_path
return logger
security_logger = _configure_logger("security", "security.log")
public_chat_logger = _configure_logger("public_chat", "public-chat.log")
dm_logger = _configure_logger("dm", "dm.log")
access_logger = _configure_logger("access", "access.log")
+6 -6
View File
@@ -1,7 +1,7 @@
from constants import * from backend.shared.constants import *
from db import * from backend.shared.db import *
from models import * from backend.shared.models import *
from validation import * from backend.shared.validation import *
from utils import * from backend.shared.utils import *
from dependencies import * from backend.shared.dependencies import *
from app import * from app import *
+153 -31
View File
@@ -8,7 +8,7 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine from sqlalchemy import create_engine
from constants import DATABASE_URL from backend.shared.constants import DATABASE_URL
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,19 +19,36 @@ def run_migrations():
This function will upgrade the database to the latest migration. This function will upgrade the database to the latest migration.
Fully automated - handles all scenarios automatically. Fully automated - handles all scenarios automatically.
""" """
# Get the directory where this script is located
current_dir = os.path.dirname(os.path.abspath(__file__))
try: try:
# Get the directory where this script is located # FIRST: Check if database has any application tables (excluding alembic_version)
current_dir = os.path.dirname(os.path.abspath(__file__)) engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import inspect
inspector = inspect(connection)
existing_tables = [table for table in inspector.get_table_names()
if not table.startswith('sqlite_') and table != 'alembic_version']
# If no application tables exist, create them directly from models
if not existing_tables:
logger.info("No application tables found. Creating all tables directly from models...")
from backend.shared.models import Base
Base.metadata.create_all(bind=engine)
logger.info("All tables created successfully from models.")
# Note: Problematic migrations are now cleaned up during Docker build
# Create Alembic configuration # Create Alembic configuration
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini")) alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
# Disable Alembic's logging configuration to avoid interfering with FastAPI # Disable Alembic's logging configuration to avoid interfering with FastAPI
alembic_cfg.set_main_option("configure_logging", "false") alembic_cfg.set_main_option("configure_logging", "false")
# Set the database URL in the config # Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL) alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Check if any migration files exist # Check if any migration files exist
versions_dir = os.path.join(current_dir, "alembic", "versions") versions_dir = os.path.join(current_dir, "alembic", "versions")
@@ -39,6 +56,13 @@ def run_migrations():
os.makedirs(versions_dir) os.makedirs(versions_dir)
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
# If no migration files exist after cleanup, create initial migration
if not migration_files:
logger.info("No migration files found after cleanup. Creating initial migration...")
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
logger.info(f"Created {len(migration_files)} initial migration(s)")
if not migration_files: if not migration_files:
logger.info("No migration files found. Creating initial migration...") logger.info("No migration files found. Creating initial migration...")
@@ -102,13 +126,32 @@ def run_migrations():
logger.info(f"No new migrations needed or error creating migration: {e}") logger.info(f"No new migrations needed or error creating migration: {e}")
pass 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 # Run the upgrade command
logger.info("Running database migrations...") logger.info("Running database migrations...")
try: try:
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully.") logger.info("Database migrations completed successfully.")
except Exception as upgrade_error: except Exception as upgrade_error:
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error): error_msg = str(upgrade_error)
# Handle PostgreSQL "already exists" errors gracefully
if "already exists" in error_msg.lower() or "relation" in error_msg.lower() and "exists" in error_msg.lower():
pass
elif "Can't locate revision identified by 'direct_creation'" in error_msg:
logger.info("Found 'direct_creation' revision - resetting migration state...") logger.info("Found 'direct_creation' revision - resetting migration state...")
# Clear the alembic_version table and start fresh # Clear the alembic_version table and start fresh
engine = create_engine(DATABASE_URL) engine = create_engine(DATABASE_URL)
@@ -116,9 +159,44 @@ def run_migrations():
from sqlalchemy import text from sqlalchemy import text
connection.execute(text("DELETE FROM alembic_version")) connection.execute(text("DELETE FROM alembic_version"))
connection.commit() 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 # Try upgrade again
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.") logger.info("Database migrations completed successfully after reset.")
elif "no such table" in str(upgrade_error).lower():
logger.info("Database tables missing - resetting migration state...")
# Clear the alembic_version table and start fresh
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import text
connection.execute(text("DELETE FROM alembic_version"))
connection.commit()
# Try upgrade again
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.")
# Note: Index-related errors are now prevented by Docker build cleanup
else: else:
raise upgrade_error raise upgrade_error
@@ -134,19 +212,37 @@ def run_migrations():
connection.execute(text("DROP TABLE IF EXISTS alembic_version")) connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
connection.commit() connection.commit()
# Remove any existing migration files to start fresh # Check if we have existing migration files
versions_dir = os.path.join(current_dir, "alembic", "versions") versions_dir = os.path.join(current_dir, "alembic", "versions")
for file in os.listdir(versions_dir): migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
if file.endswith('.py') and not file.startswith('__'):
os.remove(os.path.join(versions_dir, file))
# Create a completely fresh migration with full schema if migration_files:
logger.info("Creating fresh migration with complete schema...") # We have migration files, just fix the alembic_version table
_create_complete_migration(alembic_cfg) logger.info("Found existing migration files, fixing alembic_version table...")
latest_migration = max(migration_files)
# Run the migration migration_path = os.path.join(versions_dir, latest_migration)
command.upgrade(alembic_cfg, "head")
logger.info("Automated recovery completed successfully.") 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: except Exception as recovery_error:
logger.error(f"Automated recovery failed: {recovery_error}") logger.error(f"Automated recovery failed: {recovery_error}")
@@ -207,7 +303,7 @@ def _populate_migration_file(migration_path):
def _generate_migration_from_models(): def _generate_migration_from_models():
"""Generate migration content dynamically from SQLAlchemy models.""" """Generate migration content dynamically from SQLAlchemy models."""
from models import Base from backend.shared.models import Base
import sqlalchemy as sa import sqlalchemy as sa
from datetime import datetime from datetime import datetime
@@ -392,10 +488,12 @@ def _get_column_type(column):
def _create_database_directly(): def _create_database_directly():
"""Fallback method: create database directly using SQLAlchemy.""" """Fallback method: create database directly using SQLAlchemy."""
from models import Base from backend.shared.models import Base
from db import engine from backend.shared.db import get_engine
from sqlalchemy import text, inspect from sqlalchemy import text, inspect
engine = get_engine()
# Check existing tables and update schema # Check existing tables and update schema
with engine.connect() as connection: with engine.connect() as connection:
inspector = inspect(connection) inspector = inspect(connection)
@@ -452,14 +550,38 @@ def _create_database_directly():
logger.info(f"Creating table {table_name}") logger.info(f"Creating table {table_name}")
# Create alembic_version table manually # Create alembic_version table manually
connection.execute(text(""" engine = get_engine(DATABASE_URL)
CREATE TABLE IF NOT EXISTS alembic_version ( with engine.connect() as connection:
version_num VARCHAR(32) NOT NULL, # Create alembic_version table manually
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) connection.execute(text("""
) CREATE TABLE IF NOT EXISTS alembic_version (
""")) version_num VARCHAR(32) NOT NULL,
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
connection.commit() )
"""))
# Get the correct revision ID from existing migration files
current_dir = os.path.dirname(os.path.abspath(__file__))
versions_dir = os.path.join(current_dir, "alembic", "versions")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
if migration_files:
latest_migration = max(migration_files)
migration_path = os.path.join(versions_dir, latest_migration)
with open(migration_path, 'r') as f:
content = f.read()
import re
revision_match = re.search(r"revision: str = '([^']+)'", content)
if revision_match:
revision_id = revision_match.group(1)
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING"))
else:
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
connection.commit()
def _get_sql_type(column): def _get_sql_type(column):
+75 -2
View File
@@ -13,12 +13,17 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, 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) password_hash = Column(String(200), nullable=False)
profile_picture = Column(String(255), nullable=True) profile_picture = Column(String(255), nullable=True)
bio = Column(Text, nullable=True) bio = Column(Text, nullable=True)
online = Column(Boolean, default=False) online = Column(Boolean, default=False)
last_seen = Column(DateTime, default=datetime.now) last_seen = Column(DateTime, default=datetime.now)
created_at = 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") messages = relationship("Message", back_populates="author", lazy="select")
@@ -108,6 +113,16 @@ class PushSubscription(Base):
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class FcmToken(Base):
__tablename__ = "fcm_token"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
token = Column(Text, nullable=False, unique=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Reaction(Base): class Reaction(Base):
__tablename__ = "reaction" __tablename__ = "reaction"
@@ -141,6 +156,37 @@ class DMReaction(Base):
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),) __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 модели # Pydantic модели
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
@@ -149,10 +195,17 @@ class LoginRequest(BaseModel):
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
username: str username: str
display_name: str
password: str password: str
confirm_password: str confirm_password: str
class ChangePasswordRequest(BaseModel):
currentPasswordDerived: str
newPasswordDerived: str
logoutAllExceptCurrent: bool = False
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
content: str content: str
reply_to_id: int | None = None reply_to_id: int | None = None
@@ -178,11 +231,16 @@ class PushSubscriptionRequest(BaseModel):
class UserProfileResponse(BaseModel): class UserProfileResponse(BaseModel):
id: int id: int
username: str username: str
display_name: str
profile_picture: str | None profile_picture: str | None
bio: str | None bio: str | None
online: bool online: bool
last_seen: datetime last_seen: datetime | None
created_at: datetime created_at: datetime | None
verified: bool
suspended: bool
suspension_reason: str | None
deleted: bool
class Config: class Config:
from_attributes = True from_attributes = True
@@ -235,5 +293,20 @@ class DMReactionResponse(BaseModel):
from_attributes = True 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 # Tables are now created through Alembic migrations
# Base.metadata.create_all(bind=engine) # 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 "/logo.png",
"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()
+7
View File
@@ -2,6 +2,7 @@ PyJWT>=2.8.0
fastapi[standard]>=0.116.1 fastapi[standard]>=0.116.1
pydantic>=2.11.7 pydantic>=2.11.7
sqlalchemy>=2.0.43 sqlalchemy>=2.0.43
psycopg2-binary>=2.9.9
bcrypt>=4.3.0 bcrypt>=4.3.0
websockets>=15.0.1 websockets>=15.0.1
Pillow>=10.0.0 Pillow>=10.0.0
@@ -10,3 +11,9 @@ pywebpush>=1.14.0
cryptography>=41.0.0 cryptography>=41.0.0
alembic>=1.13.2 alembic>=1.13.2
better-profanity>=0.7.0 better-profanity>=0.7.0
user-agents>=2.2.0
httpx>=0.27.2
rich>=13.9.4
slowapi>=0.1.9
firebase_admin>=7.1.0
PyNaCl>=1.5.0
+357 -43
View File
@@ -1,25 +1,62 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from collections import defaultdict, deque
import time
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session 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 backend.shared.constants import OWNER_USERNAME
from dependencies import get_current_user, get_db from backend.shared.dependencies import get_current_user, get_db
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup
from utils import create_token, get_password_hash, verify_password from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip
from validation import is_valid_password, is_valid_username from backend.shared.validation import is_valid_password, is_valid_username, is_valid_display_name
import os
from backend.security.audit import log_security
from backend.security.profanity import contains_profanity
from backend.security.rate_limit import rate_limit_per_ip
router = APIRouter() router = APIRouter()
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
_FAILED_ATTEMPT_THRESHOLD = 5
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
def _record_failed_login(identifier: str) -> bool:
now = time.time()
attempts = _failed_login_attempts[identifier]
attempts.append(now)
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
attempts.popleft()
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
def _reset_failed_logins(identifier: str) -> None:
_failed_login_attempts.pop(identifier, None)
def _is_admin(user: User) -> bool:
return user.id == 1
def convert_user(user: User) -> dict: def convert_user(user: User) -> dict:
return { return {
"id": user.id, "id": user.id,
"created_at": user.created_at.isoformat(), "created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(), "last_seen": user.last_seen.isoformat(),
"online": user.online, "online": user.is_online,
"username": user.username, "username": user.username,
"profile_picture": user.profile_picture, "display_name": user.display_name,
"profile_picture": user.avatar_url,
"bio": user.bio, "bio": user.bio,
"admin": user.username == OWNER_USERNAME "admin": _is_admin(user),
"verified": user.verified,
"suspended": user.suspended,
"suspension_reason": user.suspension_reason,
"deleted": user.deleted
} }
@router.get("/check_auth") @router.get("/check_auth")
@@ -27,25 +64,83 @@ def check_auth(current_user: User = Depends(get_current_user)):
return { return {
"authenticated": True, "authenticated": True,
"username": current_user.username, "username": current_user.username,
"admin": current_user.username == OWNER_USERNAME "admin": _is_admin(current_user)
} }
@router.post("/login") @router.post("/login")
def login(request: LoginRequest, db: Session = Depends(get_db)): @rate_limit_per_ip("5/minute")
user = db.query(User).filter(User.username == request.username.strip()).first() 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")
if not user or not verify_password(request.password.strip(), user.password_hash): user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(login_request.password.strip(), user.hashed_password):
log_security(
"login_failed",
severity="warning",
username=username,
ip=client_ip,
reason="invalid_credentials",
)
identifiers = [f"user:{username}"]
if client_ip:
identifiers.append(f"ip:{client_ip}")
suspicious = False
for identifier in identifiers:
if _record_failed_login(identifier):
suspicious = True
if suspicious:
total_failures = {
identifier: len(_failed_login_attempts.get(identifier, []))
for identifier in identifiers
}
log_security(
"auth_bruteforce_detected",
severity="warning",
username=username,
ip=client_ip,
failures=total_failures,
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
)
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail="Неверное имя пользователя или пароль" detail="Неверное имя пользователя или пароль"
) )
# Generate session ID for JWT (device session will be created on first device service access)
session_id = uuid.uuid4().hex
user.online = True user.online = True
user.last_seen = datetime.now() user.last_seen = datetime.now()
db.commit() db.commit()
token = create_token(user.id, user.username) token = create_token(user.id, user.username, session_id)
identifiers = [f"user:{username}"]
if client_ip:
identifiers.append(f"ip:{client_ip}")
for identifier in identifiers:
_reset_failed_logins(identifier)
# Parse user agent for logging
ua = parse_ua(raw_ua or "")
device_type = "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"
log_security(
"login_success",
username=user.username,
user_id=user.id,
ip=client_ip,
session_id=session_id,
device=device_type,
os=ua.os.family,
browser=ua.browser.family,
)
return { return {
"status": "success", "status": "success",
@@ -56,26 +151,39 @@ def login(request: LoginRequest, db: Session = Depends(get_db)):
@router.post("/register") @router.post("/register")
def register(request: RegisterRequest, db: Session = Depends(get_db)): @rate_limit_per_ip("3/hour")
username = request.username.strip() def register(request: Request, register_request: RegisterRequest, db: Session = Depends(get_db)):
password = request.password.strip() username = register_request.username.strip()
confirm_password = request.confirm_password.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 # Determine if owner already exists
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None 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 # Validate input
if not is_valid_username(username): if not is_valid_username(username):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Имя пользователя должно быть от 3 до 20 символов и не содержать пробелов" 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): if not is_valid_password(password):
@@ -90,13 +198,6 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
detail="Пароли не совпадают" 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() existing_user = db.query(User).filter(User.username == username).first()
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
@@ -105,18 +206,48 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
) )
hashed_password = get_password_hash(password) 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( new_user = User(
username=username, username=username,
password_hash=hashed_password, display_name=display_name,
online=True, hashed_password=hashed_password,
last_seen=datetime.now() salt="", # Not used since bcrypt includes salt in hash
is_online=True,
last_seen=datetime.now(),
verified=is_owner
) )
db.add(new_user) db.add(new_user)
db.commit() db.commit()
db.refresh(new_user) db.refresh(new_user)
token = create_token(new_user.id, new_user.username) # Generate a temporary session ID for the token (device session will be created on first device service access)
session_id = uuid.uuid4().hex
token = create_token(new_user.id, new_user.username, session_id)
# Parse user agent for logging
raw_ua = request.headers.get("user-agent")
ua = parse_ua(raw_ua or "")
os_name = ua.os.family or "Unknown OS"
if ua.os.version_string:
os_name = f"{os_name} {ua.os.version_string}"
browser_name = ua.browser.family or "Unknown browser"
if ua.browser.version_string:
browser_name = f"{browser_name} {ua.browser.version_string}"
user_agent_summary = f"{os_name}, {browser_name}"
log_security(
"registration_success",
username=new_user.username,
display_name=new_user.display_name,
user_id=new_user.id,
ip=client_ip,
user_agent=user_agent_summary,
owner=is_owner,
)
return { return {
"status": "success", "status": "success",
@@ -136,6 +267,8 @@ def set_public_key(payload: dict, current_user: User = Depends(get_current_user)
pk = payload.get("publicKey") pk = payload.get("publicKey")
if not pk: if not pk:
raise HTTPException(status_code=400, detail="publicKey required") 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() row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
if row: if row:
row.public_key_b64 = pk row.public_key_b64 = pk
@@ -157,6 +290,8 @@ def set_backup(payload: dict, current_user: User = Depends(get_current_user), db
blob = payload.get("blob") blob = payload.get("blob")
if not blob: if not blob:
raise HTTPException(status_code=400, detail="blob required") 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() row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
if row: if row:
row.blob_json = blob row.blob_json = blob
@@ -174,7 +309,7 @@ def delete_user_as_owner(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Only owner can delete users # Only owner can delete users
if current_user.username != OWNER_USERNAME: if _is_admin(current_user):
raise HTTPException(status_code=403, detail="Only owner can perform this action") raise HTTPException(status_code=403, detail="Only owner can perform this action")
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
@@ -182,35 +317,107 @@ def delete_user_as_owner(
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting the owner account via API # Prevent deleting the owner account via API
if user.username == OWNER_USERNAME: if _is_admin(user):
raise HTTPException(status_code=400, detail="Cannot delete owner account") raise HTTPException(status_code=400, detail="Cannot delete owner account")
# Manually delete user's messages to satisfy FK constraints # Manually delete user's messages to satisfy FK constraints
from models import Message # local import to avoid circular from backend.shared.models import Message # local import to avoid circular
db.query(Message).filter(Message.user_id == user.id).delete() db.query(Message).filter(Message.user_id == user.id).delete()
db.delete(user) db.delete(user)
db.commit() 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} return {"status": "success", "deleted_user_id": user_id}
@router.get("/logout") @router.get("/logout")
def logout( def logout(
http: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Revoke current session - TODO: Move to device service
# from backend.shared.utils import verify_token as _verify_token
# payload = _verify_token(credentials.credentials)
# if payload and payload.get("session_id"):
# db.query(DeviceSession).filter(
# DeviceSession.user_id == current_user.id,
# DeviceSession.session_id == payload["session_id"],
# ).update({DeviceSession.revoked: True})
current_user.online = False current_user.online = False
current_user.last_seen = datetime.now() current_user.last_seen = datetime.now()
db.commit() db.commit()
client_ip = get_client_ip(http)
log_security(
"logout",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
session_id=None, # TODO: Get session_id from device service
)
return { return {
"status": "success", "status": "success",
"message": "Logged out successfully" "message": "Logged out successfully"
} }
@router.post("/change-password")
@rate_limit_per_ip("5/hour")
def change_password(
request: Request,
password_request: ChangePasswordRequest,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Verify current derived password against stored hash
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.hashed_password):
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
# Update password hash to hash of new derived password
current_user.hashed_password = get_password_hash(password_request.newPasswordDerived.strip())
db.commit()
# Optionally revoke all other sessions, keeping the current one - TODO: Move to device service
# if password_request.logoutAllExceptCurrent:
# from backend.shared.utils import verify_token as _verify_token
# payload = _verify_token(credentials.credentials)
# if not payload:
# raise HTTPException(status_code=401, detail="Invalid token")
# current_session_id = payload.get("session_id")
# db.query(DeviceSession).filter(
# DeviceSession.user_id == current_user.id,
# DeviceSession.session_id != current_session_id,
# ).update({DeviceSession.revoked: True})
# db.commit()
client_ip = get_client_ip(request)
log_security(
"password_changed",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
logout_others=bool(password_request.logoutAllExceptCurrent),
)
return {"status": "success"}
@router.get("/users") @router.get("/users")
def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): @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() users = db.query(User).order_by(User.username.asc()).all()
return { return {
"users": [ "users": [
@@ -220,6 +427,113 @@ def list_users(current_user: User = Depends(get_current_user), db: Session = Dep
@router.get("/crypto/public-key/of/{user_id}") @router.get("/crypto/public-key/of/{user_id}")
def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): @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() row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
return {"publicKey": row.public_key_b64 if row else None} return {"publicKey": row.public_key_b64 if row else None}
@router.get("/users/search")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if len(q.strip()) < 2:
return {"users": []}
# Case-insensitive partial match on username
users = db.query(User).filter(
User.username.ilike(f"%{q.strip()}%"),
User.id != current_user.id # Exclude current user
).order_by(User.username.asc()).limit(20).all()
return {
"users": [convert_user(u) for u in users]
}
async def _delete_user_data(user: User, db: Session):
"""
Helper function to delete user data - marks user as deleted, clears sensitive data,
deletes profile picture, removes non-whitelist user data, and sends WebSocket message.
"""
user_id = user.id
# Mark user as deleted and clear sensitive data
user.deleted = True
user.display_name = f"Deleted User #{user_id}"
user.bio = None
user.hashed_password = ""
user.username = f"deleted_{user_id}"
user.profile_picture = None
user.last_seen = None # Clear last seen timestamp
user.created_at = None # Clear member since timestamp
# Delete profile picture file if exists
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"):
try:
filename = user.profile_picture.split("/")[-1]
filepath = os.path.join("data/uploads/pfp", filename)
if os.path.exists(filepath):
os.remove(filepath)
except Exception as e:
# Log error but don't fail the request
pass
# Dynamic deletion of all non-whitelist data
WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"}
try:
inspector = inspect(db.bind)
all_tables = inspector.get_table_names()
for table_name in all_tables:
if table_name in WHITELIST_TABLES or table_name == "user":
continue
# Check if table has user_id column
columns = inspector.get_columns(table_name)
has_user_id = any(col['name'] == 'user_id' for col in columns)
if has_user_id:
# Delete all records for this user
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
db.commit()
except Exception as e:
# Log error and rollback
db.rollback()
raise HTTPException(status_code=500, detail="Failed to delete user data")
# Send WebSocket deletion message
try:
from .messaging import messagingManager
await messagingManager.send_deletion_to_user(user_id)
except Exception as e:
# Log error but don't fail the request
pass
@router.post("/delete")
async def delete_account(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Delete the current user's own account - preserves messages/DMs/reactions/files
"""
# Prevent admin/owner account self-deletion
if _is_admin(current_user):
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
await _delete_user_data(current_user, db)
log_security(
"self_delete_account",
severity="warning",
user_id=current_user.id,
username=current_user.username,
)
return {
"status": "success",
"message": "Account deleted successfully"
}
+92
View File
@@ -0,0 +1,92 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from backend.shared.dependencies import get_current_user, get_db
from backend.shared.models import User, DeviceSession
from backend.shared.utils import verify_token
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
router = APIRouter()
security = HTTPBearer()
def _get_current_session_id(credentials: HTTPAuthorizationCredentials) -> str:
token = credentials.credentials
payload = verify_token(token)
if not payload or "session_id" not in payload:
raise HTTPException(status_code=401, detail="Invalid session")
return payload["session_id"]
@router.get("")
def list_devices(
credentials: HTTPAuthorizationCredentials = Depends(security),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
current_session_id = _get_current_session_id(credentials)
sessions = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == current_user.id, DeviceSession.revoked == False)
.order_by(DeviceSession.last_seen.desc())
.all()
)
return {
"devices": [
{
"session_id": s.session_id,
"device_type": s.device_type,
"device_name": s.device_name,
"os_name": s.os_name,
"os_version": s.os_version,
"browser_name": s.browser_name,
"browser_version": s.browser_version,
"brand": s.brand,
"model": s.model,
"created_at": s.created_at.isoformat() if s.created_at else None,
"last_seen": s.last_seen.isoformat() if s.last_seen else None,
"revoked": s.revoked,
"current": s.session_id == current_session_id,
}
for s in sessions
]
}
@router.delete("/{session_id}")
def revoke_device(
session_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
if not session_id or len(session_id) > 64 or len(session_id) < 1:
raise HTTPException(status_code=400, detail="Invalid session ID")
s = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id)
.first()
)
if not s:
raise HTTPException(status_code=404, detail="Device session not found")
s.revoked = True
db.commit()
return {"status": "success"}
@router.post("/logout-all")
def logout_all_except_current(
credentials: HTTPAuthorizationCredentials = Depends(security),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
current_session_id = _get_current_session_id(credentials)
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id != current_session_id,
).update({DeviceSession.revoked: True})
db.commit()
return {"status": "success"}
+1050 -442
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import List
from backend.shared.constants import OWNER_USERNAME
from backend.shared.dependencies import get_current_user
from backend.shared.models import User
from backend.security.audit import log_security
from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
from backend.security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
class BlocklistUpdateRequest(BaseModel):
words: List[str] = Field(default_factory=list, min_items=1)
class UnblockIPRequest(BaseModel):
ip: str = Field(..., min_length=1)
router = APIRouter(prefix="/moderation", tags=["moderation"])
def _ensure_owner(user: User) -> None:
if user.username != OWNER_USERNAME:
raise HTTPException(status_code=403, detail="Only owner can perform this action")
@router.get("/blocklist")
def list_blocklist(current_user: User = Depends(get_current_user)):
_ensure_owner(current_user)
return {"words": get_blocklist()}
@router.post("/blocklist")
def append_blocklist(
request: BlocklistUpdateRequest,
current_user: User = Depends(get_current_user)
):
_ensure_owner(current_user)
added, updated = add_to_blocklist(request.words)
log_security(
"blocklist_add",
actor=current_user.username,
actor_id=current_user.id,
added=added,
)
return {"added": added, "words": updated}
@router.delete("/blocklist")
def delete_from_blocklist(
request: BlocklistUpdateRequest,
current_user: User = Depends(get_current_user)
):
_ensure_owner(current_user)
removed, updated = remove_from_blocklist(request.words)
log_security(
"blocklist_remove",
actor=current_user.username,
actor_id=current_user.id,
removed=removed,
)
return {"removed": removed, "words": updated}
@router.post("/unblock-ip")
def unblock_ip(
request: UnblockIPRequest,
current_user: User = Depends(get_current_user)
):
"""Unblock an IP address from rate limiting."""
_ensure_owner(current_user)
ip = request.ip.strip()
if not ip:
raise HTTPException(status_code=400, detail="IP address is required")
cleared = reset_rate_limit_for_ip(ip)
log_security(
"rate_limit_unblock",
actor=current_user.username,
actor_id=current_user.id,
ip=ip,
success=cleared,
)
if cleared:
return {"status": "success", "message": f"Rate limit cleared for IP: {ip}"}
else:
return {"status": "success", "message": f"No rate limit entries found for IP: {ip}"}
@router.post("/clear-all-rate-limits")
def clear_all_rate_limits_endpoint(
current_user: User = Depends(get_current_user)
):
"""Clear all rate limit entries. Use with caution."""
_ensure_owner(current_user)
cleared = clear_all_rate_limits()
log_security(
"rate_limit_clear_all",
actor=current_user.username,
actor_id=current_user.id,
entries_cleared=cleared,
)
return {"status": "success", "message": f"Cleared {cleared} rate limit entries"}
+402 -27
View File
@@ -7,25 +7,44 @@ from PIL import Image
import os import os
import uuid import uuid
import io import io
from fastapi import Request
from dependencies import get_db, get_current_user from backend.shared.dependencies import get_db, get_current_user
from models import User, UpdateBioRequest, UserProfileResponse from backend.shared.models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel from pydantic import BaseModel
from backend.shared.validation import is_valid_username, is_valid_display_name
from backend.shared.similarity import is_user_similar_to_verified
import os
import httpx
from backend.security.audit import log_security
from backend.security.profanity import contains_profanity
from backend.security.rate_limit import rate_limit_per_ip
router = APIRouter() 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 # Request models
class UpdateProfileRequest(BaseModel): class UpdateProfileRequest(BaseModel):
nickname: str | None = None username: str | None = None
display_name: str | None = None
description: str | None = None description: str | None = None
# Create uploads directory if it doesn't exist # Create uploads directory if it doesn't exist
PROFILE_PICTURES_DIR = Path("data/uploads/pfp") PROFILE_PICTURES_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "pfp"
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
@router.post("/upload-profile-picture") @router.post("/upload-profile-picture")
@rate_limit_per_ip("10/minute")
async def upload_profile_picture( async def upload_profile_picture(
request: Request,
profile_picture: UploadFile = File(...), profile_picture: UploadFile = File(...),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
@@ -99,19 +118,60 @@ async def get_user_profile(
""" """
Get current user's profile information 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 { return {
"id": current_user.id, "users": [
"username": current_user.username, UserProfileResponse(
"profile_picture": current_user.profile_picture, id=user.id,
"bio": current_user.bio, username=user.username,
"online": current_user.online, display_name=user.display_name,
"last_seen": current_user.last_seen, profile_picture=user.profile_picture,
"created_at": current_user.created_at 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") @router.put("/user/profile")
@rate_limit_per_ip("10/minute")
async def update_user_profile( async def update_user_profile(
request: UpdateProfileRequest, request: Request,
update_request: UpdateProfileRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
@@ -121,24 +181,47 @@ async def update_user_profile(
updated = False updated = False
# Update username if provided # Update username if provided
if request.nickname is not None: if update_request.username is not None:
nickname = request.nickname.strip() username = update_request.username.strip()
if len(nickname) < 3: if not is_valid_username(username):
raise HTTPException(status_code=400, detail="Username must be at least 3 characters long") raise HTTPException(
if len(nickname) > 50: status_code=400,
raise HTTPException(status_code=400, detail="Username must be 50 characters or less") detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
)
if contains_profanity(username):
raise HTTPException(
status_code=400,
detail="Имя пользователя содержит запрещённые слова"
)
# Check if username is already taken by another user # Check if username is already taken by another user
existing_user = db.query(User).filter(User.username == nickname, User.id != current_user.id).first() existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first()
if existing_user: if existing_user:
raise HTTPException(status_code=400, detail="Username already taken") raise HTTPException(status_code=400, detail="Это имя пользователя уже занято")
current_user.username = nickname 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 updated = True
# Update bio if provided # Update bio if provided
if request.description is not None: if update_request.description is not None:
bio = request.description.strip() bio = update_request.description.strip()
if len(bio) > 500: if len(bio) > 500:
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less") raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
@@ -150,29 +233,33 @@ async def update_user_profile(
return { return {
"message": "Profile updated successfully", "message": "Profile updated successfully",
"username": current_user.username, "username": current_user.username,
"display_name": current_user.display_name,
"bio": current_user.bio "bio": current_user.bio
} }
else: else:
return { return {
"message": "No changes made", "message": "No changes made",
"username": current_user.username, "username": current_user.username,
"display_name": current_user.display_name,
"bio": current_user.bio "bio": current_user.bio
} }
@router.put("/user/bio") @router.put("/user/bio")
@rate_limit_per_ip("10/minute")
async def update_user_bio( async def update_user_bio(
request: UpdateBioRequest, request: Request,
bio_request: UpdateBioRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
Update current user's bio Update current user's bio
""" """
if len(request.bio) > 500: # Limit bio to 500 characters if len(bio_request.bio) > 500: # Limit bio to 500 characters
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less") raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
current_user.bio = request.bio.strip() current_user.bio = bio_request.bio.strip()
db.commit() db.commit()
return { return {
@@ -189,17 +276,305 @@ async def get_user_by_username(
""" """
Get user profile by username 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() user = db.query(User).filter(User.username == username).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") 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( return UserProfileResponse(
id=user.id, id=user.id,
username=user.username, username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture, profile_picture=user.profile_picture,
bio=user.bio, bio=user.bio,
online=user.online, online=user.online,
last_seen=user.last_seen, last_seen=user.last_seen,
created_at=user.created_at created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
) )
@router.get("/user/id/{user_id}")
async def get_user_by_id(
user_id: int,
db: Session = Depends(get_db)
):
"""
Get user profile by user ID
"""
if user_id <= 0:
raise HTTPException(status_code=400, detail="Invalid user ID")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
_ensure_owner_unsuspended(user, db)
# Handle deleted or suspended users
if user.deleted or user.suspended:
return UserProfileResponse(
id=user.id,
username="deleted",
display_name="Deleted User",
profile_picture=None,
bio=None,
online=False,
last_seen=None, # Clear last seen timestamp
created_at=None, # Clear member since timestamp
verified=False,
suspended=False,
suspension_reason=None,
deleted=True
)
return UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=user.deleted or False
)
@router.post("/user/{user_id}/verify")
async def verify_user(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Toggle verification status for a user (owner only)
"""
# Only user with ID 1 (owner) can verify users
if current_user.id != 1:
raise HTTPException(status_code=403, detail="Only owner can verify users")
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Toggle verification status
target_user.verified = not target_user.verified
db.commit()
log_security(
"admin_verify_toggle",
actor=current_user.username,
actor_id=current_user.id,
target_username=target_user.username,
target_id=target_user.id,
verified=target_user.verified,
)
return {
"verified": target_user.verified,
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
}
@router.get("/user/check-similarity/{user_id}")
async def check_user_similarity(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Check if a user is similar to any verified user
"""
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Get all verified users
verified_users = db.query(User).filter(User.verified == True).all()
verified_users_data = [
{"username": user.username, "display_name": user.display_name}
for user in verified_users
]
# Check similarity
is_similar, similar_to = is_user_similar_to_verified(
target_user.username,
target_user.display_name,
verified_users_data
)
return {
"isSimilar": is_similar,
"similarTo": similar_to if is_similar else None
}
# Admin endpoints for user management
class SuspendUserRequest(BaseModel):
reason: str
@router.post("/user/{user_id}/suspend")
async def suspend_user(
user_id: int,
request: SuspendUserRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Suspend a user account (admin only)
"""
# Only user with ID 1 (admin) can suspend users
if current_user.id != 1:
raise HTTPException(status_code=403, detail="Only admin can suspend users")
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot suspend admin
if target_user.id == 1:
raise HTTPException(status_code=400, detail="Cannot suspend admin account")
# Suspend the user
target_user.suspended = True
target_user.suspension_reason = request.reason
db.commit()
log_security(
"admin_suspend_user",
actor=current_user.username,
actor_id=current_user.id,
target_username=target_user.username,
target_id=target_user.id,
reason=request.reason,
)
# Send WebSocket suspension message
try:
messaging_service_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{messaging_service_url}/messaging/send-suspension",
json={
"user_id": user_id,
"reason": request.reason
}
)
response.raise_for_status()
except Exception as e:
# Log error but don't fail the request
pass
return {
"status": "success",
"message": f"User {target_user.username} has been suspended",
"reason": request.reason
}
@router.post("/user/{user_id}/unsuspend")
async def unsuspend_user(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Unsuspend a user account (admin only)
"""
# Only user with ID 1 (admin) can unsuspend users
if current_user.id != 1:
raise HTTPException(status_code=403, detail="Only admin can unsuspend users")
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Unsuspend the user
target_user.suspended = False
target_user.suspension_reason = None
db.commit()
log_security(
"admin_unsuspend_user",
actor=current_user.username,
actor_id=current_user.id,
target_username=target_user.username,
target_id=target_user.id,
)
return {
"status": "success",
"message": f"User {target_user.username} has been unsuspended"
}
@router.post("/user/{user_id}/delete")
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Delete a user account (admin only) - preserves messages/DMs/reactions/files
"""
# Only user with ID 1 (admin) can delete users
if current_user.id != 1:
raise HTTPException(status_code=403, detail="Only admin can delete users")
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot delete admin
if target_user.id == 1:
raise HTTPException(status_code=400, detail="Cannot delete admin account")
snapshot_username = target_user.username
snapshot_display_name = target_user.display_name
from .account import _delete_user_data
await _delete_user_data(target_user, db)
log_security(
"admin_delete_user",
severity="warning",
actor=current_user.username,
actor_id=current_user.id,
target_username=snapshot_username,
target_display_name=snapshot_display_name,
target_id=target_user.id,
)
return {
"status": "success",
"message": f"User {target_user.username} has been deleted"
}
+27 -4
View File
@@ -1,11 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from pydantic import BaseModel
from models import User, PushSubscriptionRequest from backend.shared.dependencies import get_current_user, get_db
from push_service import push_service from backend.shared.models import User, PushSubscriptionRequest
from backend.services.push.files import push_service
router = APIRouter() router = APIRouter()
class SendPublicNotificationRequest(BaseModel):
message_id: int
exclude_user_id: int
@router.post("/subscribe") @router.post("/subscribe")
async def subscribe_to_push_notifications( async def subscribe_to_push_notifications(
request: PushSubscriptionRequest, request: PushSubscriptionRequest,
@@ -37,10 +42,28 @@ async def unsubscribe_from_push_notifications(
"""Unsubscribe user from push notifications""" """Unsubscribe user from push notifications"""
try: try:
success = await push_service.unsubscribe_user(db=db, user_id=current_user.id) success = await push_service.unsubscribe_user(db=db, user_id=current_user.id)
if success: if success:
return {"status": "success", "message": "Push notifications disabled"} return {"status": "success", "message": "Push notifications disabled"}
else: else:
raise HTTPException(status_code=500, detail="Failed to disable push notifications") raise HTTPException(status_code=500, detail="Failed to disable push notifications")
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/send-public-notification")
async def send_public_message_notification(
request: SendPublicNotificationRequest,
db: Session = Depends(get_db)
):
"""Send push notification for public message (called by messaging service)"""
try:
# Get the message from database
from backend.shared.models import Message
message = db.query(Message).filter(Message.id == request.message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
await push_service.send_public_message_notification(db, message, exclude_user_id=request.exclude_user_id)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+89
View File
@@ -0,0 +1,89 @@
import logging
import os
import hmac
import hashlib
import time
from fastapi import APIRouter, Depends
from backend.shared.dependencies import get_current_user
import traceback
router = APIRouter()
logger = logging.getLogger("uvicorn.error")
def generate_turn_credentials(username: str, secret: str, expiration_minutes: int = 60):
"""Generate time-limited TURN credentials using TURN REST API format.
This creates temporary credentials that expire after the specified time.
The username format is: timestamp:username
The password is an HMAC hash of the username and secret.
"""
# Current timestamp (seconds since epoch)
timestamp = int(time.time()) + (expiration_minutes * 60)
# Create temporary username: timestamp:original_username
temp_username = f"{timestamp}:{username}"
# Generate password using HMAC-SHA1
temp_password = hmac.new(
secret.encode('utf-8'),
temp_username.encode('utf-8'),
hashlib.sha1
).hexdigest()
return temp_username, temp_password
@router.get("/ice")
async def get_ice_servers(current_user = Depends(get_current_user)):
"""Return ICE server configuration (STUN/TURN) for WebRTC clients.
Generates time-limited TURN credentials that expire in 1 hour.
"""
try:
# Prefer using your own coturn for both STUN and TURN
turn_domain = "fromchat.ru"
stun_urls = [
f"stun:{turn_domain}:3478",
f"stuns:{turn_domain}:5349",
]
turn_urls = [
f"turn:{turn_domain}:3478",
f"turns:{turn_domain}:5349",
]
# Get TURN configuration from environment
turn_username = os.getenv("TURN_USERNAME")
turn_secret = os.getenv("TURN_SECRET")
# Check if required environment variables are set
if not turn_username:
logger.error("ERROR: TURN_USERNAME environment variable is not set")
raise ValueError("TURN_USERNAME environment variable is not set")
if not turn_secret:
logger.error("ERROR: TURN_SECRET environment variable is not set")
raise ValueError("TURN_SECRET environment variable is not set")
ice_servers: list[dict] = [{"urls": url} for url in stun_urls]
temp_username, temp_password = generate_turn_credentials(
turn_username,
turn_secret,
expiration_minutes=60 # Expires in 1 hour
)
ice_servers.append({
"urls": turn_urls,
"username": temp_username,
"credential": temp_password,
})
return {"iceServers": ice_servers}
except Exception as e:
logger.error(f"ERROR in /api/webrtc/ice: {str(e)}")
logger.error(f"ERROR type: {type(e).__name__}")
traceback.print_exc()
raise
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Local development server that runs all services in a single FastAPI application.
This provides the same monolithic experience as before, but with microservice separation.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import os
# Import service routers
from backend.routes.account import router as account_router
from backend.routes.profile import router as profile_router
from backend.routes.devices import router as device_router
from backend.routes.messaging import router as messaging_router
from backend.routes.push import router as push_router
from backend.routes.webrtc import router as webrtc_router
from backend.routes.moderation import router as moderation_router
# Import security modules
from security.audit import log_access
from security.rate_limit import limiter
from slowapi.middleware import SlowAPIMiddleware
# Create main FastAPI app
app = FastAPI(title="FromChat Local Development")
# Add rate limiting middleware
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://fromchat.ru",
"https://beta.fromchat.ru",
"https://www.fromchat.ru",
"http://127.0.0.1:8301",
"http://127.0.0.1:8300",
"http://localhost:8301",
"http://localhost:8300",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount service routers with appropriate prefixes
app.include_router(account_router, prefix="/account")
app.include_router(profile_router, prefix="/profile")
app.include_router(device_router, prefix="/devices")
app.include_router(messaging_router, prefix="/messaging")
app.include_router(push_router, prefix="/push")
app.include_router(webrtc_router, prefix="/webrtc")
app.include_router(moderation_router, prefix="/moderation")
if __name__ == "__main__":
# Run the server
port = int(os.getenv("PORT", "8301"))
host = os.getenv("HOST", "127.0.0.1")
print(f"Starting FromChat local development server on {host}:{port}")
print("Available services:")
print(" - Account: http://127.0.0.1:8301/account/")
print(" - Profile: http://127.0.0.1:8301/profile/")
print(" - Devices: http://127.0.0.1:8301/devices/")
print(" - Messaging: http://127.0.0.1:8301/messaging/")
print(" - Push: http://127.0.0.1:8301/push/")
print(" - WebRTC: http://127.0.0.1:8301/webrtc/")
print(" - Moderation: http://127.0.0.1:8301/moderation/")
uvicorn.run(
"run_local:app",
host=host,
port=port,
reload=True,
reload_dirs=["backend"]
)
+2
View File
@@ -0,0 +1,2 @@
# Package marker for security utilities
+406
View File
@@ -0,0 +1,406 @@
from __future__ import annotations
import logging
from html import unescape
from typing import Any, Callable, Dict, List
from backend.logging_config import access_logger, dm_logger, public_chat_logger, security_logger
def _clean_username(username: Any) -> str:
if not username:
return "unknown user"
return f"@{username}"
def _format_user(fields: Dict[str, Any], username_key: str = "username", user_id_key: str = "user_id") -> str:
username = fields.get(username_key)
if username is None and "_" in username_key:
base_key = username_key.split("_", 1)[0]
username = fields.get(base_key)
user_id = fields.get(user_id_key)
if user_id is None and "_" in user_id_key:
base_key = user_id_key.split("_", 1)[0]
user_id = fields.get(base_key)
if username and user_id is not None:
return f"{_clean_username(username)} (user id {user_id})"
if username:
return _clean_username(username)
if user_id is not None:
return f"user id {user_id}"
return "unknown user"
def _format_actor(fields: Dict[str, Any], prefix: str) -> str:
return _format_user(fields, f"{prefix}_username", f"{prefix}_id")
def _plural(label: str, count: int) -> str:
return f"{count} {label if count == 1 else label + 's'}"
def _yes_no(flag: Any) -> str:
return "yes" if flag else "no"
def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
if action == "login_success":
lines = [f"Login approved for {_format_user(fields)}"]
session = fields.get("session_id")
if session:
lines.append(f"Session: {session}")
client_bits: List[str] = []
if fields.get("device"):
client_bits.append(fields["device"])
if fields.get("os"):
client_bits.append(fields["os"])
if fields.get("browser"):
client_bits.append(fields["browser"])
if client_bits:
lines.append(f"Client: {', '.join(client_bits)}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "login_failed":
lines = [f"Login denied for {_format_user(fields)}"]
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "auth_bruteforce_detected":
lines = ["Brute-force login pattern detected"]
lines.append(f"Target: {_format_user(fields)}")
failures = fields.get("failures")
if isinstance(failures, dict):
for key, value in failures.items():
lines.append(f"{key}: {value}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
if fields.get("window_seconds"):
lines.append(f"Observation window: {fields['window_seconds']} seconds")
return lines
if action == "registration_success":
ip_raw = fields.get("ip")
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
display_name = fields.get("display_name") or "Unknown"
username = fields.get("username")
user_id = fields.get("user_id")
user_agent = fields.get("user_agent") or "Unknown user agent"
lines = ["Account registered"]
lines.append(f"Display name: {display_name}")
lines.append(f"Username: {_clean_username(username) if username else 'unknown'}")
if ip_display:
lines.append(f"IP: {ip_display}")
if user_agent:
lines.append(f"User agent: {user_agent}")
if user_id is not None:
lines.append(f"User ID: {user_id}")
return lines
if action == "password_changed":
lines = [f"Password changed for {_format_user(fields)}"]
lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "logout":
lines = [f"Logout recorded for {_format_user(fields)}"]
if fields.get("session_id"):
lines.append(f"Session: {fields['session_id']}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "admin_delete_user":
return [
"Account removal",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
]
if action == "admin_suspend_user":
lines = [
"User suspension",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
]
if fields.get("reason"):
lines.append(f"Reason: {fields.get('reason')}")
return lines
if action == "admin_unsuspend_user":
return [
"User unsuspension",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
]
if action == "admin_verify_toggle":
return [
"User verification",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
f"Verified: {_yes_no(fields.get('verified'))}",
]
if action == "self_delete_account":
return [f"User {_format_user(fields)} deleted their account"]
if action == "auto_suspension_public_spam":
lines = [
f"Automatic suspension triggered for {_format_user(fields)}",
]
match_type = fields.get("match_type")
if match_type:
lines.append(f"Match type: {match_type}")
similar = fields.get("similar_messages")
occurrences = fields.get("occurrences")
if similar:
lines.append(f"Similar messages detected: {similar}")
if occurrences and not similar:
lines.append(f"Occurrences: {occurrences}")
if fields.get("window_seconds"):
lines.append(f"Observation window: {fields['window_seconds']} seconds")
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
return lines
if action == "auto_suspension_public_burst":
lines = [
f"Automatic suspension triggered for {_format_user(fields)}",
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
]
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
return lines
if action == "public_message_burst":
return [
f"Rapid messaging spike for {_format_user(fields)}",
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
]
if action == "blocklist_add":
added = fields.get("added") or []
lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"]
if added:
lines.append(f"Added entries: {', '.join(added)}")
total = len(fields.get("words") or [])
lines.append(f"Total entries: {total}")
return lines
if action == "blocklist_remove":
removed = fields.get("removed") or []
lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"]
if removed:
lines.append(f"Removed entries: {', '.join(removed)}")
total = len(fields.get("words") or [])
lines.append(f"Total entries: {total}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]:
if action == "message_created":
lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"]
if fields.get("reply_to"):
lines.append(f"In reply to message #{fields['reply_to']}")
attachments = fields.get("attachments")
if attachments:
lines.append(f"Attachments: {_plural('file', attachments)}")
# If content was censored, log both raw and censored versions
if fields.get("raw_content") is not None:
lines.append("Raw content (before censoring):")
for line in unescape(fields["raw_content"]).splitlines():
lines.append(f"| {line}")
lines.append("Censored content (stored):")
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
lines.append(f"| {line}")
elif fields.get("content"):
lines.append("Content:")
for line in unescape(fields["content"]).splitlines():
lines.append(f"| {line}")
return lines
if action == "message_edited":
lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"]
if fields.get("reply_to"):
lines.append(f"Reply to #{fields['reply_to']}")
if fields.get("previous_content"):
lines.append("Previous content:")
for line in unescape(fields["previous_content"] or "").splitlines() or [""]:
lines.append(f"| {line}")
# If content was censored, log both raw and censored versions
if fields.get("raw_content") is not None:
lines.append("Raw content (before censoring):")
for line in unescape(fields["raw_content"]).splitlines():
lines.append(f"| {line}")
lines.append("Censored content (stored):")
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
lines.append(f"| {line}")
elif fields.get("content"):
lines.append("New content:")
for line in unescape(fields["content"] or "").splitlines() or [""]:
lines.append(f"| {line}")
return lines
if action == "message_deleted":
lines = [
f"Message #{fields.get('message_id')} deleted",
f"Actor: {_format_actor(fields, 'actor')}",
]
if fields.get("original_author_id") is not None:
lines.append(f"Original author: user #{fields['original_author_id']}")
if fields.get("content"):
lines.append("Previous content:")
for line in unescape(fields["content"]).splitlines():
lines.append(f"| {line}")
return lines
if action == "reaction_update":
lines = [
f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}",
f"User: {_format_user(fields)}",
]
if fields.get("emoji"):
lines.append(f"Emoji: {fields['emoji']}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]:
if action in {"message_sent", "message_sent_ws"}:
lines = [
f"Direct message #{fields.get('dm_envelope_id')} sent",
f"Sender: {_format_actor(fields, 'sender')}",
]
if fields.get("recipient_id") is not None:
lines.append(f"Recipient: user id {fields['recipient_id']}")
attachments = fields.get("attachment_count")
if attachments:
lines.append(f"Attachments: {_plural('file', attachments)}")
if fields.get("reply_to"):
lines.append(f"In reply to DM #{fields['reply_to']}")
return lines
if action == "message_edited":
return [
f"Direct message #{fields.get('dm_envelope_id')} edited",
f"Author: {_format_user(fields)}",
]
if action == "message_deleted":
lines = [
f"Direct message #{fields.get('dm_envelope_id')} deleted",
f"Actor: {_format_user(fields)}",
]
if fields.get("recipient_id") is not None:
lines.append(f"Recipient: user id {fields['recipient_id']}")
return lines
if action == "reaction_update":
lines = [
f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}",
f"User: {_format_user(fields)}",
]
if fields.get("emoji"):
lines.append(f"Emoji: {fields['emoji']}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _render_access(action: str, fields: Dict[str, Any]) -> List[str]:
ip_raw = fields.get("ip")
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
if action == "http_request":
first_line = f"{fields.get('method')} {fields.get('path')}"
if ip_display:
first_line += f" from {ip_display}"
first_line += f" -> {fields.get('status')}"
lines = [first_line]
if fields.get("user"):
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
return lines
if action == "http_error":
first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}"
if ip_display:
first_line += f" from {ip_display}"
lines = [first_line]
if fields.get("error"):
lines.append(f"Exception: {fields['error']}")
if fields.get("user"):
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
return lines
if action == "ws_connect":
lines = ["WebSocket connected"]
if fields.get("path"):
lines.append(f"Endpoint: {fields['path']}")
if ip_display:
lines.append(f"IP: {ip_display}")
return lines
if action == "ws_disconnect":
lines = ["WebSocket disconnected"]
if fields.get("path"):
lines.append(f"Endpoint: {fields['path']}")
if fields.get("code") is not None:
reason = fields.get("reason") or "no reason"
lines.append(f"Code {fields['code']} ({reason})")
if ip_display:
lines.append(f"IP: {ip_display}")
return lines
if action == "ws_event":
event_name = fields.get("event")
path = fields.get("path")
first_line = "WS"
if path:
first_line += f" {path}"
if ip_display:
first_line += f" from {ip_display}"
if event_name:
first_line += f" -> {event_name}"
lines = [first_line]
if fields.get("user"):
lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}")
for key, value in fields.items():
if key in {"path", "event", "user", "user_id", "ip"} or value is None:
continue
lines.append(f"{key.replace('_', ' ').capitalize()}: {value}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _log_event(
logger: logging.Logger,
renderer: Callable[[str, Dict[str, Any]], List[str]],
action: str,
severity: str,
fields: Dict[str, Any],
) -> None:
lines = renderer(action, fields)
if not lines:
return
level = getattr(logging, severity.upper(), logging.INFO)
logger.log(level, "\n".join(lines))
def log_security(action: str, severity: str = "info", **fields: Any) -> None:
_log_event(security_logger, _render_security, action, severity, fields)
def log_public_chat(action: str, severity: str = "info", **fields: Any) -> None:
_log_event(public_chat_logger, _render_public_chat, action, severity, fields)
def log_dm(action: str, severity: str = "info", **fields: Any) -> None:
sanitized_fields = {key: value for key, value in fields.items() if key != "content"}
_log_event(dm_logger, _render_dm, action, severity, sanitized_fields)
def log_access(action: str, severity: str = "info", **fields: Any) -> None:
_log_event(access_logger, _render_access, action, severity, fields)
+694
View File
@@ -0,0 +1,694 @@
from __future__ import annotations
import json
import re
import unicodedata
from pathlib import Path
from threading import RLock
from typing import Iterable, List, Set, Tuple
from better_profanity import Profanity
BLOCKLIST_PATH = Path(__file__).resolve().parent.parent / "data" / "profanity" / "blocklist.json"
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
_CUSTOM_RU_TERMS: Set[str] = {
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор",
"пидоры", "пидорас", "пидорасы", "пидорасов",
}
_ADULT_TERMS: Set[str] = {
"порно", "порнуха", "эротика", "эротический", "секс", "сексуальный",
"инцест", "порнография", "порностудия", "порновидео", "порносайт",
"сексчат", "сексчатик", "секслайв", "сексвидео",
}
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
# Words that should never be flagged as profanity (whitelist)
_WHITELIST: Set[str] = {
"говно", # Allow this word
}
# Phrase patterns - these will be applied to normalized text (without special chars)
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
)
# Patterns to check in original text (before normalization) to catch visual bypasses
# These patterns check for special character combinations that visually form letters
_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = (
# Catch "}{" used to visually form "х" followed by "С0С" or similar patterns
# This catches "хуесос" written as "}{¥€С0С" or variations
# Matches: }{ + any characters (including special chars) + С/с + 0 + С/с
# The pattern allows any characters between to catch special chars like ¥€
re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
# Also catch "}{" followed by "уесос" with 0 instead of о
re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
)
# Map for normalizing homoglyphs (similar-looking characters)
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
# Also includes Greek, full-width, and other Unicode variants
_LEET_MAP = {
# Numbers to letters
"0": "о",
"1": "и",
"3": "е",
"4": "а",
# Latin to Cyrillic (lowercase)
"a": "а",
"c": "с",
"e": "е",
"f": "ф",
"g": "г",
"i": "и",
"m": "м",
"n": "н",
"o": "о",
"p": "п",
"s": "с",
"t": "т",
"u": "у",
"v": "в",
"x": "х",
"y": "у",
"z": "з", # English 'z' to Cyrillic 'з'
# Latin to Cyrillic (uppercase)
"A": "а",
"C": "с",
"E": "е",
"F": "ф",
"G": "г",
"I": "и",
"M": "м",
"N": "н",
"O": "о",
"P": "п",
"S": "с",
"T": "т",
"U": "у",
"V": "в",
"X": "х",
"Y": "у",
"Z": "з", # English 'Z' to Cyrillic 'з'
# Greek letters that look like Cyrillic/Latin
"α": "а", # Greek alpha
"Α": "а",
"ο": "о", # Greek omicron
"Ο": "о",
"ρ": "р", # Greek rho (looks like Cyrillic р)
"Ρ": "р",
"υ": "у", # Greek upsilon
"Υ": "у",
"χ": "х", # Greek chi
"Χ": "х",
"ε": "е", # Greek epsilon
"Ε": "е",
"ι": "и", # Greek iota
"Ι": "и",
"ν": "н", # Greek nu
"Ν": "н",
"μ": "м", # Greek mu
"Μ": "м",
"π": "п", # Greek pi
"Π": "п",
"τ": "т", # Greek tau
"Τ": "т",
"γ": "г", # Greek gamma
"Γ": "г",
"σ": "с", # Greek sigma
"Σ": "с",
"φ": "ф", # Greek phi
"Φ": "ф",
# Full-width Latin characters
"": "а",
"": "а",
"": "с",
"": "с",
"": "е",
"": "е",
"": "ф",
"": "ф",
"": "г",
"": "г",
"": "и",
"": "и",
"": "м",
"": "м",
"": "н",
"": "н",
"": "о",
"": "о",
"": "п",
"": "п",
"": "с",
"": "с",
"": "т",
"": "т",
"": "у",
"": "у",
"": "в",
"": "в",
"": "х",
"": "х",
"": "у",
"": "у",
"": "з", # Full-width 'z' to Cyrillic 'з'
"": "з",
# Cyrillic to canonical Cyrillic (identity mappings)
"а": "а",
"с": "с",
"е": "е",
"ё": "е",
"ф": "ф",
"г": "г",
"и": "и",
"м": "м",
"н": "н",
"о": "о",
"п": "п",
"т": "т",
"у": "у",
"ү": "у", # Cyrillic capital U (U+04AE)
"Ү": "у", # Cyrillic capital U (U+04AE)
"в": "в",
"х": "х",
"р": "р",
"з": "з", # Cyrillic 'з'
"д": "д", # Cyrillic 'д'
"б": "б", # Cyrillic 'б'
"л": "л", # Cyrillic 'л'
"я": "я", # Cyrillic 'я'
"н": "н", # Already mapped, but explicit
# Special characters
"@": "а",
# Multi-character visual bypasses (handled separately in preprocessing)
# "}{" visually forms "х" - handled in _preprocess_visual_bypasses
}
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
("generic", ("айфон", "топ")),
("generic", ("самсунг", "говно")),
)
_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
def _preprocess_visual_bypasses(text: str) -> str:
"""
Preprocess text to convert multi-character visual bypasses to their intended letters.
This handles cases like "}{" visually forming "х".
"""
result = text
# Convert "}{" to "х" (visual bypass for Cyrillic х)
# The curly braces visually form the letter х when placed together
result = result.replace("}{", "х")
return result
def _normalize_char(ch: str) -> str:
"""Normalize a single character, mapping homoglyphs to canonical form."""
# First try direct mapping (preserves case for non-mapped chars)
if ch in _LEET_MAP:
return _LEET_MAP[ch]
# Then try lowercase mapping
lower = ch.lower()
if lower in _LEET_MAP:
return _LEET_MAP[lower]
# If no mapping and character is ASCII letter, return lowercase
# This preserves English words like "fromchat" as-is
if ch.isascii() and ch.isalpha():
return lower
# For other characters, return lowercase for consistency
return lower
def _normalize_token(token: str) -> str:
"""Normalize a token by mapping all homoglyphs."""
return "".join(_normalize_char(ch) for ch in token)
def _normalize_text_for_profanity(text: str) -> str:
"""
Normalize entire text by mapping homoglyphs to canonical forms.
This prevents bypasses like using English 'u' instead of Russian 'у'.
"""
return "".join(_normalize_char(ch) for ch in text)
def _strip_zero_width_chars(text: str) -> str:
"""
Remove zero-width characters that could be used to bypass filters.
"""
# Zero-width space, zero-width non-joiner, zero-width joiner, etc.
zero_width_chars = [
'\u200B', # Zero-width space
'\u200C', # Zero-width non-joiner
'\u200D', # Zero-width joiner
'\uFEFF', # Zero-width no-break space
'\u2060', # Word joiner
'\u2061', # Function application
'\u2062', # Invisible times
'\u2063', # Invisible separator
'\u2064', # Invisible plus
]
result = text
for zw_char in zero_width_chars:
result = result.replace(zw_char, '')
return result
def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]:
"""
Extract only alphanumeric characters from text and create a mapping
from normalized positions to original positions.
Args:
preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching)
Returns:
(normalized_text, position_map) where position_map[i] is the original
position of the i-th character in normalized_text
"""
# First preprocess visual bypasses (like "}{" -> "х")
text = _preprocess_visual_bypasses(text)
# Then normalize Unicode (composed vs decomposed)
normalized_unicode = unicodedata.normalize('NFKC', text)
# For phrase matching, convert zero-width chars to spaces instead of stripping
if preserve_spaces:
zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064']
for zw_char in zero_width_chars:
normalized_unicode = normalized_unicode.replace(zw_char, ' ')
else:
# Strip zero-width characters
normalized_unicode = _strip_zero_width_chars(normalized_unicode)
normalized = []
position_map = []
for i, ch in enumerate(normalized_unicode):
# Check if character is alphanumeric (including Cyrillic)
if ch.isalnum():
# For phrase matching, preserve ASCII letters as-is (just lowercase)
# to allow English words in patterns to match
if preserve_spaces and ch.isascii() and ch.isalpha():
normalized.append(ch.lower())
else:
# Normalize this character (homoglyphs, Cyrillic, etc.)
normalized.append(_normalize_char(ch))
position_map.append(i)
elif preserve_spaces:
# For phrase matching, treat any whitespace or non-alphanumeric as word separator
if ch.isspace() or not ch.isalnum():
# Normalize to single space to allow patterns to match
if normalized and normalized[-1] != ' ': # Don't add consecutive spaces
normalized.append(' ')
position_map.append(i)
return "".join(normalized), position_map
def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]:
"""
Check for profane words as substrings or subsequences in normalized text.
This catches cases like "хуй" in "хууй" (with extra characters).
Returns list of (start, end) positions where profanity is found.
"""
spans = []
normalized_lower = normalized_text.lower()
for word in profane_words:
word_lower = word.lower()
# First try exact substring match
start = 0
while True:
pos = normalized_lower.find(word_lower, start)
if pos == -1:
break
spans.append((pos, pos + len(word_lower)))
start = pos + 1
# Also check if profane word appears as a subsequence (allowing extra chars)
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
# Now applies to ALL words, not just length >= 4, to prevent bypasses
word_chars = list(word_lower)
text_chars = list(normalized_lower)
# Stricter span limits based on word length to prevent false positives
# Shorter words get much stricter limits
if len(word_lower) <= 3:
max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй")
elif len(word_lower) == 4:
max_span_ratio = 1.4 # Strict for 4-char words
elif len(word_lower) <= 5:
max_span_ratio = 1.5 # Moderate for 5-char words
else:
max_span_ratio = 1.8 # Slightly more lenient for longer words
# Try to find the word as a subsequence
i = 0 # position in text
j = 0 # position in word
seq_start = None
while i < len(text_chars) and j < len(word_chars):
if text_chars[i] == word_chars[j]:
if seq_start is None:
seq_start = i
j += 1
if j == len(word_chars):
# Found the word as subsequence
seq_end = i + 1
# Check if the span is reasonable (not too long)
span_length = seq_end - seq_start
max_allowed_span = int(len(word_lower) * max_span_ratio)
if span_length <= max_allowed_span:
# Only add if it's not already covered by exact match
if (seq_start, seq_end) not in spans:
spans.append((seq_start, seq_end))
# Reset to find next occurrence - continue from after the end of this match
next_start = seq_start + 1
seq_start = None
j = 0
i = next_start
continue
i += 1
return spans
def _check_profanity_in_normalized(normalized_text: str) -> bool:
"""
Check if normalized text contains profanity.
Uses both better_profanity library and substring matching for better detection.
Returns True if profanity is found.
"""
if not normalized_text:
return False
# Check normalized text for profanity using better_profanity
censored = _profanity.censor(normalized_text, censor_char="\\*")
# Check if better_profanity found anything
if "*" in censored:
return True
# Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня")
profane_words = _STATIC_TERMS
substring_spans = _check_profanity_substrings(normalized_text, profane_words)
# If we found any substring matches, there's profanity
if substring_spans:
return True
return False
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
tokens: List[Tuple[int, int, str]] = []
start: int | None = None
buffer: List[str] = []
for idx, ch in enumerate(text):
if ch.isalnum() or ch in {"@", "#", "_"}:
if start is None:
start = idx
buffer.append(ch)
else:
if buffer and start is not None:
token_raw = "".join(buffer)
tokens.append((start, idx, _normalize_token(token_raw)))
buffer.clear()
start = None
if buffer and start is not None:
token_raw = "".join(buffer)
tokens.append((start, len(text), _normalize_token(token_raw)))
return tokens
def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool:
if a == b:
return True
if max_distance <= 0:
return False
if abs(len(a) - len(b)) > max_distance:
return False
previous = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
current = [i]
best = current[0]
for j, cb in enumerate(b, 1):
insert_cost = current[j - 1] + 1
delete_cost = previous[j] + 1
replace_cost = previous[j - 1] + (0 if ca == cb else 1)
cost = min(insert_cost, delete_cost, replace_cost)
current.append(cost)
if cost < best:
best = cost
if best > max_distance:
return False
previous = current
return previous[-1] <= max_distance
def _load_sensitive_phrases() -> List[Tuple[str, ...]]:
if not _SENSITIVE_PHRASE_PATH.exists():
return []
try:
payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8"))
phrases: List[Tuple[str, ...]] = []
if isinstance(payload, list):
for entry in payload:
if isinstance(entry, list) and entry:
normalized = tuple(str(part).strip() for part in entry if str(part).strip())
if normalized:
phrases.append(normalized)
return phrases
except Exception:
return []
def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]:
if group not in _PHRASE_CACHE:
base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group]
if group == "sensitive":
base.extend(_load_sensitive_phrases())
_PHRASE_CACHE[group] = tuple(
tuple(_normalize_token(part) for part in phrase)
for phrase in base
)
return _PHRASE_CACHE[group]
def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]:
tokens = _tokenize_with_spans(text)
if not tokens:
return []
spans: List[Tuple[int, int]] = []
normalized_phrases = _get_phrases(group)
for index in range(len(tokens)):
for phrase in normalized_phrases:
if index + len(phrase) > len(tokens):
continue
matches = True
for offset, target in enumerate(phrase):
token = tokens[index + offset][2]
if not _edit_distance_limited(token, target):
matches = False
break
if matches:
span_start = tokens[index][0]
span_end = tokens[index + len(phrase) - 1][1]
spans.append((span_start, span_end))
return spans
_dictionary_lock = RLock()
_blocklist_signature: Tuple[str, ...] | None = None
_profanity = Profanity()
def _normalize_words(words: Iterable[str]) -> Set[str]:
normalized: Set[str] = set()
for raw in words:
if not raw:
continue
cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower()
if cleaned:
normalized.add(cleaned)
return normalized
def _load_blocklist() -> Set[str]:
if not BLOCKLIST_PATH.exists():
return set()
try:
data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8"))
if isinstance(data, list):
return _normalize_words(data)
except Exception:
pass
return set()
def _write_blocklist(words: Iterable[str]) -> None:
BLOCKLIST_PATH.write_text(
json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8"
)
def _rebuild_dictionary(force: bool = False) -> None:
global _profanity, _blocklist_signature
with _dictionary_lock:
blocklist_list = sorted(_load_blocklist())
signature = tuple(blocklist_list)
if not force and _blocklist_signature == signature and _blocklist_signature is not None:
return
profanity = Profanity()
profanity.load_censor_words()
# Remove whitelisted words from the default word list
try:
for word in _WHITELIST:
profanity.remove_censor_words([word])
except AttributeError:
# If remove_censor_words doesn't exist, we'll handle it in post-processing
pass
combined = set(_STATIC_TERMS)
combined.update(blocklist_list)
# Remove whitelisted words from our custom terms
combined -= _WHITELIST
if combined:
profanity.add_censor_words(list(combined))
_profanity = profanity
_blocklist_signature = signature
def _check_phrase_patterns(text: str) -> bool:
"""
Check if text matches any phrase patterns.
Returns True if any pattern matches.
"""
# Normalize text for phrase matching (remove special chars but preserve spaces)
normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True)
normalized_lower = normalized_text.lower()
# Check phrase patterns
for pattern in _PHRASE_PATTERNS:
if pattern.search(normalized_lower):
return True
# Check fuzzy phrase spans
if _find_fuzzy_phrase_spans(normalized_lower, "generic"):
return True
return False
def contains_profanity(text: str) -> bool:
"""
Check if text contains profanity.
Returns True if profanity is detected.
"""
if not text:
return False
_rebuild_dictionary()
# Check original text patterns first (before normalization) to catch visual bypasses
# like "}{" used to form "х"
for pattern in _ORIGINAL_TEXT_PATTERNS:
if pattern.search(text):
return True
# Check phrase patterns
if _check_phrase_patterns(text):
return True
# Normalize text for whitelist matching (to handle special characters)
normalized_for_whitelist, _ = _extract_alphanumeric_with_mapping(text)
normalized_for_whitelist_lower = normalized_for_whitelist.lower()
# Check if text contains whitelisted words - if the entire text is a whitelisted word, skip profanity check
for whitelist_word in _WHITELIST:
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
normalized_whitelist_lower = normalized_whitelist.lower()
# Check if the normalized text exactly matches a whitelisted word
if normalized_for_whitelist_lower == normalized_whitelist_lower:
return False
# Extract only alphanumeric characters and normalize homoglyphs
# This removes special characters, emojis, etc. that could be used to bypass the filter
normalized_text, _ = _extract_alphanumeric_with_mapping(text)
# Check profanity on normalized text (without special characters)
return _check_profanity_in_normalized(normalized_text)
def contains_sensitive_phrase(text: str) -> bool:
if not text:
return False
if _find_fuzzy_phrase_spans(text, "sensitive"):
return True
return False
def get_blocklist() -> List[str]:
with _dictionary_lock:
return sorted(_load_blocklist())
def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
normalized = _normalize_words(words)
if not normalized:
return [], get_blocklist()
with _dictionary_lock:
current = _load_blocklist()
added = sorted(normalized - current)
if not added:
return [], sorted(current)
updated = sorted(current | normalized)
_write_blocklist(updated)
_rebuild_dictionary(force=True)
return added, updated
def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
normalized = _normalize_words(words)
if not normalized:
return [], get_blocklist()
with _dictionary_lock:
current = _load_blocklist()
removed = sorted(word for word in normalized if word in current)
if not removed:
return [], sorted(current)
updated = sorted(current - normalized)
_write_blocklist(updated)
_rebuild_dictionary(force=True)
return removed, updated
+232
View File
@@ -0,0 +1,232 @@
from __future__ import annotations
import asyncio
import logging
import time
from typing import Callable
from fastapi import Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from backend.shared.utils import get_client_ip
logger = logging.getLogger("uvicorn.error")
def get_ip_key(request: Request) -> str:
"""Get rate limit key based on IP address."""
return get_client_ip(request) or get_remote_address(request)
# Initialize limiter with IP-based key function
# Note: We don't set default_limits to avoid affecting all users if one IP is attacked.
# Each endpoint should have an explicit rate limit based on its sensitivity.
# Rate limits automatically expire after the time window - IPs are not permanently blocked.
limiter = Limiter(
key_func=get_ip_key,
default_limits=[], # No global default - each endpoint must have explicit limits
storage_uri="memory://", # In-memory storage (can be changed to Redis later)
)
# Rate limit decorator for IP-based limiting
def rate_limit_per_ip(limit: str) -> Callable:
"""Rate limit based on IP address."""
return limiter.limit(limit, key_func=get_ip_key)
def _get_storage_dict(storage) -> dict | None:
"""Get the internal storage dictionary from slowapi's memory storage."""
if hasattr(storage, "_storage") and isinstance(storage._storage, dict):
return storage._storage
elif hasattr(storage, "storage") and isinstance(storage.storage, dict):
return storage.storage
return None
def reset_all_rate_limits() -> int:
"""
Reset all rate limits by clearing the storage.
This should be called on startup to ensure a clean state.
Returns the number of entries cleared.
"""
try:
# Access the private _storage attribute
storage = limiter._storage
storage_dict = _get_storage_dict(storage)
if storage_dict is None:
# Try using the storage's reset method if available
if hasattr(storage, "reset"):
try:
# Try reset() with no args first (clears all)
storage.reset()
logger.info("Reset all rate limits on startup using storage.reset()")
return 1 # Assume it worked
except TypeError:
# reset() might require arguments, try clearing differently
try:
# Some storage backends need explicit clearing
if hasattr(storage, "clear"):
storage.clear()
logger.info("Reset all rate limits on startup using storage.clear()")
return 1
except Exception:
pass
except Exception:
pass
logger.warning("Could not reset rate limits: storage dict not accessible and no reset method")
return 0
count = len(storage_dict)
if count > 0:
storage_dict.clear()
logger.info(f"Reset all rate limits on startup: cleared {count} entries")
return count
except Exception as e:
logger.warning(f"Failed to reset rate limits on startup: {e}")
return 0
def reset_rate_limit_for_ip(ip: str) -> bool:
"""
Manually reset rate limit for a specific IP address.
This clears all rate limit entries for the given IP.
Returns True if any entries were cleared, False otherwise.
"""
if not ip:
return False
try:
# Access the private _storage attribute
storage = limiter._storage
storage_dict = _get_storage_dict(storage)
if storage_dict is None:
# Try alternative methods
if hasattr(storage, "reset"):
try:
storage.reset(ip)
return True
except Exception:
pass
return False
cleared = False
# slowapi stores entries with keys like "LIMITER:{ip}:{endpoint}"
# We need to find all keys that contain this IP
# Also handle cases where IP might be in different positions
keys_to_remove = []
for key in list(storage_dict.keys()):
if isinstance(key, str):
# Check multiple patterns:
# - "LIMITER:{ip}:{endpoint}"
# - Keys containing the IP anywhere
# - Keys starting with the IP
if (key.startswith(f"LIMITER:{ip}:") or
key.startswith(f"LIMITER:{ip}") or
f":{ip}:" in key or
key.endswith(f":{ip}") or
(ip in key and "LIMITER" in key)):
keys_to_remove.append(key)
for key in keys_to_remove:
try:
del storage_dict[key]
cleared = True
logger.info(f"Cleared rate limit key: {key}")
except KeyError:
pass
if cleared:
logger.info(f"Successfully cleared rate limits for IP: {ip}")
else:
logger.warning(f"No rate limit entries found for IP: {ip}")
return cleared
except Exception as e:
logger.warning(f"Failed to reset rate limit for IP {ip}: {e}")
return False
def clear_all_rate_limits() -> int:
"""
Clear all rate limit entries. Use with caution - this affects all IPs.
Returns the number of entries cleared.
"""
try:
# Access the private _storage attribute
storage = limiter._storage
storage_dict = _get_storage_dict(storage)
if storage_dict is None:
return 0
count = len(storage_dict)
storage_dict.clear()
logger.warning(f"Cleared all {count} rate limit entries")
return count
except Exception as e:
logger.error(f"Failed to clear all rate limits: {e}")
return 0
def cleanup_expired_rate_limits() -> int:
"""
Clean up expired rate limit entries from memory storage.
This helps prevent rate limits from being stuck indefinitely.
Returns the number of entries cleaned up.
"""
try:
# Access the private _storage attribute
storage = limiter._storage
storage_dict = _get_storage_dict(storage)
if storage_dict is None:
return 0
# slowapi's memory storage stores entries as tuples: (count, reset_time)
# Entries should expire naturally, but we'll clean up any that are clearly expired
now = time.time()
cleaned = 0
keys_to_remove = []
for key, value in storage_dict.items():
if isinstance(value, (tuple, list)) and len(value) >= 2:
# Check if reset_time has passed (with some buffer)
reset_time = value[1] if isinstance(value[1], (int, float)) else 0
# Add 60 second buffer to ensure we don't remove active entries
if reset_time > 0 and now > (reset_time + 60):
keys_to_remove.append(key)
elif isinstance(value, dict):
# Some storage formats use dicts with 'expiry' or 'reset' fields
expiry = value.get("expiry") or value.get("reset") or value.get("reset_time")
if expiry and isinstance(expiry, (int, float)) and now > (expiry + 60):
keys_to_remove.append(key)
for key in keys_to_remove:
try:
del storage_dict[key]
cleaned += 1
except KeyError:
pass
if cleaned > 0:
logger.info(f"Cleaned up {cleaned} expired rate limit entries")
return cleaned
except Exception as e:
logger.warning(f"Failed to cleanup expired rate limits: {e}")
return 0
async def start_rate_limit_cleanup_task() -> None:
"""Start a background task to periodically clean up expired rate limit entries."""
while True:
try:
await asyncio.sleep(300) # Run every 5 minutes
cleanup_expired_rate_limits()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in rate limit cleanup task: {e}")
await asyncio.sleep(60) # Wait 1 minute before retrying
+11
View File
@@ -0,0 +1,11 @@
# Account Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/account.py /app/backend/routes/account.py
COPY backend/services/account/main.py /app/backend/services/account/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=account
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.account import router as account_router
if __name__ == "__main__":
app = FastAPI(title="Account Service")
app.include_router(account_router, prefix="/account")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+11
View File
@@ -0,0 +1,11 @@
# Device Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/devices.py /app/backend/routes/devices.py
COPY backend/services/device/main.py /app/backend/services/device/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=device
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.devices import router as device_router
if __name__ == "__main__":
app = FastAPI(title="Device Service")
app.include_router(device_router, prefix="/devices")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+14
View File
@@ -0,0 +1,14 @@
# Gateway Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/app.py /app/backend/app.py
COPY backend/main.py /app/backend/main.py
COPY backend/dependencies.py /app/backend/dependencies.py
COPY backend/security /app/backend/security/
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=gateway
EXPOSE 8301
+6
View File
@@ -0,0 +1,6 @@
# Gateway service - runs the main gateway app from backend/app.py
if __name__ == "__main__":
from backend.app import app
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8300)))
+12
View File
@@ -0,0 +1,12 @@
# Messaging Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
COPY backend/websocket /app/backend/websocket/
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=messaging
EXPOSE 8301
@@ -0,0 +1,7 @@
from .registry import WebSocketHandlerRegistry
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
# Import them directly from websocket.handlers when needed
__all__ = ["WebSocketHandlerRegistry"]
@@ -0,0 +1,570 @@
from datetime import datetime
import json
import logging
import time
from typing import Any
from fastapi import HTTPException, WebSocket, Request
from sqlalchemy.orm import Session
from backend.services.messaging.files.websocket.registry import WebSocketHandlerRegistry
from backend.routes.messaging import (
MessaggingSocketManager,
_send_message_internal,
_edit_message_internal,
get_messages,
edit_message,
delete_message,
add_reaction,
add_dm_reaction,
)
from backend.shared.models import (
User,
SendMessageRequest,
EditMessageRequest,
DMEnvelope,
ReactionRequest,
DMReactionRequest,
UpdateLog,
)
from backend.security.audit import log_access, log_dm
logger = logging.getLogger("uvicorn.error")
# Create global registry instance
handler_registry = WebSocketHandlerRegistry()
# Create decorator alias
websocket_handler = handler_registry.register
def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None:
"""Log WebSocket event."""
ws_path = getattr(getattr(websocket, "url", None), "path", None)
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
ws_path = websocket.scope.get("path")
ws_path = ws_path or "unknown"
headers = {}
if isinstance(getattr(websocket, "scope", None), dict):
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
xff = headers.get("x-forwarded-for")
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
log_access(
"ws_event",
path=ws_path,
event=event,
user=user.username if user else None,
user_id=user.id if user else None,
ip=client_ip,
**extra,
)
@websocket_handler("getUpdates", authRequired=True)
async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Handle gap detection - client requests updates from a specific sequence number."""
last_seq = data.get("lastSeq", 0)
manager.last_seq_by_ws[websocket] = last_seq
current_seq = manager.sequence_numbers.get(user.id, 0)
# Query database for missed updates
missed_updates = []
if last_seq > 0 and last_seq < current_seq:
try:
# Get all updates between last_seq and current_seq
update_logs = db.query(UpdateLog).filter(
UpdateLog.user_id == user.id,
UpdateLog.sequence > last_seq,
UpdateLog.sequence <= current_seq
).order_by(UpdateLog.sequence.asc()).all()
# Each log entry contains a batch of updates with the same sequence number
for log_entry in update_logs:
updates = json.loads(log_entry.updates)
missed_updates.append({
"seq": log_entry.sequence,
"updates": updates
})
except Exception as e:
logger.error(f"Failed to retrieve missed updates: {e}")
# Send missed updates directly (not through return value)
for batch in missed_updates:
await websocket.send_json({
"type": "updates",
"seq": batch["seq"],
"updates": batch["updates"]
})
# Update the websocket's last sequence tracking
manager.last_seq_by_ws[websocket] = current_seq
log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates))
return {
"status": "ok",
"lastSeq": current_seq,
"missedCount": len(missed_updates)
}
@websocket_handler("ping", authRequired=True)
async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Handle ping - authenticate and set user online."""
# Set user online in DB
user.online = True
user.last_seen = datetime.now()
db.commit()
# Add to online users
manager.online_users.add(user.id)
# Broadcast status change
await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db)
log(manager, websocket, user, "ping")
return {"status": "success"}
@websocket_handler("getMessages", authRequired=True)
async def getMessages(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Get all public chat messages."""
result = await get_messages(user, db)
log(manager, websocket, user, "getMessages")
return result
@websocket_handler("sendMessage", authRequired=True)
async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Send a public chat message."""
message_request: SendMessageRequest = SendMessageRequest.model_validate(data)
# Call internal function directly (rate limiting is handled at infrastructure level via Caddy)
response = await _send_message_internal(message_request, user, db, [])
await manager.broadcast({
"type": "newMessage",
"data": response["message"]
}, db)
log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"])
return response
@websocket_handler("dmSend", authRequired=True)
async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Send a direct message."""
payload = data
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
for key in required:
if key not in payload:
raise HTTPException(status_code=400, detail=f"Missing {key}")
env = DMEnvelope(
sender_id=user.id,
recipient_id=int(payload["recipientId"]),
iv_b64=payload["iv"],
ciphertext_b64=payload["ciphertext"],
salt_b64=payload["salt"],
iv2_b64=payload["iv2"],
wrapped_mk_b64=payload["wrappedMk"],
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
)
db.add(env)
db.commit()
db.refresh(env)
payload_ws = {
"type": "dmNew",
"data": {
"id": env.id,
"senderId": env.sender_id,
"recipientId": env.recipient_id,
"iv": env.iv_b64,
"ciphertext": env.ciphertext_b64,
"salt": env.salt_b64,
"iv2": env.iv2_b64,
"wrappedMk": env.wrapped_mk_b64,
"timestamp": env.timestamp.isoformat(),
"replyToId": env.reply_to_id,
}
}
# Send push notification for DM
try:
from push_service import push_service
await push_service.send_dm_notification(db, env, user)
except Exception as e:
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
await manager.send_update_to_user(env.recipient_id, "dmNew", payload_ws["data"], db)
await manager.send_update_to_user(env.sender_id, "dmNew", payload_ws["data"], db)
log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id)
log_dm(
"message_sent_ws",
dm_envelope_id=env.id,
sender_id=user.id,
sender_username=user.username,
recipient_id=env.recipient_id,
reply_to=env.reply_to_id,
)
return {"status": "ok", "id": env.id}
@websocket_handler("editMessage", authRequired=True)
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Edit a public chat message."""
message_id = data["message_id"]
edit_request: EditMessageRequest = EditMessageRequest.model_validate(data)
response = await _edit_message_internal(message_id, edit_request, user, db)
await manager.broadcast({
"type": "messageEdited",
"data": response["message"]
}, db)
log(manager, websocket, user, "editMessage", message_id=message_id)
return response
@websocket_handler("dmEdit", authRequired=True)
async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Edit a direct message."""
payload = data
env_id = int(payload["id"])
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
if not env:
raise HTTPException(status_code=404, detail="DM not found")
if env.sender_id != user.id:
raise HTTPException(status_code=403, detail="You can only edit your own messages")
# Replace ciphertext and iv
env.iv_b64 = payload["iv"]
env.ciphertext_b64 = payload["ciphertext"]
env.iv2_b64 = payload["iv2"]
env.wrapped_mk_b64 = payload["wrappedMk"]
env.salt_b64 = payload["salt"]
db.commit()
db.refresh(env)
payload_ws = {
"type": "dmEdited",
"data": {
"id": env.id,
"senderId": env.sender_id,
"recipientId": env.recipient_id,
"iv": env.iv_b64,
"ciphertext": env.ciphertext_b64,
"iv2": env.iv2_b64,
"wrappedMk": env.wrapped_mk_b64,
"salt": env.salt_b64,
"timestamp": env.timestamp.isoformat(),
}
}
await manager.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db)
await manager.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db)
log(manager, websocket, user, "dmEdit", dm_envelope_id=env.id)
log_dm(
"message_edited",
dm_envelope_id=env.id,
user_id=user.id,
username=user.username,
)
return {"status": "ok", "id": env.id}
@websocket_handler("dmDelete", authRequired=True)
async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Delete a direct message."""
payload = data
env_id = int(payload["id"])
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
if not env:
raise HTTPException(status_code=404, detail="DM not found")
if env.sender_id != user.id:
raise HTTPException(status_code=403, detail="You can only delete your own messages")
db.delete(env)
db.commit()
payload_ws = {
"type": "dmDeleted",
"data": {
"id": env_id,
"senderId": user.id,
"recipientId": payload.get("recipientId")
}
}
await manager.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db)
await manager.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db)
log(manager, websocket, user, "dmDelete", dm_envelope_id=env_id)
log_dm(
"message_deleted",
dm_envelope_id=env_id,
user_id=user.id,
username=user.username,
recipient_id=env.recipient_id,
)
return {"status": "ok", "id": env_id}
@websocket_handler("deleteMessage", authRequired=True)
async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Delete a public chat message."""
message_id = data["message_id"]
response = await delete_message(message_id, user, db)
await manager.broadcast({
"type": "messageDeleted",
"data": {"message_id": message_id}
}, db)
log(manager, websocket, user, "deleteMessage", message_id=message_id)
return response
@websocket_handler("addReaction", authRequired=True)
async def addReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Add or remove a reaction to a public chat message."""
reaction_request = ReactionRequest(
message_id=data["message_id"],
emoji=data["emoji"]
)
response = await add_reaction(reaction_request, user, db)
# Broadcast reaction update
await manager.broadcast({
"type": "reactionUpdate",
"data": {
"message_id": data["message_id"],
"emoji": data["emoji"],
"action": response["action"],
"user_id": user.id,
"username": user.username,
"reactions": response["reactions"]
}
}, db)
log(manager, websocket, user, "addReaction", message_id=data["message_id"], emoji=data["emoji"], action=response["action"])
return response
@websocket_handler("addDmReaction", authRequired=True)
async def addDmReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Add or remove a reaction to a direct message."""
reaction_request = DMReactionRequest(
dm_envelope_id=data["dm_envelope_id"],
emoji=data["emoji"]
)
response = await add_dm_reaction(reaction_request, user, db)
# Broadcast reaction update
await manager.broadcast({
"type": "dmReactionUpdate",
"data": {
"dm_envelope_id": data["dm_envelope_id"],
"emoji": data["emoji"],
"action": response["action"],
"user_id": user.id,
"username": user.username,
"reactions": response["reactions"]
}
}, db)
log(manager, websocket, user, "addDmReaction", dm_envelope_id=data["dm_envelope_id"], emoji=data["emoji"], action=response["action"])
return response
@websocket_handler("call_signaling", authRequired=True)
async def call_signaling(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Forward WebRTC signaling between peers."""
payload = data or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
# Ensure sender is set by the server
payload["fromUserId"] = user.id
payload["fromUsername"] = user.username
await manager.send_to_user(to_user_id, {
"type": "call_signaling",
"data": payload
})
log(manager, websocket, user, "call_signaling", to_user_id=to_user_id)
return {"status": "ok"}
@websocket_handler("call_video_toggle", authRequired=True)
async def call_video_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Forward video toggle state between peers."""
payload = data or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
await manager.send_update_to_user(to_user_id, "call_signaling", {
"type": "call_video_toggle",
"fromUserId": user.id,
"toUserId": to_user_id,
"data": {"enabled": payload.get("enabled", False)}
}, db)
log(manager, websocket, user, "call_video_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
return {"status": "ok"}
@websocket_handler("call_screen_share_toggle", authRequired=True)
async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Forward screen share toggle state between peers."""
payload = data or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
await manager.send_update_to_user(to_user_id, "call_signaling", {
"type": "call_screen_share_toggle",
"fromUserId": user.id,
"toUserId": to_user_id,
"data": {"enabled": payload.get("enabled", False)}
}, db)
log(manager, websocket, user, "call_screen_share_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
return {"status": "ok"}
@websocket_handler("subscribeStatus", authRequired=True)
async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Subscribe to status updates for a user."""
user_id_to_subscribe = int(data["userId"])
manager.ws_subscriptions[websocket].add(user_id_to_subscribe)
# Get current status of the user
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
if target_user:
# Send current status directly (not through return value)
await websocket.send_json({
"type": "statusUpdate",
"data": {
"userId": user_id_to_subscribe,
"online": target_user.online,
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
}
})
log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe)
return {"status": "ok"}
else:
log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found")
raise HTTPException(status_code=404, detail="User not found")
@websocket_handler("unsubscribeStatus", authRequired=True)
async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Unsubscribe from status updates for a user."""
user_id_to_unsubscribe = int(data["userId"])
manager.ws_subscriptions[websocket].discard(user_id_to_unsubscribe)
log(manager, websocket, user, "unsubscribeStatus", target_user_id=user_id_to_unsubscribe)
return {"status": "ok"}
@websocket_handler("typing", authRequired=True)
async def typing(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator start for public chat."""
was_typing = manager.typing_state.get(user.id, False)
manager.typing_users[user.id] = time.time()
# Only send update if state changed (started typing)
if not was_typing:
manager.typing_state[user.id] = True
# Broadcast to all connected users
await manager.broadcast({
"type": "typing",
"data": {
"userId": user.id,
"username": user.username
}
}, db)
# No confirmation response - privacy protection
@websocket_handler("stopTyping", authRequired=True)
async def stopTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator stop for public chat."""
was_typing = manager.typing_state.get(user.id, False)
if user.id in manager.typing_users:
del manager.typing_users[user.id]
# Only send update if state changed (stopped typing)
if was_typing:
manager.typing_state[user.id] = False
# Broadcast to all connected users
await manager.broadcast({
"type": "stopTyping",
"data": {
"userId": user.id,
"username": user.username
}
}, db)
# No confirmation response - privacy protection
log(manager, websocket, user, "stopTyping")
@websocket_handler("dmTyping", authRequired=True)
async def dmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator start for DM."""
recipient_id = int(data["recipientId"])
if user.id not in manager.dm_typing_users:
manager.dm_typing_users[user.id] = {}
if user.id not in manager.dm_typing_state:
manager.dm_typing_state[user.id] = {}
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
manager.dm_typing_users[user.id][recipient_id] = time.time()
# Only send update if state changed (started typing)
if not was_typing:
manager.dm_typing_state[user.id][recipient_id] = True
# Send only to recipient
await manager.send_update_to_user(recipient_id, "dmTyping", {
"userId": user.id,
"username": user.username
}, db)
# No confirmation response - privacy protection
@websocket_handler("stopDmTyping", authRequired=True)
async def stopDmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator stop for DM."""
recipient_id = int(data["recipientId"])
was_typing = False
if user.id in manager.dm_typing_state:
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
if user.id in manager.dm_typing_users and recipient_id in manager.dm_typing_users[user.id]:
del manager.dm_typing_users[user.id][recipient_id]
if not manager.dm_typing_users[user.id]:
del manager.dm_typing_users[user.id]
# Only send update if state changed (stopped typing)
if was_typing:
if user.id in manager.dm_typing_state:
manager.dm_typing_state[user.id][recipient_id] = False
# Send only to recipient
await manager.send_update_to_user(recipient_id, "stopDmTyping", {
"userId": user.id,
"username": user.username
}, db)
# No confirmation response - privacy protection
@@ -0,0 +1,33 @@
from typing import Callable
class WebSocketHandlerRegistry:
"""Registry for WebSocket message handlers with authentication support."""
def __init__(self):
self._handlers: dict[str, tuple[Callable, bool]] = {}
def register(self, message_type: str, authRequired: bool = True):
"""Register a handler for a message type.
Args:
message_type: The WebSocket message type to handle
authRequired: If True, handler will receive authenticated User (not None) or raise 401
"""
def decorator(func: Callable):
self._handlers[message_type] = (func, authRequired)
return func
return decorator
def get_handler(self, message_type: str) -> tuple[Callable, bool] | None:
"""Get handler and authRequired flag for a message type.
Returns:
Tuple of (handler function, authRequired flag) or None if not found
"""
return self._handlers.get(message_type)
def get_all_types(self) -> list[str]:
"""Get all registered message types for debugging/logging."""
return list(self._handlers.keys())
@@ -0,0 +1,92 @@
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from types import SimpleNamespace
from backend.shared.dependencies import get_current_user
from backend.shared.models import User
def extract_token_from_data(data: dict) -> str | None:
"""Extract authentication token from WebSocket message data.
Args:
data: WebSocket message data dictionary
Returns:
Token string or None if not present
"""
credentials = data.get("credentials")
if credentials and isinstance(credentials, dict):
return credentials.get("credentials")
return None
def get_current_user_from_token(token: str, db: Session) -> User | None:
"""Get user from authentication token.
Args:
token: JWT token string
db: Database session
Returns:
User object or None if token is invalid
"""
try:
# Ensure session is in a usable state before querying
try:
db.rollback()
except Exception:
pass
dummy_request = SimpleNamespace()
dummy_request.state = SimpleNamespace()
try:
from fastapi.security import HTTPBearer
security = HTTPBearer()
# We need to create credentials manually
credentials = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials=token
)
return get_current_user(dummy_request, credentials, db)
except HTTPException:
return None
except Exception:
try:
db.rollback()
except Exception:
pass
return None
def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None:
"""Authenticate user from WebSocket message data.
Args:
data: WebSocket message data dictionary
db: Database session
authRequired: If True, raises 401 on missing/invalid token
Returns:
User object (guaranteed not None if authRequired=True) or None
Raises:
HTTPException: 401 if authRequired=True and token is missing/invalid
"""
token = extract_token_from_data(data)
if authRequired:
if not token:
raise HTTPException(status_code=401, detail="Missing credentials")
user = get_current_user_from_token(token, db)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
return user
else:
if token:
return get_current_user_from_token(token, db)
return None
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.messaging import router as messaging_router
if __name__ == "__main__":
app = FastAPI(title="Messaging Service")
app.include_router(messaging_router, prefix="/messaging")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
@@ -0,0 +1,15 @@
# Migration Runner Dockerfile
FROM backend/base:latest
# Copy migration files
COPY backend/alembic /app/backend/alembic/
COPY backend/migration.py /app/backend/migration.py
# Copy migration runner
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=migration_runner
# Override entrypoint to run migrations
ENTRYPOINT ["python", "-m", "backend.services.migration_runner.main"]
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
Migration runner service - executes database migrations and exits.
This service runs Alembic migrations against PostgreSQL and terminates.
"""
import os
import sys
from pathlib import Path
from alembic import command
from alembic.config import Config
def run_migrations():
"""Run Alembic migrations."""
print("Starting database migrations...")
# Change to backend directory to run migrations
backend_dir = Path(__file__).parent.parent.parent
os.chdir(backend_dir)
# Ensure shared models are imported for alembic
import backend.shared.models
# Set DATABASE_URL from environment if not set
db_url = os.getenv("DATABASE_URL")
if not db_url:
print("ERROR: DATABASE_URL environment variable not set")
sys.exit(1)
# Export DATABASE_URL for alembic
os.environ["DATABASE_URL"] = db_url
try:
# Use the robust migration system from migration.py
# This handles all edge cases and recovery scenarios automatically
print("Starting database migrations...")
# Import and run the migration function
from backend.migration import run_migrations
run_migrations()
print("Database migrations completed successfully!")
except Exception as e:
print(f"ERROR: Failed to run migrations: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
run_migrations()
+13
View File
@@ -0,0 +1,13 @@
# Moderation Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
COPY backend/security/profanity.py /app/backend/security/profanity.py
COPY backend/similarity.py /app/backend/similarity.py
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=moderation
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.moderation import router as moderation_router
if __name__ == "__main__":
app = FastAPI(title="Moderation Service")
app.include_router(moderation_router, prefix="/moderation")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+11
View File
@@ -0,0 +1,11 @@
# Profile Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/profile.py /app/backend/routes/profile.py
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=profile
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.profile import router as profile_router
if __name__ == "__main__":
app = FastAPI(title="Profile Service")
app.include_router(profile_router, prefix="/profile")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+12
View File
@@ -0,0 +1,12 @@
# Push Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/push.py /app/backend/routes/push.py
COPY backend/push_service.py /app/backend/push_service.py
COPY backend/services/push/main.py /app/backend/services/push/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=push
EXPOSE 8301
+243
View File
@@ -0,0 +1,243 @@
import json
import logging
import os
from typing import List, Optional
from sqlalchemy.orm import Session
from pywebpush import webpush, WebPushException
from backend.shared.models import PushSubscription, User, Message, DMEnvelope, FcmToken
import firebase_admin
from firebase_admin import credentials as firebase_credentials
from firebase_admin import messaging as firebase_messaging
import base64
logger = logging.getLogger("uvicorn.error")
class PushNotificationService:
def __init__(self):
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
# Firebase Admin initialization (modern API). Only FIREBASE_CERT env is supported.
self.firebase_initialized = False
try:
firebase_cert = os.getenv("FIREBASE_CERT")
if not firebase_cert:
raise RuntimeError("FIREBASE_CERT env variable is required for Firebase Admin SDK initialization")
# Support raw JSON or base64-encoded JSON in FIREBASE_CERT
decoded = base64.b64decode(firebase_cert).decode("utf-8")
sa_dict = json.loads(decoded)
cred = firebase_credentials.Certificate(sa_dict)
firebase_admin.initialize_app(cred)
self.firebase_initialized = True
logger.info("Firebase Admin SDK initialized for push sending (FIREBASE_CERT)")
except Exception as e:
logger.error(f"Failed to initialize Firebase Admin SDK from FIREBASE_CERT: {e}")
raise
if (not self.vapid_public_key) or (not self.vapid_private_key):
raise ValueError("VAPID public or private key is None")
self.vapid_claims = {
"sub": "mailto:support@fromchat.ru",
"aud": "https://fcm.googleapis.com"
}
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
"""Subscribe a user to push notifications"""
try:
# Check if user already has a subscription
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
if existing_sub:
# Update existing subscription
existing_sub.endpoint = endpoint
existing_sub.p256dh_key = p256dh_key
existing_sub.auth_key = auth_key
else:
# Create new subscription
new_sub = PushSubscription(
user_id=user_id,
endpoint=endpoint,
p256dh_key=p256dh_key,
auth_key=auth_key
)
db.add(new_sub)
db.commit()
logger.info(f"Push subscription saved for user {user_id}")
return True
except Exception as e:
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
db.rollback()
return False
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
"""Send push notification for a new public chat message"""
try:
# Get all users except the sender
users = db.query(User).filter(User.id != message.user_id)
if exclude_user_id:
users = users.filter(User.id != exclude_user_id)
for user in users:
# Check if user has push subscription before trying to send
# Try all FCM tokens first (Android). If none or all fail, fall back to web push subscription.
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == user.id).all()
payload_data = {
"type": "public_message",
"message_id": message.id,
"sender_id": message.user_id,
"sender_username": message.author.username
}
title = f"{message.author.username}"
body = message.content[:100] + ("..." if len(message.content) > 100 else "")
if fcm_rows and self.firebase_initialized:
for fcm in fcm_rows:
try:
self._send_fcm_to_token(fcm.token, title, body, payload_data)
except Exception as e:
logger.error(f"Failed to send FCM to user {user.id} token {fcm.token}: {e}")
# Check if this is a permanent failure and clean up the token
self._cleanup_failed_fcm_token(db, fcm, str(e))
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
if subscription:
await self._send_notification_to_user(
db, user.id, title, body, message.author.profile_picture, payload_data
)
except Exception as e:
logger.error(f"Failed to send public message notifications: {e}")
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
"""Send push notification for a new DM"""
try:
title = f"{sender.username}"
body = "New direct message"
payload_data = {
"type": "dm",
"dm_id": dm_envelope.id,
"sender_id": sender.id,
"sender_username": sender.username
}
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == dm_envelope.recipient_id).all()
if fcm_rows and self.firebase_initialized:
for fcm in fcm_rows:
try:
self._send_fcm_to_token(fcm.token, title, body, payload_data)
except Exception as e:
logger.error(f"Failed to send FCM to user {dm_envelope.recipient_id} token {fcm.token}: {e}")
# Check if this is a permanent failure and clean up the token
self._cleanup_failed_fcm_token(db, fcm, str(e))
await self._send_notification_to_user(
db, dm_envelope.recipient_id, title, body, sender.profile_picture, payload_data
)
except Exception as e:
logger.error(f"Failed to send DM notification: {e}")
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
"""Send a push notification to a specific user"""
try:
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
if not subscription:
return
payload = {
"title": title,
"body": body,
"icon": icon or "about:blank",
"tag": f"message_{user_id}",
"data": data
}
subscription_info = {
"endpoint": subscription.endpoint,
"keys": {
"p256dh": subscription.p256dh_key,
"auth": subscription.auth_key
}
}
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=self.vapid_private_key,
vapid_claims=self.vapid_claims
)
except WebPushException as e:
logger.error(f"WebPush error for user {user_id}: {e}")
# If the subscription is invalid, remove it
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
db.commit()
except Exception as e:
logger.error(f"Failed to send push notification to user {user_id}: {e}")
def _send_fcm_to_token(self, token: str, title: str, body: str, data: dict):
"""Send an FCM data-only push to a single device token using Firebase Admin SDK.
Notification display is handled by the app, not FCM."""
if not self.firebase_initialized:
raise RuntimeError("Firebase Admin SDK not initialized (FIREBASE_CERT required)")
try:
# Send only data payload - let the app handle notification display
# This prevents FCM from auto-showing notifications
msg = firebase_messaging.Message(
token=token,
data={
"title": title,
"body": body,
**{k: str(v) for k, v in (data or {}).items()}
},
android=firebase_messaging.AndroidConfig(priority="high"),
apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"})
)
resp = firebase_messaging.send(msg)
return resp
except Exception as e:
logger.error(f"Firebase Admin send failed for token {token}: {e}")
raise
def _cleanup_failed_fcm_token(self, db: Session, fcm_token_entry, error_message: str):
"""Clean up FCM tokens that have permanent failures"""
try:
# Check for permanent failure indicators in the error message
permanent_errors = [
"unregistered", "invalidregistration", "notregistered",
"sender_id_mismatch", "invalid_argument"
]
error_lower = error_message.lower()
is_permanent = any(permanent_error in error_lower for permanent_error in permanent_errors)
if is_permanent:
logger.info(f"Removing permanently failed FCM token for user {fcm_token_entry.user_id}: {fcm_token_entry.token}")
db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete()
db.commit()
else:
logger.debug(f"Temporary FCM failure for token {fcm_token_entry.token}, keeping token: {error_message}")
except Exception as e:
logger.error(f"Failed to cleanup FCM token {fcm_token_entry.token}: {e}")
try:
db.rollback()
except Exception:
pass
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
"""Unsubscribe a user from push notifications"""
try:
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
db.commit()
logger.info(f"Push subscription removed for user {user_id}")
return True
except Exception as e:
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
db.rollback()
return False
# Global instance
push_service = PushNotificationService()
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.push import router as push_router
if __name__ == "__main__":
app = FastAPI(title="Push Service")
app.include_router(push_router, prefix="/push")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+11
View File
@@ -0,0 +1,11 @@
# WebRTC Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=webrtc
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.webrtc import router as webrtc_router
if __name__ == "__main__":
app = FastAPI(title="WebRTC Service")
app.include_router(webrtc_router, prefix="/webrtc")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+1
View File
@@ -0,0 +1 @@
# Shared modules package
+50
View File
@@ -0,0 +1,50 @@
import os
# Database
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/database.db")
# JWT
JWT_SECRET_KEY = os.getenv("JWT_SECRET", "default-jwt-secret-for-development")
JWT_ALGORITHM = "HS256"
# Token inactivity expiration - token expires if not used for this duration
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
# Owner user
OWNER_USERNAME = os.getenv("OWNER_USERNAME", "owner")
# Push notifications
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_SUBJECT = os.getenv("VAPID_SUBJECT", "mailto:admin@example.com")
# Rate limiting
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
# File uploads
MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "10485760")) # 10MB
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".avi", ".mp3", ".wav"}
# WebSocket
WEBSOCKET_PING_INTERVAL = 30
WEBSOCKET_PING_TIMEOUT = 60
# Encryption
ENCRYPTION_KEY_LENGTH = 32
ENCRYPTION_NONCE_LENGTH = 12
# Moderation
PROFANITY_THRESHOLD = float(os.getenv("PROFANITY_THRESHOLD", "0.8"))
SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", "0.85"))
# WebRTC
WEBRTC_ICE_SERVERS = [
{"urls": "stun:stun.l.google.com:19302"},
{"urls": "stun:stun1.l.google.com:19302"}
]
# Logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+62
View File
@@ -0,0 +1,62 @@
import os
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy import create_engine
from .constants import DATABASE_URL
# Database connection settings
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
POOL_CONFIG = {
"pool_size": POOL_SIZE,
"max_overflow": MAX_OVERFLOW,
"pool_recycle": POOL_RECYCLE,
"pool_timeout": POOL_TIMEOUT,
"pool_pre_ping": True,
}
def create_engine_from_url(database_url: str):
"""Create SQLAlchemy engine from database URL."""
connect_args = {}
if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine_kwargs = {
"pool_size": POOL_SIZE,
"max_overflow": MAX_OVERFLOW,
"pool_recycle": POOL_RECYCLE,
"pool_pre_ping": True,
"pool_timeout": POOL_TIMEOUT,
}
engine = create_engine(
database_url,
connect_args=connect_args,
**engine_kwargs,
)
return engine
# Create engine - this should be called by each service with its own DATABASE_URL
def get_engine(database_url: str = None):
"""Get SQLAlchemy engine for the given database URL."""
url = database_url or DATABASE_URL
return create_engine_from_url(url)
# Session factory - create per service
def get_session_factory(database_url: str = None):
"""Get session factory for the given database URL."""
engine = get_engine(database_url)
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency for FastAPI - create per service
def get_db(database_url: str = None):
"""FastAPI dependency to get database session."""
SessionLocal = get_session_factory(database_url)
db = SessionLocal()
try:
yield db
finally:
db.close()
+122
View File
@@ -0,0 +1,122 @@
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from backend.shared.utils import verify_token
from backend.shared.models import User, DeviceSession
from backend.shared.db import get_session_factory
import logging
security = HTTPBearer()
logger = logging.getLogger("uvicorn.error")
# Зависимость для получения сессии БД
SessionLocal = get_session_factory()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Зависимость для получения текущего пользователя
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
try:
payload = verify_token(token)
except Exception as e:
logger.warning("get_current_user: token verification error: %s", str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
if not payload:
logger.info("get_current_user: verify_token returned empty payload")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.id == payload["user_id"]).first()
if not user:
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if user.id == 1 and user.suspended:
user.suspended = False
user.suspension_reason = None
db.commit()
db.refresh(user)
# Validate device session from JWT
session_id = payload.get("session_id")
if not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid session",
headers={"WWW-Authenticate": "Bearer"},
)
device_session = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id)
.first()
)
if not device_session or device_session.revoked:
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session revoked or not found",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if session has been inactive for too long (sliding expiration)
from backend.shared.constants import TOKEN_INACTIVITY_EXPIRE_HOURS
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
if device_session.last_seen < inactivity_threshold:
# Session expired due to inactivity - revoke it
device_session.revoked = True
db.commit()
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to inactivity",
headers={"WWW-Authenticate": "Bearer"},
)
# Touch last_seen on valid session (sliding expiration - extends token life)
device_session.last_seen = datetime.now()
db.commit()
# Check if user is suspended
if user.suspended:
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account suspended",
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
)
# Check if user is deleted
if user.deleted:
logger.info("get_current_user: account deleted for user_id=%s", user.id)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account deleted",
)
request.state.current_user = user
request.state.session_id = session_id
return user
+423
View File
@@ -0,0 +1,423 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float, JSON, BigInteger, UniqueConstraint
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
import json
from pydantic import BaseModel
Base = declarative_base()
class User(Base):
__tablename__ = "users"
__table_args__ = {"schema": "account_schema"}
id = Column(BigInteger, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
salt = Column(String(64), nullable=False)
display_name = Column(String(100), nullable=True)
bio = Column(Text, nullable=True)
avatar_url = Column(String(255), nullable=True)
is_online = Column(Boolean, default=False)
last_seen = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
verified = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True)
reset_token = Column(String(255), nullable=True)
reset_token_expires = Column(DateTime, nullable=True)
two_factor_enabled = Column(Boolean, default=False)
two_factor_secret = Column(String(255), nullable=True)
login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
public_key = Column(Text, nullable=True)
private_key = Column(Text, nullable=True)
encryption_enabled = Column(Boolean, default=False)
suspended = Column(Boolean, default=False)
suspension_reason = Column(Text, nullable=True)
deleted = Column(Boolean, default=False)
# Relationships
messages = relationship("Message", back_populates="sender", cascade="all, delete-orphan")
message_recipients = relationship("MessageRecipient", back_populates="recipient", cascade="all, delete-orphan")
devices = relationship("Device", back_populates="user", cascade="all, delete-orphan")
push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan")
class Message(Base):
__tablename__ = "messages"
__table_args__ = {"schema": "messaging_schema"}
id = Column(BigInteger, primary_key=True, index=True)
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
content = Column(Text, nullable=False)
content_type = Column(String(50), default="text")
encrypted_content = Column(Text, nullable=True)
signature = Column(Text, nullable=True)
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
edited_at = Column(DateTime, nullable=True)
edited = Column(Boolean, default=False)
deleted = Column(Boolean, default=False)
reply_to_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
thread_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
is_public = Column(Boolean, default=False)
# Relationships
sender = relationship("User", back_populates="messages")
recipients = relationship("MessageRecipient", back_populates="message", cascade="all, delete-orphan")
reply_to = relationship("Message", remote_side=[id], foreign_keys=[reply_to_id])
thread = relationship("Message", remote_side=[id], foreign_keys=[thread_id])
reactions = relationship("MessageReaction", back_populates="message", cascade="all, delete-orphan")
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan")
class MessageRecipient(Base):
__tablename__ = "message_recipients"
__table_args__ = {"schema": "messaging_schema"}
id = Column(BigInteger, primary_key=True, index=True)
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
read_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
encrypted_key = Column(Text, nullable=True)
# Relationships
message = relationship("Message", back_populates="recipients")
recipient = relationship("User", back_populates="message_recipients")
class MessageReaction(Base):
__tablename__ = "message_reactions"
__table_args__ = {"schema": "messaging_schema"}
id = Column(BigInteger, primary_key=True, index=True)
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
reaction = Column(String(50), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
message = relationship("Message", back_populates="reactions")
class Device(Base):
__tablename__ = "devices"
__table_args__ = {"schema": "device_schema"}
id = Column(BigInteger, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
device_id = Column(String(255), unique=True, nullable=False, index=True)
device_name = Column(String(255), nullable=True)
device_type = Column(String(50), nullable=True)
public_key = Column(Text, nullable=True)
signed_prekey = Column(Text, nullable=True)
one_time_prekeys = Column(JSON, nullable=True)
last_active = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="devices")
push_subscriptions = relationship("PushSubscription", back_populates="device", cascade="all, delete-orphan")
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
__table_args__ = {"schema": "push_schema"}
id = Column(BigInteger, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
device_id = Column(BigInteger, ForeignKey("device_schema.devices.id"), nullable=True, index=True)
endpoint = Column(String(500), nullable=False)
p256dh = Column(String(255), nullable=False)
auth = Column(String(255), nullable=False)
user_agent = Column(String(500), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="push_subscriptions")
device = relationship("Device", back_populates="push_subscriptions")
class WebRTCSession(Base):
__tablename__ = "webrtc_sessions"
__table_args__ = {"schema": "webrtc_schema"}
id = Column(BigInteger, primary_key=True, index=True)
session_id = Column(String(255), unique=True, nullable=False, index=True)
initiator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
participant_ids = Column(JSON, nullable=False)
offer = Column(JSON, nullable=True)
answer = Column(JSON, nullable=True)
ice_candidates = Column(JSON, nullable=True)
status = Column(String(50), default="pending")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ModerationAction(Base):
__tablename__ = "moderation_actions"
__table_args__ = {"schema": "moderation_schema"}
id = Column(BigInteger, primary_key=True, index=True)
moderator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
target_user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=True)
target_message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
action_type = Column(String(50), nullable=False)
reason = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True)
class MessageFile(Base):
__tablename__ = "message_file"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
path = Column(Text, nullable=False)
name = Column(Text, nullable=False)
message = relationship("Message", back_populates="files")
class CryptoPublicKey(Base):
__tablename__ = "crypto_public_key"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
public_key_b64 = Column(Text, nullable=False)
class CryptoBackup(Base):
__tablename__ = "crypto_backup"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
blob_json = Column(Text, nullable=False)
class DMEnvelope(Base):
__tablename__ = "dm_envelope"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
iv_b64 = Column(Text, nullable=False)
ciphertext_b64 = Column(Text, nullable=False)
salt_b64 = Column(Text, nullable=False)
iv2_b64 = Column(Text, nullable=False)
wrapped_mk_b64 = Column(Text, nullable=False)
reply_to_id = Column(Integer, nullable=True)
timestamp = Column(DateTime, default=datetime.now)
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
class DMFile(Base):
__tablename__ = "dm_file"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
name = Column(Text, nullable=False)
path = Column(Text, nullable=False)
message = relationship("DMEnvelope", back_populates="files")
class FcmToken(Base):
__tablename__ = "fcm_token"
__table_args__ = {"schema": "push_schema"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
token = Column(Text, nullable=False, unique=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Reaction(Base):
__tablename__ = "reaction"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
# Ensure unique combination of message, user, and emoji
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
class DMReaction(Base):
__tablename__ = "dm_reaction"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
dm_envelope_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
# Ensure unique combination of dm_envelope, user, and emoji
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
# Tracks authenticated device sessions per user
class DeviceSession(Base):
__tablename__ = "device_session"
__table_args__ = {"schema": "device_schema"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
# Raw User-Agent for reference/debugging
raw_user_agent = Column(Text, nullable=True)
# Parsed fields
device_name = Column(String(128), nullable=True)
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
os_name = Column(String(64), nullable=True)
os_version = Column(String(64), nullable=True)
browser_name = Column(String(64), nullable=True)
browser_version = Column(String(64), nullable=True)
brand = Column(String(64), nullable=True)
model = Column(String(64), nullable=True)
# Session identity embedded into JWTs
session_id = Column(String(64), unique=True, nullable=False, index=True)
# Lifecycle
created_at = Column(DateTime, default=datetime.now)
last_seen = Column(DateTime, default=datetime.now)
revoked = Column(Boolean, default=False)
# Relationship back to user (optional lazy to avoid heavy loads)
user = relationship("User", lazy="select")
# Pydantic models
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
display_name: str
password: str
confirm_password: str
class ChangePasswordRequest(BaseModel):
currentPasswordDerived: str
newPasswordDerived: str
logoutAllExceptCurrent: bool = False
class SendMessageRequest(BaseModel):
content: str
reply_to_id: int | None = None
class EditMessageRequest(BaseModel):
content: str
class DeleteMessageRequest(BaseModel):
message_id: int
class UpdateBioRequest(BaseModel):
bio: str
class PushSubscriptionRequest(BaseModel):
endpoint: str
keys: dict
class UserProfileResponse(BaseModel):
id: int
username: str
display_name: str
profile_picture: str | None
bio: str | None
online: bool
last_seen: datetime | None
created_at: datetime | None
verified: bool
suspended: bool
suspension_reason: str | None
deleted: bool
class Config:
from_attributes = True
class MessageResponse(BaseModel):
id: int
content: str
timestamp: datetime
is_author: bool
is_read: bool
username: str
profile_picture: str | None
class Config:
from_attributes = True
class ReactionRequest(BaseModel):
message_id: int
emoji: str
class ReactionResponse(BaseModel):
id: int
message_id: int
user_id: int
emoji: str
timestamp: datetime
username: str
class Config:
from_attributes = True
class DMReactionRequest(BaseModel):
dm_envelope_id: int
emoji: str
class DMReactionResponse(BaseModel):
id: int
dm_envelope_id: int
user_id: int
emoji: str
timestamp: datetime
username: str
class Config:
from_attributes = True
class UpdateLog(Base):
"""Stores update sequence numbers and updates for gap detection"""
__tablename__ = "update_log"
__table_args__ = {"schema": "public"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
sequence = Column(Integer, nullable=False, index=True)
updates = Column(Text, nullable=False) # JSON array of updates
timestamp = Column(DateTime, default=datetime.now, index=True)
__table_args__ = (
UniqueConstraint("user_id", "sequence", name="uq_user_sequence"),
)
+147
View File
@@ -0,0 +1,147 @@
"""
Similarity detection utilities for username and display name comparison.
Implements both edit distance and visual similarity detection.
"""
def levenshtein_distance(s1: str, s2: str) -> int:
"""Calculate Levenshtein distance between two strings."""
if len(s1) < len(s2):
return levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = list(range(len(s2) + 1))
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
def check_visual_similarity(s1: str, s2: str) -> bool:
"""
Check if two strings are visually similar using common homoglyphs.
Returns True if strings are visually similar.
"""
if len(s1) != len(s2):
return False
# Common homoglyph mappings
homoglyphs = {
'0': ['O', 'o', 'Q'],
'O': ['0', 'o', 'Q'],
'o': ['0', 'O', 'Q'],
'1': ['l', 'I', '|'],
'l': ['1', 'I', '|'],
'I': ['1', 'l', '|'],
'5': ['S', 's'],
'S': ['5', 's'],
's': ['5', 'S'],
'6': ['G', 'g'],
'G': ['6', 'g'],
'g': ['6', 'G'],
'8': ['B', 'b'],
'B': ['8', 'b'],
'b': ['8', 'B'],
'9': ['g', 'q'],
'g': ['9', 'q'],
'q': ['9', 'g'],
'2': ['Z', 'z'],
'Z': ['2', 'z'],
'z': ['2', 'Z'],
'3': ['E'],
'E': ['3'],
'4': ['A'],
'A': ['4'],
'7': ['T', 't'],
'T': ['7', 't'],
't': ['7', 'T'],
}
for i in range(len(s1)):
c1, c2 = s1[i], s2[i]
if c1 == c2:
continue
# Check if characters are homoglyphs
if (c1 in homoglyphs and c2 in homoglyphs[c1]) or \
(c2 in homoglyphs and c1 in homoglyphs[c2]):
continue
return False
return True
def check_username_similarity(username1: str, username2: str) -> bool:
"""
Check if two usernames are similar using both edit distance and visual similarity.
Returns True if usernames are considered similar.
"""
if username1 == username2:
return False
# Check edit distance (Levenshtein distance <= 2)
edit_distance = levenshtein_distance(username1.lower(), username2.lower())
if edit_distance <= 2:
return True
# Check visual similarity
if check_visual_similarity(username1, username2):
return True
return False
def check_display_name_similarity(display_name1: str, display_name2: str) -> bool:
"""
Check if two display names are similar using both edit distance and visual similarity.
Returns True if display names are considered similar.
"""
if display_name1 == display_name2:
return False
# Check edit distance (Levenshtein distance <= 2)
edit_distance = levenshtein_distance(display_name1.lower(), display_name2.lower())
if edit_distance <= 2:
return True
# Check visual similarity
if check_visual_similarity(display_name1, display_name2):
return True
return False
def is_user_similar_to_verified(user_username: str, user_display_name: str,
verified_users: list[dict]) -> tuple[bool, str]:
"""
Check if a user is similar to any verified user.
Args:
user_username: Username to check
user_display_name: Display name to check
verified_users: List of verified user dictionaries with 'username' and 'display_name' keys
Returns:
Tuple of (is_similar, similar_to_username)
"""
for verified_user in verified_users:
verified_username = verified_user.get('username', '')
verified_display_name = verified_user.get('display_name', '')
# Check username similarity
if check_username_similarity(user_username, verified_username):
return True, verified_username
# Check display name similarity
if check_display_name_similarity(user_display_name, verified_display_name):
return True, verified_username
return False, ""
+234
View File
@@ -0,0 +1,234 @@
import secrets
import string
import hashlib
import hmac
import base64
import json
from datetime import datetime, timedelta
from typing import Optional
import re
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.backends import default_backend
import nacl.secret
import nacl.utils
from fastapi import Request
import jwt
import bcrypt
from backend.shared.constants import JWT_SECRET_KEY, JWT_ALGORITHM, MAX_TOKEN_LIFETIME_HOURS
import ipaddress
def generate_secure_token(length: int = 32) -> str:
"""Generate a cryptographically secure random token."""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def hash_password(password: str, salt: Optional[bytes] = None) -> tuple[str, bytes]:
"""Hash a password with PBKDF2 and return (hash, salt)."""
if salt is None:
salt = secrets.token_bytes(32)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
backend=default_backend()
)
key = kdf.derive(password.encode())
return base64.b64encode(key).decode(), salt
def verify_password(password: str, hashed: str, salt: bytes) -> bool:
"""Verify a password against its hash and salt."""
try:
key = base64.b64decode(hashed)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
backend=default_backend()
)
kdf.verify(password.encode(), key)
return True
except:
return False
def generate_verification_token() -> str:
"""Generate a verification token for email verification."""
return generate_secure_token(64)
def generate_reset_token() -> str:
"""Generate a password reset token."""
return generate_secure_token(64)
def get_client_ip(request: Request) -> str:
"""Extract the real client IP from the request."""
# Check X-Forwarded-For header first
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# Take the first IP in case of multiple proxies
client_ip = forwarded_for.split(",")[0].strip()
try:
# Validate IP address
ipaddress.ip_address(client_ip)
return client_ip
except ValueError:
pass
# Check X-Real-IP header
real_ip = request.headers.get("X-Real-IP")
if real_ip:
try:
ipaddress.ip_address(real_ip)
return real_ip
except ValueError:
pass
# Fallback to request.client.host
client_host = request.client.host if request.client else "unknown"
try:
ipaddress.ip_address(client_host)
return client_host
except ValueError:
return "unknown"
def validate_email(email: str) -> bool:
"""Validate email address format."""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def validate_username(username: str) -> bool:
"""Validate username format."""
if not username or len(username) < 3 or len(username) > 50:
return False
# Allow alphanumeric, underscore, and hyphen
pattern = r'^[a-zA-Z0-9_-]+$'
return re.match(pattern, username) is not None
def sanitize_filename(filename: str) -> str:
"""Sanitize filename to prevent directory traversal."""
return re.sub(r'[^\w\.-]', '_', filename)
def generate_file_hash(content: bytes) -> str:
"""Generate SHA256 hash of file content."""
return hashlib.sha256(content).hexdigest()
def encrypt_data(data: str, key: bytes) -> str:
"""Encrypt data using NaCl secret box."""
box = nacl.secret.SecretBox(key)
encrypted = box.encrypt(data.encode())
return base64.b64encode(encrypted).decode()
def decrypt_data(encrypted_data: str, key: bytes) -> str:
"""Decrypt data using NaCl secret box."""
box = nacl.secret.SecretBox(key)
encrypted = base64.b64decode(encrypted_data)
decrypted = box.decrypt(encrypted)
return decrypted.decode()
def generate_encryption_key() -> bytes:
"""Generate a new encryption key."""
return nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
def format_datetime(dt: datetime) -> str:
"""Format datetime for API responses."""
return dt.isoformat()
def parse_datetime(dt_str: str) -> Optional[datetime]:
"""Parse datetime from API requests."""
try:
return datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
except:
return None
def calculate_age(birth_date: datetime) -> int:
"""Calculate age from birth date."""
today = datetime.now()
age = today.year - birth_date.year
if today.month < birth_date.month or (today.month == birth_date.month and today.day < birth_date.day):
age -= 1
return age
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
"""Truncate text to max length with suffix."""
if len(text) <= max_length:
return text
return text[:max_length - len(suffix)] + suffix
def is_valid_url(url: str) -> bool:
"""Validate URL format."""
pattern = r'^https?://[^\s/$.?#].[^\s]*$'
return re.match(pattern, url) is not None
def generate_device_id() -> str:
"""Generate a unique device identifier."""
return generate_secure_token(32)
def normalize_phone_number(phone: str) -> str:
"""Normalize phone number format."""
# Remove all non-digit characters except +
normalized = re.sub(r'[^\d+]', '', phone)
# Ensure it starts with +
if not normalized.startswith('+'):
if normalized.startswith('00'):
normalized = '+' + normalized[2:]
else:
normalized = '+' + normalized
return normalized
def create_token(user_id: int, username: str, session_id: str) -> str:
# Set a long expiration as safety net (actual expiration based on inactivity)
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
payload = {
"user_id": user_id,
"username": username,
"session_id": session_id,
"exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int)
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def verify_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def _is_admin(user) -> bool:
return user.id == 1
def convert_user(user) -> dict:
return {
"id": user.id,
"created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(),
"online": user.online,
"username": user.username,
"display_name": user.display_name,
"profile_picture": user.profile_picture,
"bio": user.bio,
"admin": _is_admin(user),
"verified": user.verified,
"suspended": user.suspended or False,
"suspension_reason": user.suspension_reason,
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
}
+132
View File
@@ -0,0 +1,132 @@
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, validator
import re
def is_valid_username(username: str) -> bool:
if len(username) < 3 or len(username) > 20:
return False
# Only allow English letters, numbers, dashes and underscores
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
return False
return True
def is_valid_display_name(display_name: str) -> bool:
if len(display_name) < 1 or len(display_name) > 64:
return False
# Check if not blank (only whitespace)
if not display_name.strip():
return False
return True
def is_valid_password(password: str) -> bool:
if len(password) < 5 or len(password) > 50:
return False
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
return False
return True
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: Optional[str] = Field(None, max_length=100)
@validator('username')
def username_alphanumeric(cls, v):
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
raise ValueError('Username must be alphanumeric with underscores or hyphens')
return v
@validator('password')
def password_strength(cls, v):
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain at least one digit')
return v
class UserLogin(BaseModel):
username_or_email: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=128)
class UserUpdate(BaseModel):
display_name: Optional[str] = Field(None, max_length=100)
bio: Optional[str] = Field(None, max_length=500)
avatar_url: Optional[str] = Field(None, max_length=255)
@validator('avatar_url')
def validate_avatar_url(cls, v):
if v and not v.startswith(('http://', 'https://')):
raise ValueError('Avatar URL must be a valid HTTP/HTTPS URL')
return v
class MessageCreate(BaseModel):
content: str = Field(min_length=1, max_length=10000)
content_type: str = Field(default="text", pattern=r'^(text|image|video|audio|file)$')
reply_to_id: Optional[int] = None
recipient_ids: list[int] = Field(min_items=1, max_items=100)
class MessageUpdate(BaseModel):
content: str = Field(min_length=1, max_length=10000)
class DeviceRegister(BaseModel):
device_id: str = Field(min_length=1, max_length=255)
device_name: Optional[str] = Field(None, max_length=255)
device_type: Optional[str] = Field(None, max_length=50)
public_key: Optional[str] = Field(None, max_length=10000)
class PushSubscriptionCreate(BaseModel):
endpoint: str = Field(max_length=500)
p256dh: str = Field(max_length=255)
auth: str = Field(max_length=255)
device_id: Optional[str] = Field(None, max_length=255)
class WebRTCOffer(BaseModel):
offer: dict
participant_ids: list[int] = Field(min_items=1, max_items=10)
class WebRTCAnswer(BaseModel):
answer: dict
session_id: str = Field(max_length=255)
class WebRTCIceCandidate(BaseModel):
candidate: dict
session_id: str = Field(max_length=255)
class ModerationActionCreate(BaseModel):
target_user_id: Optional[int] = None
target_message_id: Optional[int] = None
action_type: str = Field(pattern=r'^(ban|mute|delete|warn)$')
reason: Optional[str] = Field(None, max_length=1000)
duration_hours: Optional[int] = Field(None, gt=0, le=8760) # Max 1 year
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordReset(BaseModel):
token: str = Field(min_length=64, max_length=64)
new_password: str = Field(..., min_length=8, max_length=128)
@validator('new_password')
def password_strength(cls, v):
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain at least one digit')
return v
class TwoFactorSetup(BaseModel):
code: str = Field(pattern=r'^\d{6}$')
class TwoFactorVerify(BaseModel):
code: str = Field(pattern=r'^\d{6}$')
class EmailVerification(BaseModel):
token: str = Field(min_length=64, max_length=64)
+44 -6
View File
@@ -1,17 +1,20 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import Request
import jwt import jwt
from typing import Optional from typing import Optional, Any
import bcrypt import bcrypt
from constants import * from constants import MAX_TOKEN_LIFETIME_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
# JWT Helper Functions # JWT Helper Functions
def create_token(user_id: int, username: str) -> str: def create_token(user_id: int, username: str, session_id: str) -> str:
expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) # Set a long expiration as safety net (actual expiration based on inactivity)
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
payload = { payload = {
"user_id": user_id, "user_id": user_id,
"username": username, "username": username,
"exp": expire "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) return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
@@ -30,4 +33,39 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") 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
+11 -1
View File
@@ -3,7 +3,17 @@ import re
def is_valid_username(username: str) -> bool: def is_valid_username(username: str) -> bool:
if len(username) < 3 or len(username) > 20: if len(username) < 3 or len(username) > 20:
return False return False
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', username): # 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 False
return True return True
+2 -1
View File
@@ -30,4 +30,5 @@ coverage
test_results/ test_results/
out out
data data
logs
+6 -2
View File
@@ -14,12 +14,16 @@ FROM python:3.12-slim AS runtime
WORKDIR /app WORKDIR /app
RUN useradd -u 1000 app && \ RUN useradd -u 1000 app && \
chown -R app /app chown -R app /app
USER app
# 2.2. Copy content and create dirs # 2.2. Copy content and create dirs
COPY --chown=app backend . COPY --chown=app backend .
COPY --from=builder --chown=app /app/.venv .venv COPY --from=builder --chown=app /app/.venv .venv
RUN mkdir -p /app/data 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 # 3. Final command
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
+88
View File
@@ -0,0 +1,88 @@
fromchat.ru {
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 {
lb_policy first
header_up X-Real-IP {remote_host}
}
# Security headers
header {
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
X-Frame-Options "DENY" # Prevent clickjacking
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
}
rate_limit {
zone global {
key {remote_ip}
window 1m
burst 20
events 500
}
}
handle_errors {
@errors {
expression {err.status_code} >= 400
}
handle @errors {
rewrite * /{err.status_code}
reverse_proxy https://http.cat {
header_up Host {upstream_hostport}
replace_status {err.status_code}
}
}
}
}
beta.fromchat.ru {
reverse_proxy 95.165.0.162:8301 {
header_up X-Real-IP {remote_host}
}
# Security headers
header {
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
X-Frame-Options "DENY" # Prevent clickjacking
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
}
rate_limit {
zone global {
key {remote_ip}
window 1m
burst 20
events 1000
}
}
handle_errors {
@errors {
expression {err.status_code} >= 400
}
handle @errors {
rewrite * /{err.status_code}
reverse_proxy https://http.cat {
header_up Host {upstream_hostport}
replace_status {err.status_code}
}
}
}
}
api.getgadgets.toolbox-io.ru {
reverse_proxy 95.165.0.162:8400
}
getgadgets.toolbox-io.ru {
reverse_proxy 95.165.0.162:8401
}
+13
View File
@@ -0,0 +1,13 @@
#
# Custom Caddy built with:
# - Rate limit plugin
#
FROM caddy:2-builder AS builder
RUN xcaddy build \
--with github.com/mholt/caddy-ratelimit
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile /etc/caddy/Caddyfile
+70
View File
@@ -0,0 +1,70 @@
-- Initialize database roles and schemas for FromChat microservices
-- This script creates dedicated users with limited privileges for each service
-- Create service-specific database roles with limited privileges
-- All services use the same password
CREATE ROLE account_service_user LOGIN PASSWORD 'development';
CREATE ROLE profile_service_user LOGIN PASSWORD 'development';
CREATE ROLE device_service_user LOGIN PASSWORD 'development';
CREATE ROLE messaging_service_user LOGIN PASSWORD 'development';
CREATE ROLE push_service_user LOGIN PASSWORD 'development';
CREATE ROLE webrtc_service_user LOGIN PASSWORD 'development';
CREATE ROLE moderation_service_user LOGIN PASSWORD 'development';
CREATE ROLE gateway_user LOGIN PASSWORD 'development';
-- Create dedicated schemas for each service
CREATE SCHEMA IF NOT EXISTS account_schema AUTHORIZATION account_service_user;
CREATE SCHEMA IF NOT EXISTS profile_schema AUTHORIZATION profile_service_user;
CREATE SCHEMA IF NOT EXISTS device_schema AUTHORIZATION device_service_user;
CREATE SCHEMA IF NOT EXISTS messaging_schema AUTHORIZATION messaging_service_user;
CREATE SCHEMA IF NOT EXISTS push_schema AUTHORIZATION push_service_user;
CREATE SCHEMA IF NOT EXISTS webrtc_schema AUTHORIZATION webrtc_service_user;
CREATE SCHEMA IF NOT EXISTS moderation_schema AUTHORIZATION moderation_service_user;
-- Grant basic connection privileges
GRANT CONNECT ON DATABASE fromchat TO account_service_user, profile_service_user, device_service_user, messaging_service_user, push_service_user, webrtc_service_user, moderation_service_user;
-- Grant schema-level privileges (limited to each service's schema)
-- Account service
GRANT USAGE ON SCHEMA account_schema TO account_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA account_schema TO account_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA account_schema TO account_service_user;
-- Profile service
GRANT USAGE ON SCHEMA profile_schema TO profile_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA profile_schema TO profile_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA profile_schema TO profile_service_user;
-- Device service
GRANT USAGE ON SCHEMA device_schema TO device_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA device_schema TO device_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA device_schema TO device_service_user;
-- Messaging service
GRANT USAGE ON SCHEMA messaging_schema TO messaging_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA messaging_schema TO messaging_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA messaging_schema TO messaging_service_user;
-- Push service
GRANT USAGE ON SCHEMA push_schema TO push_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA push_schema TO push_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA push_schema TO push_service_user;
-- WebRTC service
GRANT USAGE ON SCHEMA webrtc_schema TO webrtc_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA webrtc_schema TO webrtc_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA webrtc_schema TO webrtc_service_user;
-- Moderation service
GRANT USAGE ON SCHEMA moderation_schema TO moderation_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA moderation_schema TO moderation_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA moderation_schema TO moderation_service_user;
-- Set default privileges for future objects
ALTER DEFAULT PRIVILEGES IN SCHEMA account_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO account_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA profile_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO profile_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA device_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO device_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA messaging_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO messaging_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA push_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO push_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA webrtc_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO webrtc_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA moderation_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO moderation_service_user;
+158
View File
@@ -0,0 +1,158 @@
-- Create tables for FromChat microservices
-- This script creates the necessary tables in their respective schemas
-- Note: In production, tables will be created by Alembic migrations
-- This script provides a fallback or reference for manual setup
-- Account schema tables
CREATE TABLE IF NOT EXISTS account_schema.users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
salt VARCHAR(64) NOT NULL,
display_name VARCHAR(100),
bio TEXT,
avatar_url VARCHAR(255),
is_online BOOLEAN DEFAULT FALSE,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
verified BOOLEAN DEFAULT FALSE,
verification_token VARCHAR(255),
reset_token VARCHAR(255),
reset_token_expires TIMESTAMP,
two_factor_enabled BOOLEAN DEFAULT FALSE,
two_factor_secret VARCHAR(255),
login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP,
public_key TEXT,
private_key TEXT,
encryption_enabled BOOLEAN DEFAULT FALSE,
suspended BOOLEAN DEFAULT FALSE,
suspension_reason TEXT,
deleted BOOLEAN DEFAULT FALSE
);
-- Profile schema tables (references account_schema.users)
CREATE TABLE IF NOT EXISTS profile_schema.user_profiles (
user_id BIGINT PRIMARY KEY REFERENCES account_schema.users(id) ON DELETE CASCADE,
display_name VARCHAR(100),
bio TEXT,
avatar_url VARCHAR(255),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Device schema tables
CREATE TABLE IF NOT EXISTS device_schema.devices (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
device_id VARCHAR(255) UNIQUE NOT NULL,
device_name VARCHAR(255),
device_type VARCHAR(50),
public_key TEXT,
signed_prekey TEXT,
one_time_prekeys JSONB,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS device_schema.device_session (
id SERIAL PRIMARY KEY,
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
raw_user_agent TEXT,
device_name VARCHAR(128),
device_type VARCHAR(32),
os_name VARCHAR(64),
os_version VARCHAR(64),
browser_name VARCHAR(64),
browser_version VARCHAR(64),
brand VARCHAR(64),
model VARCHAR(64),
session_id VARCHAR(64) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked BOOLEAN DEFAULT FALSE
);
-- Messaging schema tables
CREATE TABLE IF NOT EXISTS messaging_schema.messages (
id BIGSERIAL PRIMARY KEY,
sender_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
content_type VARCHAR(50) DEFAULT 'text',
encrypted_content TEXT,
signature TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
edited_at TIMESTAMP,
edited BOOLEAN DEFAULT FALSE,
deleted BOOLEAN DEFAULT FALSE,
reply_to_id BIGINT REFERENCES messaging_schema.messages(id),
thread_id BIGINT REFERENCES messaging_schema.messages(id),
is_public BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS messaging_schema.message_recipients (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
recipient_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
read_at TIMESTAMP,
delivered_at TIMESTAMP,
encrypted_key TEXT
);
CREATE TABLE IF NOT EXISTS messaging_schema.message_reactions (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
reaction VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Push schema tables
CREATE TABLE IF NOT EXISTS push_schema.push_subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
device_id BIGINT REFERENCES device_schema.devices(id),
endpoint VARCHAR(500) NOT NULL,
p256dh VARCHAR(255) NOT NULL,
auth VARCHAR(255) NOT NULL,
user_agent VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- WebRTC schema tables
CREATE TABLE IF NOT EXISTS webrtc_schema.webrtc_sessions (
id BIGSERIAL PRIMARY KEY,
session_id VARCHAR(255) UNIQUE NOT NULL,
initiator_id BIGINT REFERENCES account_schema.users(id),
participant_ids JSONB NOT NULL,
offer JSONB,
answer JSONB,
ice_candidates JSONB,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Moderation schema tables
CREATE TABLE IF NOT EXISTS moderation_schema.moderation_actions (
id BIGSERIAL PRIMARY KEY,
moderator_id BIGINT REFERENCES account_schema.users(id),
target_user_id BIGINT REFERENCES account_schema.users(id),
target_message_id BIGINT REFERENCES messaging_schema.messages(id),
action_type VARCHAR(50) NOT NULL,
reason TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP
);
-- Grant permissions on sequences (after all tables are created)
GRANT USAGE ON SEQUENCE account_schema.users_id_seq TO account_service_user;
GRANT USAGE ON SEQUENCE device_schema.devices_id_seq TO device_service_user;
GRANT USAGE ON SEQUENCE device_schema.device_session_id_seq TO account_service_user;
GRANT USAGE ON SEQUENCE messaging_schema.messages_id_seq TO messaging_service_user;
GRANT USAGE ON SEQUENCE messaging_schema.message_recipients_id_seq TO messaging_service_user;
GRANT USAGE ON SEQUENCE messaging_schema.message_reactions_id_seq TO messaging_service_user;
GRANT USAGE ON SEQUENCE push_schema.push_subscriptions_id_seq TO push_service_user;
GRANT USAGE ON SEQUENCE webrtc_schema.webrtc_sessions_id_seq TO webrtc_service_user;
GRANT USAGE ON SEQUENCE moderation_schema.moderation_actions_id_seq TO moderation_service_user;
+354 -30
View File
@@ -1,44 +1,368 @@
services: services:
backend: # Database service
build: database:
dockerfile: deployment/Dockerfile.backend image: postgres:15
context: ..
environment: environment:
PORT: 8300 POSTGRES_DB: fromchat
JWT_SECRET: ${JWT_SECRET} POSTGRES_USER: fromchat_admin
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} POSTGRES_PASSWORD: ${DB_PASSWORD}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
volumes: volumes:
- "data:/app/data" - database:/var/lib/postgresql/data
- ./db-init:/docker-entrypoint-initdb.d
networks:
- fromchat_internal
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fromchat_admin -d fromchat"]
interval: 1s
timeout: 5s
retries: 5
# Migration runner - runs once before other services
migration_runner:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: migration_runner
environment:
DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD}@database:5432/fromchat
JWT_SECRET: ${JWT_SECRET}
depends_on:
database:
condition: service_healthy
networks:
- fromchat_internal
develop: develop:
watch: watch:
- action: sync
path: backend/alembic.ini
target: /app/backend/alembic.ini
- action: sync
path: backend/alembic
target: /app/backend/alembic
- action: sync
path: backend/migration.py
target: /app/backend/migration.py
- action: sync
path: backend/services/migration_runner
target: /app/backend/services/migration_runner
- action: sync+restart - action: sync+restart
path: ../backend path: backend/shared
target: /app target: /app/backend/shared
- action: rebuild
path: ../backend/requirements.txt
frontend: # Gateway service - handles complex operations
build: gateway:
dockerfile: deployment/frontend/Dockerfile build:
context: .. context: ..
dockerfile: docker/Dockerfile.multi
target: gateway
ports: ["8300:8300"]
environment: environment:
PORT: 8301 DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD}@database:5432/fromchat
BACKEND_HOST: http://backend:8300 PORT: 8300
ACCOUNT_SERVICE_URL: http://account_service:8302
PROFILE_SERVICE_URL: http://profile_service:8303
DEVICE_SERVICE_URL: http://device_service:8304
MESSAGING_SERVICE_URL: http://messaging_service:8305
PUSH_SERVICE_URL: http://push_service:8306
WEBRTC_SERVICE_URL: http://webrtc_service:8307
MODERATION_SERVICE_URL: http://moderation_service:8308
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
- fromchat_external
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/app.py
target: /app/backend/app.py
- action: sync
path: backend/main.py
target: /app/backend/main.py
- action: sync
path: backend/dependencies.py
target: /app/backend/dependencies.py
- action: sync
path: backend/security
target: /app/backend/security
- action: sync
path: backend/services/gateway
target: /app/backend/services/gateway
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Account service
account_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: account_service
environment:
DATABASE_URL: postgresql://account_service_user:${DB_PASSWORD}@database:5432/fromchat
JWT_SECRET: ${JWT_SECRET}
PORT: 8302
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/account.py
target: /app/backend/routes/account.py
- action: sync
path: backend/services/account
target: /app/backend/services/account
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Profile service
profile_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: profile_service
environment:
DATABASE_URL: postgresql://profile_service_user:${DB_PASSWORD}@database:5432/fromchat
FIREBASE_CERT: ${FIREBASE_CERT}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
MESSAGING_SERVICE_URL: http://messaging_service:8305
PORT: 8303
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/profile.py
target: /app/backend/routes/profile.py
- action: sync
path: backend/services/profile
target: /app/backend/services/profile
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Device service
device_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: device_service
environment:
DATABASE_URL: postgresql://device_service_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8304
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/devices.py
target: /app/backend/routes/devices.py
- action: sync
path: backend/services/device
target: /app/backend/services/device
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Messaging service
messaging_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: messaging_service
ports:
- "8305:8305"
environment:
DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD}@database:5432/fromchat
FIREBASE_CERT: ${FIREBASE_CERT}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
PUSH_SERVICE_URL: http://push_service:8306
PORT: 8305
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/messaging.py
target: /app/backend/routes/messaging.py
- action: sync
path: backend/websocket
target: /app/backend/websocket
- action: sync
path: backend/services/messaging
target: /app/backend/services/messaging
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Push service
push_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: push_service
environment:
DATABASE_URL: postgresql://push_service_user:${DB_PASSWORD}@database:5432/fromchat
FIREBASE_CERT: ${FIREBASE_CERT}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
PORT: 8306
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/push.py
target: /app/backend/routes/push.py
- action: sync
path: backend/push_service.py
target: /app/backend/push_service.py
- action: sync
path: backend/services/push
target: /app/backend/services/push
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# WebRTC service
webrtc_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: webrtc_service
environment:
DATABASE_URL: postgresql://webrtc_service_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8307
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/webrtc.py
target: /app/backend/routes/webrtc.py
- action: sync
path: backend/services/webrtc
target: /app/backend/services/webrtc
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Moderation service
moderation_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: moderation_service
environment:
DATABASE_URL: postgresql://moderation_service_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8308
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/moderation.py
target: /app/backend/routes/moderation.py
- action: sync
path: backend/security
target: /app/backend/security
- action: sync
path: backend/similarity.py
target: /app/backend/similarity.py
- action: sync
path: backend/services/moderation
target: /app/backend/services/moderation
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Caddy reverse proxy
caddy:
build:
context: ./caddy
dockerfile: Dockerfile
ports:
- "80:80"
- "443:443"
volumes:
- certs:/root/site/certs
networks:
- fromchat_external
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- XDG_DATA_HOME=/root/site/certs
- XDG_CONFIG_HOME=/root/site/certs
restart: unless-stopped
depends_on:
- gateway
profiles: ["prod"]
# Frontend service - serves the React app
frontend:
build:
context: ..
dockerfile: deployment/frontend/Dockerfile
ports: ports:
- "8301:8301" - "8301:8301"
depends_on: environment:
- backend - PORT=8301
develop: - BACKEND_HOST=http://gateway:8300
watch: restart: unless-stopped
- action: rebuild networks:
path: ../frontend - fromchat_external
- action: sync+restart - fromchat_internal
path: server.js
target: /server/server.js
- action: rebuild
path: package.json
volumes: volumes:
database:
name: fromchat-database
certs:
name: fromchat-certs
logs:
name: fromchat-logs
data: data:
name: fromchat-data name: fromchat-data
networks:
fromchat_internal:
driver: bridge
internal: true
fromchat_external:
driver: bridge
+3 -1
View File
@@ -3,11 +3,13 @@ FROM node:24 AS frontend
# 1.1. Install npm dependencies # 1.1. Install npm dependencies
WORKDIR /app WORKDIR /app
# Copy package.json and workspace package directory first (needed for workspace resolution)
COPY package.json . COPY package.json .
COPY frontend/packages/ frontend/packages/
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm install --ignore-scripts npm install --ignore-scripts
# 1.2. Build # 1.2. Copy remaining frontend code and build
COPY frontend frontend COPY frontend frontend
RUN npm run frontend:build RUN npm run frontend:build
+24 -8
View File
@@ -1,28 +1,44 @@
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware'; import { createProxyMiddleware } from 'http-proxy-middleware';
import { resolve } from 'path'; import { resolve } from 'path';
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = Number(process.env.PORT) || 8301;
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300"; const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
const filePath = process.env.STATIC_FILE_PATH || "."; const filePath = process.env.STATIC_FILE_PATH || ".";
// API proxy middleware // Direct WebSocket proxy for chat - bypass gateway (must come before general API proxy)
app.use('/api', createProxyMiddleware({ app.use('/api/chat/ws', createProxyMiddleware({
target: backendHost, target: 'http://messaging_service:8305',
changeOrigin: true, changeOrigin: true,
pathRewrite: { '^/api': '' }, pathRewrite: { '^/api/chat/ws': '/messaging/chat/ws' },
ws: true ws: true
})); }));
// API proxy middleware (exclude WebSocket paths)
app.use('/api', (req, res, next) => {
// Skip WebSocket upgrade requests - let them be handled by specific proxies
if (req.headers.upgrade === 'websocket') {
return next();
}
createProxyMiddleware({
target: backendHost,
changeOrigin: true,
pathRewrite: { '^/api': '' },
ws: true
})(req, res, next);
});
// Serve static files // Serve static files
app.use(express.static(resolve(filePath))); app.use(express.static(resolve(filePath)));
// SPA routing - catch all handler for client-side routing // SPA routing - catch all handler for client-side routing
app.use((_req, res) => { app.use((_req: Request, res: Response) => {
res.sendFile(resolve(filePath, 'index.html')); res.sendFile(resolve(filePath, 'index.html'));
}); });
app.listen(port, () => { app.listen(port, '0.0.0.0', () => {
console.log(`Server launched on http://localhost:${port}`); console.log(`Backend host: ${backendHost}`);
console.log(`Server launched on http://0.0.0.0:${port}`);
}); });
+123
View File
@@ -0,0 +1,123 @@
# Multi-stage Dockerfile for all FromChat microservices
# This combines the base image and all services in one file
# Base stage - common setup for all services
FROM python:3.11-slim AS base
# Install system dependencies
RUN apt-get update && apt-get install -y \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd --create-home --shell /bin/bash fromchat
# Set working directory
WORKDIR /app
# Create logs and data directories with proper permissions
RUN mkdir -p /app/backend/logs /app/backend/data /app/backend/data/profanity /app/backend/data/uploads /app/backend/data/uploads/pfp && \
chown -R fromchat:fromchat /app/backend/logs /app/backend/data && \
chmod -R 755 /app/backend/logs /app/backend/data
# Copy requirements first for better caching
COPY backend/requirements.txt /app/requirements.txt
# Install Python dependencies with cache mounts
RUN --mount=type=cache,target=/home/fromchat/.cache/pip \
pip install --no-cache-dir -r requirements.txt
# Copy shared modules
COPY backend/shared /app/backend/shared/
# Copy entrypoint script
COPY docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Switch to non-root user
USER fromchat
# Set entrypoint
ENTRYPOINT ["/app/entrypoint.sh"]
# Account service - minimal files only
FROM base AS account_service
COPY backend/routes/account.py /app/backend/routes/account.py
COPY backend/security /app/backend/security/
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/services/account/main.py /app/backend/services/account/main.py
ENV SERVICE_NAME=account
# Profile service - minimal files only
FROM base AS profile_service
COPY backend/routes/profile.py /app/backend/routes/profile.py
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
COPY backend/security /app/backend/security/
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
ENV SERVICE_NAME=profile
# Device service - minimal files only
FROM base AS device_service
COPY backend/routes/devices.py /app/backend/routes/devices.py
COPY backend/services/device/main.py /app/backend/services/device/main.py
ENV SERVICE_NAME=device
# Messaging service - minimal files only
FROM base AS messaging_service
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
COPY backend/security /app/backend/security/
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
ENV SERVICE_NAME=messaging
# Push service - minimal files only
FROM base AS push_service
COPY backend/routes/push.py /app/backend/routes/push.py
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
COPY backend/services/push/main.py /app/backend/services/push/main.py
ENV SERVICE_NAME=push
# WebRTC service - minimal files only
FROM base AS webrtc_service
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
ENV SERVICE_NAME=webrtc
# Moderation service - minimal files only
FROM base AS moderation_service
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
COPY backend/security /app/backend/security/
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
ENV SERVICE_NAME=moderation
# Gateway service - minimal files only
FROM base AS gateway
COPY backend/app.py /app/backend/app.py
COPY backend/main.py /app/backend/main.py
COPY backend/dependencies.py /app/backend/dependencies.py
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/security /app/backend/security/
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
ENV SERVICE_NAME=gateway
# Migration runner - needs alembic config and migration files
FROM base AS migration_runner
# Temporarily switch back to root to manage file permissions
USER root
COPY backend/alembic.ini /app/backend/alembic.ini
COPY backend/alembic /app/backend/alembic/
COPY backend/migration.py /app/backend/migration.py
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
# Clean up problematic migrations as root
RUN find /app/backend/alembic/versions -name "*auto_generated_migration_for_schema_*" | xargs rm -f || true
# Switch back to fromchat user
USER fromchat
ENV SERVICE_NAME=migration_runner
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Entrypoint script for FromChat microservices
# Run the service module
exec python -m backend.services.${SERVICE_NAME}.main
+1 -1
View File
@@ -1,5 +1,5 @@
import { app, BrowserWindow, Notification, ipcMain } from 'electron'; import { app, BrowserWindow, Notification, ipcMain } from 'electron';
import path from "node:path"; import path from "path";
import type { NotificationShowOptions } from '../electron.d.ts'; import type { NotificationShowOptions } from '../electron.d.ts';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
-141
View File
@@ -1,141 +0,0 @@
import js from "@eslint/js";
import typescript from "@typescript-eslint/eslint-plugin";
import typescriptParser from "@typescript-eslint/parser";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
js.configs.recommended,
{
files: ["**/*.{js,jsx,ts,tsx}"],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true
}
}
},
plugins: {
"@typescript-eslint": typescript,
"react": react,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"jsx-a11y": jsxA11y
},
rules: {
// TypeScript rules
...typescript.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "off",
// React rules
...react.configs.recommended.rules,
"react/react-in-jsx-scope": "off", // Not needed with React 17+
"react/prop-types": "off", // Using TypeScript instead
"react/jsx-uses-react": "off", // Not needed with React 17+
"react/jsx-uses-vars": "error",
"react/jsx-no-undef": "error",
"react/jsx-key": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-pascal-case": "error",
"react/no-array-index-key": "off",
"react/no-danger": "off",
"react/no-deprecated": "error",
"react/no-direct-mutation-state": "error",
"react/no-unescaped-entities": "error",
"react/no-unknown-property": "error",
"react/require-render-return": "error",
"react/self-closing-comp": "error",
"react/jsx-wrap-multilines": "error",
"react/jsx-closing-bracket-location": "off",
"react/jsx-closing-tag-location": "error",
"react/jsx-curly-spacing": ["error", "never"],
"react/jsx-equals-spacing": ["error", "never"],
"react/jsx-first-prop-new-line": ["off", "multiline-multiprop"],
"react/jsx-max-props-per-line": ["error", { maximum: 2, when: "multiline" }],
"react/jsx-no-bind": "off",
"react/jsx-no-literals": "off",
"react/jsx-sort-props": "off",
// React Hooks rules
...reactHooks.configs.recommended.rules,
// React Refresh rules
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true }
],
// Accessibility rules
...jsxA11y.configs.recommended.rules,
"jsx-a11y/alt-text": "off",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/img-redundant-alt": "warn",
"jsx-a11y/no-access-key": "error",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
"jsx-a11y/scope": "error",
"jsx-a11y/tabindex-no-positive": "error",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/anchor-is-valid": "off",
// General JavaScript/TypeScript rules
"no-console": "off",
"no-debugger": "error",
"no-unused-vars": "off", // Handled by TypeScript version
"prefer-const": "error",
"no-var": "error",
"no-undef": "off", // Handled by TypeScript version
"eqeqeq": ["error", "always"],
"curly": "off", // Changed from error to warn
"brace-style": ["off", "1tbs"],
"comma-dangle": "warn", // Changed from error to warn
"comma-spacing": ["error", { before: false, after: true }],
"comma-style": ["error", "last"],
"computed-property-spacing": ["error", "never"],
"func-call-spacing": ["off", "never"],
"key-spacing": ["error", { beforeColon: false, afterColon: true }],
"keyword-spacing": ["error", { before: true, after: true }],
"object-curly-spacing": ["error", "always"],
"semi-spacing": ["error", { before: false, after: true }],
"space-before-blocks": "error",
"space-before-function-paren": ["off", "never"],
"space-in-parens": ["error", "never"],
"space-infix-ops": "error",
"space-unary-ops": ["error", { words: true, nonwords: false }],
"quotes": "warn", // Changed from error to warn
"max-len": ["warn", { code: 150, ignoreUrls: true, ignoreStrings: true }],
"no-empty": "off"
},
settings: {
react: {
version: "detect"
}
}
},
{
ignores: [
"node_modules/**",
"dist/**",
"build/**",
"out/**",
"*.min.js",
"coverage/**",
".nyc_output/**",
"backend/**",
"deployment/**",
"web-calls/**"
]
}
];
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading...</title> <title>Loading...</title>
<link rel="icon" href="./src/images/logo.png" /> <link rel="icon" href="./src/images/logo.svg" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.DS_Store
package-lock.json
@@ -0,0 +1,8 @@
src/
tsconfig.json
node_modules/
package-lock.json
*.log
.DS_Store
@@ -0,0 +1,171 @@
# Publishing FromChat Protocol
This guide explains how to publish the `@fromchat/protocol` package to npm or GitHub Packages.
## Prerequisites
1. **npm account**: Create one at [npmjs.com](https://www.npmjs.com/signup)
2. **GitHub account**: For GitHub Packages
3. **Node.js**: Version 18 or higher
## Publishing to npm
### Important: Scoped Package Setup
The package uses the `@fromchat` scope. You have two options:
**Option A: Create an npm organization (Recommended)**
1. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create)
2. Create an organization named `fromchat`
3. Add yourself as a member
4. Then proceed with publishing below
**Option B: Use unscoped package name**
If you prefer not to create an organization, change the package name in `package.json`:
```json
{
"name": "fromchat-protocol" // Remove the @fromchat/ scope
}
```
Then update all imports in your codebase from `@fromchat/protocol` to `fromchat-protocol`.
### 1. Build the package
```bash
cd frontend/packages/fromchat-protocol
npm run build
```
This compiles TypeScript to JavaScript in the `dist/` directory.
### 2. Login to npm
```bash
npm login
```
Enter your npm username, password, and email.
### 3. Publish
**If using scoped package (`@fromchat/protocol`):**
```bash
npm publish --access public
```
**If using unscoped package (`fromchat-protocol`):**
```bash
npm publish
```
The `--access public` flag is required for scoped packages (packages starting with `@`).
### 4. Verify
Check your package at: `https://www.npmjs.com/package/@fromchat/protocol`
### 5. Update version for future releases
```bash
# Patch version (1.0.0 -> 1.0.1)
npm version patch
# Minor version (1.0.0 -> 1.1.0)
npm version minor
# Major version (1.0.0 -> 2.0.0)
npm version major
# Then publish
npm publish --access public
```
## Publishing to GitHub Packages
### 1. Create a GitHub Personal Access Token
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
2. Generate a new token with `write:packages` and `read:packages` permissions
3. Save the token securely
### 2. Configure npm to use GitHub Packages
Create or edit `~/.npmrc`:
```
@fromchat:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
```
Or add to `package.json`:
```json
{
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
```
### 3. Update package.json
Update the repository URL to match your GitHub repository:
```json
{
"repository": {
"type": "git",
"url": "https://github.com/YOUR_USERNAME/YOUR_REPO.git",
"directory": "frontend/packages/fromchat-protocol"
}
}
```
### 4. Build and publish
```bash
cd frontend/packages/fromchat-protocol
npm run build
npm publish
```
### 5. Install from GitHub Packages
Users can install your package with:
```bash
npm install @fromchat/protocol@npm:@fromchat/protocol
```
Or add to `.npmrc`:
```
@fromchat:registry=https://npm.pkg.github.com
```
## Using the Published Package
### From npm
```bash
npm install @fromchat/protocol
```
```typescript
import { FromChatProtocol } from "@fromchat/protocol";
```
### From GitHub Packages
```bash
npm install @fromchat/protocol@npm:@fromchat/protocol
```
## Notes
- The package is built to `dist/` directory
- Source files in `src/` are excluded from the published package
- Only `dist/` and `README.md` are included in the published package
- The package uses ES modules (ESM) format
- TypeScript definitions are included in `dist/`
@@ -0,0 +1,99 @@
# FromChat Protocol
Simple ECDH-based encryption protocol for direct messages.
## Overview
The FromChat Protocol provides end-to-end encryption for direct messages using:
- **X25519** (ECDH) for key exchange
- **HKDF** for key derivation
- **AES-GCM** for symmetric encryption
This module is completely independent and can be used in any JavaScript/TypeScript project.
## Protocol Flow
### Encryption
1. Generate a random message key (mk) - 32 bytes
2. Generate a random salt (wkSalt) - 16 bytes
3. Derive shared secret from ECDH: `ecdhSharedSecret(myPrivateKey, theirPublicKey)`
4. Derive wrapping key: `deriveWrappingKey(sharedSecret, wkSalt, info)` using HKDF
5. Encrypt message with mk using AES-GCM → (iv, ciphertext)
6. Encrypt (wrap) mk with wrapping key using AES-GCM → (iv2, wrappedMk)
7. Send: `{ iv, ciphertext, salt, iv2, wrappedMk }`
### Decryption
1. Derive shared secret from ECDH
2. Derive wrapping key from shared secret using salt from message
3. Decrypt wrappedMk to get mk
4. Decrypt ciphertext with mk
## Usage
```typescript
import { FromChatProtocol } from "@fromchat/protocol";
// Initialize with your private key
const protocol = new FromChatProtocol(privateKey);
// Encrypt a message
const encrypted = await protocol.encryptMessage(recipientPublicKey, "Hello!");
// Decrypt a message
const decrypted = await protocol.decryptMessage(senderPublicKey, encrypted);
```
## API
### `FromChatProtocol`
#### Constructor
- `constructor(privateKey: Uint8Array)` - Initialize protocol with your X25519 private key
#### Methods
- `encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage>` - Encrypt a message
- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string>` - Decrypt a message
### Types
```typescript
interface EncryptedMessage {
iv: string; // Base64 encoded IV for message encryption
ciphertext: string; // Base64 encoded encrypted message
salt: string; // Base64 encoded salt for wrapping key derivation
iv2: string; // Base64 encoded IV for message key wrapping
wrappedMk: string; // Base64 encoded wrapped message key
}
```
## Backup & Key Management
The protocol also includes utilities for backing up and restoring private keys:
```typescript
import {
encryptBackupWithPassword,
decryptBackupWithPassword,
encodeBlob,
decodeBlob
} from "@fromchat/protocol";
// Create a backup of a private key
const bundle = { version: 1, privateKey: myPrivateKey };
const encrypted = await encryptBackupWithPassword("my-password", bundle);
const backupString = encodeBlob(encrypted); // Store this string
// Restore from backup
const encryptedBlob = decodeBlob(backupString);
const restored = await decryptBackupWithPassword("my-password", encryptedBlob);
```
## Security Notes
- Each message uses a fresh random message key
- The protocol does not provide forward secrecy
- Keys are derived using HKDF with SHA-256
- All encryption uses AES-GCM with 12-byte IVs
- Backup encryption uses PBKDF2 with 210,000 iterations
@@ -0,0 +1,54 @@
{
"name": "@fromchat/protocol",
"version": "1.0.0",
"description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"encryption",
"ecdh",
"e2ee",
"end-to-end-encryption",
"x25519",
"aes-gcm",
"hkdf"
],
"author": "denis0001-dev",
"license": "GPL-3.0",
"repository": {
"type": "git",
"url": "https://github.com/Toolbox-io/FromChat.git",
"directory": "frontend/packages/fromchat-protocol"
},
"bugs": {
"url": "https://github.com/Toolbox-io/FromChat/issues"
},
"homepage": "https://github.com/Toolbox-io/FromChat#readme",
"dependencies": {
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"@types/node": "^25.0.2",
"typescript": "^5.0.0"
},
"files": [
"dist",
"README.md"
],
"engines": {
"node": ">=24.0.0"
}
}
@@ -1,6 +1,4 @@
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; import { aesGcmDecrypt, aesGcmEncrypt, importPassword, deriveKEK, randomBytes } from "../crypto/index";
import { importPassword, deriveKEK, randomBytes } from "./kdf";
import { b64, ub64 } from "@/utils/utils";
export interface PrivateKeyBundle { export interface PrivateKeyBundle {
version: 1; version: 1;
@@ -47,6 +45,7 @@ export async function decryptBackupWithPassword(password: string, blob: Encrypte
} }
export function encodeBlob(blob: EncryptedBackupBlob): string { export function encodeBlob(blob: EncryptedBackupBlob): string {
function b64(a: Uint8Array) { return btoa(String.fromCharCode(...a)); }
return JSON.stringify({ return JSON.stringify({
salt: b64(blob.salt), salt: b64(blob.salt),
iv: b64(blob.iv), iv: b64(blob.iv),
@@ -55,6 +54,13 @@ export function encodeBlob(blob: EncryptedBackupBlob): string {
} }
export function decodeBlob(json: string): EncryptedBackupBlob { export function decodeBlob(json: string): EncryptedBackupBlob {
function ub64(s: string) {
const bin = atob(s);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
const obj = JSON.parse(json); const obj = JSON.parse(json);
return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) }; return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) };
} }
@@ -6,18 +6,16 @@ export interface X25519KeyPair {
privateKey: Uint8Array; privateKey: Uint8Array;
} }
export type KeyPair = X25519KeyPair;
export function generateX25519KeyPair(): X25519KeyPair { export function generateX25519KeyPair(): X25519KeyPair {
const kp = nacl.box.keyPair(); const kp = nacl.box.keyPair();
return { publicKey: kp.publicKey, privateKey: kp.secretKey }; return { publicKey: kp.publicKey, privateKey: kp.secretKey };
} }
export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array { export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array {
// nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF.
return nacl.box.before(theirPublicKey, myPrivateKey); return nacl.box.before(theirPublicKey, myPrivateKey);
} }
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> { export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32); return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
} }
@@ -0,0 +1,7 @@
// Re-export all crypto functions for convenience
export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./asymmetric";
export type { X25519KeyPair } from "./asymmetric";
export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./symmetric";
export type { AesGcmCiphertext } from "./symmetric";
export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./kdf";
@@ -1,32 +1,8 @@
export async function importPassword(password: string): Promise<CryptoKey> { export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise<Uint8Array> {
const enc = new TextEncoder();
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
}
export async function deriveKEK(
passwordKey: CryptoKey,
salt: Uint8Array | ArrayBuffer,
iterations = 210_000
): Promise<CryptoKey> {
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer,
salt: Uint8Array | ArrayBuffer,
info: Uint8Array | ArrayBuffer,
length = 32
): Promise<Uint8Array> {
const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial; const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial;
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info; const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info;
const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]); const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8); const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8);
return new Uint8Array(bits); return new Uint8Array(bits);
@@ -36,4 +12,20 @@ export function randomBytes(length: number): Uint8Array {
const out = new Uint8Array(length); const out = new Uint8Array(length);
crypto.getRandomValues(out); crypto.getRandomValues(out);
return out; return out;
} }
export async function importPassword(password: string): Promise<CryptoKey> {
const enc = new TextEncoder();
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
}
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> {
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
@@ -10,17 +10,11 @@ export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | Arra
return { iv, ciphertext: new Uint8Array(ct) }; return { iv, ciphertext: new Uint8Array(ct) };
} }
export async function aesGcmDecrypt( export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
key: CryptoKey,
iv: Uint8Array | ArrayBuffer,
ciphertext: Uint8Array | ArrayBuffer
): Promise<Uint8Array> {
// Normalize IV to ArrayBuffer (12 bytes for AES-GCM)
const ivBuf: ArrayBuffer = iv instanceof Uint8Array const ivBuf: ArrayBuffer = iv instanceof Uint8Array
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength) ? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
: (iv as ArrayBuffer); : (iv as ArrayBuffer);
// Normalize ciphertext to a contiguous ArrayBuffer slice
const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array
? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength) ? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
: (ciphertext as ArrayBuffer); : (ciphertext as ArrayBuffer);
@@ -30,6 +24,8 @@ export async function aesGcmDecrypt(
} }
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> { export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
const keyBuffer = rawKey instanceof Uint8Array ? rawKey.buffer as ArrayBuffer : rawKey; const keyBuffer = rawKey instanceof Uint8Array
? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength)
: (rawKey as ArrayBuffer);
return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
} }
@@ -0,0 +1,20 @@
export { FromChatProtocol } from "./protocol/FromChatProtocol";
export type { EncryptedMessage } from "./protocol/types";
// Export crypto functions
export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./crypto/asymmetric";
export type { X25519KeyPair } from "./crypto/asymmetric";
export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./crypto/symmetric";
export type { AesGcmCiphertext } from "./crypto/symmetric";
export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./crypto/kdf";
// Export backup functions
export {
encryptBackupWithPassword,
decryptBackupWithPassword,
encodeBlob,
decodeBlob,
serializeBundle,
deserializeBundle
} from "./backup/backup";
export type { PrivateKeyBundle, EncryptedBackupBlob } from "./backup/backup";
@@ -0,0 +1,102 @@
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
import { randomBytes } from "../crypto/kdf";
import type { EncryptedMessage } from "./types";
/**
* FromChat Protocol - Simple ECDH-based encryption
*
* Protocol:
* 1. Generate random message key (mk) - 32 bytes
* 2. Generate random salt (wkSalt) - 16 bytes
* 3. Derive shared secret from ECDH (X25519)
* 4. Derive wrapping key from shared secret using HKDF with salt
* 5. Encrypt message with mk using AES-GCM
* 6. Encrypt (wrap) mk with wrapping key using AES-GCM
* 7. Send: { iv, ciphertext, salt, iv2, wrappedMk }
*/
export class FromChatProtocol {
private privateKey: Uint8Array;
constructor(privateKey: Uint8Array) {
this.privateKey = privateKey;
}
/**
* Encrypt a message for a recipient
* @param recipientPublicKey - Recipient's X25519 public key
* @param plaintext - Message to encrypt
* @returns Encrypted message with all necessary fields
*/
async encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage> {
// Generate random message key
const mk = randomBytes(32);
// Generate random salt for wrapping key derivation
const wkSalt = randomBytes(16);
// Derive shared secret from ECDH
const shared = ecdhSharedSecret(this.privateKey, recipientPublicKey);
// Derive wrapping key from shared secret using HKDF
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the message with message key
const plaintextBytes = new TextEncoder().encode(plaintext);
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), plaintextBytes);
// Encrypt (wrap) the message key with wrapping key
const wrap = await aesGcmEncrypt(wk, mk);
// Convert to base64 for transmission
return {
iv: btoa(String.fromCharCode(...encMsg.iv)),
ciphertext: btoa(String.fromCharCode(...encMsg.ciphertext)),
salt: btoa(String.fromCharCode(...wkSalt)),
iv2: btoa(String.fromCharCode(...wrap.iv)),
wrappedMk: btoa(String.fromCharCode(...wrap.ciphertext))
};
}
/**
* Decrypt a message from a sender
* @param senderPublicKey - Sender's X25519 public key
* @param message - Encrypted message
* @returns Decrypted plaintext
*/
async decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string> {
// Decode base64 fields
const salt = new Uint8Array(
atob(message.salt).split("").map(c => c.charCodeAt(0))
);
const iv2 = new Uint8Array(
atob(message.iv2).split("").map(c => c.charCodeAt(0))
);
const wrappedMk = new Uint8Array(
atob(message.wrappedMk).split("").map(c => c.charCodeAt(0))
);
const iv = new Uint8Array(
atob(message.iv).split("").map(c => c.charCodeAt(0))
);
const ciphertext = new Uint8Array(
atob(message.ciphertext).split("").map(c => c.charCodeAt(0))
);
// Derive shared secret from ECDH
const shared = ecdhSharedSecret(this.privateKey, senderPublicKey);
// Derive wrapping key from shared secret using salt from message
const wkRaw = await deriveWrappingKey(shared, salt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Decrypt (unwrap) the message key
const mk = await aesGcmDecrypt(wk, iv2, wrappedMk);
// Decrypt the message with message key
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
return new TextDecoder().decode(decrypted);
}
}
@@ -0,0 +1,10 @@
/**
* Encrypted message format
*/
export interface EncryptedMessage {
iv: string; // Base64 encoded IV for message encryption
ciphertext: string; // Base64 encoded encrypted message
salt: string; // Base64 encoded salt for wrapping key derivation
iv2: string; // Base64 encoded IV for message key wrapping
wrappedMk: string; // Base64 encoded wrapped message key
}
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

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