mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 19:45:05 +03:00
Compare commits
120 Commits
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Run the command "npm run frontend:typecheck" and fix all errors listed in the command if there's any.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Documentation rules
|
alwaysApply: true
|
||||||
alwaysApply: false
|
|
||||||
---
|
---
|
||||||
|
|
||||||
When documenting this project, follow these rules:
|
When documenting this project, follow these rules:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
Executable
+116
@@ -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) &
|
||||||
|
|
||||||
Vendored
+6
-4
@@ -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
|
|
||||||
}
|
}
|
||||||
Vendored
+16
-1
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
FromChat - полностью открытый мессенджер.
|
FromChat - полностью открытый мессенджер.
|
||||||
|
|
||||||
Демо версию можно попробовать на [сайте](http://95.165.0.162:8301).
|
Его можно попробовать на [сайте](http://fromchat.ru).
|
||||||
|
|
||||||
## Содержание:
|
## Содержание:
|
||||||
- [Основные моменты](#highlights)
|
- [Основные моменты](#highlights)
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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()
|
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from backend.shared.constants import OWNER_USERNAME
|
||||||
|
from backend.shared.dependencies import get_current_user
|
||||||
|
from backend.shared.models import User
|
||||||
|
from backend.security.audit import log_security
|
||||||
|
from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||||
|
from backend.security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
||||||
|
|
||||||
|
|
||||||
|
class BlocklistUpdateRequest(BaseModel):
|
||||||
|
words: List[str] = Field(default_factory=list, min_items=1)
|
||||||
|
|
||||||
|
|
||||||
|
class UnblockIPRequest(BaseModel):
|
||||||
|
ip: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/moderation", tags=["moderation"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_owner(user: User) -> None:
|
||||||
|
if user.username != OWNER_USERNAME:
|
||||||
|
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/blocklist")
|
||||||
|
def list_blocklist(current_user: User = Depends(get_current_user)):
|
||||||
|
_ensure_owner(current_user)
|
||||||
|
return {"words": get_blocklist()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/blocklist")
|
||||||
|
def append_blocklist(
|
||||||
|
request: BlocklistUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
_ensure_owner(current_user)
|
||||||
|
added, updated = add_to_blocklist(request.words)
|
||||||
|
log_security(
|
||||||
|
"blocklist_add",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
added=added,
|
||||||
|
)
|
||||||
|
return {"added": added, "words": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/blocklist")
|
||||||
|
def delete_from_blocklist(
|
||||||
|
request: BlocklistUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
_ensure_owner(current_user)
|
||||||
|
removed, updated = remove_from_blocklist(request.words)
|
||||||
|
log_security(
|
||||||
|
"blocklist_remove",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
removed=removed,
|
||||||
|
)
|
||||||
|
return {"removed": removed, "words": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unblock-ip")
|
||||||
|
def unblock_ip(
|
||||||
|
request: UnblockIPRequest,
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Unblock an IP address from rate limiting."""
|
||||||
|
_ensure_owner(current_user)
|
||||||
|
ip = request.ip.strip()
|
||||||
|
|
||||||
|
if not ip:
|
||||||
|
raise HTTPException(status_code=400, detail="IP address is required")
|
||||||
|
|
||||||
|
cleared = reset_rate_limit_for_ip(ip)
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"rate_limit_unblock",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
ip=ip,
|
||||||
|
success=cleared,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cleared:
|
||||||
|
return {"status": "success", "message": f"Rate limit cleared for IP: {ip}"}
|
||||||
|
else:
|
||||||
|
return {"status": "success", "message": f"No rate limit entries found for IP: {ip}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-all-rate-limits")
|
||||||
|
def clear_all_rate_limits_endpoint(
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Clear all rate limit entries. Use with caution."""
|
||||||
|
_ensure_owner(current_user)
|
||||||
|
|
||||||
|
cleared = clear_all_rate_limits()
|
||||||
|
|
||||||
|
log_security(
|
||||||
|
"rate_limit_clear_all",
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
entries_cleared=cleared,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "message": f"Cleared {cleared} rate limit entries"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+402
-27
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from backend.shared.dependencies import get_current_user
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_turn_credentials(username: str, secret: str, expiration_minutes: int = 60):
|
||||||
|
"""Generate time-limited TURN credentials using TURN REST API format.
|
||||||
|
|
||||||
|
This creates temporary credentials that expire after the specified time.
|
||||||
|
The username format is: timestamp:username
|
||||||
|
The password is an HMAC hash of the username and secret.
|
||||||
|
"""
|
||||||
|
# Current timestamp (seconds since epoch)
|
||||||
|
timestamp = int(time.time()) + (expiration_minutes * 60)
|
||||||
|
|
||||||
|
# Create temporary username: timestamp:original_username
|
||||||
|
temp_username = f"{timestamp}:{username}"
|
||||||
|
|
||||||
|
# Generate password using HMAC-SHA1
|
||||||
|
temp_password = hmac.new(
|
||||||
|
secret.encode('utf-8'),
|
||||||
|
temp_username.encode('utf-8'),
|
||||||
|
hashlib.sha1
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
return temp_username, temp_password
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ice")
|
||||||
|
async def get_ice_servers(current_user = Depends(get_current_user)):
|
||||||
|
"""Return ICE server configuration (STUN/TURN) for WebRTC clients.
|
||||||
|
|
||||||
|
Generates time-limited TURN credentials that expire in 1 hour.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Prefer using your own coturn for both STUN and TURN
|
||||||
|
turn_domain = "fromchat.ru"
|
||||||
|
stun_urls = [
|
||||||
|
f"stun:{turn_domain}:3478",
|
||||||
|
f"stuns:{turn_domain}:5349",
|
||||||
|
]
|
||||||
|
|
||||||
|
turn_urls = [
|
||||||
|
f"turn:{turn_domain}:3478",
|
||||||
|
f"turns:{turn_domain}:5349",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get TURN configuration from environment
|
||||||
|
turn_username = os.getenv("TURN_USERNAME")
|
||||||
|
turn_secret = os.getenv("TURN_SECRET")
|
||||||
|
|
||||||
|
# Check if required environment variables are set
|
||||||
|
if not turn_username:
|
||||||
|
logger.error("ERROR: TURN_USERNAME environment variable is not set")
|
||||||
|
raise ValueError("TURN_USERNAME environment variable is not set")
|
||||||
|
|
||||||
|
if not turn_secret:
|
||||||
|
logger.error("ERROR: TURN_SECRET environment variable is not set")
|
||||||
|
raise ValueError("TURN_SECRET environment variable is not set")
|
||||||
|
|
||||||
|
ice_servers: list[dict] = [{"urls": url} for url in stun_urls]
|
||||||
|
|
||||||
|
temp_username, temp_password = generate_turn_credentials(
|
||||||
|
turn_username,
|
||||||
|
turn_secret,
|
||||||
|
expiration_minutes=60 # Expires in 1 hour
|
||||||
|
)
|
||||||
|
|
||||||
|
ice_servers.append({
|
||||||
|
"urls": turn_urls,
|
||||||
|
"username": temp_username,
|
||||||
|
"credential": temp_password,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"iceServers": ice_servers}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ERROR in /api/webrtc/ice: {str(e)}")
|
||||||
|
logger.error(f"ERROR type: {type(e).__name__}")
|
||||||
|
traceback.print_exc()
|
||||||
|
raise
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Local development server that runs all services in a single FastAPI application.
|
||||||
|
This provides the same monolithic experience as before, but with microservice separation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import uvicorn
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Import service routers
|
||||||
|
from backend.routes.account import router as account_router
|
||||||
|
from backend.routes.profile import router as profile_router
|
||||||
|
from backend.routes.devices import router as device_router
|
||||||
|
from backend.routes.messaging import router as messaging_router
|
||||||
|
from backend.routes.push import router as push_router
|
||||||
|
from backend.routes.webrtc import router as webrtc_router
|
||||||
|
from backend.routes.moderation import router as moderation_router
|
||||||
|
|
||||||
|
# Import security modules
|
||||||
|
from security.audit import log_access
|
||||||
|
from security.rate_limit import limiter
|
||||||
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
|
|
||||||
|
# Create main FastAPI app
|
||||||
|
app = FastAPI(title="FromChat Local Development")
|
||||||
|
|
||||||
|
# Add rate limiting middleware
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
"https://fromchat.ru",
|
||||||
|
"https://beta.fromchat.ru",
|
||||||
|
"https://www.fromchat.ru",
|
||||||
|
"http://127.0.0.1:8301",
|
||||||
|
"http://127.0.0.1:8300",
|
||||||
|
"http://localhost:8301",
|
||||||
|
"http://localhost:8300",
|
||||||
|
],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount service routers with appropriate prefixes
|
||||||
|
app.include_router(account_router, prefix="/account")
|
||||||
|
app.include_router(profile_router, prefix="/profile")
|
||||||
|
app.include_router(device_router, prefix="/devices")
|
||||||
|
app.include_router(messaging_router, prefix="/messaging")
|
||||||
|
app.include_router(push_router, prefix="/push")
|
||||||
|
app.include_router(webrtc_router, prefix="/webrtc")
|
||||||
|
app.include_router(moderation_router, prefix="/moderation")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run the server
|
||||||
|
port = int(os.getenv("PORT", "8301"))
|
||||||
|
host = os.getenv("HOST", "127.0.0.1")
|
||||||
|
|
||||||
|
print(f"Starting FromChat local development server on {host}:{port}")
|
||||||
|
print("Available services:")
|
||||||
|
print(" - Account: http://127.0.0.1:8301/account/")
|
||||||
|
print(" - Profile: http://127.0.0.1:8301/profile/")
|
||||||
|
print(" - Devices: http://127.0.0.1:8301/devices/")
|
||||||
|
print(" - Messaging: http://127.0.0.1:8301/messaging/")
|
||||||
|
print(" - Push: http://127.0.0.1:8301/push/")
|
||||||
|
print(" - WebRTC: http://127.0.0.1:8301/webrtc/")
|
||||||
|
print(" - Moderation: http://127.0.0.1:8301/moderation/")
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"run_local:app",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
reload=True,
|
||||||
|
reload_dirs=["backend"]
|
||||||
|
)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Package marker for security utilities
|
||||||
|
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from html import unescape
|
||||||
|
from typing import Any, Callable, Dict, List
|
||||||
|
|
||||||
|
from backend.logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_username(username: Any) -> str:
|
||||||
|
if not username:
|
||||||
|
return "unknown user"
|
||||||
|
return f"@{username}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_user(fields: Dict[str, Any], username_key: str = "username", user_id_key: str = "user_id") -> str:
|
||||||
|
username = fields.get(username_key)
|
||||||
|
if username is None and "_" in username_key:
|
||||||
|
base_key = username_key.split("_", 1)[0]
|
||||||
|
username = fields.get(base_key)
|
||||||
|
|
||||||
|
user_id = fields.get(user_id_key)
|
||||||
|
if user_id is None and "_" in user_id_key:
|
||||||
|
base_key = user_id_key.split("_", 1)[0]
|
||||||
|
user_id = fields.get(base_key)
|
||||||
|
|
||||||
|
if username and user_id is not None:
|
||||||
|
return f"{_clean_username(username)} (user id {user_id})"
|
||||||
|
if username:
|
||||||
|
return _clean_username(username)
|
||||||
|
if user_id is not None:
|
||||||
|
return f"user id {user_id}"
|
||||||
|
return "unknown user"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_actor(fields: Dict[str, Any], prefix: str) -> str:
|
||||||
|
return _format_user(fields, f"{prefix}_username", f"{prefix}_id")
|
||||||
|
|
||||||
|
|
||||||
|
def _plural(label: str, count: int) -> str:
|
||||||
|
return f"{count} {label if count == 1 else label + 's'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _yes_no(flag: Any) -> str:
|
||||||
|
return "yes" if flag else "no"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
|
||||||
|
if action == "login_success":
|
||||||
|
lines = [f"Login approved for {_format_user(fields)}"]
|
||||||
|
session = fields.get("session_id")
|
||||||
|
if session:
|
||||||
|
lines.append(f"Session: {session}")
|
||||||
|
client_bits: List[str] = []
|
||||||
|
if fields.get("device"):
|
||||||
|
client_bits.append(fields["device"])
|
||||||
|
if fields.get("os"):
|
||||||
|
client_bits.append(fields["os"])
|
||||||
|
if fields.get("browser"):
|
||||||
|
client_bits.append(fields["browser"])
|
||||||
|
if client_bits:
|
||||||
|
lines.append(f"Client: {', '.join(client_bits)}")
|
||||||
|
if fields.get("ip"):
|
||||||
|
lines.append(f"IP address: {fields['ip']}")
|
||||||
|
return lines
|
||||||
|
if action == "login_failed":
|
||||||
|
lines = [f"Login denied for {_format_user(fields)}"]
|
||||||
|
if fields.get("reason"):
|
||||||
|
lines.append(f"Reason: {fields['reason']}")
|
||||||
|
if fields.get("ip"):
|
||||||
|
lines.append(f"IP address: {fields['ip']}")
|
||||||
|
return lines
|
||||||
|
if action == "auth_bruteforce_detected":
|
||||||
|
lines = ["Brute-force login pattern detected"]
|
||||||
|
lines.append(f"Target: {_format_user(fields)}")
|
||||||
|
failures = fields.get("failures")
|
||||||
|
if isinstance(failures, dict):
|
||||||
|
for key, value in failures.items():
|
||||||
|
lines.append(f"{key}: {value}")
|
||||||
|
if fields.get("ip"):
|
||||||
|
lines.append(f"IP address: {fields['ip']}")
|
||||||
|
if fields.get("window_seconds"):
|
||||||
|
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
||||||
|
return lines
|
||||||
|
if action == "registration_success":
|
||||||
|
ip_raw = fields.get("ip")
|
||||||
|
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
|
||||||
|
display_name = fields.get("display_name") or "Unknown"
|
||||||
|
username = fields.get("username")
|
||||||
|
user_id = fields.get("user_id")
|
||||||
|
user_agent = fields.get("user_agent") or "Unknown user agent"
|
||||||
|
lines = ["Account registered"]
|
||||||
|
lines.append(f"Display name: {display_name}")
|
||||||
|
lines.append(f"Username: {_clean_username(username) if username else 'unknown'}")
|
||||||
|
if ip_display:
|
||||||
|
lines.append(f"IP: {ip_display}")
|
||||||
|
if user_agent:
|
||||||
|
lines.append(f"User agent: {user_agent}")
|
||||||
|
if user_id is not None:
|
||||||
|
lines.append(f"User ID: {user_id}")
|
||||||
|
return lines
|
||||||
|
if action == "password_changed":
|
||||||
|
lines = [f"Password changed for {_format_user(fields)}"]
|
||||||
|
lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}")
|
||||||
|
if fields.get("ip"):
|
||||||
|
lines.append(f"IP address: {fields['ip']}")
|
||||||
|
return lines
|
||||||
|
if action == "logout":
|
||||||
|
lines = [f"Logout recorded for {_format_user(fields)}"]
|
||||||
|
if fields.get("session_id"):
|
||||||
|
lines.append(f"Session: {fields['session_id']}")
|
||||||
|
if fields.get("ip"):
|
||||||
|
lines.append(f"IP address: {fields['ip']}")
|
||||||
|
return lines
|
||||||
|
if action == "admin_delete_user":
|
||||||
|
return [
|
||||||
|
"Account removal",
|
||||||
|
f"Actor: {_format_actor(fields, 'actor')}",
|
||||||
|
f"Target: {_format_actor(fields, 'target')}",
|
||||||
|
]
|
||||||
|
if action == "admin_suspend_user":
|
||||||
|
lines = [
|
||||||
|
"User suspension",
|
||||||
|
f"Actor: {_format_actor(fields, 'actor')}",
|
||||||
|
f"Target: {_format_actor(fields, 'target')}",
|
||||||
|
]
|
||||||
|
if fields.get("reason"):
|
||||||
|
lines.append(f"Reason: {fields.get('reason')}")
|
||||||
|
return lines
|
||||||
|
if action == "admin_unsuspend_user":
|
||||||
|
return [
|
||||||
|
"User unsuspension",
|
||||||
|
f"Actor: {_format_actor(fields, 'actor')}",
|
||||||
|
f"Target: {_format_actor(fields, 'target')}",
|
||||||
|
]
|
||||||
|
if action == "admin_verify_toggle":
|
||||||
|
return [
|
||||||
|
"User verification",
|
||||||
|
f"Actor: {_format_actor(fields, 'actor')}",
|
||||||
|
f"Target: {_format_actor(fields, 'target')}",
|
||||||
|
f"Verified: {_yes_no(fields.get('verified'))}",
|
||||||
|
]
|
||||||
|
if action == "self_delete_account":
|
||||||
|
return [f"User {_format_user(fields)} deleted their account"]
|
||||||
|
if action == "auto_suspension_public_spam":
|
||||||
|
lines = [
|
||||||
|
f"Automatic suspension triggered for {_format_user(fields)}",
|
||||||
|
]
|
||||||
|
match_type = fields.get("match_type")
|
||||||
|
if match_type:
|
||||||
|
lines.append(f"Match type: {match_type}")
|
||||||
|
similar = fields.get("similar_messages")
|
||||||
|
occurrences = fields.get("occurrences")
|
||||||
|
if similar:
|
||||||
|
lines.append(f"Similar messages detected: {similar}")
|
||||||
|
if occurrences and not similar:
|
||||||
|
lines.append(f"Occurrences: {occurrences}")
|
||||||
|
if fields.get("window_seconds"):
|
||||||
|
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
||||||
|
if fields.get("reason"):
|
||||||
|
lines.append(f"Reason: {fields['reason']}")
|
||||||
|
return lines
|
||||||
|
if action == "auto_suspension_public_burst":
|
||||||
|
lines = [
|
||||||
|
f"Automatic suspension triggered for {_format_user(fields)}",
|
||||||
|
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
|
||||||
|
]
|
||||||
|
if fields.get("reason"):
|
||||||
|
lines.append(f"Reason: {fields['reason']}")
|
||||||
|
return lines
|
||||||
|
if action == "public_message_burst":
|
||||||
|
return [
|
||||||
|
f"Rapid messaging spike for {_format_user(fields)}",
|
||||||
|
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
|
||||||
|
]
|
||||||
|
if action == "blocklist_add":
|
||||||
|
added = fields.get("added") or []
|
||||||
|
lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"]
|
||||||
|
if added:
|
||||||
|
lines.append(f"Added entries: {', '.join(added)}")
|
||||||
|
total = len(fields.get("words") or [])
|
||||||
|
lines.append(f"Total entries: {total}")
|
||||||
|
return lines
|
||||||
|
if action == "blocklist_remove":
|
||||||
|
removed = fields.get("removed") or []
|
||||||
|
lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"]
|
||||||
|
if removed:
|
||||||
|
lines.append(f"Removed entries: {', '.join(removed)}")
|
||||||
|
total = len(fields.get("words") or [])
|
||||||
|
lines.append(f"Total entries: {total}")
|
||||||
|
return lines
|
||||||
|
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
||||||
|
f"{key.replace('_', ' ').capitalize()}: {value}"
|
||||||
|
for key, value in fields.items()
|
||||||
|
if value is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]:
|
||||||
|
if action == "message_created":
|
||||||
|
lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"]
|
||||||
|
if fields.get("reply_to"):
|
||||||
|
lines.append(f"In reply to message #{fields['reply_to']}")
|
||||||
|
attachments = fields.get("attachments")
|
||||||
|
if attachments:
|
||||||
|
lines.append(f"Attachments: {_plural('file', attachments)}")
|
||||||
|
|
||||||
|
# If content was censored, log both raw and censored versions
|
||||||
|
if fields.get("raw_content") is not None:
|
||||||
|
lines.append("Raw content (before censoring):")
|
||||||
|
for line in unescape(fields["raw_content"]).splitlines():
|
||||||
|
lines.append(f"| {line}")
|
||||||
|
lines.append("Censored content (stored):")
|
||||||
|
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
|
||||||
|
lines.append(f"| {line}")
|
||||||
|
elif fields.get("content"):
|
||||||
|
lines.append("Content:")
|
||||||
|
for line in unescape(fields["content"]).splitlines():
|
||||||
|
lines.append(f"| {line}")
|
||||||
|
return lines
|
||||||
|
if action == "message_edited":
|
||||||
|
lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"]
|
||||||
|
if fields.get("reply_to"):
|
||||||
|
lines.append(f"Reply to #{fields['reply_to']}")
|
||||||
|
if fields.get("previous_content"):
|
||||||
|
lines.append("Previous content:")
|
||||||
|
for line in unescape(fields["previous_content"] or "").splitlines() or [""]:
|
||||||
|
lines.append(f"| {line}")
|
||||||
|
|
||||||
|
# If content was censored, log both raw and censored versions
|
||||||
|
if fields.get("raw_content") is not None:
|
||||||
|
lines.append("Raw content (before censoring):")
|
||||||
|
for line in unescape(fields["raw_content"]).splitlines():
|
||||||
|
lines.append(f"| {line}")
|
||||||
|
lines.append("Censored content (stored):")
|
||||||
|
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
|
||||||
|
lines.append(f"| {line}")
|
||||||
|
elif fields.get("content"):
|
||||||
|
lines.append("New content:")
|
||||||
|
for line in unescape(fields["content"] or "").splitlines() or [""]:
|
||||||
|
lines.append(f"| {line}")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
if action == "message_deleted":
|
||||||
|
lines = [
|
||||||
|
f"Message #{fields.get('message_id')} deleted",
|
||||||
|
f"Actor: {_format_actor(fields, 'actor')}",
|
||||||
|
]
|
||||||
|
if fields.get("original_author_id") is not None:
|
||||||
|
lines.append(f"Original author: user #{fields['original_author_id']}")
|
||||||
|
if fields.get("content"):
|
||||||
|
lines.append("Previous content:")
|
||||||
|
for line in unescape(fields["content"]).splitlines():
|
||||||
|
lines.append(f"| {line}")
|
||||||
|
return lines
|
||||||
|
if action == "reaction_update":
|
||||||
|
lines = [
|
||||||
|
f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}",
|
||||||
|
f"User: {_format_user(fields)}",
|
||||||
|
]
|
||||||
|
if fields.get("emoji"):
|
||||||
|
lines.append(f"Emoji: {fields['emoji']}")
|
||||||
|
return lines
|
||||||
|
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
||||||
|
f"{key.replace('_', ' ').capitalize()}: {value}"
|
||||||
|
for key, value in fields.items()
|
||||||
|
if value is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]:
|
||||||
|
if action in {"message_sent", "message_sent_ws"}:
|
||||||
|
lines = [
|
||||||
|
f"Direct message #{fields.get('dm_envelope_id')} sent",
|
||||||
|
f"Sender: {_format_actor(fields, 'sender')}",
|
||||||
|
]
|
||||||
|
if fields.get("recipient_id") is not None:
|
||||||
|
lines.append(f"Recipient: user id {fields['recipient_id']}")
|
||||||
|
attachments = fields.get("attachment_count")
|
||||||
|
if attachments:
|
||||||
|
lines.append(f"Attachments: {_plural('file', attachments)}")
|
||||||
|
if fields.get("reply_to"):
|
||||||
|
lines.append(f"In reply to DM #{fields['reply_to']}")
|
||||||
|
return lines
|
||||||
|
if action == "message_edited":
|
||||||
|
return [
|
||||||
|
f"Direct message #{fields.get('dm_envelope_id')} edited",
|
||||||
|
f"Author: {_format_user(fields)}",
|
||||||
|
]
|
||||||
|
if action == "message_deleted":
|
||||||
|
lines = [
|
||||||
|
f"Direct message #{fields.get('dm_envelope_id')} deleted",
|
||||||
|
f"Actor: {_format_user(fields)}",
|
||||||
|
]
|
||||||
|
if fields.get("recipient_id") is not None:
|
||||||
|
lines.append(f"Recipient: user id {fields['recipient_id']}")
|
||||||
|
return lines
|
||||||
|
if action == "reaction_update":
|
||||||
|
lines = [
|
||||||
|
f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}",
|
||||||
|
f"User: {_format_user(fields)}",
|
||||||
|
]
|
||||||
|
if fields.get("emoji"):
|
||||||
|
lines.append(f"Emoji: {fields['emoji']}")
|
||||||
|
return lines
|
||||||
|
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
||||||
|
f"{key.replace('_', ' ').capitalize()}: {value}"
|
||||||
|
for key, value in fields.items()
|
||||||
|
if value is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_access(action: str, fields: Dict[str, Any]) -> List[str]:
|
||||||
|
ip_raw = fields.get("ip")
|
||||||
|
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
|
||||||
|
if action == "http_request":
|
||||||
|
first_line = f"{fields.get('method')} {fields.get('path')}"
|
||||||
|
if ip_display:
|
||||||
|
first_line += f" from {ip_display}"
|
||||||
|
first_line += f" -> {fields.get('status')}"
|
||||||
|
lines = [first_line]
|
||||||
|
if fields.get("user"):
|
||||||
|
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
|
||||||
|
return lines
|
||||||
|
if action == "http_error":
|
||||||
|
first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}"
|
||||||
|
if ip_display:
|
||||||
|
first_line += f" from {ip_display}"
|
||||||
|
lines = [first_line]
|
||||||
|
if fields.get("error"):
|
||||||
|
lines.append(f"Exception: {fields['error']}")
|
||||||
|
if fields.get("user"):
|
||||||
|
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
|
||||||
|
return lines
|
||||||
|
if action == "ws_connect":
|
||||||
|
lines = ["WebSocket connected"]
|
||||||
|
if fields.get("path"):
|
||||||
|
lines.append(f"Endpoint: {fields['path']}")
|
||||||
|
if ip_display:
|
||||||
|
lines.append(f"IP: {ip_display}")
|
||||||
|
return lines
|
||||||
|
if action == "ws_disconnect":
|
||||||
|
lines = ["WebSocket disconnected"]
|
||||||
|
if fields.get("path"):
|
||||||
|
lines.append(f"Endpoint: {fields['path']}")
|
||||||
|
if fields.get("code") is not None:
|
||||||
|
reason = fields.get("reason") or "no reason"
|
||||||
|
lines.append(f"Code {fields['code']} ({reason})")
|
||||||
|
if ip_display:
|
||||||
|
lines.append(f"IP: {ip_display}")
|
||||||
|
return lines
|
||||||
|
if action == "ws_event":
|
||||||
|
event_name = fields.get("event")
|
||||||
|
path = fields.get("path")
|
||||||
|
first_line = "WS"
|
||||||
|
if path:
|
||||||
|
first_line += f" {path}"
|
||||||
|
if ip_display:
|
||||||
|
first_line += f" from {ip_display}"
|
||||||
|
if event_name:
|
||||||
|
first_line += f" -> {event_name}"
|
||||||
|
lines = [first_line]
|
||||||
|
if fields.get("user"):
|
||||||
|
lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}")
|
||||||
|
for key, value in fields.items():
|
||||||
|
if key in {"path", "event", "user", "user_id", "ip"} or value is None:
|
||||||
|
continue
|
||||||
|
lines.append(f"{key.replace('_', ' ').capitalize()}: {value}")
|
||||||
|
return lines
|
||||||
|
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
||||||
|
f"{key.replace('_', ' ').capitalize()}: {value}"
|
||||||
|
for key, value in fields.items()
|
||||||
|
if value is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _log_event(
|
||||||
|
logger: logging.Logger,
|
||||||
|
renderer: Callable[[str, Dict[str, Any]], List[str]],
|
||||||
|
action: str,
|
||||||
|
severity: str,
|
||||||
|
fields: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
lines = renderer(action, fields)
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
level = getattr(logging, severity.upper(), logging.INFO)
|
||||||
|
logger.log(level, "\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def log_security(action: str, severity: str = "info", **fields: Any) -> None:
|
||||||
|
_log_event(security_logger, _render_security, action, severity, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def log_public_chat(action: str, severity: str = "info", **fields: Any) -> None:
|
||||||
|
_log_event(public_chat_logger, _render_public_chat, action, severity, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def log_dm(action: str, severity: str = "info", **fields: Any) -> None:
|
||||||
|
sanitized_fields = {key: value for key, value in fields.items() if key != "content"}
|
||||||
|
_log_event(dm_logger, _render_dm, action, severity, sanitized_fields)
|
||||||
|
|
||||||
|
|
||||||
|
def log_access(action: str, severity: str = "info", **fields: Any) -> None:
|
||||||
|
_log_event(access_logger, _render_access, action, severity, fields)
|
||||||
|
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import RLock
|
||||||
|
from typing import Iterable, List, Set, Tuple
|
||||||
|
|
||||||
|
from better_profanity import Profanity
|
||||||
|
|
||||||
|
BLOCKLIST_PATH = Path(__file__).resolve().parent.parent / "data" / "profanity" / "blocklist.json"
|
||||||
|
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
_CUSTOM_RU_TERMS: Set[str] = {
|
||||||
|
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
||||||
|
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
|
||||||
|
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
|
||||||
|
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
||||||
|
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор",
|
||||||
|
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ADULT_TERMS: Set[str] = {
|
||||||
|
"порно", "порнуха", "эротика", "эротический", "секс", "сексуальный",
|
||||||
|
"инцест", "порнография", "порностудия", "порновидео", "порносайт",
|
||||||
|
"сексчат", "сексчатик", "секслайв", "сексвидео",
|
||||||
|
}
|
||||||
|
|
||||||
|
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
|
||||||
|
|
||||||
|
# Words that should never be flagged as profanity (whitelist)
|
||||||
|
_WHITELIST: Set[str] = {
|
||||||
|
"говно", # Allow this word
|
||||||
|
}
|
||||||
|
|
||||||
|
# Phrase patterns - these will be applied to normalized text (without special chars)
|
||||||
|
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
||||||
|
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patterns to check in original text (before normalization) to catch visual bypasses
|
||||||
|
# These patterns check for special character combinations that visually form letters
|
||||||
|
_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
||||||
|
# Catch "}{" used to visually form "х" followed by "С0С" or similar patterns
|
||||||
|
# This catches "хуесос" written as "}{¥€С0С" or variations
|
||||||
|
# Matches: }{ + any characters (including special chars) + С/с + 0 + С/с
|
||||||
|
# The pattern allows any characters between to catch special chars like ¥€
|
||||||
|
re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
||||||
|
# Also catch "}{" followed by "уесос" with 0 instead of о
|
||||||
|
re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map for normalizing homoglyphs (similar-looking characters)
|
||||||
|
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
|
||||||
|
# Also includes Greek, full-width, and other Unicode variants
|
||||||
|
_LEET_MAP = {
|
||||||
|
# Numbers to letters
|
||||||
|
"0": "о",
|
||||||
|
"1": "и",
|
||||||
|
"3": "е",
|
||||||
|
"4": "а",
|
||||||
|
# Latin to Cyrillic (lowercase)
|
||||||
|
"a": "а",
|
||||||
|
"c": "с",
|
||||||
|
"e": "е",
|
||||||
|
"f": "ф",
|
||||||
|
"g": "г",
|
||||||
|
"i": "и",
|
||||||
|
"m": "м",
|
||||||
|
"n": "н",
|
||||||
|
"o": "о",
|
||||||
|
"p": "п",
|
||||||
|
"s": "с",
|
||||||
|
"t": "т",
|
||||||
|
"u": "у",
|
||||||
|
"v": "в",
|
||||||
|
"x": "х",
|
||||||
|
"y": "у",
|
||||||
|
"z": "з", # English 'z' to Cyrillic 'з'
|
||||||
|
# Latin to Cyrillic (uppercase)
|
||||||
|
"A": "а",
|
||||||
|
"C": "с",
|
||||||
|
"E": "е",
|
||||||
|
"F": "ф",
|
||||||
|
"G": "г",
|
||||||
|
"I": "и",
|
||||||
|
"M": "м",
|
||||||
|
"N": "н",
|
||||||
|
"O": "о",
|
||||||
|
"P": "п",
|
||||||
|
"S": "с",
|
||||||
|
"T": "т",
|
||||||
|
"U": "у",
|
||||||
|
"V": "в",
|
||||||
|
"X": "х",
|
||||||
|
"Y": "у",
|
||||||
|
"Z": "з", # English 'Z' to Cyrillic 'з'
|
||||||
|
# Greek letters that look like Cyrillic/Latin
|
||||||
|
"α": "а", # Greek alpha
|
||||||
|
"Α": "а",
|
||||||
|
"ο": "о", # Greek omicron
|
||||||
|
"Ο": "о",
|
||||||
|
"ρ": "р", # Greek rho (looks like Cyrillic р)
|
||||||
|
"Ρ": "р",
|
||||||
|
"υ": "у", # Greek upsilon
|
||||||
|
"Υ": "у",
|
||||||
|
"χ": "х", # Greek chi
|
||||||
|
"Χ": "х",
|
||||||
|
"ε": "е", # Greek epsilon
|
||||||
|
"Ε": "е",
|
||||||
|
"ι": "и", # Greek iota
|
||||||
|
"Ι": "и",
|
||||||
|
"ν": "н", # Greek nu
|
||||||
|
"Ν": "н",
|
||||||
|
"μ": "м", # Greek mu
|
||||||
|
"Μ": "м",
|
||||||
|
"π": "п", # Greek pi
|
||||||
|
"Π": "п",
|
||||||
|
"τ": "т", # Greek tau
|
||||||
|
"Τ": "т",
|
||||||
|
"γ": "г", # Greek gamma
|
||||||
|
"Γ": "г",
|
||||||
|
"σ": "с", # Greek sigma
|
||||||
|
"Σ": "с",
|
||||||
|
"φ": "ф", # Greek phi
|
||||||
|
"Φ": "ф",
|
||||||
|
# Full-width Latin characters
|
||||||
|
"a": "а",
|
||||||
|
"A": "а",
|
||||||
|
"c": "с",
|
||||||
|
"C": "с",
|
||||||
|
"e": "е",
|
||||||
|
"E": "е",
|
||||||
|
"f": "ф",
|
||||||
|
"F": "ф",
|
||||||
|
"g": "г",
|
||||||
|
"G": "г",
|
||||||
|
"i": "и",
|
||||||
|
"I": "и",
|
||||||
|
"m": "м",
|
||||||
|
"M": "м",
|
||||||
|
"n": "н",
|
||||||
|
"N": "н",
|
||||||
|
"o": "о",
|
||||||
|
"O": "о",
|
||||||
|
"p": "п",
|
||||||
|
"P": "п",
|
||||||
|
"s": "с",
|
||||||
|
"S": "с",
|
||||||
|
"t": "т",
|
||||||
|
"T": "т",
|
||||||
|
"u": "у",
|
||||||
|
"U": "у",
|
||||||
|
"v": "в",
|
||||||
|
"V": "в",
|
||||||
|
"x": "х",
|
||||||
|
"X": "х",
|
||||||
|
"y": "у",
|
||||||
|
"Y": "у",
|
||||||
|
"z": "з", # Full-width 'z' to Cyrillic 'з'
|
||||||
|
"Z": "з",
|
||||||
|
# Cyrillic to canonical Cyrillic (identity mappings)
|
||||||
|
"а": "а",
|
||||||
|
"с": "с",
|
||||||
|
"е": "е",
|
||||||
|
"ё": "е",
|
||||||
|
"ф": "ф",
|
||||||
|
"г": "г",
|
||||||
|
"и": "и",
|
||||||
|
"м": "м",
|
||||||
|
"н": "н",
|
||||||
|
"о": "о",
|
||||||
|
"п": "п",
|
||||||
|
"т": "т",
|
||||||
|
"у": "у",
|
||||||
|
"ү": "у", # Cyrillic capital U (U+04AE)
|
||||||
|
"Ү": "у", # Cyrillic capital U (U+04AE)
|
||||||
|
"в": "в",
|
||||||
|
"х": "х",
|
||||||
|
"р": "р",
|
||||||
|
"з": "з", # Cyrillic 'з'
|
||||||
|
"д": "д", # Cyrillic 'д'
|
||||||
|
"б": "б", # Cyrillic 'б'
|
||||||
|
"л": "л", # Cyrillic 'л'
|
||||||
|
"я": "я", # Cyrillic 'я'
|
||||||
|
"н": "н", # Already mapped, but explicit
|
||||||
|
# Special characters
|
||||||
|
"@": "а",
|
||||||
|
# Multi-character visual bypasses (handled separately in preprocessing)
|
||||||
|
# "}{" visually forms "х" - handled in _preprocess_visual_bypasses
|
||||||
|
}
|
||||||
|
|
||||||
|
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
||||||
|
("generic", ("айфон", "топ")),
|
||||||
|
("generic", ("самсунг", "говно")),
|
||||||
|
)
|
||||||
|
|
||||||
|
_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
|
||||||
|
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess_visual_bypasses(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Preprocess text to convert multi-character visual bypasses to their intended letters.
|
||||||
|
This handles cases like "}{" visually forming "х".
|
||||||
|
"""
|
||||||
|
result = text
|
||||||
|
# Convert "}{" to "х" (visual bypass for Cyrillic х)
|
||||||
|
# The curly braces visually form the letter х when placed together
|
||||||
|
result = result.replace("}{", "х")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_char(ch: str) -> str:
|
||||||
|
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
||||||
|
# First try direct mapping (preserves case for non-mapped chars)
|
||||||
|
if ch in _LEET_MAP:
|
||||||
|
return _LEET_MAP[ch]
|
||||||
|
# Then try lowercase mapping
|
||||||
|
lower = ch.lower()
|
||||||
|
if lower in _LEET_MAP:
|
||||||
|
return _LEET_MAP[lower]
|
||||||
|
# If no mapping and character is ASCII letter, return lowercase
|
||||||
|
# This preserves English words like "fromchat" as-is
|
||||||
|
if ch.isascii() and ch.isalpha():
|
||||||
|
return lower
|
||||||
|
# For other characters, return lowercase for consistency
|
||||||
|
return lower
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_token(token: str) -> str:
|
||||||
|
"""Normalize a token by mapping all homoglyphs."""
|
||||||
|
return "".join(_normalize_char(ch) for ch in token)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text_for_profanity(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize entire text by mapping homoglyphs to canonical forms.
|
||||||
|
This prevents bypasses like using English 'u' instead of Russian 'у'.
|
||||||
|
"""
|
||||||
|
return "".join(_normalize_char(ch) for ch in text)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_zero_width_chars(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Remove zero-width characters that could be used to bypass filters.
|
||||||
|
"""
|
||||||
|
# Zero-width space, zero-width non-joiner, zero-width joiner, etc.
|
||||||
|
zero_width_chars = [
|
||||||
|
'\u200B', # Zero-width space
|
||||||
|
'\u200C', # Zero-width non-joiner
|
||||||
|
'\u200D', # Zero-width joiner
|
||||||
|
'\uFEFF', # Zero-width no-break space
|
||||||
|
'\u2060', # Word joiner
|
||||||
|
'\u2061', # Function application
|
||||||
|
'\u2062', # Invisible times
|
||||||
|
'\u2063', # Invisible separator
|
||||||
|
'\u2064', # Invisible plus
|
||||||
|
]
|
||||||
|
result = text
|
||||||
|
for zw_char in zero_width_chars:
|
||||||
|
result = result.replace(zw_char, '')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]:
|
||||||
|
"""
|
||||||
|
Extract only alphanumeric characters from text and create a mapping
|
||||||
|
from normalized positions to original positions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(normalized_text, position_map) where position_map[i] is the original
|
||||||
|
position of the i-th character in normalized_text
|
||||||
|
"""
|
||||||
|
# First preprocess visual bypasses (like "}{" -> "х")
|
||||||
|
text = _preprocess_visual_bypasses(text)
|
||||||
|
|
||||||
|
# Then normalize Unicode (composed vs decomposed)
|
||||||
|
normalized_unicode = unicodedata.normalize('NFKC', text)
|
||||||
|
|
||||||
|
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
||||||
|
if preserve_spaces:
|
||||||
|
zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064']
|
||||||
|
for zw_char in zero_width_chars:
|
||||||
|
normalized_unicode = normalized_unicode.replace(zw_char, ' ')
|
||||||
|
else:
|
||||||
|
# Strip zero-width characters
|
||||||
|
normalized_unicode = _strip_zero_width_chars(normalized_unicode)
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
position_map = []
|
||||||
|
|
||||||
|
for i, ch in enumerate(normalized_unicode):
|
||||||
|
# Check if character is alphanumeric (including Cyrillic)
|
||||||
|
if ch.isalnum():
|
||||||
|
# For phrase matching, preserve ASCII letters as-is (just lowercase)
|
||||||
|
# to allow English words in patterns to match
|
||||||
|
if preserve_spaces and ch.isascii() and ch.isalpha():
|
||||||
|
normalized.append(ch.lower())
|
||||||
|
else:
|
||||||
|
# Normalize this character (homoglyphs, Cyrillic, etc.)
|
||||||
|
normalized.append(_normalize_char(ch))
|
||||||
|
position_map.append(i)
|
||||||
|
elif preserve_spaces:
|
||||||
|
# For phrase matching, treat any whitespace or non-alphanumeric as word separator
|
||||||
|
if ch.isspace() or not ch.isalnum():
|
||||||
|
# Normalize to single space to allow patterns to match
|
||||||
|
if normalized and normalized[-1] != ' ': # Don't add consecutive spaces
|
||||||
|
normalized.append(' ')
|
||||||
|
position_map.append(i)
|
||||||
|
|
||||||
|
return "".join(normalized), position_map
|
||||||
|
|
||||||
|
|
||||||
|
def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]:
|
||||||
|
"""
|
||||||
|
Check for profane words as substrings or subsequences in normalized text.
|
||||||
|
This catches cases like "хуй" in "хууй" (with extra characters).
|
||||||
|
Returns list of (start, end) positions where profanity is found.
|
||||||
|
"""
|
||||||
|
spans = []
|
||||||
|
normalized_lower = normalized_text.lower()
|
||||||
|
|
||||||
|
for word in profane_words:
|
||||||
|
word_lower = word.lower()
|
||||||
|
|
||||||
|
# First try exact substring match
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
pos = normalized_lower.find(word_lower, start)
|
||||||
|
if pos == -1:
|
||||||
|
break
|
||||||
|
spans.append((pos, pos + len(word_lower)))
|
||||||
|
start = pos + 1
|
||||||
|
|
||||||
|
# Also check if profane word appears as a subsequence (allowing extra chars)
|
||||||
|
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
|
||||||
|
# Now applies to ALL words, not just length >= 4, to prevent bypasses
|
||||||
|
word_chars = list(word_lower)
|
||||||
|
text_chars = list(normalized_lower)
|
||||||
|
|
||||||
|
# Stricter span limits based on word length to prevent false positives
|
||||||
|
# Shorter words get much stricter limits
|
||||||
|
if len(word_lower) <= 3:
|
||||||
|
max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй")
|
||||||
|
elif len(word_lower) == 4:
|
||||||
|
max_span_ratio = 1.4 # Strict for 4-char words
|
||||||
|
elif len(word_lower) <= 5:
|
||||||
|
max_span_ratio = 1.5 # Moderate for 5-char words
|
||||||
|
else:
|
||||||
|
max_span_ratio = 1.8 # Slightly more lenient for longer words
|
||||||
|
|
||||||
|
# Try to find the word as a subsequence
|
||||||
|
i = 0 # position in text
|
||||||
|
j = 0 # position in word
|
||||||
|
seq_start = None
|
||||||
|
|
||||||
|
while i < len(text_chars) and j < len(word_chars):
|
||||||
|
if text_chars[i] == word_chars[j]:
|
||||||
|
if seq_start is None:
|
||||||
|
seq_start = i
|
||||||
|
j += 1
|
||||||
|
if j == len(word_chars):
|
||||||
|
# Found the word as subsequence
|
||||||
|
seq_end = i + 1
|
||||||
|
# Check if the span is reasonable (not too long)
|
||||||
|
span_length = seq_end - seq_start
|
||||||
|
max_allowed_span = int(len(word_lower) * max_span_ratio)
|
||||||
|
if span_length <= max_allowed_span:
|
||||||
|
# Only add if it's not already covered by exact match
|
||||||
|
if (seq_start, seq_end) not in spans:
|
||||||
|
spans.append((seq_start, seq_end))
|
||||||
|
# Reset to find next occurrence - continue from after the end of this match
|
||||||
|
next_start = seq_start + 1
|
||||||
|
seq_start = None
|
||||||
|
j = 0
|
||||||
|
i = next_start
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
def _check_profanity_in_normalized(normalized_text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if normalized text contains profanity.
|
||||||
|
Uses both better_profanity library and substring matching for better detection.
|
||||||
|
|
||||||
|
Returns True if profanity is found.
|
||||||
|
"""
|
||||||
|
if not normalized_text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check normalized text for profanity using better_profanity
|
||||||
|
censored = _profanity.censor(normalized_text, censor_char="\\*")
|
||||||
|
|
||||||
|
# Check if better_profanity found anything
|
||||||
|
if "*" in censored:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня")
|
||||||
|
profane_words = _STATIC_TERMS
|
||||||
|
substring_spans = _check_profanity_substrings(normalized_text, profane_words)
|
||||||
|
|
||||||
|
# If we found any substring matches, there's profanity
|
||||||
|
if substring_spans:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
|
||||||
|
tokens: List[Tuple[int, int, str]] = []
|
||||||
|
start: int | None = None
|
||||||
|
buffer: List[str] = []
|
||||||
|
|
||||||
|
for idx, ch in enumerate(text):
|
||||||
|
if ch.isalnum() or ch in {"@", "#", "_"}:
|
||||||
|
if start is None:
|
||||||
|
start = idx
|
||||||
|
buffer.append(ch)
|
||||||
|
else:
|
||||||
|
if buffer and start is not None:
|
||||||
|
token_raw = "".join(buffer)
|
||||||
|
tokens.append((start, idx, _normalize_token(token_raw)))
|
||||||
|
buffer.clear()
|
||||||
|
start = None
|
||||||
|
if buffer and start is not None:
|
||||||
|
token_raw = "".join(buffer)
|
||||||
|
tokens.append((start, len(text), _normalize_token(token_raw)))
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool:
|
||||||
|
if a == b:
|
||||||
|
return True
|
||||||
|
if max_distance <= 0:
|
||||||
|
return False
|
||||||
|
if abs(len(a) - len(b)) > max_distance:
|
||||||
|
return False
|
||||||
|
|
||||||
|
previous = list(range(len(b) + 1))
|
||||||
|
for i, ca in enumerate(a, 1):
|
||||||
|
current = [i]
|
||||||
|
best = current[0]
|
||||||
|
for j, cb in enumerate(b, 1):
|
||||||
|
insert_cost = current[j - 1] + 1
|
||||||
|
delete_cost = previous[j] + 1
|
||||||
|
replace_cost = previous[j - 1] + (0 if ca == cb else 1)
|
||||||
|
cost = min(insert_cost, delete_cost, replace_cost)
|
||||||
|
current.append(cost)
|
||||||
|
if cost < best:
|
||||||
|
best = cost
|
||||||
|
if best > max_distance:
|
||||||
|
return False
|
||||||
|
previous = current
|
||||||
|
return previous[-1] <= max_distance
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sensitive_phrases() -> List[Tuple[str, ...]]:
|
||||||
|
if not _SENSITIVE_PHRASE_PATH.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8"))
|
||||||
|
phrases: List[Tuple[str, ...]] = []
|
||||||
|
if isinstance(payload, list):
|
||||||
|
for entry in payload:
|
||||||
|
if isinstance(entry, list) and entry:
|
||||||
|
normalized = tuple(str(part).strip() for part in entry if str(part).strip())
|
||||||
|
if normalized:
|
||||||
|
phrases.append(normalized)
|
||||||
|
return phrases
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]:
|
||||||
|
if group not in _PHRASE_CACHE:
|
||||||
|
base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group]
|
||||||
|
if group == "sensitive":
|
||||||
|
base.extend(_load_sensitive_phrases())
|
||||||
|
_PHRASE_CACHE[group] = tuple(
|
||||||
|
tuple(_normalize_token(part) for part in phrase)
|
||||||
|
for phrase in base
|
||||||
|
)
|
||||||
|
return _PHRASE_CACHE[group]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]:
|
||||||
|
tokens = _tokenize_with_spans(text)
|
||||||
|
if not tokens:
|
||||||
|
return []
|
||||||
|
|
||||||
|
spans: List[Tuple[int, int]] = []
|
||||||
|
normalized_phrases = _get_phrases(group)
|
||||||
|
|
||||||
|
for index in range(len(tokens)):
|
||||||
|
for phrase in normalized_phrases:
|
||||||
|
if index + len(phrase) > len(tokens):
|
||||||
|
continue
|
||||||
|
matches = True
|
||||||
|
for offset, target in enumerate(phrase):
|
||||||
|
token = tokens[index + offset][2]
|
||||||
|
if not _edit_distance_limited(token, target):
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
if matches:
|
||||||
|
span_start = tokens[index][0]
|
||||||
|
span_end = tokens[index + len(phrase) - 1][1]
|
||||||
|
spans.append((span_start, span_end))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
_dictionary_lock = RLock()
|
||||||
|
_blocklist_signature: Tuple[str, ...] | None = None
|
||||||
|
_profanity = Profanity()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_words(words: Iterable[str]) -> Set[str]:
|
||||||
|
normalized: Set[str] = set()
|
||||||
|
for raw in words:
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower()
|
||||||
|
if cleaned:
|
||||||
|
normalized.add(cleaned)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _load_blocklist() -> Set[str]:
|
||||||
|
if not BLOCKLIST_PATH.exists():
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, list):
|
||||||
|
return _normalize_words(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_blocklist(words: Iterable[str]) -> None:
|
||||||
|
BLOCKLIST_PATH.write_text(
|
||||||
|
json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_dictionary(force: bool = False) -> None:
|
||||||
|
global _profanity, _blocklist_signature
|
||||||
|
with _dictionary_lock:
|
||||||
|
blocklist_list = sorted(_load_blocklist())
|
||||||
|
signature = tuple(blocklist_list)
|
||||||
|
if not force and _blocklist_signature == signature and _blocklist_signature is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
profanity = Profanity()
|
||||||
|
profanity.load_censor_words()
|
||||||
|
# Remove whitelisted words from the default word list
|
||||||
|
try:
|
||||||
|
for word in _WHITELIST:
|
||||||
|
profanity.remove_censor_words([word])
|
||||||
|
except AttributeError:
|
||||||
|
# If remove_censor_words doesn't exist, we'll handle it in post-processing
|
||||||
|
pass
|
||||||
|
combined = set(_STATIC_TERMS)
|
||||||
|
combined.update(blocklist_list)
|
||||||
|
# Remove whitelisted words from our custom terms
|
||||||
|
combined -= _WHITELIST
|
||||||
|
if combined:
|
||||||
|
profanity.add_censor_words(list(combined))
|
||||||
|
|
||||||
|
_profanity = profanity
|
||||||
|
_blocklist_signature = signature
|
||||||
|
|
||||||
|
|
||||||
|
def _check_phrase_patterns(text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if text matches any phrase patterns.
|
||||||
|
Returns True if any pattern matches.
|
||||||
|
"""
|
||||||
|
# Normalize text for phrase matching (remove special chars but preserve spaces)
|
||||||
|
normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True)
|
||||||
|
normalized_lower = normalized_text.lower()
|
||||||
|
|
||||||
|
# Check phrase patterns
|
||||||
|
for pattern in _PHRASE_PATTERNS:
|
||||||
|
if pattern.search(normalized_lower):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check fuzzy phrase spans
|
||||||
|
if _find_fuzzy_phrase_spans(normalized_lower, "generic"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def contains_profanity(text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if text contains profanity.
|
||||||
|
Returns True if profanity is detected.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_rebuild_dictionary()
|
||||||
|
|
||||||
|
# Check original text patterns first (before normalization) to catch visual bypasses
|
||||||
|
# like "}{" used to form "х"
|
||||||
|
for pattern in _ORIGINAL_TEXT_PATTERNS:
|
||||||
|
if pattern.search(text):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check phrase patterns
|
||||||
|
if _check_phrase_patterns(text):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Normalize text for whitelist matching (to handle special characters)
|
||||||
|
normalized_for_whitelist, _ = _extract_alphanumeric_with_mapping(text)
|
||||||
|
normalized_for_whitelist_lower = normalized_for_whitelist.lower()
|
||||||
|
|
||||||
|
# Check if text contains whitelisted words - if the entire text is a whitelisted word, skip profanity check
|
||||||
|
for whitelist_word in _WHITELIST:
|
||||||
|
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
|
||||||
|
normalized_whitelist_lower = normalized_whitelist.lower()
|
||||||
|
|
||||||
|
# Check if the normalized text exactly matches a whitelisted word
|
||||||
|
if normalized_for_whitelist_lower == normalized_whitelist_lower:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Extract only alphanumeric characters and normalize homoglyphs
|
||||||
|
# This removes special characters, emojis, etc. that could be used to bypass the filter
|
||||||
|
normalized_text, _ = _extract_alphanumeric_with_mapping(text)
|
||||||
|
|
||||||
|
# Check profanity on normalized text (without special characters)
|
||||||
|
return _check_profanity_in_normalized(normalized_text)
|
||||||
|
|
||||||
|
|
||||||
|
def contains_sensitive_phrase(text: str) -> bool:
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
if _find_fuzzy_phrase_spans(text, "sensitive"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_blocklist() -> List[str]:
|
||||||
|
with _dictionary_lock:
|
||||||
|
return sorted(_load_blocklist())
|
||||||
|
|
||||||
|
|
||||||
|
def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
||||||
|
normalized = _normalize_words(words)
|
||||||
|
if not normalized:
|
||||||
|
return [], get_blocklist()
|
||||||
|
|
||||||
|
with _dictionary_lock:
|
||||||
|
current = _load_blocklist()
|
||||||
|
added = sorted(normalized - current)
|
||||||
|
if not added:
|
||||||
|
return [], sorted(current)
|
||||||
|
|
||||||
|
updated = sorted(current | normalized)
|
||||||
|
_write_blocklist(updated)
|
||||||
|
_rebuild_dictionary(force=True)
|
||||||
|
return added, updated
|
||||||
|
|
||||||
|
|
||||||
|
def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
||||||
|
normalized = _normalize_words(words)
|
||||||
|
if not normalized:
|
||||||
|
return [], get_blocklist()
|
||||||
|
|
||||||
|
with _dictionary_lock:
|
||||||
|
current = _load_blocklist()
|
||||||
|
removed = sorted(word for word in normalized if word in current)
|
||||||
|
if not removed:
|
||||||
|
return [], sorted(current)
|
||||||
|
|
||||||
|
updated = sorted(current - normalized)
|
||||||
|
_write_blocklist(updated)
|
||||||
|
_rebuild_dictionary(force=True)
|
||||||
|
return removed, updated
|
||||||
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Callable
|
||||||
|
from fastapi import Request
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
from backend.shared.utils import get_client_ip
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
def get_ip_key(request: Request) -> str:
|
||||||
|
"""Get rate limit key based on IP address."""
|
||||||
|
return get_client_ip(request) or get_remote_address(request)
|
||||||
|
|
||||||
|
# Initialize limiter with IP-based key function
|
||||||
|
# Note: We don't set default_limits to avoid affecting all users if one IP is attacked.
|
||||||
|
# Each endpoint should have an explicit rate limit based on its sensitivity.
|
||||||
|
# Rate limits automatically expire after the time window - IPs are not permanently blocked.
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_ip_key,
|
||||||
|
default_limits=[], # No global default - each endpoint must have explicit limits
|
||||||
|
storage_uri="memory://", # In-memory storage (can be changed to Redis later)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Rate limit decorator for IP-based limiting
|
||||||
|
def rate_limit_per_ip(limit: str) -> Callable:
|
||||||
|
"""Rate limit based on IP address."""
|
||||||
|
return limiter.limit(limit, key_func=get_ip_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_storage_dict(storage) -> dict | None:
|
||||||
|
"""Get the internal storage dictionary from slowapi's memory storage."""
|
||||||
|
if hasattr(storage, "_storage") and isinstance(storage._storage, dict):
|
||||||
|
return storage._storage
|
||||||
|
elif hasattr(storage, "storage") and isinstance(storage.storage, dict):
|
||||||
|
return storage.storage
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_all_rate_limits() -> int:
|
||||||
|
"""
|
||||||
|
Reset all rate limits by clearing the storage.
|
||||||
|
This should be called on startup to ensure a clean state.
|
||||||
|
Returns the number of entries cleared.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Access the private _storage attribute
|
||||||
|
storage = limiter._storage
|
||||||
|
storage_dict = _get_storage_dict(storage)
|
||||||
|
|
||||||
|
if storage_dict is None:
|
||||||
|
# Try using the storage's reset method if available
|
||||||
|
if hasattr(storage, "reset"):
|
||||||
|
try:
|
||||||
|
# Try reset() with no args first (clears all)
|
||||||
|
storage.reset()
|
||||||
|
logger.info("Reset all rate limits on startup using storage.reset()")
|
||||||
|
return 1 # Assume it worked
|
||||||
|
except TypeError:
|
||||||
|
# reset() might require arguments, try clearing differently
|
||||||
|
try:
|
||||||
|
# Some storage backends need explicit clearing
|
||||||
|
if hasattr(storage, "clear"):
|
||||||
|
storage.clear()
|
||||||
|
logger.info("Reset all rate limits on startup using storage.clear()")
|
||||||
|
return 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.warning("Could not reset rate limits: storage dict not accessible and no reset method")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = len(storage_dict)
|
||||||
|
if count > 0:
|
||||||
|
storage_dict.clear()
|
||||||
|
logger.info(f"Reset all rate limits on startup: cleared {count} entries")
|
||||||
|
return count
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def reset_rate_limit_for_ip(ip: str) -> bool:
|
||||||
|
"""
|
||||||
|
Manually reset rate limit for a specific IP address.
|
||||||
|
This clears all rate limit entries for the given IP.
|
||||||
|
Returns True if any entries were cleared, False otherwise.
|
||||||
|
"""
|
||||||
|
if not ip:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Access the private _storage attribute
|
||||||
|
storage = limiter._storage
|
||||||
|
storage_dict = _get_storage_dict(storage)
|
||||||
|
|
||||||
|
if storage_dict is None:
|
||||||
|
# Try alternative methods
|
||||||
|
if hasattr(storage, "reset"):
|
||||||
|
try:
|
||||||
|
storage.reset(ip)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
cleared = False
|
||||||
|
# slowapi stores entries with keys like "LIMITER:{ip}:{endpoint}"
|
||||||
|
# We need to find all keys that contain this IP
|
||||||
|
# Also handle cases where IP might be in different positions
|
||||||
|
keys_to_remove = []
|
||||||
|
|
||||||
|
for key in list(storage_dict.keys()):
|
||||||
|
if isinstance(key, str):
|
||||||
|
# Check multiple patterns:
|
||||||
|
# - "LIMITER:{ip}:{endpoint}"
|
||||||
|
# - Keys containing the IP anywhere
|
||||||
|
# - Keys starting with the IP
|
||||||
|
if (key.startswith(f"LIMITER:{ip}:") or
|
||||||
|
key.startswith(f"LIMITER:{ip}") or
|
||||||
|
f":{ip}:" in key or
|
||||||
|
key.endswith(f":{ip}") or
|
||||||
|
(ip in key and "LIMITER" in key)):
|
||||||
|
keys_to_remove.append(key)
|
||||||
|
|
||||||
|
for key in keys_to_remove:
|
||||||
|
try:
|
||||||
|
del storage_dict[key]
|
||||||
|
cleared = True
|
||||||
|
logger.info(f"Cleared rate limit key: {key}")
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if cleared:
|
||||||
|
logger.info(f"Successfully cleared rate limits for IP: {ip}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"No rate limit entries found for IP: {ip}")
|
||||||
|
|
||||||
|
return cleared
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to reset rate limit for IP {ip}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clear_all_rate_limits() -> int:
|
||||||
|
"""
|
||||||
|
Clear all rate limit entries. Use with caution - this affects all IPs.
|
||||||
|
Returns the number of entries cleared.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Access the private _storage attribute
|
||||||
|
storage = limiter._storage
|
||||||
|
storage_dict = _get_storage_dict(storage)
|
||||||
|
|
||||||
|
if storage_dict is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = len(storage_dict)
|
||||||
|
storage_dict.clear()
|
||||||
|
logger.warning(f"Cleared all {count} rate limit entries")
|
||||||
|
return count
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to clear all rate limits: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired_rate_limits() -> int:
|
||||||
|
"""
|
||||||
|
Clean up expired rate limit entries from memory storage.
|
||||||
|
This helps prevent rate limits from being stuck indefinitely.
|
||||||
|
Returns the number of entries cleaned up.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Access the private _storage attribute
|
||||||
|
storage = limiter._storage
|
||||||
|
storage_dict = _get_storage_dict(storage)
|
||||||
|
|
||||||
|
if storage_dict is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# slowapi's memory storage stores entries as tuples: (count, reset_time)
|
||||||
|
# Entries should expire naturally, but we'll clean up any that are clearly expired
|
||||||
|
now = time.time()
|
||||||
|
cleaned = 0
|
||||||
|
keys_to_remove = []
|
||||||
|
|
||||||
|
for key, value in storage_dict.items():
|
||||||
|
if isinstance(value, (tuple, list)) and len(value) >= 2:
|
||||||
|
# Check if reset_time has passed (with some buffer)
|
||||||
|
reset_time = value[1] if isinstance(value[1], (int, float)) else 0
|
||||||
|
# Add 60 second buffer to ensure we don't remove active entries
|
||||||
|
if reset_time > 0 and now > (reset_time + 60):
|
||||||
|
keys_to_remove.append(key)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
# Some storage formats use dicts with 'expiry' or 'reset' fields
|
||||||
|
expiry = value.get("expiry") or value.get("reset") or value.get("reset_time")
|
||||||
|
if expiry and isinstance(expiry, (int, float)) and now > (expiry + 60):
|
||||||
|
keys_to_remove.append(key)
|
||||||
|
|
||||||
|
for key in keys_to_remove:
|
||||||
|
try:
|
||||||
|
del storage_dict[key]
|
||||||
|
cleaned += 1
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if cleaned > 0:
|
||||||
|
logger.info(f"Cleaned up {cleaned} expired rate limit entries")
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to cleanup expired rate limits: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def start_rate_limit_cleanup_task() -> None:
|
||||||
|
"""Start a background task to periodically clean up expired rate limit entries."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(300) # Run every 5 minutes
|
||||||
|
cleanup_expired_rate_limits()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in rate limit cleanup task: {e}")
|
||||||
|
await asyncio.sleep(60) # Wait 1 minute before retrying
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Account Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/account.py /app/backend/routes/account.py
|
||||||
|
COPY backend/services/account/main.py /app/backend/services/account/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=account
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.account import router as account_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Account Service")
|
||||||
|
app.include_router(account_router, prefix="/account")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Device Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/devices.py /app/backend/routes/devices.py
|
||||||
|
COPY backend/services/device/main.py /app/backend/services/device/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=device
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.devices import router as device_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Device Service")
|
||||||
|
app.include_router(device_router, prefix="/devices")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Gateway Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/app.py /app/backend/app.py
|
||||||
|
COPY backend/main.py /app/backend/main.py
|
||||||
|
COPY backend/dependencies.py /app/backend/dependencies.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=gateway
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Gateway service - runs the main gateway app from backend/app.py
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from backend.app import app
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8300)))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Messaging Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
|
||||||
|
COPY backend/websocket /app/backend/websocket/
|
||||||
|
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=messaging
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from .registry import WebSocketHandlerRegistry
|
||||||
|
|
||||||
|
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
|
||||||
|
# Import them directly from websocket.handlers when needed
|
||||||
|
|
||||||
|
__all__ = ["WebSocketHandlerRegistry"]
|
||||||
|
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from fastapi import HTTPException, WebSocket, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.services.messaging.files.websocket.registry import WebSocketHandlerRegistry
|
||||||
|
from backend.routes.messaging import (
|
||||||
|
MessaggingSocketManager,
|
||||||
|
_send_message_internal,
|
||||||
|
_edit_message_internal,
|
||||||
|
get_messages,
|
||||||
|
edit_message,
|
||||||
|
delete_message,
|
||||||
|
add_reaction,
|
||||||
|
add_dm_reaction,
|
||||||
|
)
|
||||||
|
from backend.shared.models import (
|
||||||
|
User,
|
||||||
|
SendMessageRequest,
|
||||||
|
EditMessageRequest,
|
||||||
|
DMEnvelope,
|
||||||
|
ReactionRequest,
|
||||||
|
DMReactionRequest,
|
||||||
|
UpdateLog,
|
||||||
|
)
|
||||||
|
from backend.security.audit import log_access, log_dm
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
# Create global registry instance
|
||||||
|
handler_registry = WebSocketHandlerRegistry()
|
||||||
|
|
||||||
|
# Create decorator alias
|
||||||
|
websocket_handler = handler_registry.register
|
||||||
|
|
||||||
|
|
||||||
|
def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None:
|
||||||
|
"""Log WebSocket event."""
|
||||||
|
ws_path = getattr(getattr(websocket, "url", None), "path", None)
|
||||||
|
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
|
||||||
|
ws_path = websocket.scope.get("path")
|
||||||
|
ws_path = ws_path or "unknown"
|
||||||
|
headers = {}
|
||||||
|
if isinstance(getattr(websocket, "scope", None), dict):
|
||||||
|
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
|
||||||
|
xff = headers.get("x-forwarded-for")
|
||||||
|
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
|
||||||
|
|
||||||
|
log_access(
|
||||||
|
"ws_event",
|
||||||
|
path=ws_path,
|
||||||
|
event=event,
|
||||||
|
user=user.username if user else None,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
ip=client_ip,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("getUpdates", authRequired=True)
|
||||||
|
async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Handle gap detection - client requests updates from a specific sequence number."""
|
||||||
|
last_seq = data.get("lastSeq", 0)
|
||||||
|
manager.last_seq_by_ws[websocket] = last_seq
|
||||||
|
current_seq = manager.sequence_numbers.get(user.id, 0)
|
||||||
|
|
||||||
|
# Query database for missed updates
|
||||||
|
missed_updates = []
|
||||||
|
if last_seq > 0 and last_seq < current_seq:
|
||||||
|
try:
|
||||||
|
# Get all updates between last_seq and current_seq
|
||||||
|
update_logs = db.query(UpdateLog).filter(
|
||||||
|
UpdateLog.user_id == user.id,
|
||||||
|
UpdateLog.sequence > last_seq,
|
||||||
|
UpdateLog.sequence <= current_seq
|
||||||
|
).order_by(UpdateLog.sequence.asc()).all()
|
||||||
|
|
||||||
|
# Each log entry contains a batch of updates with the same sequence number
|
||||||
|
for log_entry in update_logs:
|
||||||
|
updates = json.loads(log_entry.updates)
|
||||||
|
missed_updates.append({
|
||||||
|
"seq": log_entry.sequence,
|
||||||
|
"updates": updates
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to retrieve missed updates: {e}")
|
||||||
|
|
||||||
|
# Send missed updates directly (not through return value)
|
||||||
|
for batch in missed_updates:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "updates",
|
||||||
|
"seq": batch["seq"],
|
||||||
|
"updates": batch["updates"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update the websocket's last sequence tracking
|
||||||
|
manager.last_seq_by_ws[websocket] = current_seq
|
||||||
|
log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"lastSeq": current_seq,
|
||||||
|
"missedCount": len(missed_updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("ping", authRequired=True)
|
||||||
|
async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Handle ping - authenticate and set user online."""
|
||||||
|
# Set user online in DB
|
||||||
|
user.online = True
|
||||||
|
user.last_seen = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
# Add to online users
|
||||||
|
manager.online_users.add(user.id)
|
||||||
|
# Broadcast status change
|
||||||
|
await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "ping")
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("getMessages", authRequired=True)
|
||||||
|
async def getMessages(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Get all public chat messages."""
|
||||||
|
result = await get_messages(user, db)
|
||||||
|
log(manager, websocket, user, "getMessages")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("sendMessage", authRequired=True)
|
||||||
|
async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Send a public chat message."""
|
||||||
|
message_request: SendMessageRequest = SendMessageRequest.model_validate(data)
|
||||||
|
|
||||||
|
# Call internal function directly (rate limiting is handled at infrastructure level via Caddy)
|
||||||
|
response = await _send_message_internal(message_request, user, db, [])
|
||||||
|
await manager.broadcast({
|
||||||
|
"type": "newMessage",
|
||||||
|
"data": response["message"]
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("dmSend", authRequired=True)
|
||||||
|
async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Send a direct message."""
|
||||||
|
payload = data
|
||||||
|
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
||||||
|
for key in required:
|
||||||
|
if key not in payload:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
||||||
|
|
||||||
|
env = DMEnvelope(
|
||||||
|
sender_id=user.id,
|
||||||
|
recipient_id=int(payload["recipientId"]),
|
||||||
|
iv_b64=payload["iv"],
|
||||||
|
ciphertext_b64=payload["ciphertext"],
|
||||||
|
salt_b64=payload["salt"],
|
||||||
|
iv2_b64=payload["iv2"],
|
||||||
|
wrapped_mk_b64=payload["wrappedMk"],
|
||||||
|
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
|
||||||
|
)
|
||||||
|
db.add(env)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(env)
|
||||||
|
|
||||||
|
payload_ws = {
|
||||||
|
"type": "dmNew",
|
||||||
|
"data": {
|
||||||
|
"id": env.id,
|
||||||
|
"senderId": env.sender_id,
|
||||||
|
"recipientId": env.recipient_id,
|
||||||
|
"iv": env.iv_b64,
|
||||||
|
"ciphertext": env.ciphertext_b64,
|
||||||
|
"salt": env.salt_b64,
|
||||||
|
"iv2": env.iv2_b64,
|
||||||
|
"wrappedMk": env.wrapped_mk_b64,
|
||||||
|
"timestamp": env.timestamp.isoformat(),
|
||||||
|
"replyToId": env.reply_to_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send push notification for DM
|
||||||
|
try:
|
||||||
|
from push_service import push_service
|
||||||
|
await push_service.send_dm_notification(db, env, user)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||||
|
|
||||||
|
await manager.send_update_to_user(env.recipient_id, "dmNew", payload_ws["data"], db)
|
||||||
|
await manager.send_update_to_user(env.sender_id, "dmNew", payload_ws["data"], db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id)
|
||||||
|
log_dm(
|
||||||
|
"message_sent_ws",
|
||||||
|
dm_envelope_id=env.id,
|
||||||
|
sender_id=user.id,
|
||||||
|
sender_username=user.username,
|
||||||
|
recipient_id=env.recipient_id,
|
||||||
|
reply_to=env.reply_to_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "ok", "id": env.id}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("editMessage", authRequired=True)
|
||||||
|
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Edit a public chat message."""
|
||||||
|
|
||||||
|
message_id = data["message_id"]
|
||||||
|
edit_request: EditMessageRequest = EditMessageRequest.model_validate(data)
|
||||||
|
|
||||||
|
response = await _edit_message_internal(message_id, edit_request, user, db)
|
||||||
|
await manager.broadcast({
|
||||||
|
"type": "messageEdited",
|
||||||
|
"data": response["message"]
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "editMessage", message_id=message_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("dmEdit", authRequired=True)
|
||||||
|
async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Edit a direct message."""
|
||||||
|
payload = data
|
||||||
|
env_id = int(payload["id"])
|
||||||
|
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
||||||
|
if not env:
|
||||||
|
raise HTTPException(status_code=404, detail="DM not found")
|
||||||
|
if env.sender_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
||||||
|
|
||||||
|
# Replace ciphertext and iv
|
||||||
|
env.iv_b64 = payload["iv"]
|
||||||
|
env.ciphertext_b64 = payload["ciphertext"]
|
||||||
|
env.iv2_b64 = payload["iv2"]
|
||||||
|
env.wrapped_mk_b64 = payload["wrappedMk"]
|
||||||
|
env.salt_b64 = payload["salt"]
|
||||||
|
db.commit()
|
||||||
|
db.refresh(env)
|
||||||
|
|
||||||
|
payload_ws = {
|
||||||
|
"type": "dmEdited",
|
||||||
|
"data": {
|
||||||
|
"id": env.id,
|
||||||
|
"senderId": env.sender_id,
|
||||||
|
"recipientId": env.recipient_id,
|
||||||
|
"iv": env.iv_b64,
|
||||||
|
"ciphertext": env.ciphertext_b64,
|
||||||
|
"iv2": env.iv2_b64,
|
||||||
|
"wrappedMk": env.wrapped_mk_b64,
|
||||||
|
"salt": env.salt_b64,
|
||||||
|
"timestamp": env.timestamp.isoformat(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await manager.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db)
|
||||||
|
await manager.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "dmEdit", dm_envelope_id=env.id)
|
||||||
|
log_dm(
|
||||||
|
"message_edited",
|
||||||
|
dm_envelope_id=env.id,
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "ok", "id": env.id}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("dmDelete", authRequired=True)
|
||||||
|
async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Delete a direct message."""
|
||||||
|
payload = data
|
||||||
|
env_id = int(payload["id"])
|
||||||
|
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
||||||
|
if not env:
|
||||||
|
raise HTTPException(status_code=404, detail="DM not found")
|
||||||
|
if env.sender_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
||||||
|
|
||||||
|
db.delete(env)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
payload_ws = {
|
||||||
|
"type": "dmDeleted",
|
||||||
|
"data": {
|
||||||
|
"id": env_id,
|
||||||
|
"senderId": user.id,
|
||||||
|
"recipientId": payload.get("recipientId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await manager.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db)
|
||||||
|
await manager.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "dmDelete", dm_envelope_id=env_id)
|
||||||
|
log_dm(
|
||||||
|
"message_deleted",
|
||||||
|
dm_envelope_id=env_id,
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
recipient_id=env.recipient_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "ok", "id": env_id}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("deleteMessage", authRequired=True)
|
||||||
|
async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Delete a public chat message."""
|
||||||
|
message_id = data["message_id"]
|
||||||
|
response = await delete_message(message_id, user, db)
|
||||||
|
await manager.broadcast({
|
||||||
|
"type": "messageDeleted",
|
||||||
|
"data": {"message_id": message_id}
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "deleteMessage", message_id=message_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("addReaction", authRequired=True)
|
||||||
|
async def addReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Add or remove a reaction to a public chat message."""
|
||||||
|
reaction_request = ReactionRequest(
|
||||||
|
message_id=data["message_id"],
|
||||||
|
emoji=data["emoji"]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await add_reaction(reaction_request, user, db)
|
||||||
|
|
||||||
|
# Broadcast reaction update
|
||||||
|
await manager.broadcast({
|
||||||
|
"type": "reactionUpdate",
|
||||||
|
"data": {
|
||||||
|
"message_id": data["message_id"],
|
||||||
|
"emoji": data["emoji"],
|
||||||
|
"action": response["action"],
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"reactions": response["reactions"]
|
||||||
|
}
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "addReaction", message_id=data["message_id"], emoji=data["emoji"], action=response["action"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("addDmReaction", authRequired=True)
|
||||||
|
async def addDmReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Add or remove a reaction to a direct message."""
|
||||||
|
reaction_request = DMReactionRequest(
|
||||||
|
dm_envelope_id=data["dm_envelope_id"],
|
||||||
|
emoji=data["emoji"]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await add_dm_reaction(reaction_request, user, db)
|
||||||
|
|
||||||
|
# Broadcast reaction update
|
||||||
|
await manager.broadcast({
|
||||||
|
"type": "dmReactionUpdate",
|
||||||
|
"data": {
|
||||||
|
"dm_envelope_id": data["dm_envelope_id"],
|
||||||
|
"emoji": data["emoji"],
|
||||||
|
"action": response["action"],
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"reactions": response["reactions"]
|
||||||
|
}
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "addDmReaction", dm_envelope_id=data["dm_envelope_id"], emoji=data["emoji"], action=response["action"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("call_signaling", authRequired=True)
|
||||||
|
async def call_signaling(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Forward WebRTC signaling between peers."""
|
||||||
|
payload = data or {}
|
||||||
|
to_user_id = int(payload.get("toUserId") or 0)
|
||||||
|
if not to_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing toUserId")
|
||||||
|
|
||||||
|
# Ensure sender is set by the server
|
||||||
|
payload["fromUserId"] = user.id
|
||||||
|
payload["fromUsername"] = user.username
|
||||||
|
|
||||||
|
await manager.send_to_user(to_user_id, {
|
||||||
|
"type": "call_signaling",
|
||||||
|
"data": payload
|
||||||
|
})
|
||||||
|
|
||||||
|
log(manager, websocket, user, "call_signaling", to_user_id=to_user_id)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("call_video_toggle", authRequired=True)
|
||||||
|
async def call_video_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Forward video toggle state between peers."""
|
||||||
|
payload = data or {}
|
||||||
|
to_user_id = int(payload.get("toUserId") or 0)
|
||||||
|
if not to_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing toUserId")
|
||||||
|
|
||||||
|
await manager.send_update_to_user(to_user_id, "call_signaling", {
|
||||||
|
"type": "call_video_toggle",
|
||||||
|
"fromUserId": user.id,
|
||||||
|
"toUserId": to_user_id,
|
||||||
|
"data": {"enabled": payload.get("enabled", False)}
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "call_video_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("call_screen_share_toggle", authRequired=True)
|
||||||
|
async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Forward screen share toggle state between peers."""
|
||||||
|
payload = data or {}
|
||||||
|
to_user_id = int(payload.get("toUserId") or 0)
|
||||||
|
if not to_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing toUserId")
|
||||||
|
|
||||||
|
await manager.send_update_to_user(to_user_id, "call_signaling", {
|
||||||
|
"type": "call_screen_share_toggle",
|
||||||
|
"fromUserId": user.id,
|
||||||
|
"toUserId": to_user_id,
|
||||||
|
"data": {"enabled": payload.get("enabled", False)}
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "call_screen_share_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("subscribeStatus", authRequired=True)
|
||||||
|
async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Subscribe to status updates for a user."""
|
||||||
|
user_id_to_subscribe = int(data["userId"])
|
||||||
|
manager.ws_subscriptions[websocket].add(user_id_to_subscribe)
|
||||||
|
|
||||||
|
# Get current status of the user
|
||||||
|
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
|
||||||
|
if target_user:
|
||||||
|
# Send current status directly (not through return value)
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "statusUpdate",
|
||||||
|
"data": {
|
||||||
|
"userId": user_id_to_subscribe,
|
||||||
|
"online": target_user.online,
|
||||||
|
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe)
|
||||||
|
return {"status": "ok"}
|
||||||
|
else:
|
||||||
|
log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found")
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("unsubscribeStatus", authRequired=True)
|
||||||
|
async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
|
"""Unsubscribe from status updates for a user."""
|
||||||
|
user_id_to_unsubscribe = int(data["userId"])
|
||||||
|
manager.ws_subscriptions[websocket].discard(user_id_to_unsubscribe)
|
||||||
|
|
||||||
|
log(manager, websocket, user, "unsubscribeStatus", target_user_id=user_id_to_unsubscribe)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("typing", authRequired=True)
|
||||||
|
async def typing(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
||||||
|
"""Handle typing indicator start for public chat."""
|
||||||
|
was_typing = manager.typing_state.get(user.id, False)
|
||||||
|
manager.typing_users[user.id] = time.time()
|
||||||
|
|
||||||
|
# Only send update if state changed (started typing)
|
||||||
|
if not was_typing:
|
||||||
|
manager.typing_state[user.id] = True
|
||||||
|
# Broadcast to all connected users
|
||||||
|
await manager.broadcast({
|
||||||
|
"type": "typing",
|
||||||
|
"data": {
|
||||||
|
"userId": user.id,
|
||||||
|
"username": user.username
|
||||||
|
}
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
# No confirmation response - privacy protection
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("stopTyping", authRequired=True)
|
||||||
|
async def stopTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
||||||
|
"""Handle typing indicator stop for public chat."""
|
||||||
|
was_typing = manager.typing_state.get(user.id, False)
|
||||||
|
if user.id in manager.typing_users:
|
||||||
|
del manager.typing_users[user.id]
|
||||||
|
|
||||||
|
# Only send update if state changed (stopped typing)
|
||||||
|
if was_typing:
|
||||||
|
manager.typing_state[user.id] = False
|
||||||
|
# Broadcast to all connected users
|
||||||
|
await manager.broadcast({
|
||||||
|
"type": "stopTyping",
|
||||||
|
"data": {
|
||||||
|
"userId": user.id,
|
||||||
|
"username": user.username
|
||||||
|
}
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
# No confirmation response - privacy protection
|
||||||
|
log(manager, websocket, user, "stopTyping")
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("dmTyping", authRequired=True)
|
||||||
|
async def dmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
||||||
|
"""Handle typing indicator start for DM."""
|
||||||
|
recipient_id = int(data["recipientId"])
|
||||||
|
|
||||||
|
if user.id not in manager.dm_typing_users:
|
||||||
|
manager.dm_typing_users[user.id] = {}
|
||||||
|
if user.id not in manager.dm_typing_state:
|
||||||
|
manager.dm_typing_state[user.id] = {}
|
||||||
|
|
||||||
|
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
|
||||||
|
manager.dm_typing_users[user.id][recipient_id] = time.time()
|
||||||
|
|
||||||
|
# Only send update if state changed (started typing)
|
||||||
|
if not was_typing:
|
||||||
|
manager.dm_typing_state[user.id][recipient_id] = True
|
||||||
|
# Send only to recipient
|
||||||
|
await manager.send_update_to_user(recipient_id, "dmTyping", {
|
||||||
|
"userId": user.id,
|
||||||
|
"username": user.username
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
# No confirmation response - privacy protection
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_handler("stopDmTyping", authRequired=True)
|
||||||
|
async def stopDmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
||||||
|
"""Handle typing indicator stop for DM."""
|
||||||
|
recipient_id = int(data["recipientId"])
|
||||||
|
|
||||||
|
was_typing = False
|
||||||
|
if user.id in manager.dm_typing_state:
|
||||||
|
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
|
||||||
|
|
||||||
|
if user.id in manager.dm_typing_users and recipient_id in manager.dm_typing_users[user.id]:
|
||||||
|
del manager.dm_typing_users[user.id][recipient_id]
|
||||||
|
if not manager.dm_typing_users[user.id]:
|
||||||
|
del manager.dm_typing_users[user.id]
|
||||||
|
|
||||||
|
# Only send update if state changed (stopped typing)
|
||||||
|
if was_typing:
|
||||||
|
if user.id in manager.dm_typing_state:
|
||||||
|
manager.dm_typing_state[user.id][recipient_id] = False
|
||||||
|
# Send only to recipient
|
||||||
|
await manager.send_update_to_user(recipient_id, "stopDmTyping", {
|
||||||
|
"userId": user.id,
|
||||||
|
"username": user.username
|
||||||
|
}, db)
|
||||||
|
|
||||||
|
# No confirmation response - privacy protection
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketHandlerRegistry:
|
||||||
|
"""Registry for WebSocket message handlers with authentication support."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._handlers: dict[str, tuple[Callable, bool]] = {}
|
||||||
|
|
||||||
|
def register(self, message_type: str, authRequired: bool = True):
|
||||||
|
"""Register a handler for a message type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_type: The WebSocket message type to handle
|
||||||
|
authRequired: If True, handler will receive authenticated User (not None) or raise 401
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable):
|
||||||
|
self._handlers[message_type] = (func, authRequired)
|
||||||
|
return func
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def get_handler(self, message_type: str) -> tuple[Callable, bool] | None:
|
||||||
|
"""Get handler and authRequired flag for a message type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (handler function, authRequired flag) or None if not found
|
||||||
|
"""
|
||||||
|
return self._handlers.get(message_type)
|
||||||
|
|
||||||
|
def get_all_types(self) -> list[str]:
|
||||||
|
"""Get all registered message types for debugging/logging."""
|
||||||
|
return list(self._handlers.keys())
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from backend.shared.dependencies import get_current_user
|
||||||
|
from backend.shared.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def extract_token_from_data(data: dict) -> str | None:
|
||||||
|
"""Extract authentication token from WebSocket message data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: WebSocket message data dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token string or None if not present
|
||||||
|
"""
|
||||||
|
credentials = data.get("credentials")
|
||||||
|
if credentials and isinstance(credentials, dict):
|
||||||
|
return credentials.get("credentials")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_from_token(token: str, db: Session) -> User | None:
|
||||||
|
"""Get user from authentication token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT token string
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object or None if token is invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Ensure session is in a usable state before querying
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dummy_request = SimpleNamespace()
|
||||||
|
dummy_request.state = SimpleNamespace()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi.security import HTTPBearer
|
||||||
|
security = HTTPBearer()
|
||||||
|
# We need to create credentials manually
|
||||||
|
credentials = HTTPAuthorizationCredentials(
|
||||||
|
scheme="Bearer",
|
||||||
|
credentials=token
|
||||||
|
)
|
||||||
|
return get_current_user(dummy_request, credentials, db)
|
||||||
|
except HTTPException:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None:
|
||||||
|
"""Authenticate user from WebSocket message data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: WebSocket message data dictionary
|
||||||
|
db: Database session
|
||||||
|
authRequired: If True, raises 401 on missing/invalid token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object (guaranteed not None if authRequired=True) or None
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 401 if authRequired=True and token is missing/invalid
|
||||||
|
"""
|
||||||
|
token = extract_token_from_data(data)
|
||||||
|
|
||||||
|
if authRequired:
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing credentials")
|
||||||
|
|
||||||
|
user = get_current_user_from_token(token, db)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
return user
|
||||||
|
else:
|
||||||
|
if token:
|
||||||
|
return get_current_user_from_token(token, db)
|
||||||
|
return None
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.messaging import router as messaging_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Messaging Service")
|
||||||
|
app.include_router(messaging_router, prefix="/messaging")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Migration Runner Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy migration files
|
||||||
|
COPY backend/alembic /app/backend/alembic/
|
||||||
|
COPY backend/migration.py /app/backend/migration.py
|
||||||
|
|
||||||
|
# Copy migration runner
|
||||||
|
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=migration_runner
|
||||||
|
|
||||||
|
# Override entrypoint to run migrations
|
||||||
|
ENTRYPOINT ["python", "-m", "backend.services.migration_runner.main"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration runner service - executes database migrations and exits.
|
||||||
|
This service runs Alembic migrations against PostgreSQL and terminates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
def run_migrations():
|
||||||
|
"""Run Alembic migrations."""
|
||||||
|
print("Starting database migrations...")
|
||||||
|
|
||||||
|
# Change to backend directory to run migrations
|
||||||
|
backend_dir = Path(__file__).parent.parent.parent
|
||||||
|
os.chdir(backend_dir)
|
||||||
|
|
||||||
|
# Ensure shared models are imported for alembic
|
||||||
|
import backend.shared.models
|
||||||
|
|
||||||
|
# Set DATABASE_URL from environment if not set
|
||||||
|
db_url = os.getenv("DATABASE_URL")
|
||||||
|
if not db_url:
|
||||||
|
print("ERROR: DATABASE_URL environment variable not set")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Export DATABASE_URL for alembic
|
||||||
|
os.environ["DATABASE_URL"] = db_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the robust migration system from migration.py
|
||||||
|
# This handles all edge cases and recovery scenarios automatically
|
||||||
|
print("Starting database migrations...")
|
||||||
|
|
||||||
|
# Import and run the migration function
|
||||||
|
from backend.migration import run_migrations
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
print("Database migrations completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to run migrations: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_migrations()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Moderation Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
|
||||||
|
COPY backend/security/profanity.py /app/backend/security/profanity.py
|
||||||
|
COPY backend/similarity.py /app/backend/similarity.py
|
||||||
|
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=moderation
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.moderation import router as moderation_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Moderation Service")
|
||||||
|
app.include_router(moderation_router, prefix="/moderation")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Profile Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/profile.py /app/backend/routes/profile.py
|
||||||
|
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=profile
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.profile import router as profile_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Profile Service")
|
||||||
|
app.include_router(profile_router, prefix="/profile")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Push Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/push.py /app/backend/routes/push.py
|
||||||
|
COPY backend/push_service.py /app/backend/push_service.py
|
||||||
|
COPY backend/services/push/main.py /app/backend/services/push/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=push
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
from backend.shared.models import PushSubscription, User, Message, DMEnvelope, FcmToken
|
||||||
|
import firebase_admin
|
||||||
|
from firebase_admin import credentials as firebase_credentials
|
||||||
|
from firebase_admin import messaging as firebase_messaging
|
||||||
|
import base64
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
class PushNotificationService:
|
||||||
|
def __init__(self):
|
||||||
|
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
|
||||||
|
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
|
||||||
|
# Firebase Admin initialization (modern API). Only FIREBASE_CERT env is supported.
|
||||||
|
self.firebase_initialized = False
|
||||||
|
try:
|
||||||
|
firebase_cert = os.getenv("FIREBASE_CERT")
|
||||||
|
if not firebase_cert:
|
||||||
|
raise RuntimeError("FIREBASE_CERT env variable is required for Firebase Admin SDK initialization")
|
||||||
|
|
||||||
|
# Support raw JSON or base64-encoded JSON in FIREBASE_CERT
|
||||||
|
decoded = base64.b64decode(firebase_cert).decode("utf-8")
|
||||||
|
sa_dict = json.loads(decoded)
|
||||||
|
|
||||||
|
cred = firebase_credentials.Certificate(sa_dict)
|
||||||
|
firebase_admin.initialize_app(cred)
|
||||||
|
self.firebase_initialized = True
|
||||||
|
logger.info("Firebase Admin SDK initialized for push sending (FIREBASE_CERT)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize Firebase Admin SDK from FIREBASE_CERT: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if (not self.vapid_public_key) or (not self.vapid_private_key):
|
||||||
|
raise ValueError("VAPID public or private key is None")
|
||||||
|
|
||||||
|
self.vapid_claims = {
|
||||||
|
"sub": "mailto:support@fromchat.ru",
|
||||||
|
"aud": "https://fcm.googleapis.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
|
||||||
|
"""Subscribe a user to push notifications"""
|
||||||
|
try:
|
||||||
|
# Check if user already has a subscription
|
||||||
|
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||||
|
|
||||||
|
if existing_sub:
|
||||||
|
# Update existing subscription
|
||||||
|
existing_sub.endpoint = endpoint
|
||||||
|
existing_sub.p256dh_key = p256dh_key
|
||||||
|
existing_sub.auth_key = auth_key
|
||||||
|
else:
|
||||||
|
# Create new subscription
|
||||||
|
new_sub = PushSubscription(
|
||||||
|
user_id=user_id,
|
||||||
|
endpoint=endpoint,
|
||||||
|
p256dh_key=p256dh_key,
|
||||||
|
auth_key=auth_key
|
||||||
|
)
|
||||||
|
db.add(new_sub)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Push subscription saved for user {user_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
|
||||||
|
db.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
|
||||||
|
"""Send push notification for a new public chat message"""
|
||||||
|
try:
|
||||||
|
# Get all users except the sender
|
||||||
|
users = db.query(User).filter(User.id != message.user_id)
|
||||||
|
if exclude_user_id:
|
||||||
|
users = users.filter(User.id != exclude_user_id)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
# Check if user has push subscription before trying to send
|
||||||
|
# Try all FCM tokens first (Android). If none or all fail, fall back to web push subscription.
|
||||||
|
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == user.id).all()
|
||||||
|
payload_data = {
|
||||||
|
"type": "public_message",
|
||||||
|
"message_id": message.id,
|
||||||
|
"sender_id": message.user_id,
|
||||||
|
"sender_username": message.author.username
|
||||||
|
}
|
||||||
|
title = f"{message.author.username}"
|
||||||
|
body = message.content[:100] + ("..." if len(message.content) > 100 else "")
|
||||||
|
|
||||||
|
if fcm_rows and self.firebase_initialized:
|
||||||
|
for fcm in fcm_rows:
|
||||||
|
try:
|
||||||
|
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send FCM to user {user.id} token {fcm.token}: {e}")
|
||||||
|
# Check if this is a permanent failure and clean up the token
|
||||||
|
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
||||||
|
|
||||||
|
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
||||||
|
if subscription:
|
||||||
|
await self._send_notification_to_user(
|
||||||
|
db, user.id, title, body, message.author.profile_picture, payload_data
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send public message notifications: {e}")
|
||||||
|
|
||||||
|
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
||||||
|
"""Send push notification for a new DM"""
|
||||||
|
try:
|
||||||
|
title = f"{sender.username}"
|
||||||
|
body = "New direct message"
|
||||||
|
payload_data = {
|
||||||
|
"type": "dm",
|
||||||
|
"dm_id": dm_envelope.id,
|
||||||
|
"sender_id": sender.id,
|
||||||
|
"sender_username": sender.username
|
||||||
|
}
|
||||||
|
|
||||||
|
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == dm_envelope.recipient_id).all()
|
||||||
|
if fcm_rows and self.firebase_initialized:
|
||||||
|
for fcm in fcm_rows:
|
||||||
|
try:
|
||||||
|
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send FCM to user {dm_envelope.recipient_id} token {fcm.token}: {e}")
|
||||||
|
# Check if this is a permanent failure and clean up the token
|
||||||
|
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
||||||
|
|
||||||
|
await self._send_notification_to_user(
|
||||||
|
db, dm_envelope.recipient_id, title, body, sender.profile_picture, payload_data
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send DM notification: {e}")
|
||||||
|
|
||||||
|
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
|
||||||
|
"""Send a push notification to a specific user"""
|
||||||
|
try:
|
||||||
|
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||||
|
if not subscription:
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"icon": icon or "about:blank",
|
||||||
|
"tag": f"message_{user_id}",
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription_info = {
|
||||||
|
"endpoint": subscription.endpoint,
|
||||||
|
"keys": {
|
||||||
|
"p256dh": subscription.p256dh_key,
|
||||||
|
"auth": subscription.auth_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=json.dumps(payload),
|
||||||
|
vapid_private_key=self.vapid_private_key,
|
||||||
|
vapid_claims=self.vapid_claims
|
||||||
|
)
|
||||||
|
|
||||||
|
except WebPushException as e:
|
||||||
|
logger.error(f"WebPush error for user {user_id}: {e}")
|
||||||
|
# If the subscription is invalid, remove it
|
||||||
|
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
|
||||||
|
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
||||||
|
|
||||||
|
def _send_fcm_to_token(self, token: str, title: str, body: str, data: dict):
|
||||||
|
"""Send an FCM data-only push to a single device token using Firebase Admin SDK.
|
||||||
|
Notification display is handled by the app, not FCM."""
|
||||||
|
if not self.firebase_initialized:
|
||||||
|
raise RuntimeError("Firebase Admin SDK not initialized (FIREBASE_CERT required)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send only data payload - let the app handle notification display
|
||||||
|
# This prevents FCM from auto-showing notifications
|
||||||
|
msg = firebase_messaging.Message(
|
||||||
|
token=token,
|
||||||
|
data={
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
**{k: str(v) for k, v in (data or {}).items()}
|
||||||
|
},
|
||||||
|
android=firebase_messaging.AndroidConfig(priority="high"),
|
||||||
|
apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"})
|
||||||
|
)
|
||||||
|
resp = firebase_messaging.send(msg)
|
||||||
|
return resp
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Firebase Admin send failed for token {token}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _cleanup_failed_fcm_token(self, db: Session, fcm_token_entry, error_message: str):
|
||||||
|
"""Clean up FCM tokens that have permanent failures"""
|
||||||
|
try:
|
||||||
|
# Check for permanent failure indicators in the error message
|
||||||
|
permanent_errors = [
|
||||||
|
"unregistered", "invalidregistration", "notregistered",
|
||||||
|
"sender_id_mismatch", "invalid_argument"
|
||||||
|
]
|
||||||
|
|
||||||
|
error_lower = error_message.lower()
|
||||||
|
is_permanent = any(permanent_error in error_lower for permanent_error in permanent_errors)
|
||||||
|
|
||||||
|
if is_permanent:
|
||||||
|
logger.info(f"Removing permanently failed FCM token for user {fcm_token_entry.user_id}: {fcm_token_entry.token}")
|
||||||
|
db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete()
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
logger.debug(f"Temporary FCM failure for token {fcm_token_entry.token}, keeping token: {error_message}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to cleanup FCM token {fcm_token_entry.token}: {e}")
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
||||||
|
"""Unsubscribe a user from push notifications"""
|
||||||
|
try:
|
||||||
|
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Push subscription removed for user {user_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
|
||||||
|
db.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
push_service = PushNotificationService()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.push import router as push_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="Push Service")
|
||||||
|
app.include_router(push_router, prefix="/push")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# WebRTC Service Dockerfile
|
||||||
|
FROM backend/base:latest
|
||||||
|
|
||||||
|
# Copy service-specific files
|
||||||
|
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
|
||||||
|
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
|
||||||
|
|
||||||
|
# Set service name for entrypoint
|
||||||
|
ENV SERVICE_NAME=webrtc
|
||||||
|
|
||||||
|
EXPOSE 8301
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from backend.routes.webrtc import router as webrtc_router
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FastAPI(title="WebRTC Service")
|
||||||
|
app.include_router(webrtc_router, prefix="/webrtc")
|
||||||
|
|
||||||
|
import os, uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Shared modules package
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/database.db")
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY = os.getenv("JWT_SECRET", "default-jwt-secret-for-development")
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
# Token inactivity expiration - token expires if not used for this duration
|
||||||
|
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
||||||
|
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
|
||||||
|
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
|
||||||
|
|
||||||
|
# Owner user
|
||||||
|
OWNER_USERNAME = os.getenv("OWNER_USERNAME", "owner")
|
||||||
|
|
||||||
|
# Push notifications
|
||||||
|
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "")
|
||||||
|
VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "")
|
||||||
|
VAPID_SUBJECT = os.getenv("VAPID_SUBJECT", "mailto:admin@example.com")
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||||
|
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
||||||
|
|
||||||
|
# File uploads
|
||||||
|
MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "10485760")) # 10MB
|
||||||
|
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".avi", ".mp3", ".wav"}
|
||||||
|
|
||||||
|
# WebSocket
|
||||||
|
WEBSOCKET_PING_INTERVAL = 30
|
||||||
|
WEBSOCKET_PING_TIMEOUT = 60
|
||||||
|
|
||||||
|
# Encryption
|
||||||
|
ENCRYPTION_KEY_LENGTH = 32
|
||||||
|
ENCRYPTION_NONCE_LENGTH = 12
|
||||||
|
|
||||||
|
# Moderation
|
||||||
|
PROFANITY_THRESHOLD = float(os.getenv("PROFANITY_THRESHOLD", "0.8"))
|
||||||
|
SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", "0.85"))
|
||||||
|
|
||||||
|
# WebRTC
|
||||||
|
WEBRTC_ICE_SERVERS = [
|
||||||
|
{"urls": "stun:stun.l.google.com:19302"},
|
||||||
|
{"urls": "stun:stun1.l.google.com:19302"}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||||
|
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import os
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from .constants import DATABASE_URL
|
||||||
|
|
||||||
|
# Database connection settings
|
||||||
|
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
|
||||||
|
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
|
||||||
|
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||||
|
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
||||||
|
|
||||||
|
POOL_CONFIG = {
|
||||||
|
"pool_size": POOL_SIZE,
|
||||||
|
"max_overflow": MAX_OVERFLOW,
|
||||||
|
"pool_recycle": POOL_RECYCLE,
|
||||||
|
"pool_timeout": POOL_TIMEOUT,
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_engine_from_url(database_url: str):
|
||||||
|
"""Create SQLAlchemy engine from database URL."""
|
||||||
|
connect_args = {}
|
||||||
|
if database_url.startswith("sqlite"):
|
||||||
|
connect_args["check_same_thread"] = False
|
||||||
|
|
||||||
|
engine_kwargs = {
|
||||||
|
"pool_size": POOL_SIZE,
|
||||||
|
"max_overflow": MAX_OVERFLOW,
|
||||||
|
"pool_recycle": POOL_RECYCLE,
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
"pool_timeout": POOL_TIMEOUT,
|
||||||
|
}
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
database_url,
|
||||||
|
connect_args=connect_args,
|
||||||
|
**engine_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return engine
|
||||||
|
|
||||||
|
# Create engine - this should be called by each service with its own DATABASE_URL
|
||||||
|
def get_engine(database_url: str = None):
|
||||||
|
"""Get SQLAlchemy engine for the given database URL."""
|
||||||
|
url = database_url or DATABASE_URL
|
||||||
|
return create_engine_from_url(url)
|
||||||
|
|
||||||
|
# Session factory - create per service
|
||||||
|
def get_session_factory(database_url: str = None):
|
||||||
|
"""Get session factory for the given database URL."""
|
||||||
|
engine = get_engine(database_url)
|
||||||
|
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
# Dependency for FastAPI - create per service
|
||||||
|
def get_db(database_url: str = None):
|
||||||
|
"""FastAPI dependency to get database session."""
|
||||||
|
SessionLocal = get_session_factory(database_url)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from backend.shared.utils import verify_token
|
||||||
|
from backend.shared.models import User, DeviceSession
|
||||||
|
from backend.shared.db import get_session_factory
|
||||||
|
import logging
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
# Зависимость для получения сессии БД
|
||||||
|
SessionLocal = get_session_factory()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Зависимость для получения текущего пользователя
|
||||||
|
def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
token = credentials.credentials
|
||||||
|
try:
|
||||||
|
payload = verify_token(token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("get_current_user: token verification error: %s", str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
if not payload:
|
||||||
|
logger.info("get_current_user: verify_token returned empty payload")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
||||||
|
if not user:
|
||||||
|
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.id == 1 and user.suspended:
|
||||||
|
user.suspended = False
|
||||||
|
user.suspension_reason = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
# Validate device session from JWT
|
||||||
|
session_id = payload.get("session_id")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid session",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
device_session = (
|
||||||
|
db.query(DeviceSession)
|
||||||
|
.filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not device_session or device_session.revoked:
|
||||||
|
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Session revoked or not found",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if session has been inactive for too long (sliding expiration)
|
||||||
|
from backend.shared.constants import TOKEN_INACTIVITY_EXPIRE_HOURS
|
||||||
|
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
|
||||||
|
if device_session.last_seen < inactivity_threshold:
|
||||||
|
# Session expired due to inactivity - revoke it
|
||||||
|
device_session.revoked = True
|
||||||
|
db.commit()
|
||||||
|
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Session expired due to inactivity",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Touch last_seen on valid session (sliding expiration - extends token life)
|
||||||
|
device_session.last_seen = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Check if user is suspended
|
||||||
|
if user.suspended:
|
||||||
|
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Account suspended",
|
||||||
|
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user is deleted
|
||||||
|
if user.deleted:
|
||||||
|
logger.info("get_current_user: account deleted for user_id=%s", user.id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Account deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
request.state.current_user = user
|
||||||
|
request.state.session_id = session_id
|
||||||
|
|
||||||
|
return user
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float, JSON, BigInteger, UniqueConstraint
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
__table_args__ = {"schema": "account_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
salt = Column(String(64), nullable=False)
|
||||||
|
display_name = Column(String(100), nullable=True)
|
||||||
|
bio = Column(Text, nullable=True)
|
||||||
|
avatar_url = Column(String(255), nullable=True)
|
||||||
|
is_online = Column(Boolean, default=False)
|
||||||
|
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
verified = Column(Boolean, default=False)
|
||||||
|
verification_token = Column(String(255), nullable=True)
|
||||||
|
reset_token = Column(String(255), nullable=True)
|
||||||
|
reset_token_expires = Column(DateTime, nullable=True)
|
||||||
|
two_factor_enabled = Column(Boolean, default=False)
|
||||||
|
two_factor_secret = Column(String(255), nullable=True)
|
||||||
|
login_attempts = Column(Integer, default=0)
|
||||||
|
locked_until = Column(DateTime, nullable=True)
|
||||||
|
public_key = Column(Text, nullable=True)
|
||||||
|
private_key = Column(Text, nullable=True)
|
||||||
|
encryption_enabled = Column(Boolean, default=False)
|
||||||
|
suspended = Column(Boolean, default=False)
|
||||||
|
suspension_reason = Column(Text, nullable=True)
|
||||||
|
deleted = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
messages = relationship("Message", back_populates="sender", cascade="all, delete-orphan")
|
||||||
|
message_recipients = relationship("MessageRecipient", back_populates="recipient", cascade="all, delete-orphan")
|
||||||
|
devices = relationship("Device", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
content_type = Column(String(50), default="text")
|
||||||
|
encrypted_content = Column(Text, nullable=True)
|
||||||
|
signature = Column(Text, nullable=True)
|
||||||
|
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
edited_at = Column(DateTime, nullable=True)
|
||||||
|
edited = Column(Boolean, default=False)
|
||||||
|
deleted = Column(Boolean, default=False)
|
||||||
|
reply_to_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
||||||
|
thread_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
||||||
|
is_public = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
sender = relationship("User", back_populates="messages")
|
||||||
|
recipients = relationship("MessageRecipient", back_populates="message", cascade="all, delete-orphan")
|
||||||
|
reply_to = relationship("Message", remote_side=[id], foreign_keys=[reply_to_id])
|
||||||
|
thread = relationship("Message", remote_side=[id], foreign_keys=[thread_id])
|
||||||
|
reactions = relationship("MessageReaction", back_populates="message", cascade="all, delete-orphan")
|
||||||
|
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class MessageRecipient(Base):
|
||||||
|
__tablename__ = "message_recipients"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||||
|
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
read_at = Column(DateTime, nullable=True)
|
||||||
|
delivered_at = Column(DateTime, nullable=True)
|
||||||
|
encrypted_key = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
message = relationship("Message", back_populates="recipients")
|
||||||
|
recipient = relationship("User", back_populates="message_recipients")
|
||||||
|
|
||||||
|
class MessageReaction(Base):
|
||||||
|
__tablename__ = "message_reactions"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
reaction = Column(String(50), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
message = relationship("Message", back_populates="reactions")
|
||||||
|
|
||||||
|
class Device(Base):
|
||||||
|
__tablename__ = "devices"
|
||||||
|
__table_args__ = {"schema": "device_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
device_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
device_name = Column(String(255), nullable=True)
|
||||||
|
device_type = Column(String(50), nullable=True)
|
||||||
|
public_key = Column(Text, nullable=True)
|
||||||
|
signed_prekey = Column(Text, nullable=True)
|
||||||
|
one_time_prekeys = Column(JSON, nullable=True)
|
||||||
|
last_active = Column(DateTime, default=datetime.utcnow)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="devices")
|
||||||
|
push_subscriptions = relationship("PushSubscription", back_populates="device", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class PushSubscription(Base):
|
||||||
|
__tablename__ = "push_subscriptions"
|
||||||
|
__table_args__ = {"schema": "push_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
device_id = Column(BigInteger, ForeignKey("device_schema.devices.id"), nullable=True, index=True)
|
||||||
|
endpoint = Column(String(500), nullable=False)
|
||||||
|
p256dh = Column(String(255), nullable=False)
|
||||||
|
auth = Column(String(255), nullable=False)
|
||||||
|
user_agent = Column(String(500), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="push_subscriptions")
|
||||||
|
device = relationship("Device", back_populates="push_subscriptions")
|
||||||
|
|
||||||
|
class WebRTCSession(Base):
|
||||||
|
__tablename__ = "webrtc_sessions"
|
||||||
|
__table_args__ = {"schema": "webrtc_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
session_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
initiator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
participant_ids = Column(JSON, nullable=False)
|
||||||
|
offer = Column(JSON, nullable=True)
|
||||||
|
answer = Column(JSON, nullable=True)
|
||||||
|
ice_candidates = Column(JSON, nullable=True)
|
||||||
|
status = Column(String(50), default="pending")
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
class ModerationAction(Base):
|
||||||
|
__tablename__ = "moderation_actions"
|
||||||
|
__table_args__ = {"schema": "moderation_schema"}
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
moderator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
target_user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=True)
|
||||||
|
target_message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
||||||
|
action_type = Column(String(50), nullable=False)
|
||||||
|
reason = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFile(Base):
|
||||||
|
__tablename__ = "message_file"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||||
|
path = Column(Text, nullable=False)
|
||||||
|
name = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
message = relationship("Message", back_populates="files")
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoPublicKey(Base):
|
||||||
|
__tablename__ = "crypto_public_key"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
|
||||||
|
public_key_b64 = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoBackup(Base):
|
||||||
|
__tablename__ = "crypto_backup"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
|
||||||
|
blob_json = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DMEnvelope(Base):
|
||||||
|
__tablename__ = "dm_envelope"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
iv_b64 = Column(Text, nullable=False)
|
||||||
|
ciphertext_b64 = Column(Text, nullable=False)
|
||||||
|
salt_b64 = Column(Text, nullable=False)
|
||||||
|
iv2_b64 = Column(Text, nullable=False)
|
||||||
|
wrapped_mk_b64 = Column(Text, nullable=False)
|
||||||
|
reply_to_id = Column(Integer, nullable=True)
|
||||||
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
|
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||||
|
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
||||||
|
|
||||||
|
|
||||||
|
class DMFile(Base):
|
||||||
|
__tablename__ = "dm_file"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
message_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
|
||||||
|
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
name = Column(Text, nullable=False)
|
||||||
|
path = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
message = relationship("DMEnvelope", back_populates="files")
|
||||||
|
|
||||||
|
|
||||||
|
class FcmToken(Base):
|
||||||
|
__tablename__ = "fcm_token"
|
||||||
|
__table_args__ = {"schema": "push_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
token = Column(Text, nullable=False, unique=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class Reaction(Base):
|
||||||
|
__tablename__ = "reaction"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||||
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
# Ensure unique combination of message, user, and emoji
|
||||||
|
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
|
||||||
|
|
||||||
|
|
||||||
|
class DMReaction(Base):
|
||||||
|
__tablename__ = "dm_reaction"
|
||||||
|
__table_args__ = {"schema": "messaging_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
dm_envelope_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||||
|
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||||
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User")
|
||||||
|
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
|
||||||
|
|
||||||
|
# Ensure unique combination of dm_envelope, user, and emoji
|
||||||
|
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
||||||
|
|
||||||
|
|
||||||
|
# Tracks authenticated device sessions per user
|
||||||
|
class DeviceSession(Base):
|
||||||
|
__tablename__ = "device_session"
|
||||||
|
__table_args__ = {"schema": "device_schema"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Raw User-Agent for reference/debugging
|
||||||
|
raw_user_agent = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Parsed fields
|
||||||
|
device_name = Column(String(128), nullable=True)
|
||||||
|
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
|
||||||
|
os_name = Column(String(64), nullable=True)
|
||||||
|
os_version = Column(String(64), nullable=True)
|
||||||
|
browser_name = Column(String(64), nullable=True)
|
||||||
|
browser_version = Column(String(64), nullable=True)
|
||||||
|
brand = Column(String(64), nullable=True)
|
||||||
|
model = Column(String(64), nullable=True)
|
||||||
|
|
||||||
|
# Session identity embedded into JWTs
|
||||||
|
session_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Lifecycle
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
last_seen = Column(DateTime, default=datetime.now)
|
||||||
|
revoked = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Relationship back to user (optional lazy to avoid heavy loads)
|
||||||
|
user = relationship("User", lazy="select")
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic models
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
password: str
|
||||||
|
confirm_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
currentPasswordDerived: str
|
||||||
|
newPasswordDerived: str
|
||||||
|
logoutAllExceptCurrent: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SendMessageRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
reply_to_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EditMessageRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteMessageRequest(BaseModel):
|
||||||
|
message_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateBioRequest(BaseModel):
|
||||||
|
bio: str
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscriptionRequest(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
keys: dict
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
profile_picture: str | None
|
||||||
|
bio: str | None
|
||||||
|
online: bool
|
||||||
|
last_seen: datetime | None
|
||||||
|
created_at: datetime | None
|
||||||
|
verified: bool
|
||||||
|
suspended: bool
|
||||||
|
suspension_reason: str | None
|
||||||
|
deleted: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
content: str
|
||||||
|
timestamp: datetime
|
||||||
|
is_author: bool
|
||||||
|
is_read: bool
|
||||||
|
username: str
|
||||||
|
profile_picture: str | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionRequest(BaseModel):
|
||||||
|
message_id: int
|
||||||
|
emoji: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
message_id: int
|
||||||
|
user_id: int
|
||||||
|
emoji: str
|
||||||
|
timestamp: datetime
|
||||||
|
username: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DMReactionRequest(BaseModel):
|
||||||
|
dm_envelope_id: int
|
||||||
|
emoji: str
|
||||||
|
|
||||||
|
|
||||||
|
class DMReactionResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
dm_envelope_id: int
|
||||||
|
user_id: int
|
||||||
|
emoji: str
|
||||||
|
timestamp: datetime
|
||||||
|
username: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateLog(Base):
|
||||||
|
"""Stores update sequence numbers and updates for gap detection"""
|
||||||
|
__tablename__ = "update_log"
|
||||||
|
__table_args__ = {"schema": "public"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||||
|
sequence = Column(Integer, nullable=False, index=True)
|
||||||
|
updates = Column(Text, nullable=False) # JSON array of updates
|
||||||
|
timestamp = Column(DateTime, default=datetime.now, index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "sequence", name="uq_user_sequence"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
Similarity detection utilities for username and display name comparison.
|
||||||
|
Implements both edit distance and visual similarity detection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def levenshtein_distance(s1: str, s2: str) -> int:
|
||||||
|
"""Calculate Levenshtein distance between two strings."""
|
||||||
|
if len(s1) < len(s2):
|
||||||
|
return levenshtein_distance(s2, s1)
|
||||||
|
|
||||||
|
if len(s2) == 0:
|
||||||
|
return len(s1)
|
||||||
|
|
||||||
|
previous_row = list(range(len(s2) + 1))
|
||||||
|
for i, c1 in enumerate(s1):
|
||||||
|
current_row = [i + 1]
|
||||||
|
for j, c2 in enumerate(s2):
|
||||||
|
insertions = previous_row[j + 1] + 1
|
||||||
|
deletions = current_row[j] + 1
|
||||||
|
substitutions = previous_row[j] + (c1 != c2)
|
||||||
|
current_row.append(min(insertions, deletions, substitutions))
|
||||||
|
previous_row = current_row
|
||||||
|
|
||||||
|
return previous_row[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def check_visual_similarity(s1: str, s2: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if two strings are visually similar using common homoglyphs.
|
||||||
|
Returns True if strings are visually similar.
|
||||||
|
"""
|
||||||
|
if len(s1) != len(s2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Common homoglyph mappings
|
||||||
|
homoglyphs = {
|
||||||
|
'0': ['O', 'o', 'Q'],
|
||||||
|
'O': ['0', 'o', 'Q'],
|
||||||
|
'o': ['0', 'O', 'Q'],
|
||||||
|
'1': ['l', 'I', '|'],
|
||||||
|
'l': ['1', 'I', '|'],
|
||||||
|
'I': ['1', 'l', '|'],
|
||||||
|
'5': ['S', 's'],
|
||||||
|
'S': ['5', 's'],
|
||||||
|
's': ['5', 'S'],
|
||||||
|
'6': ['G', 'g'],
|
||||||
|
'G': ['6', 'g'],
|
||||||
|
'g': ['6', 'G'],
|
||||||
|
'8': ['B', 'b'],
|
||||||
|
'B': ['8', 'b'],
|
||||||
|
'b': ['8', 'B'],
|
||||||
|
'9': ['g', 'q'],
|
||||||
|
'g': ['9', 'q'],
|
||||||
|
'q': ['9', 'g'],
|
||||||
|
'2': ['Z', 'z'],
|
||||||
|
'Z': ['2', 'z'],
|
||||||
|
'z': ['2', 'Z'],
|
||||||
|
'3': ['E'],
|
||||||
|
'E': ['3'],
|
||||||
|
'4': ['A'],
|
||||||
|
'A': ['4'],
|
||||||
|
'7': ['T', 't'],
|
||||||
|
'T': ['7', 't'],
|
||||||
|
't': ['7', 'T'],
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in range(len(s1)):
|
||||||
|
c1, c2 = s1[i], s2[i]
|
||||||
|
if c1 == c2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if characters are homoglyphs
|
||||||
|
if (c1 in homoglyphs and c2 in homoglyphs[c1]) or \
|
||||||
|
(c2 in homoglyphs and c1 in homoglyphs[c2]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_username_similarity(username1: str, username2: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if two usernames are similar using both edit distance and visual similarity.
|
||||||
|
Returns True if usernames are considered similar.
|
||||||
|
"""
|
||||||
|
if username1 == username2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check edit distance (Levenshtein distance <= 2)
|
||||||
|
edit_distance = levenshtein_distance(username1.lower(), username2.lower())
|
||||||
|
if edit_distance <= 2:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check visual similarity
|
||||||
|
if check_visual_similarity(username1, username2):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_display_name_similarity(display_name1: str, display_name2: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if two display names are similar using both edit distance and visual similarity.
|
||||||
|
Returns True if display names are considered similar.
|
||||||
|
"""
|
||||||
|
if display_name1 == display_name2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check edit distance (Levenshtein distance <= 2)
|
||||||
|
edit_distance = levenshtein_distance(display_name1.lower(), display_name2.lower())
|
||||||
|
if edit_distance <= 2:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check visual similarity
|
||||||
|
if check_visual_similarity(display_name1, display_name2):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_user_similar_to_verified(user_username: str, user_display_name: str,
|
||||||
|
verified_users: list[dict]) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Check if a user is similar to any verified user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_username: Username to check
|
||||||
|
user_display_name: Display name to check
|
||||||
|
verified_users: List of verified user dictionaries with 'username' and 'display_name' keys
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_similar, similar_to_username)
|
||||||
|
"""
|
||||||
|
for verified_user in verified_users:
|
||||||
|
verified_username = verified_user.get('username', '')
|
||||||
|
verified_display_name = verified_user.get('display_name', '')
|
||||||
|
|
||||||
|
# Check username similarity
|
||||||
|
if check_username_similarity(user_username, verified_username):
|
||||||
|
return True, verified_username
|
||||||
|
|
||||||
|
# Check display name similarity
|
||||||
|
if check_display_name_similarity(user_display_name, verified_display_name):
|
||||||
|
return True, verified_username
|
||||||
|
|
||||||
|
return False, ""
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
import re
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
import nacl.secret
|
||||||
|
import nacl.utils
|
||||||
|
from fastapi import Request
|
||||||
|
import jwt
|
||||||
|
import bcrypt
|
||||||
|
from backend.shared.constants import JWT_SECRET_KEY, JWT_ALGORITHM, MAX_TOKEN_LIFETIME_HOURS
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
def generate_secure_token(length: int = 32) -> str:
|
||||||
|
"""Generate a cryptographically secure random token."""
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
def hash_password(password: str, salt: Optional[bytes] = None) -> tuple[str, bytes]:
|
||||||
|
"""Hash a password with PBKDF2 and return (hash, salt)."""
|
||||||
|
if salt is None:
|
||||||
|
salt = secrets.token_bytes(32)
|
||||||
|
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt,
|
||||||
|
iterations=100000,
|
||||||
|
backend=default_backend()
|
||||||
|
)
|
||||||
|
|
||||||
|
key = kdf.derive(password.encode())
|
||||||
|
return base64.b64encode(key).decode(), salt
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str, salt: bytes) -> bool:
|
||||||
|
"""Verify a password against its hash and salt."""
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(hashed)
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt,
|
||||||
|
iterations=100000,
|
||||||
|
backend=default_backend()
|
||||||
|
)
|
||||||
|
kdf.verify(password.encode(), key)
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def generate_verification_token() -> str:
|
||||||
|
"""Generate a verification token for email verification."""
|
||||||
|
return generate_secure_token(64)
|
||||||
|
|
||||||
|
def generate_reset_token() -> str:
|
||||||
|
"""Generate a password reset token."""
|
||||||
|
return generate_secure_token(64)
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""Extract the real client IP from the request."""
|
||||||
|
# Check X-Forwarded-For header first
|
||||||
|
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded_for:
|
||||||
|
# Take the first IP in case of multiple proxies
|
||||||
|
client_ip = forwarded_for.split(",")[0].strip()
|
||||||
|
try:
|
||||||
|
# Validate IP address
|
||||||
|
ipaddress.ip_address(client_ip)
|
||||||
|
return client_ip
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check X-Real-IP header
|
||||||
|
real_ip = request.headers.get("X-Real-IP")
|
||||||
|
if real_ip:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(real_ip)
|
||||||
|
return real_ip
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to request.client.host
|
||||||
|
client_host = request.client.host if request.client else "unknown"
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(client_host)
|
||||||
|
return client_host
|
||||||
|
except ValueError:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def validate_email(email: str) -> bool:
|
||||||
|
"""Validate email address format."""
|
||||||
|
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||||
|
return re.match(pattern, email) is not None
|
||||||
|
|
||||||
|
def validate_username(username: str) -> bool:
|
||||||
|
"""Validate username format."""
|
||||||
|
if not username or len(username) < 3 or len(username) > 50:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Allow alphanumeric, underscore, and hyphen
|
||||||
|
pattern = r'^[a-zA-Z0-9_-]+$'
|
||||||
|
return re.match(pattern, username) is not None
|
||||||
|
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
"""Sanitize filename to prevent directory traversal."""
|
||||||
|
return re.sub(r'[^\w\.-]', '_', filename)
|
||||||
|
|
||||||
|
def generate_file_hash(content: bytes) -> str:
|
||||||
|
"""Generate SHA256 hash of file content."""
|
||||||
|
return hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
def encrypt_data(data: str, key: bytes) -> str:
|
||||||
|
"""Encrypt data using NaCl secret box."""
|
||||||
|
box = nacl.secret.SecretBox(key)
|
||||||
|
encrypted = box.encrypt(data.encode())
|
||||||
|
return base64.b64encode(encrypted).decode()
|
||||||
|
|
||||||
|
def decrypt_data(encrypted_data: str, key: bytes) -> str:
|
||||||
|
"""Decrypt data using NaCl secret box."""
|
||||||
|
box = nacl.secret.SecretBox(key)
|
||||||
|
encrypted = base64.b64decode(encrypted_data)
|
||||||
|
decrypted = box.decrypt(encrypted)
|
||||||
|
return decrypted.decode()
|
||||||
|
|
||||||
|
def generate_encryption_key() -> bytes:
|
||||||
|
"""Generate a new encryption key."""
|
||||||
|
return nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
|
||||||
|
|
||||||
|
def format_datetime(dt: datetime) -> str:
|
||||||
|
"""Format datetime for API responses."""
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
|
def parse_datetime(dt_str: str) -> Optional[datetime]:
|
||||||
|
"""Parse datetime from API requests."""
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calculate_age(birth_date: datetime) -> int:
|
||||||
|
"""Calculate age from birth date."""
|
||||||
|
today = datetime.now()
|
||||||
|
age = today.year - birth_date.year
|
||||||
|
if today.month < birth_date.month or (today.month == birth_date.month and today.day < birth_date.day):
|
||||||
|
age -= 1
|
||||||
|
return age
|
||||||
|
|
||||||
|
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
|
||||||
|
"""Truncate text to max length with suffix."""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
return text[:max_length - len(suffix)] + suffix
|
||||||
|
|
||||||
|
def is_valid_url(url: str) -> bool:
|
||||||
|
"""Validate URL format."""
|
||||||
|
pattern = r'^https?://[^\s/$.?#].[^\s]*$'
|
||||||
|
return re.match(pattern, url) is not None
|
||||||
|
|
||||||
|
def generate_device_id() -> str:
|
||||||
|
"""Generate a unique device identifier."""
|
||||||
|
return generate_secure_token(32)
|
||||||
|
|
||||||
|
def normalize_phone_number(phone: str) -> str:
|
||||||
|
"""Normalize phone number format."""
|
||||||
|
# Remove all non-digit characters except +
|
||||||
|
normalized = re.sub(r'[^\d+]', '', phone)
|
||||||
|
|
||||||
|
# Ensure it starts with +
|
||||||
|
if not normalized.startswith('+'):
|
||||||
|
if normalized.startswith('00'):
|
||||||
|
normalized = '+' + normalized[2:]
|
||||||
|
else:
|
||||||
|
normalized = '+' + normalized
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(user_id: int, username: str, session_id: str) -> str:
|
||||||
|
# Set a long expiration as safety net (actual expiration based on inactivity)
|
||||||
|
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
|
||||||
|
payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": username,
|
||||||
|
"session_id": session_id,
|
||||||
|
"exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int)
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return None
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(user) -> bool:
|
||||||
|
return user.id == 1
|
||||||
|
|
||||||
|
|
||||||
|
def convert_user(user) -> dict:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"created_at": user.created_at.isoformat(),
|
||||||
|
"last_seen": user.last_seen.isoformat(),
|
||||||
|
"online": user.online,
|
||||||
|
"username": user.username,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"profile_picture": user.profile_picture,
|
||||||
|
"bio": user.bio,
|
||||||
|
"admin": _is_admin(user),
|
||||||
|
"verified": user.verified,
|
||||||
|
"suspended": user.suspended or False,
|
||||||
|
"suspension_reason": user.suspension_reason,
|
||||||
|
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, validator
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_username(username: str) -> bool:
|
||||||
|
if len(username) < 3 or len(username) > 20:
|
||||||
|
return False
|
||||||
|
# Only allow English letters, numbers, dashes and underscores
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_display_name(display_name: str) -> bool:
|
||||||
|
if len(display_name) < 1 or len(display_name) > 64:
|
||||||
|
return False
|
||||||
|
# Check if not blank (only whitespace)
|
||||||
|
if not display_name.strip():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_password(password: str) -> bool:
|
||||||
|
if len(password) < 5 or len(password) > 50:
|
||||||
|
return False
|
||||||
|
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=50)
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
display_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
|
||||||
|
@validator('username')
|
||||||
|
def username_alphanumeric(cls, v):
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
|
||||||
|
raise ValueError('Username must be alphanumeric with underscores or hyphens')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('password')
|
||||||
|
def password_strength(cls, v):
|
||||||
|
if not re.search(r'[A-Z]', v):
|
||||||
|
raise ValueError('Password must contain at least one uppercase letter')
|
||||||
|
if not re.search(r'[a-z]', v):
|
||||||
|
raise ValueError('Password must contain at least one lowercase letter')
|
||||||
|
if not re.search(r'\d', v):
|
||||||
|
raise ValueError('Password must contain at least one digit')
|
||||||
|
return v
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
username_or_email: str = Field(min_length=1, max_length=100)
|
||||||
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
display_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
bio: Optional[str] = Field(None, max_length=500)
|
||||||
|
avatar_url: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
|
@validator('avatar_url')
|
||||||
|
def validate_avatar_url(cls, v):
|
||||||
|
if v and not v.startswith(('http://', 'https://')):
|
||||||
|
raise ValueError('Avatar URL must be a valid HTTP/HTTPS URL')
|
||||||
|
return v
|
||||||
|
|
||||||
|
class MessageCreate(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=10000)
|
||||||
|
content_type: str = Field(default="text", pattern=r'^(text|image|video|audio|file)$')
|
||||||
|
reply_to_id: Optional[int] = None
|
||||||
|
recipient_ids: list[int] = Field(min_items=1, max_items=100)
|
||||||
|
|
||||||
|
class MessageUpdate(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
class DeviceRegister(BaseModel):
|
||||||
|
device_id: str = Field(min_length=1, max_length=255)
|
||||||
|
device_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
device_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
public_key: Optional[str] = Field(None, max_length=10000)
|
||||||
|
|
||||||
|
class PushSubscriptionCreate(BaseModel):
|
||||||
|
endpoint: str = Field(max_length=500)
|
||||||
|
p256dh: str = Field(max_length=255)
|
||||||
|
auth: str = Field(max_length=255)
|
||||||
|
device_id: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
|
class WebRTCOffer(BaseModel):
|
||||||
|
offer: dict
|
||||||
|
participant_ids: list[int] = Field(min_items=1, max_items=10)
|
||||||
|
|
||||||
|
class WebRTCAnswer(BaseModel):
|
||||||
|
answer: dict
|
||||||
|
session_id: str = Field(max_length=255)
|
||||||
|
|
||||||
|
class WebRTCIceCandidate(BaseModel):
|
||||||
|
candidate: dict
|
||||||
|
session_id: str = Field(max_length=255)
|
||||||
|
|
||||||
|
class ModerationActionCreate(BaseModel):
|
||||||
|
target_user_id: Optional[int] = None
|
||||||
|
target_message_id: Optional[int] = None
|
||||||
|
action_type: str = Field(pattern=r'^(ban|mute|delete|warn)$')
|
||||||
|
reason: Optional[str] = Field(None, max_length=1000)
|
||||||
|
duration_hours: Optional[int] = Field(None, gt=0, le=8760) # Max 1 year
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
class PasswordReset(BaseModel):
|
||||||
|
token: str = Field(min_length=64, max_length=64)
|
||||||
|
new_password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
@validator('new_password')
|
||||||
|
def password_strength(cls, v):
|
||||||
|
if not re.search(r'[A-Z]', v):
|
||||||
|
raise ValueError('Password must contain at least one uppercase letter')
|
||||||
|
if not re.search(r'[a-z]', v):
|
||||||
|
raise ValueError('Password must contain at least one lowercase letter')
|
||||||
|
if not re.search(r'\d', v):
|
||||||
|
raise ValueError('Password must contain at least one digit')
|
||||||
|
return v
|
||||||
|
|
||||||
|
class TwoFactorSetup(BaseModel):
|
||||||
|
code: str = Field(pattern=r'^\d{6}$')
|
||||||
|
|
||||||
|
class TwoFactorVerify(BaseModel):
|
||||||
|
code: str = Field(pattern=r'^\d{6}$')
|
||||||
|
|
||||||
|
class EmailVerification(BaseModel):
|
||||||
|
token: str = Field(min_length=64, max_length=64)
|
||||||
+44
-6
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ coverage
|
|||||||
test_results/
|
test_results/
|
||||||
out
|
out
|
||||||
|
|
||||||
data
|
data
|
||||||
|
logs
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
fromchat.ru {
|
||||||
|
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 {
|
||||||
|
lb_policy first
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||||
|
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||||
|
X-Frame-Options "DENY" # Prevent clickjacking
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||||
|
}
|
||||||
|
|
||||||
|
rate_limit {
|
||||||
|
zone global {
|
||||||
|
key {remote_ip}
|
||||||
|
window 1m
|
||||||
|
burst 20
|
||||||
|
events 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_errors {
|
||||||
|
@errors {
|
||||||
|
expression {err.status_code} >= 400
|
||||||
|
}
|
||||||
|
|
||||||
|
handle @errors {
|
||||||
|
rewrite * /{err.status_code}
|
||||||
|
reverse_proxy https://http.cat {
|
||||||
|
header_up Host {upstream_hostport}
|
||||||
|
replace_status {err.status_code}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beta.fromchat.ru {
|
||||||
|
reverse_proxy 95.165.0.162:8301 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||||
|
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||||
|
X-Frame-Options "DENY" # Prevent clickjacking
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||||
|
}
|
||||||
|
|
||||||
|
rate_limit {
|
||||||
|
zone global {
|
||||||
|
key {remote_ip}
|
||||||
|
window 1m
|
||||||
|
burst 20
|
||||||
|
events 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_errors {
|
||||||
|
@errors {
|
||||||
|
expression {err.status_code} >= 400
|
||||||
|
}
|
||||||
|
|
||||||
|
handle @errors {
|
||||||
|
rewrite * /{err.status_code}
|
||||||
|
reverse_proxy https://http.cat {
|
||||||
|
header_up Host {upstream_hostport}
|
||||||
|
replace_status {err.status_code}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getgadgets.toolbox-io.ru {
|
||||||
|
reverse_proxy 95.165.0.162:8400
|
||||||
|
}
|
||||||
|
|
||||||
|
getgadgets.toolbox-io.ru {
|
||||||
|
reverse_proxy 95.165.0.162:8401
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#
|
||||||
|
# Custom Caddy built with:
|
||||||
|
# - Rate limit plugin
|
||||||
|
#
|
||||||
|
|
||||||
|
FROM caddy:2-builder AS builder
|
||||||
|
RUN xcaddy build \
|
||||||
|
--with github.com/mholt/caddy-ratelimit
|
||||||
|
|
||||||
|
FROM caddy:2
|
||||||
|
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- Initialize database roles and schemas for FromChat microservices
|
||||||
|
-- This script creates dedicated users with limited privileges for each service
|
||||||
|
|
||||||
|
-- Create service-specific database roles with limited privileges
|
||||||
|
-- All services use the same password
|
||||||
|
CREATE ROLE account_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE profile_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE device_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE messaging_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE push_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE webrtc_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE moderation_service_user LOGIN PASSWORD 'development';
|
||||||
|
CREATE ROLE gateway_user LOGIN PASSWORD 'development';
|
||||||
|
|
||||||
|
-- Create dedicated schemas for each service
|
||||||
|
CREATE SCHEMA IF NOT EXISTS account_schema AUTHORIZATION account_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS profile_schema AUTHORIZATION profile_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS device_schema AUTHORIZATION device_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS messaging_schema AUTHORIZATION messaging_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS push_schema AUTHORIZATION push_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS webrtc_schema AUTHORIZATION webrtc_service_user;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS moderation_schema AUTHORIZATION moderation_service_user;
|
||||||
|
|
||||||
|
-- Grant basic connection privileges
|
||||||
|
GRANT CONNECT ON DATABASE fromchat TO account_service_user, profile_service_user, device_service_user, messaging_service_user, push_service_user, webrtc_service_user, moderation_service_user;
|
||||||
|
|
||||||
|
-- Grant schema-level privileges (limited to each service's schema)
|
||||||
|
-- Account service
|
||||||
|
GRANT USAGE ON SCHEMA account_schema TO account_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA account_schema TO account_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA account_schema TO account_service_user;
|
||||||
|
|
||||||
|
-- Profile service
|
||||||
|
GRANT USAGE ON SCHEMA profile_schema TO profile_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA profile_schema TO profile_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA profile_schema TO profile_service_user;
|
||||||
|
|
||||||
|
-- Device service
|
||||||
|
GRANT USAGE ON SCHEMA device_schema TO device_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA device_schema TO device_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA device_schema TO device_service_user;
|
||||||
|
|
||||||
|
-- Messaging service
|
||||||
|
GRANT USAGE ON SCHEMA messaging_schema TO messaging_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA messaging_schema TO messaging_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA messaging_schema TO messaging_service_user;
|
||||||
|
|
||||||
|
-- Push service
|
||||||
|
GRANT USAGE ON SCHEMA push_schema TO push_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA push_schema TO push_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA push_schema TO push_service_user;
|
||||||
|
|
||||||
|
-- WebRTC service
|
||||||
|
GRANT USAGE ON SCHEMA webrtc_schema TO webrtc_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA webrtc_schema TO webrtc_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA webrtc_schema TO webrtc_service_user;
|
||||||
|
|
||||||
|
-- Moderation service
|
||||||
|
GRANT USAGE ON SCHEMA moderation_schema TO moderation_service_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA moderation_schema TO moderation_service_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA moderation_schema TO moderation_service_user;
|
||||||
|
|
||||||
|
-- Set default privileges for future objects
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA account_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO account_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA profile_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO profile_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA device_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO device_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA messaging_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO messaging_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA push_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO push_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA webrtc_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO webrtc_service_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA moderation_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO moderation_service_user;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
-- Create tables for FromChat microservices
|
||||||
|
-- This script creates the necessary tables in their respective schemas
|
||||||
|
|
||||||
|
-- Note: In production, tables will be created by Alembic migrations
|
||||||
|
-- This script provides a fallback or reference for manual setup
|
||||||
|
|
||||||
|
-- Account schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS account_schema.users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
hashed_password VARCHAR(255) NOT NULL,
|
||||||
|
salt VARCHAR(64) NOT NULL,
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
bio TEXT,
|
||||||
|
avatar_url VARCHAR(255),
|
||||||
|
is_online BOOLEAN DEFAULT FALSE,
|
||||||
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
verified BOOLEAN DEFAULT FALSE,
|
||||||
|
verification_token VARCHAR(255),
|
||||||
|
reset_token VARCHAR(255),
|
||||||
|
reset_token_expires TIMESTAMP,
|
||||||
|
two_factor_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
two_factor_secret VARCHAR(255),
|
||||||
|
login_attempts INTEGER DEFAULT 0,
|
||||||
|
locked_until TIMESTAMP,
|
||||||
|
public_key TEXT,
|
||||||
|
private_key TEXT,
|
||||||
|
encryption_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
suspended BOOLEAN DEFAULT FALSE,
|
||||||
|
suspension_reason TEXT,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Profile schema tables (references account_schema.users)
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_schema.user_profiles (
|
||||||
|
user_id BIGINT PRIMARY KEY REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
bio TEXT,
|
||||||
|
avatar_url VARCHAR(255),
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Device schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS device_schema.devices (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
device_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
device_name VARCHAR(255),
|
||||||
|
device_type VARCHAR(50),
|
||||||
|
public_key TEXT,
|
||||||
|
signed_prekey TEXT,
|
||||||
|
one_time_prekeys JSONB,
|
||||||
|
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS device_schema.device_session (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
raw_user_agent TEXT,
|
||||||
|
device_name VARCHAR(128),
|
||||||
|
device_type VARCHAR(32),
|
||||||
|
os_name VARCHAR(64),
|
||||||
|
os_version VARCHAR(64),
|
||||||
|
browser_name VARCHAR(64),
|
||||||
|
browser_version VARCHAR(64),
|
||||||
|
brand VARCHAR(64),
|
||||||
|
model VARCHAR(64),
|
||||||
|
session_id VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revoked BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Messaging schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS messaging_schema.messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
sender_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
content_type VARCHAR(50) DEFAULT 'text',
|
||||||
|
encrypted_content TEXT,
|
||||||
|
signature TEXT,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
edited_at TIMESTAMP,
|
||||||
|
edited BOOLEAN DEFAULT FALSE,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE,
|
||||||
|
reply_to_id BIGINT REFERENCES messaging_schema.messages(id),
|
||||||
|
thread_id BIGINT REFERENCES messaging_schema.messages(id),
|
||||||
|
is_public BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messaging_schema.message_recipients (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
|
||||||
|
recipient_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMP,
|
||||||
|
delivered_at TIMESTAMP,
|
||||||
|
encrypted_key TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messaging_schema.message_reactions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
reaction VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Push schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS push_schema.push_subscriptions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||||
|
device_id BIGINT REFERENCES device_schema.devices(id),
|
||||||
|
endpoint VARCHAR(500) NOT NULL,
|
||||||
|
p256dh VARCHAR(255) NOT NULL,
|
||||||
|
auth VARCHAR(255) NOT NULL,
|
||||||
|
user_agent VARCHAR(500),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- WebRTC schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS webrtc_schema.webrtc_sessions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
initiator_id BIGINT REFERENCES account_schema.users(id),
|
||||||
|
participant_ids JSONB NOT NULL,
|
||||||
|
offer JSONB,
|
||||||
|
answer JSONB,
|
||||||
|
ice_candidates JSONB,
|
||||||
|
status VARCHAR(50) DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Moderation schema tables
|
||||||
|
CREATE TABLE IF NOT EXISTS moderation_schema.moderation_actions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
moderator_id BIGINT REFERENCES account_schema.users(id),
|
||||||
|
target_user_id BIGINT REFERENCES account_schema.users(id),
|
||||||
|
target_message_id BIGINT REFERENCES messaging_schema.messages(id),
|
||||||
|
action_type VARCHAR(50) NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grant permissions on sequences (after all tables are created)
|
||||||
|
GRANT USAGE ON SEQUENCE account_schema.users_id_seq TO account_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE device_schema.devices_id_seq TO device_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE device_schema.device_session_id_seq TO account_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE messaging_schema.messages_id_seq TO messaging_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE messaging_schema.message_recipients_id_seq TO messaging_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE messaging_schema.message_reactions_id_seq TO messaging_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE push_schema.push_subscriptions_id_seq TO push_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE webrtc_schema.webrtc_sessions_id_seq TO webrtc_service_user;
|
||||||
|
GRANT USAGE ON SEQUENCE moderation_schema.moderation_actions_id_seq TO moderation_service_user;
|
||||||
+354
-30
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Multi-stage Dockerfile for all FromChat microservices
|
||||||
|
# This combines the base image and all services in one file
|
||||||
|
|
||||||
|
# Base stage - common setup for all services
|
||||||
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpq5 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd --create-home --shell /bin/bash fromchat
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create logs and data directories with proper permissions
|
||||||
|
RUN mkdir -p /app/backend/logs /app/backend/data /app/backend/data/profanity /app/backend/data/uploads /app/backend/data/uploads/pfp && \
|
||||||
|
chown -R fromchat:fromchat /app/backend/logs /app/backend/data && \
|
||||||
|
chmod -R 755 /app/backend/logs /app/backend/data
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY backend/requirements.txt /app/requirements.txt
|
||||||
|
|
||||||
|
# Install Python dependencies with cache mounts
|
||||||
|
RUN --mount=type=cache,target=/home/fromchat/.cache/pip \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy shared modules
|
||||||
|
COPY backend/shared /app/backend/shared/
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY docker/entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER fromchat
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|
||||||
|
# Account service - minimal files only
|
||||||
|
FROM base AS account_service
|
||||||
|
COPY backend/routes/account.py /app/backend/routes/account.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/services/account/main.py /app/backend/services/account/main.py
|
||||||
|
ENV SERVICE_NAME=account
|
||||||
|
|
||||||
|
# Profile service - minimal files only
|
||||||
|
FROM base AS profile_service
|
||||||
|
COPY backend/routes/profile.py /app/backend/routes/profile.py
|
||||||
|
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
|
||||||
|
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
|
||||||
|
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
|
||||||
|
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
|
||||||
|
ENV SERVICE_NAME=profile
|
||||||
|
|
||||||
|
# Device service - minimal files only
|
||||||
|
FROM base AS device_service
|
||||||
|
COPY backend/routes/devices.py /app/backend/routes/devices.py
|
||||||
|
COPY backend/services/device/main.py /app/backend/services/device/main.py
|
||||||
|
ENV SERVICE_NAME=device
|
||||||
|
|
||||||
|
# Messaging service - minimal files only
|
||||||
|
FROM base AS messaging_service
|
||||||
|
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
|
||||||
|
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
|
||||||
|
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
|
||||||
|
ENV SERVICE_NAME=messaging
|
||||||
|
|
||||||
|
# Push service - minimal files only
|
||||||
|
FROM base AS push_service
|
||||||
|
COPY backend/routes/push.py /app/backend/routes/push.py
|
||||||
|
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
||||||
|
COPY backend/services/push/main.py /app/backend/services/push/main.py
|
||||||
|
ENV SERVICE_NAME=push
|
||||||
|
|
||||||
|
# WebRTC service - minimal files only
|
||||||
|
FROM base AS webrtc_service
|
||||||
|
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
|
||||||
|
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
|
||||||
|
ENV SERVICE_NAME=webrtc
|
||||||
|
|
||||||
|
# Moderation service - minimal files only
|
||||||
|
FROM base AS moderation_service
|
||||||
|
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
|
||||||
|
ENV SERVICE_NAME=moderation
|
||||||
|
|
||||||
|
# Gateway service - minimal files only
|
||||||
|
FROM base AS gateway
|
||||||
|
COPY backend/app.py /app/backend/app.py
|
||||||
|
COPY backend/main.py /app/backend/main.py
|
||||||
|
COPY backend/dependencies.py /app/backend/dependencies.py
|
||||||
|
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||||
|
COPY backend/security /app/backend/security/
|
||||||
|
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
|
||||||
|
ENV SERVICE_NAME=gateway
|
||||||
|
|
||||||
|
# Migration runner - needs alembic config and migration files
|
||||||
|
FROM base AS migration_runner
|
||||||
|
# Temporarily switch back to root to manage file permissions
|
||||||
|
USER root
|
||||||
|
COPY backend/alembic.ini /app/backend/alembic.ini
|
||||||
|
COPY backend/alembic /app/backend/alembic/
|
||||||
|
COPY backend/migration.py /app/backend/migration.py
|
||||||
|
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
|
||||||
|
# Clean up problematic migrations as root
|
||||||
|
RUN find /app/backend/alembic/versions -name "*auto_generated_migration_for_schema_*" | xargs rm -f || true
|
||||||
|
# Switch back to fromchat user
|
||||||
|
USER fromchat
|
||||||
|
ENV SERVICE_NAME=migration_runner
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Entrypoint script for FromChat microservices
|
||||||
|
|
||||||
|
# Run the service module
|
||||||
|
exec python -m backend.services.${SERVICE_NAME}.main
|
||||||
@@ -1,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;
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-4
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|
||||||
+2
-4
@@ -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";
|
||||||
|
|
||||||
+19
-27
@@ -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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
+5
-9
@@ -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
Reference in New Issue
Block a user