mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-24 12:05:05 +03:00
Compare commits
3 Commits
@@ -1,40 +1,4 @@
|
|||||||
# Code Cleanup Command
|
View git diff between the branch i specified and HEAD. If no branch is specified,
|
||||||
|
default to main. Identify code that needs to be cleaned up, like debug logs,
|
||||||
## Overview
|
unused variables etc. Think twice before removing or adding code, because you
|
||||||
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.
|
mustn't alter the behavior.
|
||||||
|
|
||||||
## Process
|
|
||||||
1. **Get diff**: Run `git diff <branch>..HEAD` to see changes
|
|
||||||
2. **Identify issues**: Look for code quality problems in the diff
|
|
||||||
3. **Clean up**: Remove only the identified issues
|
|
||||||
4. **Verify**: Ensure no behavioral changes
|
|
||||||
|
|
||||||
## What to Clean Up
|
|
||||||
- **Debug artifacts**: `console.log()`, `debugger`, `print()` statements
|
|
||||||
- **Unused code**: Variables, imports, functions, parameters
|
|
||||||
- **Commented code**: Dead code blocks, TODO comments (unless active)
|
|
||||||
- **Formatting**: Inconsistent spacing, trailing whitespace
|
|
||||||
- **Temporary code**: Test values, hardcoded strings meant to be dynamic
|
|
||||||
- **Redundant code**: Duplicate logic, unnecessary intermediate variables
|
|
||||||
|
|
||||||
## What NOT to Touch
|
|
||||||
- **Functional logic**: Don't change how features work
|
|
||||||
- **API interfaces**: Keep method signatures intact
|
|
||||||
- **Configuration**: Don't modify settings or constants
|
|
||||||
- **Comments**: Keep documentation and explanatory comments
|
|
||||||
- **Error handling**: Don't remove try-catch blocks or validation
|
|
||||||
|
|
||||||
## Safety Rules
|
|
||||||
- ✅ Only modify code that appears in the git diff
|
|
||||||
- ✅ Preserve all existing functionality
|
|
||||||
- ✅ Maintain code readability and structure
|
|
||||||
- ❌ Don't refactor or optimize beyond cleanup
|
|
||||||
- ❌ Don't add new features or improvements
|
|
||||||
- ❌ Don't change variable names or function signatures
|
|
||||||
|
|
||||||
## Example
|
|
||||||
```bash
|
|
||||||
# If user specifies: "/clean-up main"
|
|
||||||
git diff main
|
|
||||||
# Clean only the issues found in this diff
|
|
||||||
```
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Run the command "npm run frontend:typecheck" and fix all errors listed in the command if there's any.
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
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,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
alwaysApply: true
|
description: Documentation rules
|
||||||
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
When documenting this project, follow these rules:
|
When documenting this project, follow these rules:
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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.
|
||||||
@@ -31,7 +30,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);` from `@/utils/utils` in an async function. If the current function is not async,
|
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async,
|
||||||
make it async.
|
make it async.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
@@ -51,11 +50,3 @@ 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,11 +3,6 @@ alwaysApply: true
|
|||||||
---
|
---
|
||||||
When you work with UI:
|
When you work with UI:
|
||||||
|
|
||||||
1. Use MDUI components through the wrapper: `@/utils/material`. If the component you want to use is missing in that wrapper,
|
1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML.
|
||||||
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`.
|
|
||||||
@@ -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: raspberry-pi
|
runs-on: self-hosted
|
||||||
env:
|
env:
|
||||||
HOME: "/root"
|
HOME: "/root"
|
||||||
environment:
|
environment:
|
||||||
@@ -44,8 +44,6 @@ 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: |
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ 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
|
||||||
@@ -575,4 +574,3 @@ 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
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/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
+5
-6
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/__pycache__": true,
|
"**/__pycache__": true,
|
||||||
"**/package-lock.json": true,
|
"**/package-lock.json": true
|
||||||
"**/*.module.scss.d.ts": true,
|
},
|
||||||
"**/.husky/_": true,
|
"github-actions.workflows.pinned.workflows": [],
|
||||||
"**/.venv": true,
|
"github-actions.workflows.pinned.workflows.ignore": true,
|
||||||
"**/node_modules": true
|
"github-actions.workflows.pinned.workflows.ignoreContextAccess": true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Vendored
+1
-16
@@ -33,7 +33,7 @@
|
|||||||
"panel": "shared"
|
"panel": "shared"
|
||||||
},
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "build"
|
"kind": "build",
|
||||||
},
|
},
|
||||||
"isBackground": true
|
"isBackground": true
|
||||||
},
|
},
|
||||||
@@ -69,9 +69,6 @@
|
|||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"focus": false,
|
"focus": false,
|
||||||
"panel": "shared"
|
"panel": "shared"
|
||||||
},
|
|
||||||
"runOptions": {
|
|
||||||
"runOn": "folderOpen"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -87,18 +84,6 @@
|
|||||||
"focus": false,
|
"focus": false,
|
||||||
"panel": "shared"
|
"panel": "shared"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Deploy",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "npm run deploy",
|
|
||||||
"presentation": {
|
|
||||||
"echo": true,
|
|
||||||
"reveal": "always",
|
|
||||||
"focus": true,
|
|
||||||
"panel": "dedicated",
|
|
||||||
"clear": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
FromChat - полностью открытый мессенджер.
|
FromChat - полностью открытый мессенджер.
|
||||||
|
|
||||||
Его можно попробовать на [сайте](http://fromchat.ru).
|
Демо версию можно попробовать на [сайте](http://95.165.0.162:8301).
|
||||||
|
|
||||||
## Содержание:
|
## Содержание:
|
||||||
- [Основные моменты](#highlights)
|
- [Основные моменты](#highlights)
|
||||||
|
|||||||
@@ -1,417 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import sys
|
|
||||||
from getpass import getpass
|
|
||||||
from typing import Iterable, List, Optional, Tuple
|
|
||||||
import readline
|
|
||||||
import httpx
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.panel import Panel
|
|
||||||
from rich.table import Table
|
|
||||||
|
|
||||||
|
|
||||||
class CLIError(Exception):
|
|
||||||
"""Generic CLI error with a human-readable message."""
|
|
||||||
|
|
||||||
|
|
||||||
def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
|
|
||||||
return hmac.new(salt, ikm, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
|
|
||||||
blocks: list[bytes] = []
|
|
||||||
previous = b""
|
|
||||||
counter = 1
|
|
||||||
while len(b"".join(blocks)) < length:
|
|
||||||
previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest()
|
|
||||||
blocks.append(previous)
|
|
||||||
counter += 1
|
|
||||||
return b"".join(blocks)[:length]
|
|
||||||
|
|
||||||
|
|
||||||
def derive_auth_secret(username: str, password: str) -> str:
|
|
||||||
salt = f"fromchat.user:{username}".encode("utf-8")
|
|
||||||
prk = _hkdf_extract(salt, password.encode("utf-8"))
|
|
||||||
okm = _hkdf_expand(prk, b"auth-secret", 32)
|
|
||||||
return base64.b64encode(okm).decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_single_key() -> str:
|
|
||||||
try: # Windows
|
|
||||||
import msvcrt # type: ignore
|
|
||||||
|
|
||||||
ch = msvcrt.getch()
|
|
||||||
return ch.decode("utf-8", errors="ignore").lower()
|
|
||||||
except ImportError:
|
|
||||||
import termios
|
|
||||||
import tty
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setraw(fd)
|
|
||||||
ch = sys.stdin.read(1)
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
return ch.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminCLI:
|
|
||||||
def __init__(self, api_url: str) -> None:
|
|
||||||
self.console = Console()
|
|
||||||
self.api_url = api_url.rstrip("/")
|
|
||||||
self.client = httpx.Client(base_url=self.api_url, timeout=30.0)
|
|
||||||
self.username: Optional[str] = None
|
|
||||||
self.token: Optional[str] = None
|
|
||||||
|
|
||||||
# --------------------------- HTTP helpers --------------------------- #
|
|
||||||
def _auth_headers(self) -> dict:
|
|
||||||
headers: dict = {}
|
|
||||||
if self.token:
|
|
||||||
headers["Authorization"] = f"Bearer {self.token}"
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def _request(self, method: str, path: str, *, auth: bool = True, **kwargs) -> httpx.Response:
|
|
||||||
rel_path = path.lstrip("/")
|
|
||||||
headers = kwargs.pop("headers", {})
|
|
||||||
if auth:
|
|
||||||
headers.update(self._auth_headers())
|
|
||||||
response = self.client.request(method, rel_path, headers=headers, **kwargs)
|
|
||||||
if response.status_code >= 400:
|
|
||||||
detail = ""
|
|
||||||
try:
|
|
||||||
payload = response.json()
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
detail = payload.get("detail") or payload.get("message") or ""
|
|
||||||
except Exception:
|
|
||||||
detail = response.text
|
|
||||||
message = f"{response.status_code} {response.reason_phrase}"
|
|
||||||
if detail:
|
|
||||||
message = f"{message}: {detail}"
|
|
||||||
raise CLIError(message.strip())
|
|
||||||
return response
|
|
||||||
|
|
||||||
# --------------------------- CLI primitives ------------------------- #
|
|
||||||
def _require_auth(self) -> None:
|
|
||||||
if not self.token:
|
|
||||||
raise CLIError("You must login before running this command.")
|
|
||||||
|
|
||||||
def _resolve_user(self, identifier: str) -> dict:
|
|
||||||
self._require_auth()
|
|
||||||
if identifier.isdigit():
|
|
||||||
response = self._request("GET", f"user/id/{identifier}")
|
|
||||||
else:
|
|
||||||
response = self._request("GET", f"user/{identifier.replace('@', '')}")
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def _confirm(self, prompt: str) -> bool:
|
|
||||||
self.console.print(f"[bold yellow]{prompt}[/] [green](y)[/] / [red](n)[/]: ", end="")
|
|
||||||
choice = _read_single_key()
|
|
||||||
self.console.print("") # move to next line
|
|
||||||
return choice == "y"
|
|
||||||
|
|
||||||
def _render_user(self, user: dict) -> None:
|
|
||||||
table = Table(show_header=False)
|
|
||||||
table.add_row("ID", str(user.get("id")))
|
|
||||||
table.add_row("Username", user.get("username", ""))
|
|
||||||
table.add_row("Display name", user.get("display_name", ""))
|
|
||||||
table.add_row("Verified", "✅" if user.get("verified") else "❌")
|
|
||||||
if user.get("suspended"):
|
|
||||||
table.add_row("Suspended", f"🚫 ({user.get('suspension_reason') or 'no reason'})")
|
|
||||||
else:
|
|
||||||
table.add_row("Suspended", "✅ Active")
|
|
||||||
self.console.print(table)
|
|
||||||
|
|
||||||
# --------------------------- Commands ------------------------------- #
|
|
||||||
def cmd_login(self, args: List[str]) -> None:
|
|
||||||
if args:
|
|
||||||
username = args[0]
|
|
||||||
else:
|
|
||||||
username = self.console.input("[bold cyan]Username[/]: ").strip()
|
|
||||||
if not username:
|
|
||||||
raise CLIError("Username is required.")
|
|
||||||
|
|
||||||
password = getpass("Password: ")
|
|
||||||
derived_password = derive_auth_secret(username, password)
|
|
||||||
payload = {"username": username, "password": derived_password}
|
|
||||||
response = self._request("POST", "login", json=payload, auth=False)
|
|
||||||
body = response.json()
|
|
||||||
token = body.get("token")
|
|
||||||
if not token:
|
|
||||||
raise CLIError("Authentication succeeded but token was not returned.")
|
|
||||||
self.token = token
|
|
||||||
self.username = username
|
|
||||||
self.console.print("[bold green]Login successful.[/]")
|
|
||||||
|
|
||||||
def cmd_suspend(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: suspend <user_id|username>")
|
|
||||||
identifier = args[0]
|
|
||||||
user = self._resolve_user(identifier)
|
|
||||||
self.console.print(Panel.fit("[bold red]Suspend user[/]", style="red"))
|
|
||||||
self._render_user(user)
|
|
||||||
reason = self.console.input("[bold yellow]Reason (press Enter to leave empty)[/]: ").strip()
|
|
||||||
if not self._confirm(f"Confirm suspension of {user.get('username')}?"):
|
|
||||||
self.console.print("[yellow]Suspension cancelled.[/]")
|
|
||||||
return
|
|
||||||
payload = {"reason": reason}
|
|
||||||
self._request("POST", f"user/{user['id']}/suspend", json=payload)
|
|
||||||
log_reason = reason or "no reason provided"
|
|
||||||
self.console.print(f"[bold red]User {user['username']} suspended ({log_reason}).[/]")
|
|
||||||
|
|
||||||
def cmd_unsuspend(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: unsuspend <user_id|username>")
|
|
||||||
identifier = args[0]
|
|
||||||
user = self._resolve_user(identifier)
|
|
||||||
self.console.print(Panel.fit("[bold green]Unsuspend user[/]", style="green"))
|
|
||||||
self._render_user(user)
|
|
||||||
if not self._confirm(f"Unsuspend {user.get('username')}?"):
|
|
||||||
self.console.print("[yellow]Unsuspension cancelled.[/]")
|
|
||||||
return
|
|
||||||
self._request("POST", f"user/{user['id']}/unsuspend")
|
|
||||||
self.console.print(f"[bold green]User {user['username']} unsuspended.[/]")
|
|
||||||
|
|
||||||
def cmd_block_word(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: block-word <word or phrase> [additional words...]")
|
|
||||||
self._require_auth()
|
|
||||||
words = args
|
|
||||||
response = self._request("POST", "moderation/blocklist", json={"words": words})
|
|
||||||
data = response.json()
|
|
||||||
added = data.get("added", [])
|
|
||||||
current = data.get("words", [])
|
|
||||||
if added:
|
|
||||||
self.console.print(f"[bold green]Added {len(added)} entr{'y' if len(added)==1 else 'ies'} to blocklist.[/]")
|
|
||||||
else:
|
|
||||||
self.console.print("[yellow]No new words added.[/]")
|
|
||||||
self.console.print(f"Blocklist size: {len(current)}")
|
|
||||||
|
|
||||||
def cmd_list_users(self) -> None:
|
|
||||||
self._require_auth()
|
|
||||||
payload = self._request("GET", "user/list").json()
|
|
||||||
users = payload.get("users", [])
|
|
||||||
table = Table(title="Users", show_lines=False)
|
|
||||||
table.add_column("ID")
|
|
||||||
table.add_column("Username")
|
|
||||||
table.add_column("Display name")
|
|
||||||
table.add_column("Suspended")
|
|
||||||
for user in users:
|
|
||||||
table.add_row(
|
|
||||||
str(user.get("id")),
|
|
||||||
user.get("username", ""),
|
|
||||||
user.get("display_name", ""),
|
|
||||||
"🚫" if user.get("suspended") else "✅",
|
|
||||||
)
|
|
||||||
self.console.print(table)
|
|
||||||
|
|
||||||
def cmd_user(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: user <user_id|username>")
|
|
||||||
user = self._resolve_user(args[0])
|
|
||||||
self._render_user(user)
|
|
||||||
|
|
||||||
def cmd_delete(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: delete <user_id|username>")
|
|
||||||
user = self._resolve_user(args[0])
|
|
||||||
self.console.print(Panel.fit("[bold red]Delete user[/]", style="red"))
|
|
||||||
self._render_user(user)
|
|
||||||
if not self._confirm(f"Permanently delete {user.get('username')}?"):
|
|
||||||
self.console.print("[yellow]Deletion cancelled.[/]")
|
|
||||||
return
|
|
||||||
self._request("POST", f"user/{user['id']}/delete")
|
|
||||||
self.console.print(f"[bold red]User {user['username']} deleted.[/]")
|
|
||||||
|
|
||||||
def cmd_unblock_word(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: unblock-word <word or phrase> [additional words...]")
|
|
||||||
self._require_auth()
|
|
||||||
response = self._request("DELETE", "moderation/blocklist", json={"words": args})
|
|
||||||
data = response.json()
|
|
||||||
removed = data.get("removed", [])
|
|
||||||
current = data.get("words", [])
|
|
||||||
if removed:
|
|
||||||
self.console.print(f"[bold green]Removed {len(removed)} entr{'y' if len(removed)==1 else 'ies'} from blocklist.[/]")
|
|
||||||
else:
|
|
||||||
self.console.print("[yellow]No matching words removed.[/]")
|
|
||||||
self.console.print(f"Blocklist size: {len(current)}")
|
|
||||||
|
|
||||||
def cmd_verify(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: verify <user_id|username>")
|
|
||||||
user = self._resolve_user(args[0])
|
|
||||||
if user.get("verified"):
|
|
||||||
self.console.print(f"[yellow]{user['username']} is already verified.[/]")
|
|
||||||
return
|
|
||||||
self._request("POST", f"user/{user['id']}/verify")
|
|
||||||
self.console.print(f"[bold green]{user['username']} marked as verified.[/]")
|
|
||||||
|
|
||||||
def cmd_unverify(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: unverify <user_id|username>")
|
|
||||||
user = self._resolve_user(args[0])
|
|
||||||
if not user.get("verified"):
|
|
||||||
self.console.print(f"[yellow]{user['username']} is already unverified.[/]")
|
|
||||||
return
|
|
||||||
self._request("POST", f"user/{user['id']}/verify")
|
|
||||||
self.console.print(f"[bold green]{user['username']} is now unverified.[/]")
|
|
||||||
|
|
||||||
def cmd_list_blocklist(self) -> None:
|
|
||||||
self._require_auth()
|
|
||||||
response = self._request("GET", "moderation/blocklist")
|
|
||||||
words = response.json().get("words", [])
|
|
||||||
if not words:
|
|
||||||
self.console.print("[cyan]Blocklist is empty.[/]")
|
|
||||||
return
|
|
||||||
table = Table(title="Blocked Words", show_lines=True)
|
|
||||||
table.add_column("Word / Phrase")
|
|
||||||
for entry in words:
|
|
||||||
table.add_row(entry)
|
|
||||||
self.console.print(table)
|
|
||||||
|
|
||||||
def cmd_unblock_ip(self, args: List[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
raise CLIError("Usage: unblock-ip <ip_address>")
|
|
||||||
self._require_auth()
|
|
||||||
ip = args[0].strip()
|
|
||||||
if not ip:
|
|
||||||
raise CLIError("IP address cannot be empty")
|
|
||||||
response = self._request("POST", "moderation/unblock-ip", json={"ip": ip})
|
|
||||||
data = response.json()
|
|
||||||
message = data.get("message", "IP unblocked")
|
|
||||||
self.console.print(f"[bold green]{message}[/]")
|
|
||||||
|
|
||||||
def cmd_clear_all_rate_limits(self) -> None:
|
|
||||||
"""Clear all rate limit entries. Use with caution."""
|
|
||||||
self._require_auth()
|
|
||||||
if not self._confirm("Clear ALL rate limit entries? This affects all IPs."):
|
|
||||||
self.console.print("[yellow]Operation cancelled.[/]")
|
|
||||||
return
|
|
||||||
response = self._request("POST", "moderation/clear-all-rate-limits")
|
|
||||||
data = response.json()
|
|
||||||
message = data.get("message", "Rate limits cleared")
|
|
||||||
self.console.print(f"[bold green]{message}[/]")
|
|
||||||
|
|
||||||
def cmd_help(self) -> None:
|
|
||||||
cmds = {
|
|
||||||
"login [username]": "Authenticate as owner/admin.",
|
|
||||||
"suspend <user>": "Suspend account (alias: ban).",
|
|
||||||
"unsuspend <user>": "Unsuspend account (alias: unban).",
|
|
||||||
"delete <user>": "Permanently delete the user account.",
|
|
||||||
"verify <user>": "Mark user as verified.",
|
|
||||||
"unverify <user>": "Remove verification flag.",
|
|
||||||
"block-word <words>": "Add words/phrases to chat filter.",
|
|
||||||
"unblock-word <words>": "Remove words/phrases from filter.",
|
|
||||||
"blocklist": "Show current blocklist.",
|
|
||||||
"unblock-ip <ip>": "Unblock an IP address from rate limiting.",
|
|
||||||
"clear-all-rate-limits": "Clear all rate limit entries (use with caution).",
|
|
||||||
"list": "List all users.",
|
|
||||||
"user <user>": "Show detailed user information.",
|
|
||||||
"whoami": "Display current session context.",
|
|
||||||
"help": "Show this help panel.",
|
|
||||||
"exit": "Quit the CLI.",
|
|
||||||
}
|
|
||||||
table = Table(title="Available Commands")
|
|
||||||
table.add_column("Command", style="cyan")
|
|
||||||
table.add_column("Description", style="white")
|
|
||||||
for cmd, desc in cmds.items():
|
|
||||||
table.add_row(cmd, desc)
|
|
||||||
self.console.print(table)
|
|
||||||
|
|
||||||
def cmd_whoami(self) -> None:
|
|
||||||
if not self.token:
|
|
||||||
self.console.print("[yellow]Not authenticated.[/]")
|
|
||||||
return
|
|
||||||
self.console.print(f"[green]Logged in as[/] [bold]{self.username}[/] ({self.api_url})")
|
|
||||||
|
|
||||||
# --------------------------- Main loop ------------------------------ #
|
|
||||||
def run(self) -> None:
|
|
||||||
self.console.print(Panel.fit("[bold magenta]FromChat Admin CLI[/]", style="magenta"))
|
|
||||||
while True:
|
|
||||||
prompt_identity = self.username or "guest"
|
|
||||||
try:
|
|
||||||
prompt_str = f"\033[36m{prompt_identity}\033[0m \033[1m>\033[0m "
|
|
||||||
raw = input(prompt_str).strip()
|
|
||||||
except (KeyboardInterrupt, EOFError):
|
|
||||||
self.console.print("\n[red]Exiting...[/]")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
parts = shlex.split(raw)
|
|
||||||
except ValueError as exc:
|
|
||||||
self.console.print(f"[red]Parse error:[/] {exc}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
command = parts[0].lstrip("/").lower()
|
|
||||||
args = parts[1:]
|
|
||||||
|
|
||||||
if command in {"exit", "quit"}:
|
|
||||||
self.console.print("[red]Goodbye.[/]")
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
if command == "login":
|
|
||||||
self.cmd_login(args)
|
|
||||||
elif command in {"suspend", "ban"}:
|
|
||||||
self.cmd_suspend(args)
|
|
||||||
elif command in {"unsuspend", "unban"}:
|
|
||||||
self.cmd_unsuspend(args)
|
|
||||||
elif command == "block-word":
|
|
||||||
self.cmd_block_word(args)
|
|
||||||
elif command == "unblock-word":
|
|
||||||
self.cmd_unblock_word(args)
|
|
||||||
elif command == "blocklist":
|
|
||||||
self.cmd_list_blocklist()
|
|
||||||
elif command == "unblock-ip":
|
|
||||||
self.cmd_unblock_ip(args)
|
|
||||||
elif command == "clear-all-rate-limits":
|
|
||||||
self.cmd_clear_all_rate_limits()
|
|
||||||
elif command == "verify":
|
|
||||||
self.cmd_verify(args)
|
|
||||||
elif command == "unverify":
|
|
||||||
self.cmd_unverify(args)
|
|
||||||
elif command in {"delete", "remove"}:
|
|
||||||
self.cmd_delete(args)
|
|
||||||
elif command == "list":
|
|
||||||
self.cmd_list_users()
|
|
||||||
elif command == "user":
|
|
||||||
self.cmd_user(args)
|
|
||||||
elif command == "help":
|
|
||||||
self.cmd_help()
|
|
||||||
elif command == "whoami":
|
|
||||||
self.cmd_whoami()
|
|
||||||
else:
|
|
||||||
self.console.print("[yellow]Unknown command. Type /help for a list of commands.[/]")
|
|
||||||
except CLIError as err:
|
|
||||||
self.console.print(f"[red]Error:[/] {err}")
|
|
||||||
except httpx.RequestError as err:
|
|
||||||
self.console.print(f"[red]Network error:[/] {err}")
|
|
||||||
|
|
||||||
self.client.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[Iterable[str]] = None) -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="FromChat Emergency Admin CLI")
|
|
||||||
parser.add_argument(
|
|
||||||
"--api-url",
|
|
||||||
default=os.getenv("FC_ADMIN_API_URL", "http://127.0.0.1:8300"),
|
|
||||||
help="Base API URL for the FromChat backend (default: %(default)s).",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
||||||
cli = AdminCLI(args.api_url)
|
|
||||||
cli.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
||||||
+9
-125
@@ -1,30 +1,17 @@
|
|||||||
import asyncio
|
from fastapi import FastAPI
|
||||||
import time
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
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
|
||||||
from routes import account, messaging, profile, push, webrtc, devices, moderation
|
|
||||||
import logging
|
|
||||||
from models import User
|
|
||||||
from constants import OWNER_USERNAME
|
|
||||||
from utils import get_client_ip
|
|
||||||
|
|
||||||
from db import POOL_CONFIG, SessionLocal
|
from routes import account, messaging, profile, push
|
||||||
from logging_config import access_logger # noqa: F401 - ensure loggers configured
|
|
||||||
from security.audit import log_access
|
|
||||||
from security.rate_limit import limiter
|
|
||||||
from slowapi.middleware import SlowAPIMiddleware
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup - run migration in separate process to avoid logging interference
|
# Startup - run migration in separate process to avoid logging interference
|
||||||
try:
|
try:
|
||||||
logger.info("Starting database migration check...")
|
print("Starting database migration check...")
|
||||||
# Run migration in a separate process
|
# Run migration in a separate process
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
@@ -33,125 +20,25 @@ async def lifespan(app: FastAPI):
|
|||||||
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
||||||
],
|
],
|
||||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to run database migrations: {e}")
|
print(f"Failed to run database migrations: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
|
||||||
with SessionLocal() as db:
|
|
||||||
owner = db.query(User).filter(User.username == OWNER_USERNAME).first()
|
|
||||||
if owner and not owner.verified:
|
|
||||||
owner.verified = True
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Owner user '{OWNER_USERNAME}' has been verified")
|
|
||||||
elif owner and owner.verified:
|
|
||||||
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to ensure owner verification: {e}")
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
|
|
||||||
POOL_CONFIG["pool_size"],
|
|
||||||
POOL_CONFIG["max_overflow"],
|
|
||||||
POOL_CONFIG["pool_timeout"],
|
|
||||||
POOL_CONFIG["pool_recycle"],
|
|
||||||
POOL_CONFIG["pool_pre_ping"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start the messaging cleanup task
|
|
||||||
try:
|
|
||||||
from routes.messaging import messagingManager
|
|
||||||
messagingManager.start_cleanup_task()
|
|
||||||
logger.info("Messaging cleanup task started")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start messaging cleanup task: {e}")
|
|
||||||
|
|
||||||
# Reset all rate limits on startup to ensure clean state
|
|
||||||
# This prevents rate limits from persisting across restarts
|
|
||||||
try:
|
|
||||||
from security.rate_limit import reset_all_rate_limits
|
|
||||||
cleared = reset_all_rate_limits()
|
|
||||||
if cleared > 0:
|
|
||||||
logger.info(f"Cleared {cleared} rate limit entries on startup")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
|
||||||
|
|
||||||
# Start the rate limit cleanup task
|
|
||||||
try:
|
|
||||||
from security.rate_limit import start_rate_limit_cleanup_task
|
|
||||||
cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task())
|
|
||||||
logger.info("Rate limit cleanup task started")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start rate limit cleanup task: {e}")
|
|
||||||
cleanup_task = None
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown - cancel cleanup task if it exists
|
# Shutdown (if needed in the future)
|
||||||
if cleanup_task:
|
# logger.info("Application shutdown")
|
||||||
cleanup_task.cancel()
|
|
||||||
try:
|
|
||||||
await cleanup_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Инициализация 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):
|
|
||||||
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=["*"],
|
||||||
@@ -162,6 +49,3 @@ app.include_router(account.router)
|
|||||||
app.include_router(messaging.router)
|
app.include_router(messaging.router)
|
||||||
app.include_router(profile.router)
|
app.include_router(profile.router)
|
||||||
app.include_router(push.router, prefix="/push")
|
app.include_router(push.router, prefix="/push")
|
||||||
app.include_router(webrtc.router, prefix="/webrtc")
|
|
||||||
app.include_router(devices.router, prefix="/devices")
|
|
||||||
app.include_router(moderation.router)
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
DATABASE_URL = "sqlite:///./data/database.db"
|
DATABASE_URL = "sqlite:///./data/database.db"
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
# Token inactivity expiration - token expires if not used for this duration
|
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||||
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")
|
||||||
|
|
||||||
|
|||||||
+1
-31
@@ -6,35 +6,5 @@ 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)
|
||||||
|
|
||||||
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
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)
|
||||||
+4
-70
@@ -1,9 +1,8 @@
|
|||||||
from datetime import datetime, timedelta
|
from fastapi import Depends, HTTPException, status
|
||||||
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 verify_token
|
from utils import *
|
||||||
from models import User, DeviceSession
|
from models import *
|
||||||
from db import SessionLocal
|
from db import SessionLocal
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
@@ -18,9 +17,8 @@ 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)
|
payload = verify_token(token)
|
||||||
@@ -37,68 +35,4 @@ def get_current_user(
|
|||||||
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:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Session revoked or not found",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if session has been inactive for too long (sliding expiration)
|
|
||||||
from constants import TOKEN_INACTIVITY_EXPIRE_HOURS
|
|
||||||
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
|
|
||||||
if device_session.last_seen < inactivity_threshold:
|
|
||||||
# Session expired due to inactivity - revoke it
|
|
||||||
device_session.revoked = True
|
|
||||||
db.commit()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Session expired due to inactivity",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Touch last_seen on valid session (sliding expiration - extends token life)
|
|
||||||
device_session.last_seen = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Check if user is suspended
|
|
||||||
if user.suspended:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Account suspended",
|
|
||||||
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if user is deleted
|
|
||||||
if user.deleted:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Account deleted",
|
|
||||||
)
|
|
||||||
|
|
||||||
request.state.current_user = user
|
|
||||||
request.state.session_id = session_id
|
|
||||||
|
|
||||||
return user
|
return user
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from pathlib import Path
|
|
||||||
from threading import RLock
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
LOGS_DIR = Path(__file__).resolve().parent / "logs"
|
|
||||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
class HumanReadableFileHandler(RotatingFileHandler):
|
|
||||||
def __init__(self, filename: Path, level: int) -> None:
|
|
||||||
super().__init__(filename, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", delay=True)
|
|
||||||
self.level = level
|
|
||||||
self._lock = RLock()
|
|
||||||
self._last_date: str | None = None
|
|
||||||
self._previous_entry: str | None = None
|
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord) -> None:
|
|
||||||
try:
|
|
||||||
message = record.getMessage().strip()
|
|
||||||
if not message:
|
|
||||||
return
|
|
||||||
|
|
||||||
timestamp = datetime.fromtimestamp(record.created)
|
|
||||||
date_str = timestamp.strftime("%d.%m.%Y")
|
|
||||||
time_str = timestamp.strftime("%H:%M:%S")
|
|
||||||
lines = [line.rstrip() for line in message.splitlines() if line.strip()]
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
if self.stream is None:
|
|
||||||
self.stream = self._open()
|
|
||||||
|
|
||||||
if self._last_date != date_str:
|
|
||||||
if self._last_date is not None:
|
|
||||||
self.stream.write("\n")
|
|
||||||
separator = "-" * 11
|
|
||||||
self.stream.write(f"\n\n{separator}\n{date_str}\n{separator}\n\n")
|
|
||||||
self._last_date = date_str
|
|
||||||
|
|
||||||
entry_lines: list[str] = []
|
|
||||||
if lines:
|
|
||||||
entry_lines.append(f"{time_str} {lines[0]}")
|
|
||||||
for line in lines[1:]:
|
|
||||||
if line.startswith("|"):
|
|
||||||
entry_lines.append(f" {line}")
|
|
||||||
else:
|
|
||||||
entry_lines.append(f" ↳ {line}")
|
|
||||||
else:
|
|
||||||
entry_lines.append(time_str)
|
|
||||||
entry_text = "\n".join(entry_lines)
|
|
||||||
if entry_text == self._previous_entry:
|
|
||||||
return
|
|
||||||
self.stream.write(entry_text + "\n")
|
|
||||||
self._previous_entry = entry_text
|
|
||||||
self.flush()
|
|
||||||
except Exception:
|
|
||||||
self.handleError(record)
|
|
||||||
|
|
||||||
|
|
||||||
_HANDLED_FILES: Dict[str, Path] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_logger(name: str, filename: str, level: int = logging.INFO) -> logging.Logger:
|
|
||||||
logger = logging.getLogger(name)
|
|
||||||
target_path = LOGS_DIR / filename
|
|
||||||
|
|
||||||
if _HANDLED_FILES.get(name) == target_path:
|
|
||||||
return logger
|
|
||||||
|
|
||||||
logger.handlers.clear()
|
|
||||||
|
|
||||||
handler = HumanReadableFileHandler(target_path, level)
|
|
||||||
handler.setLevel(level)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(level)
|
|
||||||
logger.propagate = False
|
|
||||||
|
|
||||||
_HANDLED_FILES[name] = target_path
|
|
||||||
return logger
|
|
||||||
|
|
||||||
|
|
||||||
security_logger = _configure_logger("security", "security.log")
|
|
||||||
public_chat_logger = _configure_logger("public_chat", "public-chat.log")
|
|
||||||
dm_logger = _configure_logger("dm", "dm.log")
|
|
||||||
access_logger = _configure_logger("access", "access.log")
|
|
||||||
|
|
||||||
+6
-94
@@ -102,21 +102,6 @@ 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:
|
||||||
@@ -131,40 +116,6 @@ 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
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
logger.info("Database migrations completed successfully after reset.")
|
|
||||||
elif "no such table" in str(upgrade_error).lower():
|
|
||||||
logger.info("Database tables missing - resetting migration state...")
|
|
||||||
# Clear the alembic_version table and start fresh
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
with engine.connect() as connection:
|
|
||||||
from sqlalchemy import text
|
|
||||||
connection.execute(text("DELETE FROM alembic_version"))
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
# Try upgrade again
|
# 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.")
|
||||||
@@ -183,32 +134,14 @@ 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()
|
||||||
|
|
||||||
# Check if we have existing migration files
|
# Remove any existing migration files to start fresh
|
||||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
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('__')]
|
for file in os.listdir(versions_dir):
|
||||||
|
if file.endswith('.py') and not file.startswith('__'):
|
||||||
|
os.remove(os.path.join(versions_dir, file))
|
||||||
|
|
||||||
if migration_files:
|
# Create a completely fresh migration with full schema
|
||||||
# We have migration files, just fix the alembic_version table
|
logger.info("Creating fresh migration with complete schema...")
|
||||||
logger.info("Found existing migration files, fixing alembic_version table...")
|
|
||||||
latest_migration = max(migration_files)
|
|
||||||
migration_path = os.path.join(versions_dir, latest_migration)
|
|
||||||
|
|
||||||
with open(migration_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
import re
|
|
||||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
|
||||||
if revision_match:
|
|
||||||
revision_id = revision_match.group(1)
|
|
||||||
logger.info(f"Setting alembic_version to {revision_id}")
|
|
||||||
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
# Try upgrade again
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
logger.info("Automated recovery completed successfully.")
|
|
||||||
else:
|
|
||||||
# No migration files, create fresh ones
|
|
||||||
logger.info("No migration files found, creating fresh migration...")
|
|
||||||
_create_complete_migration(alembic_cfg)
|
_create_complete_migration(alembic_cfg)
|
||||||
|
|
||||||
# Run the migration
|
# Run the migration
|
||||||
@@ -525,28 +458,7 @@ def _create_database_directly():
|
|||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||||
)
|
)
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
# Get the correct revision ID from existing migration files
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
|
||||||
|
|
||||||
if migration_files:
|
|
||||||
latest_migration = max(migration_files)
|
|
||||||
migration_path = os.path.join(versions_dir, latest_migration)
|
|
||||||
|
|
||||||
with open(migration_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
import re
|
|
||||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
|
||||||
if revision_match:
|
|
||||||
revision_id = revision_match.group(1)
|
|
||||||
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
|
||||||
else:
|
|
||||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||||
else:
|
|
||||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
|
||||||
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-114
@@ -13,17 +13,12 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
@@ -71,55 +66,6 @@ class CryptoBackup(Base):
|
|||||||
blob_json = Column(Text, nullable=False)
|
blob_json = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class SignalPreKeyBundle(Base):
|
|
||||||
__tablename__ = "signal_prekey_bundle"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
|
||||||
bundle_json = Column(Text, nullable=False) # JSON string of PreKeyBundleData (identity, signed prekey, registration ID)
|
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
|
||||||
|
|
||||||
|
|
||||||
class SignalPreKey(Base):
|
|
||||||
__tablename__ = "signal_prekey"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
|
||||||
prekey_id = Column(Integer, nullable=False) # The prekey ID from the client
|
|
||||||
public_key = Column(Text, nullable=False) # Base64 encoded public key
|
|
||||||
used = Column(Boolean, default=False, nullable=False, index=True) # Whether this prekey has been used
|
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint('user_id', 'prekey_id', name='_user_prekey_uc'),)
|
|
||||||
|
|
||||||
|
|
||||||
class SignalSession(Base):
|
|
||||||
__tablename__ = "signal_session"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
|
||||||
recipient_id = Column(Integer, nullable=False, index=True) # The other user in the session
|
|
||||||
device_id = Column(Integer, default=1, nullable=False) # Device ID (always 1 for now)
|
|
||||||
encrypted_session_data = Column(Text, nullable=False) # Encrypted session record (JSON with salt, iv, ciphertext)
|
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_uc'),)
|
|
||||||
|
|
||||||
|
|
||||||
class SentMessagePlaintext(Base):
|
|
||||||
"""Stores encrypted plaintexts of sent messages for history display"""
|
|
||||||
__tablename__ = "sent_message_plaintext"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
|
||||||
message_id = Column(Integer, nullable=False, index=True) # DM envelope ID
|
|
||||||
recipient_id = Column(Integer, nullable=False, index=True) # The recipient of the message
|
|
||||||
encrypted_data = Column(Text, nullable=False) # Encrypted plaintext (JSON with salt, iv, ciphertext)
|
|
||||||
created_at = Column(DateTime, default=datetime.now, index=True)
|
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint('user_id', 'message_id', name='_user_message_uc'),)
|
|
||||||
|
|
||||||
|
|
||||||
class DMEnvelope(Base):
|
class DMEnvelope(Base):
|
||||||
__tablename__ = "dm_envelope"
|
__tablename__ = "dm_envelope"
|
||||||
|
|
||||||
@@ -195,37 +141,6 @@ 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
|
||||||
@@ -234,17 +149,10 @@ 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
|
||||||
@@ -270,16 +178,11 @@ 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 | None
|
last_seen: datetime
|
||||||
created_at: datetime | None
|
created_at: datetime
|
||||||
verified: bool
|
|
||||||
suspended: bool
|
|
||||||
suspension_reason: str | None
|
|
||||||
deleted: bool
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -332,20 +235,5 @@ 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)
|
||||||
@@ -107,7 +107,7 @@ class PushNotificationService:
|
|||||||
payload = {
|
payload = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"body": body,
|
"body": body,
|
||||||
"icon": icon or "about:blank",
|
"icon": icon or "/logo.png",
|
||||||
"tag": f"message_{user_id}",
|
"tag": f"message_{user_id}",
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,3 @@ 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
|
|
||||||
|
|||||||
+18
-776
@@ -1,44 +1,15 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from collections import defaultdict, deque
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
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 constants import OWNER_USERNAME
|
||||||
from dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession, SentMessagePlaintext
|
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
|
||||||
from utils import create_token, get_password_hash, verify_password, get_client_ip
|
from utils import create_token, get_password_hash, verify_password
|
||||||
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
from validation import is_valid_password, is_valid_username
|
||||||
import os
|
|
||||||
|
|
||||||
from security.audit import log_security
|
|
||||||
from security.profanity import contains_profanity
|
|
||||||
from security.rate_limit import rate_limit_per_ip
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
|
||||||
_FAILED_ATTEMPT_THRESHOLD = 5
|
|
||||||
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_failed_login(identifier: str) -> bool:
|
|
||||||
now = time.time()
|
|
||||||
attempts = _failed_login_attempts[identifier]
|
|
||||||
attempts.append(now)
|
|
||||||
|
|
||||||
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
|
|
||||||
attempts.popleft()
|
|
||||||
|
|
||||||
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_failed_logins(identifier: str) -> None:
|
|
||||||
_failed_login_attempts.pop(identifier, None)
|
|
||||||
|
|
||||||
def convert_user(user: User) -> dict:
|
def convert_user(user: User) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
@@ -46,14 +17,9 @@ def convert_user(user: User) -> dict:
|
|||||||
"last_seen": user.last_seen.isoformat(),
|
"last_seen": user.last_seen.isoformat(),
|
||||||
"online": user.online,
|
"online": user.online,
|
||||||
"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,
|
||||||
"admin": user.username == OWNER_USERNAME,
|
"admin": user.username == OWNER_USERNAME
|
||||||
"verified": user.verified,
|
|
||||||
"suspended": user.suspended or False,
|
|
||||||
"suspension_reason": user.suspension_reason,
|
|
||||||
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/check_auth")
|
@router.get("/check_auth")
|
||||||
@@ -66,95 +32,20 @@ def check_auth(current_user: User = Depends(get_current_user)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
@rate_limit_per_ip("5/minute")
|
def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||||
def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)):
|
user = db.query(User).filter(User.username == request.username.strip()).first()
|
||||||
username = login_request.username.strip()
|
|
||||||
client_ip = get_client_ip(request)
|
|
||||||
raw_ua = request.headers.get("user-agent")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.username == username).first()
|
if not user or not verify_password(request.password.strip(), user.password_hash):
|
||||||
|
|
||||||
if not user or not verify_password(login_request.password.strip(), user.password_hash):
|
|
||||||
log_security(
|
|
||||||
"login_failed",
|
|
||||||
severity="warning",
|
|
||||||
username=username,
|
|
||||||
ip=client_ip,
|
|
||||||
reason="invalid_credentials",
|
|
||||||
)
|
|
||||||
identifiers = [f"user:{username}"]
|
|
||||||
if client_ip:
|
|
||||||
identifiers.append(f"ip:{client_ip}")
|
|
||||||
|
|
||||||
suspicious = False
|
|
||||||
for identifier in identifiers:
|
|
||||||
if _record_failed_login(identifier):
|
|
||||||
suspicious = True
|
|
||||||
|
|
||||||
if suspicious:
|
|
||||||
total_failures = {
|
|
||||||
identifier: len(_failed_login_attempts.get(identifier, []))
|
|
||||||
for identifier in identifiers
|
|
||||||
}
|
|
||||||
log_security(
|
|
||||||
"auth_bruteforce_detected",
|
|
||||||
severity="warning",
|
|
||||||
username=username,
|
|
||||||
ip=client_ip,
|
|
||||||
failures=total_failures,
|
|
||||||
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Неверное имя пользователя или пароль"
|
detail="Неверное имя пользователя или пароль"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create device session and embed into JWT
|
|
||||||
raw_ua = request.headers.get("user-agent")
|
|
||||||
device_name = request.headers.get("x-device-name")
|
|
||||||
ua = parse_ua(raw_ua or "")
|
|
||||||
session_id = uuid.uuid4().hex
|
|
||||||
|
|
||||||
device = DeviceSession(
|
|
||||||
user_id=user.id,
|
|
||||||
raw_user_agent=raw_ua,
|
|
||||||
device_name=device_name,
|
|
||||||
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
|
||||||
os_name=(ua.os.family or None),
|
|
||||||
os_version=(ua.os.version_string or None),
|
|
||||||
browser_name=(ua.browser.family or None),
|
|
||||||
browser_version=(ua.browser.version_string or None),
|
|
||||||
brand=(ua.device.brand or None),
|
|
||||||
model=(ua.device.model or None),
|
|
||||||
session_id=session_id,
|
|
||||||
created_at=datetime.now(),
|
|
||||||
last_seen=datetime.now(),
|
|
||||||
revoked=False,
|
|
||||||
)
|
|
||||||
db.add(device)
|
|
||||||
|
|
||||||
user.online = True
|
user.online = True
|
||||||
user.last_seen = datetime.now()
|
user.last_seen = datetime.now()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
token = create_token(user.id, user.username, session_id)
|
token = create_token(user.id, user.username)
|
||||||
|
|
||||||
identifiers = [f"user:{username}"]
|
|
||||||
if client_ip:
|
|
||||||
identifiers.append(f"ip:{client_ip}")
|
|
||||||
for identifier in identifiers:
|
|
||||||
_reset_failed_logins(identifier)
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"login_success",
|
|
||||||
username=user.username,
|
|
||||||
user_id=user.id,
|
|
||||||
ip=client_ip,
|
|
||||||
session_id=session_id,
|
|
||||||
device=device.device_type,
|
|
||||||
os=device.os_name,
|
|
||||||
browser=device.browser_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -165,14 +56,10 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
@rate_limit_per_ip("3/hour")
|
def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||||
def register(request: Request, register_request: RegisterRequest, db: Session = Depends(get_db)):
|
username = request.username.strip()
|
||||||
username = register_request.username.strip()
|
password = request.password.strip()
|
||||||
display_name = register_request.display_name.strip()
|
confirm_password = request.confirm_password.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
|
||||||
@@ -188,23 +75,7 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
|||||||
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):
|
||||||
@@ -234,66 +105,18 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
|||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
display_name=display_name,
|
|
||||||
password_hash=hashed_password,
|
password_hash=hashed_password,
|
||||||
online=True,
|
online=True,
|
||||||
last_seen=datetime.now(),
|
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)
|
||||||
|
|
||||||
# Create initial device session
|
token = create_token(new_user.id, new_user.username)
|
||||||
raw_ua = request.headers.get("user-agent")
|
|
||||||
device_name = request.headers.get("x-device-name")
|
|
||||||
ua = parse_ua(raw_ua or "")
|
|
||||||
session_id = uuid.uuid4().hex
|
|
||||||
device = DeviceSession(
|
|
||||||
user_id=new_user.id,
|
|
||||||
raw_user_agent=raw_ua,
|
|
||||||
device_name=device_name,
|
|
||||||
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
|
||||||
os_name=(ua.os.family or None),
|
|
||||||
os_version=(ua.os.version_string or None),
|
|
||||||
browser_name=(ua.browser.family or None),
|
|
||||||
browser_version=(ua.browser.version_string or None),
|
|
||||||
brand=(ua.device.brand or None),
|
|
||||||
model=(ua.device.model or None),
|
|
||||||
session_id=session_id,
|
|
||||||
created_at=datetime.now(),
|
|
||||||
last_seen=datetime.now(),
|
|
||||||
revoked=False,
|
|
||||||
)
|
|
||||||
db.add(device)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
token = create_token(new_user.id, new_user.username, session_id)
|
|
||||||
|
|
||||||
os_name = ua.os.family or "Unknown OS"
|
|
||||||
if ua.os.version_string:
|
|
||||||
os_name = f"{os_name} {ua.os.version_string}"
|
|
||||||
browser_name = ua.browser.family or "Unknown browser"
|
|
||||||
if ua.browser.version_string:
|
|
||||||
browser_name = f"{browser_name} {ua.browser.version_string}"
|
|
||||||
user_agent_summary = f"{os_name}, {browser_name}"
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"registration_success",
|
|
||||||
username=new_user.username,
|
|
||||||
display_name=new_user.display_name,
|
|
||||||
user_id=new_user.id,
|
|
||||||
ip=client_ip,
|
|
||||||
user_agent=user_agent_summary,
|
|
||||||
owner=is_owner,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -313,8 +136,6 @@ 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
|
||||||
@@ -336,8 +157,6 @@ 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
|
||||||
@@ -373,97 +192,25 @@ def delete_user_as_owner(
|
|||||||
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
|
|
||||||
from utils import verify_token as _verify_token
|
|
||||||
payload = _verify_token(credentials.credentials)
|
|
||||||
if payload and payload.get("session_id"):
|
|
||||||
db.query(DeviceSession).filter(
|
|
||||||
DeviceSession.user_id == current_user.id,
|
|
||||||
DeviceSession.session_id == payload["session_id"],
|
|
||||||
).update({DeviceSession.revoked: True})
|
|
||||||
|
|
||||||
current_user.online = False
|
current_user.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=payload.get("session_id") if payload else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
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.password_hash):
|
|
||||||
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
|
|
||||||
|
|
||||||
# Update password hash to hash of new derived password
|
|
||||||
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip())
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Optionally revoke all other sessions, keeping the current one
|
|
||||||
if password_request.logoutAllExceptCurrent:
|
|
||||||
from utils import verify_token as _verify_token
|
|
||||||
payload = _verify_token(credentials.credentials)
|
|
||||||
if not payload:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid token")
|
|
||||||
current_session_id = payload.get("session_id")
|
|
||||||
db.query(DeviceSession).filter(
|
|
||||||
DeviceSession.user_id == current_user.id,
|
|
||||||
DeviceSession.session_id != current_session_id,
|
|
||||||
).update({DeviceSession.revoked: True})
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
client_ip = get_client_ip(request)
|
|
||||||
log_security(
|
|
||||||
"password_changed",
|
|
||||||
username=current_user.username,
|
|
||||||
user_id=current_user.id,
|
|
||||||
ip=client_ip,
|
|
||||||
logout_others=bool(password_request.logoutAllExceptCurrent),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse
|
def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
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": [
|
||||||
@@ -473,511 +220,6 @@ def list_users(request: Request, current_user: User = Depends(get_current_user),
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/crypto/public-key/of/{user_id}")
|
@router.get("/crypto/public-key/of/{user_id}")
|
||||||
@rate_limit_per_ip("100/minute") # Per-IP limit to prevent abuse
|
def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
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.post("/crypto/signal/prekey-bundle")
|
|
||||||
@rate_limit_per_ip("10/minute")
|
|
||||||
def upload_prekey_bundle(
|
|
||||||
request: Request,
|
|
||||||
payload: dict,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Upload Signal Protocol prekey bundle for the current user"""
|
|
||||||
from models import SignalPreKeyBundle, SignalPreKey
|
|
||||||
import json
|
|
||||||
|
|
||||||
bundle = payload.get("bundle")
|
|
||||||
if not bundle:
|
|
||||||
raise HTTPException(status_code=400, detail="bundle required")
|
|
||||||
|
|
||||||
# Validate bundle structure
|
|
||||||
if not isinstance(bundle, dict):
|
|
||||||
raise HTTPException(status_code=400, detail="bundle must be a JSON object")
|
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
required_fields = ["registrationId", "identityKey", "signedPreKey"]
|
|
||||||
for field in required_fields:
|
|
||||||
if field not in bundle:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
|
||||||
|
|
||||||
if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
|
|
||||||
|
|
||||||
# Store bundle (identity key, signed prekey, registration ID) - without the one-time prekey
|
|
||||||
bundle_without_prekey = {
|
|
||||||
"registrationId": bundle["registrationId"],
|
|
||||||
"identityKey": bundle["identityKey"],
|
|
||||||
"signedPreKey": bundle["signedPreKey"]
|
|
||||||
}
|
|
||||||
bundle_json = json.dumps(bundle_without_prekey)
|
|
||||||
if len(bundle_json) > 50000: # 50KB limit
|
|
||||||
raise HTTPException(status_code=400, detail="Bundle too large")
|
|
||||||
|
|
||||||
# Store or update the bundle
|
|
||||||
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
|
||||||
if row:
|
|
||||||
row.bundle_json = bundle_json
|
|
||||||
row.updated_at = datetime.now()
|
|
||||||
else:
|
|
||||||
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
|
|
||||||
db.add(row)
|
|
||||||
|
|
||||||
# Store the one-time prekey if provided
|
|
||||||
if "preKey" in bundle and bundle["preKey"]:
|
|
||||||
prekey = bundle["preKey"]
|
|
||||||
if isinstance(prekey, dict) and "keyId" in prekey and "publicKey" in prekey:
|
|
||||||
# Check if this prekey already exists
|
|
||||||
existing = db.query(SignalPreKey).filter(
|
|
||||||
SignalPreKey.user_id == current_user.id,
|
|
||||||
SignalPreKey.prekey_id == prekey["keyId"]
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing prekey (mark as unused if it was used)
|
|
||||||
existing.public_key = prekey["publicKey"]
|
|
||||||
existing.used = False
|
|
||||||
existing.created_at = datetime.now()
|
|
||||||
else:
|
|
||||||
# Add new prekey
|
|
||||||
new_prekey = SignalPreKey(
|
|
||||||
user_id=current_user.id,
|
|
||||||
prekey_id=prekey["keyId"],
|
|
||||||
public_key=prekey["publicKey"],
|
|
||||||
used=False
|
|
||||||
)
|
|
||||||
db.add(new_prekey)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/crypto/signal/prekeys/bulk")
|
|
||||||
@rate_limit_per_ip("10/minute")
|
|
||||||
def upload_prekeys_bulk(
|
|
||||||
request: Request,
|
|
||||||
payload: dict,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Upload multiple Signal Protocol prekeys in one request"""
|
|
||||||
from models import SignalPreKeyBundle, SignalPreKey
|
|
||||||
import json
|
|
||||||
|
|
||||||
base_bundle = payload.get("baseBundle")
|
|
||||||
prekeys = payload.get("prekeys", [])
|
|
||||||
|
|
||||||
if not base_bundle:
|
|
||||||
raise HTTPException(status_code=400, detail="baseBundle required")
|
|
||||||
|
|
||||||
if not isinstance(prekeys, list):
|
|
||||||
raise HTTPException(status_code=400, detail="prekeys must be an array")
|
|
||||||
|
|
||||||
# Validate base bundle structure
|
|
||||||
if not isinstance(base_bundle, dict):
|
|
||||||
raise HTTPException(status_code=400, detail="baseBundle must be a JSON object")
|
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
required_fields = ["registrationId", "identityKey", "signedPreKey"]
|
|
||||||
for field in required_fields:
|
|
||||||
if field not in base_bundle:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Missing required field in baseBundle: {field}")
|
|
||||||
|
|
||||||
if not isinstance(base_bundle["signedPreKey"], dict) or "keyId" not in base_bundle["signedPreKey"]:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
|
|
||||||
|
|
||||||
# Store or update the base bundle (identity key, signed prekey, registration ID)
|
|
||||||
bundle_without_prekey = {
|
|
||||||
"registrationId": base_bundle["registrationId"],
|
|
||||||
"identityKey": base_bundle["identityKey"],
|
|
||||||
"signedPreKey": base_bundle["signedPreKey"]
|
|
||||||
}
|
|
||||||
bundle_json = json.dumps(bundle_without_prekey)
|
|
||||||
if len(bundle_json) > 50000: # 50KB limit
|
|
||||||
raise HTTPException(status_code=400, detail="Bundle too large")
|
|
||||||
|
|
||||||
# Store or update the bundle
|
|
||||||
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
|
||||||
if row:
|
|
||||||
row.bundle_json = bundle_json
|
|
||||||
row.updated_at = datetime.now()
|
|
||||||
else:
|
|
||||||
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
|
|
||||||
db.add(row)
|
|
||||||
|
|
||||||
# Store all prekeys
|
|
||||||
for prekey in prekeys:
|
|
||||||
if not isinstance(prekey, dict) or "keyId" not in prekey or "publicKey" not in prekey:
|
|
||||||
continue # Skip invalid prekeys
|
|
||||||
|
|
||||||
# Check if this prekey already exists
|
|
||||||
existing = db.query(SignalPreKey).filter(
|
|
||||||
SignalPreKey.user_id == current_user.id,
|
|
||||||
SignalPreKey.prekey_id == prekey["keyId"]
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing prekey (mark as unused if it was used)
|
|
||||||
existing.public_key = prekey["publicKey"]
|
|
||||||
existing.used = False
|
|
||||||
existing.created_at = datetime.now()
|
|
||||||
else:
|
|
||||||
# Add new prekey
|
|
||||||
new_prekey = SignalPreKey(
|
|
||||||
user_id=current_user.id,
|
|
||||||
prekey_id=prekey["keyId"],
|
|
||||||
public_key=prekey["publicKey"],
|
|
||||||
used=False
|
|
||||||
)
|
|
||||||
db.add(new_prekey)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"status": "ok", "uploaded": len(prekeys)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/crypto/signal/prekey-bundle")
|
|
||||||
def get_prekey_bundle(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get Signal Protocol prekey bundle for the current user"""
|
|
||||||
from models import SignalPreKeyBundle
|
|
||||||
import json
|
|
||||||
|
|
||||||
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
|
||||||
|
|
||||||
try:
|
|
||||||
bundle = json.loads(row.bundle_json)
|
|
||||||
return {"bundle": bundle}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/crypto/signal/prekey-bundle/of/{user_id}")
|
|
||||||
@rate_limit_per_ip("100/minute")
|
|
||||||
def get_prekey_bundle_of(
|
|
||||||
request: Request,
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get Signal Protocol prekey bundle for another user with prekey rotation"""
|
|
||||||
from models import SignalPreKeyBundle, SignalPreKey
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Get the base bundle (identity key, signed prekey, registration ID)
|
|
||||||
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
|
||||||
|
|
||||||
try:
|
|
||||||
bundle = json.loads(row.bundle_json)
|
|
||||||
|
|
||||||
# Find an unused prekey for this user
|
|
||||||
unused_prekey = db.query(SignalPreKey).filter(
|
|
||||||
SignalPreKey.user_id == user_id,
|
|
||||||
SignalPreKey.used == False
|
|
||||||
).order_by(SignalPreKey.created_at.asc()).first()
|
|
||||||
|
|
||||||
if unused_prekey:
|
|
||||||
# Mark this prekey as used (atomic operation)
|
|
||||||
unused_prekey.used = True
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Add the prekey to the bundle
|
|
||||||
bundle["preKey"] = {
|
|
||||||
"keyId": unused_prekey.prekey_id,
|
|
||||||
"publicKey": unused_prekey.public_key
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# No unused prekeys available - return bundle without prekey
|
|
||||||
# The client will need to establish a session using the signed prekey only
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"bundle": bundle}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/crypto/signal/sessions")
|
|
||||||
@rate_limit_per_ip("100/minute")
|
|
||||||
def upload_signal_sessions(
|
|
||||||
request: Request,
|
|
||||||
payload: dict,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Upload encrypted Signal Protocol sessions for the current user"""
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
sessions = payload.get("sessions")
|
|
||||||
if not isinstance(sessions, list):
|
|
||||||
raise HTTPException(status_code=400, detail="sessions must be a list")
|
|
||||||
|
|
||||||
uploaded_count = 0
|
|
||||||
for session_data in sessions:
|
|
||||||
if not isinstance(session_data, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
recipient_id = session_data.get("recipientId")
|
|
||||||
device_id = session_data.get("deviceId", 1)
|
|
||||||
encrypted_data = session_data.get("encryptedData")
|
|
||||||
|
|
||||||
if not recipient_id or not encrypted_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Validate encrypted_data is valid JSON
|
|
||||||
json.loads(encrypted_data)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Store or update session
|
|
||||||
existing = db.query(SignalSession).filter(
|
|
||||||
SignalSession.user_id == current_user.id,
|
|
||||||
SignalSession.recipient_id == recipient_id,
|
|
||||||
SignalSession.device_id == device_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.encrypted_session_data = encrypted_data
|
|
||||||
existing.updated_at = datetime.now()
|
|
||||||
else:
|
|
||||||
new_session = SignalSession(
|
|
||||||
user_id=current_user.id,
|
|
||||||
recipient_id=recipient_id,
|
|
||||||
device_id=device_id,
|
|
||||||
encrypted_session_data=encrypted_data
|
|
||||||
)
|
|
||||||
db.add(new_session)
|
|
||||||
uploaded_count += 1
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
return {"status": "ok", "uploaded_count": uploaded_count}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/crypto/signal/sessions")
|
|
||||||
@rate_limit_per_ip("60/minute")
|
|
||||||
def get_signal_sessions(
|
|
||||||
request: Request,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get all encrypted Signal Protocol sessions for the current user"""
|
|
||||||
sessions = db.query(SignalSession).filter(
|
|
||||||
SignalSession.user_id == current_user.id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"sessions": [
|
|
||||||
{
|
|
||||||
"recipientId": s.recipient_id,
|
|
||||||
"deviceId": s.device_id,
|
|
||||||
"encryptedData": s.encrypted_session_data,
|
|
||||||
"updatedAt": s.updated_at.isoformat()
|
|
||||||
}
|
|
||||||
for s in sessions
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/crypto/signal/message-plaintexts")
|
|
||||||
@rate_limit_per_ip("100/minute")
|
|
||||||
def upload_message_plaintexts(
|
|
||||||
request: Request,
|
|
||||||
payload: dict,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Upload encrypted plaintexts of sent messages"""
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
messages = payload.get("messages")
|
|
||||||
if not isinstance(messages, list):
|
|
||||||
raise HTTPException(status_code=400, detail="messages must be a list")
|
|
||||||
|
|
||||||
uploaded_count = 0
|
|
||||||
for msg_data in messages:
|
|
||||||
if not isinstance(msg_data, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
message_id = msg_data.get("messageId")
|
|
||||||
recipient_id = msg_data.get("recipientId")
|
|
||||||
encrypted_data = msg_data.get("encryptedData")
|
|
||||||
|
|
||||||
if not message_id or not recipient_id or not encrypted_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Validate encrypted_data is valid JSON
|
|
||||||
json.loads(encrypted_data)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Store or update plaintext
|
|
||||||
existing = db.query(SentMessagePlaintext).filter(
|
|
||||||
SentMessagePlaintext.user_id == current_user.id,
|
|
||||||
SentMessagePlaintext.message_id == message_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.encrypted_data = encrypted_data
|
|
||||||
else:
|
|
||||||
new_plaintext = SentMessagePlaintext(
|
|
||||||
user_id=current_user.id,
|
|
||||||
message_id=message_id,
|
|
||||||
recipient_id=recipient_id,
|
|
||||||
encrypted_data=encrypted_data
|
|
||||||
)
|
|
||||||
db.add(new_plaintext)
|
|
||||||
uploaded_count += 1
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
return {"status": "ok", "uploaded_count": uploaded_count}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/crypto/signal/message-plaintexts")
|
|
||||||
@rate_limit_per_ip("60/minute")
|
|
||||||
def get_message_plaintexts(
|
|
||||||
request: Request,
|
|
||||||
recipient_id: int | None = None, # Optional filter by recipient
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get encrypted plaintexts of sent messages for the current user"""
|
|
||||||
query = db.query(SentMessagePlaintext).filter(
|
|
||||||
SentMessagePlaintext.user_id == current_user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if recipient_id is not None:
|
|
||||||
query = query.filter(SentMessagePlaintext.recipient_id == recipient_id)
|
|
||||||
|
|
||||||
plaintexts = query.all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"messageId": p.message_id,
|
|
||||||
"recipientId": p.recipient_id,
|
|
||||||
"encryptedData": p.encrypted_data,
|
|
||||||
"createdAt": p.created_at.isoformat()
|
|
||||||
}
|
|
||||||
for p in plaintexts
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/search")
|
|
||||||
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
|
||||||
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
||||||
if len(q.strip()) < 2:
|
|
||||||
return {"users": []}
|
|
||||||
|
|
||||||
# Case-insensitive partial match on username
|
|
||||||
users = db.query(User).filter(
|
|
||||||
User.username.ilike(f"%{q.strip()}%"),
|
|
||||||
User.id != current_user.id # Exclude current user
|
|
||||||
).order_by(User.username.asc()).limit(20).all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"users": [convert_user(u) for u in users]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _delete_user_data(user: User, db: Session):
|
|
||||||
"""
|
|
||||||
Helper function to delete user data - marks user as deleted, clears sensitive data,
|
|
||||||
deletes profile picture, removes non-whitelist user data, and sends WebSocket message.
|
|
||||||
"""
|
|
||||||
user_id = user.id
|
|
||||||
|
|
||||||
# Mark user as deleted and clear sensitive data
|
|
||||||
user.deleted = True
|
|
||||||
user.display_name = f"Deleted User #{user_id}"
|
|
||||||
user.bio = None
|
|
||||||
user.password_hash = ""
|
|
||||||
user.username = f"deleted_{user_id}"
|
|
||||||
user.profile_picture = None
|
|
||||||
user.last_seen = None # Clear last seen timestamp
|
|
||||||
user.created_at = None # Clear member since timestamp
|
|
||||||
|
|
||||||
# Delete profile picture file if exists
|
|
||||||
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"):
|
|
||||||
try:
|
|
||||||
filename = user.profile_picture.split("/")[-1]
|
|
||||||
filepath = os.path.join("data/uploads/pfp", filename)
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but don't fail the request
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Dynamic deletion of all non-whitelist data
|
|
||||||
WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
inspector = inspect(db.bind)
|
|
||||||
all_tables = inspector.get_table_names()
|
|
||||||
|
|
||||||
for table_name in all_tables:
|
|
||||||
if table_name in WHITELIST_TABLES or table_name == "user":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if table has user_id column
|
|
||||||
columns = inspector.get_columns(table_name)
|
|
||||||
has_user_id = any(col['name'] == 'user_id' for col in columns)
|
|
||||||
|
|
||||||
if has_user_id:
|
|
||||||
# Delete all records for this user
|
|
||||||
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
except Exception as e:
|
|
||||||
# Log error and rollback
|
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to delete user data")
|
|
||||||
|
|
||||||
# Send WebSocket deletion message
|
|
||||||
try:
|
|
||||||
from .messaging import messagingManager
|
|
||||||
await messagingManager.send_deletion_to_user(user_id)
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but don't fail the request
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete")
|
|
||||||
async def delete_account(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete the current user's own account - preserves messages/DMs/reactions/files
|
|
||||||
"""
|
|
||||||
# Prevent admin/owner account self-deletion
|
|
||||||
if current_user.username == OWNER_USERNAME or current_user.id == 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
|
|
||||||
|
|
||||||
await _delete_user_data(current_user, db)
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"self_delete_account",
|
|
||||||
severity="warning",
|
|
||||||
user_id=current_user.id,
|
|
||||||
username=current_user.username,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Account deleted successfully"
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dependencies import get_current_user, get_db
|
|
||||||
from models import User, DeviceSession
|
|
||||||
from utils import verify_token
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_current_session_id(credentials: HTTPAuthorizationCredentials) -> str:
|
|
||||||
token = credentials.credentials
|
|
||||||
payload = verify_token(token)
|
|
||||||
if not payload or "session_id" not in payload:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid session")
|
|
||||||
return payload["session_id"]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_devices(
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
current_session_id = _get_current_session_id(credentials)
|
|
||||||
sessions = (
|
|
||||||
db.query(DeviceSession)
|
|
||||||
.filter(DeviceSession.user_id == current_user.id, DeviceSession.revoked == False)
|
|
||||||
.order_by(DeviceSession.last_seen.desc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"devices": [
|
|
||||||
{
|
|
||||||
"session_id": s.session_id,
|
|
||||||
"device_type": s.device_type,
|
|
||||||
"device_name": s.device_name,
|
|
||||||
"os_name": s.os_name,
|
|
||||||
"os_version": s.os_version,
|
|
||||||
"browser_name": s.browser_name,
|
|
||||||
"browser_version": s.browser_version,
|
|
||||||
"brand": s.brand,
|
|
||||||
"model": s.model,
|
|
||||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
|
||||||
"last_seen": s.last_seen.isoformat() if s.last_seen else None,
|
|
||||||
"revoked": s.revoked,
|
|
||||||
"current": s.session_id == current_session_id,
|
|
||||||
}
|
|
||||||
for s in sessions
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{session_id}")
|
|
||||||
def revoke_device(
|
|
||||||
session_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
if not session_id or len(session_id) > 64 or len(session_id) < 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid session ID")
|
|
||||||
|
|
||||||
s = (
|
|
||||||
db.query(DeviceSession)
|
|
||||||
.filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not s:
|
|
||||||
raise HTTPException(status_code=404, detail="Device session not found")
|
|
||||||
s.revoked = True
|
|
||||||
db.commit()
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout-all")
|
|
||||||
def logout_all_except_current(
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
current_session_id = _get_current_session_id(credentials)
|
|
||||||
db.query(DeviceSession).filter(
|
|
||||||
DeviceSession.user_id == current_user.id,
|
|
||||||
DeviceSession.session_id != current_session_id,
|
|
||||||
).update({DeviceSession.revoked: True})
|
|
||||||
db.commit()
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
|
|
||||||
+392
-797
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from constants import OWNER_USERNAME
|
|
||||||
from dependencies import get_current_user
|
|
||||||
from models import User
|
|
||||||
from security.audit import log_security
|
|
||||||
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
|
||||||
from security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
|
||||||
|
|
||||||
|
|
||||||
class BlocklistUpdateRequest(BaseModel):
|
|
||||||
words: List[str] = Field(default_factory=list, min_items=1)
|
|
||||||
|
|
||||||
|
|
||||||
class UnblockIPRequest(BaseModel):
|
|
||||||
ip: str = Field(..., min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/moderation", tags=["moderation"])
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_owner(user: User) -> None:
|
|
||||||
if user.username != OWNER_USERNAME:
|
|
||||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/blocklist")
|
|
||||||
def list_blocklist(current_user: User = Depends(get_current_user)):
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
return {"words": get_blocklist()}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/blocklist")
|
|
||||||
def append_blocklist(
|
|
||||||
request: BlocklistUpdateRequest,
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
added, updated = add_to_blocklist(request.words)
|
|
||||||
log_security(
|
|
||||||
"blocklist_add",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
added=added,
|
|
||||||
)
|
|
||||||
return {"added": added, "words": updated}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/blocklist")
|
|
||||||
def delete_from_blocklist(
|
|
||||||
request: BlocklistUpdateRequest,
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
removed, updated = remove_from_blocklist(request.words)
|
|
||||||
log_security(
|
|
||||||
"blocklist_remove",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
removed=removed,
|
|
||||||
)
|
|
||||||
return {"removed": removed, "words": updated}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/unblock-ip")
|
|
||||||
def unblock_ip(
|
|
||||||
request: UnblockIPRequest,
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""Unblock an IP address from rate limiting."""
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
ip = request.ip.strip()
|
|
||||||
|
|
||||||
if not ip:
|
|
||||||
raise HTTPException(status_code=400, detail="IP address is required")
|
|
||||||
|
|
||||||
cleared = reset_rate_limit_for_ip(ip)
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"rate_limit_unblock",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
ip=ip,
|
|
||||||
success=cleared,
|
|
||||||
)
|
|
||||||
|
|
||||||
if cleared:
|
|
||||||
return {"status": "success", "message": f"Rate limit cleared for IP: {ip}"}
|
|
||||||
else:
|
|
||||||
return {"status": "success", "message": f"No rate limit entries found for IP: {ip}"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clear-all-rate-limits")
|
|
||||||
def clear_all_rate_limits_endpoint(
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""Clear all rate limit entries. Use with caution."""
|
|
||||||
_ensure_owner(current_user)
|
|
||||||
|
|
||||||
cleared = clear_all_rate_limits()
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"rate_limit_clear_all",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
entries_cleared=cleared,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "success", "message": f"Cleared {cleared} rate limit entries"}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+24
-389
@@ -7,32 +7,16 @@ 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 dependencies import get_db, get_current_user
|
||||||
from models import User, UpdateBioRequest, UserProfileResponse
|
from models import User, UpdateBioRequest, UserProfileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from validation import is_valid_username, is_valid_display_name
|
|
||||||
from similarity import is_user_similar_to_verified
|
|
||||||
from .messaging import messagingManager
|
|
||||||
from security.audit import log_security
|
|
||||||
from security.profanity import contains_profanity
|
|
||||||
from security.rate_limit import rate_limit_per_ip
|
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
||||||
username: str | None = None
|
nickname: 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
|
||||||
@@ -41,9 +25,7 @@ PROFILE_PICTURES_DIR = Path("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)
|
||||||
@@ -117,60 +99,19 @@ 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 {
|
||||||
"users": [
|
"id": current_user.id,
|
||||||
UserProfileResponse(
|
"username": current_user.username,
|
||||||
id=user.id,
|
"profile_picture": current_user.profile_picture,
|
||||||
username=user.username,
|
"bio": current_user.bio,
|
||||||
display_name=user.display_name,
|
"online": current_user.online,
|
||||||
profile_picture=user.profile_picture,
|
"last_seen": current_user.last_seen,
|
||||||
bio=user.bio,
|
"created_at": current_user.created_at
|
||||||
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: Request,
|
request: UpdateProfileRequest,
|
||||||
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)
|
||||||
):
|
):
|
||||||
@@ -180,47 +121,24 @@ async def update_user_profile(
|
|||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
# Update username if provided
|
# Update username if provided
|
||||||
if update_request.username is not None:
|
if request.nickname is not None:
|
||||||
username = update_request.username.strip()
|
nickname = request.nickname.strip()
|
||||||
if not is_valid_username(username):
|
if len(nickname) < 3:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Username must be at least 3 characters long")
|
||||||
status_code=400,
|
if len(nickname) > 50:
|
||||||
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
raise HTTPException(status_code=400, detail="Username must be 50 characters or less")
|
||||||
)
|
|
||||||
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 == username, User.id != current_user.id).first()
|
existing_user = db.query(User).filter(User.username == nickname, User.id != current_user.id).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(status_code=400, detail="Это имя пользователя уже занято")
|
raise HTTPException(status_code=400, detail="Username already taken")
|
||||||
|
|
||||||
current_user.username = username
|
current_user.username = nickname
|
||||||
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 update_request.description is not None:
|
if request.description is not None:
|
||||||
bio = update_request.description.strip()
|
bio = 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")
|
||||||
|
|
||||||
@@ -232,33 +150,29 @@ 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: Request,
|
request: UpdateBioRequest,
|
||||||
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(bio_request.bio) > 500: # Limit bio to 500 characters
|
if len(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 = bio_request.bio.strip()
|
current_user.bio = request.bio.strip()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -275,296 +189,17 @@ 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:
|
|
||||||
await messagingManager.send_suspension_to_user(user_id, request.reason)
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but don't fail the request
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"User {target_user.username} has been suspended",
|
|
||||||
"reason": request.reason
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/user/{user_id}/unsuspend")
|
|
||||||
async def unsuspend_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Unsuspend a user account (admin only)
|
|
||||||
"""
|
|
||||||
# Only user with ID 1 (admin) can unsuspend users
|
|
||||||
if current_user.id != 1:
|
|
||||||
raise HTTPException(status_code=403, detail="Only admin can unsuspend users")
|
|
||||||
|
|
||||||
target_user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not target_user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Unsuspend the user
|
|
||||||
target_user.suspended = False
|
|
||||||
target_user.suspension_reason = None
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"admin_unsuspend_user",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_username=target_user.username,
|
|
||||||
target_id=target_user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"User {target_user.username} has been unsuspended"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/user/{user_id}/delete")
|
|
||||||
async def delete_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete a user account (admin only) - preserves messages/DMs/reactions/files
|
|
||||||
"""
|
|
||||||
# Only user with ID 1 (admin) can delete users
|
|
||||||
if current_user.id != 1:
|
|
||||||
raise HTTPException(status_code=403, detail="Only admin can delete users")
|
|
||||||
|
|
||||||
target_user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not target_user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Cannot delete admin
|
|
||||||
if target_user.id == 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete admin account")
|
|
||||||
|
|
||||||
snapshot_username = target_user.username
|
|
||||||
snapshot_display_name = target_user.display_name
|
|
||||||
|
|
||||||
from .account import _delete_user_data
|
|
||||||
await _delete_user_data(target_user, db)
|
|
||||||
|
|
||||||
log_security(
|
|
||||||
"admin_delete_user",
|
|
||||||
severity="warning",
|
|
||||||
actor=current_user.username,
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_username=snapshot_username,
|
|
||||||
target_display_name=snapshot_display_name,
|
|
||||||
target_id=target_user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"User {target_user.username} has been deleted"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
import hmac
|
|
||||||
import hashlib
|
|
||||||
import time
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from dependencies import get_current_user
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
|
|
||||||
def generate_turn_credentials(username: str, secret: str, expiration_minutes: int = 60):
|
|
||||||
"""Generate time-limited TURN credentials using TURN REST API format.
|
|
||||||
|
|
||||||
This creates temporary credentials that expire after the specified time.
|
|
||||||
The username format is: timestamp:username
|
|
||||||
The password is an HMAC hash of the username and secret.
|
|
||||||
"""
|
|
||||||
# Current timestamp (seconds since epoch)
|
|
||||||
timestamp = int(time.time()) + (expiration_minutes * 60)
|
|
||||||
|
|
||||||
# Create temporary username: timestamp:original_username
|
|
||||||
temp_username = f"{timestamp}:{username}"
|
|
||||||
|
|
||||||
# Generate password using HMAC-SHA1
|
|
||||||
temp_password = hmac.new(
|
|
||||||
secret.encode('utf-8'),
|
|
||||||
temp_username.encode('utf-8'),
|
|
||||||
hashlib.sha1
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
return temp_username, temp_password
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ice")
|
|
||||||
async def get_ice_servers(current_user = Depends(get_current_user)):
|
|
||||||
"""Return ICE server configuration (STUN/TURN) for WebRTC clients.
|
|
||||||
|
|
||||||
Generates time-limited TURN credentials that expire in 1 hour.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Prefer using your own coturn for both STUN and TURN
|
|
||||||
turn_domain = "fromchat.ru"
|
|
||||||
stun_urls = [
|
|
||||||
f"stun:{turn_domain}:3478",
|
|
||||||
f"stuns:{turn_domain}:5349",
|
|
||||||
]
|
|
||||||
|
|
||||||
turn_urls = [
|
|
||||||
f"turn:{turn_domain}:3478",
|
|
||||||
f"turns:{turn_domain}:5349",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Get TURN configuration from environment
|
|
||||||
turn_username = os.getenv("TURN_USERNAME")
|
|
||||||
turn_secret = os.getenv("TURN_SECRET")
|
|
||||||
|
|
||||||
# Check if required environment variables are set
|
|
||||||
if not turn_username:
|
|
||||||
logger.error("ERROR: TURN_USERNAME environment variable is not set")
|
|
||||||
raise ValueError("TURN_USERNAME environment variable is not set")
|
|
||||||
|
|
||||||
if not turn_secret:
|
|
||||||
logger.error("ERROR: TURN_SECRET environment variable is not set")
|
|
||||||
raise ValueError("TURN_SECRET environment variable is not set")
|
|
||||||
|
|
||||||
ice_servers: list[dict] = [{"urls": url} for url in stun_urls]
|
|
||||||
|
|
||||||
temp_username, temp_password = generate_turn_credentials(
|
|
||||||
turn_username,
|
|
||||||
turn_secret,
|
|
||||||
expiration_minutes=60 # Expires in 1 hour
|
|
||||||
)
|
|
||||||
|
|
||||||
ice_servers.append({
|
|
||||||
"urls": turn_urls,
|
|
||||||
"username": temp_username,
|
|
||||||
"credential": temp_password,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"iceServers": ice_servers}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"ERROR in /api/webrtc/ice: {str(e)}")
|
|
||||||
logger.error(f"ERROR type: {type(e).__name__}")
|
|
||||||
traceback.print_exc()
|
|
||||||
raise
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Package marker for security utilities
|
|
||||||
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from html import unescape
|
|
||||||
from typing import Any, Callable, Dict, List
|
|
||||||
|
|
||||||
from logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_username(username: Any) -> str:
|
|
||||||
if not username:
|
|
||||||
return "unknown user"
|
|
||||||
return f"@{username}"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_user(fields: Dict[str, Any], username_key: str = "username", user_id_key: str = "user_id") -> str:
|
|
||||||
username = fields.get(username_key)
|
|
||||||
if username is None and "_" in username_key:
|
|
||||||
base_key = username_key.split("_", 1)[0]
|
|
||||||
username = fields.get(base_key)
|
|
||||||
|
|
||||||
user_id = fields.get(user_id_key)
|
|
||||||
if user_id is None and "_" in user_id_key:
|
|
||||||
base_key = user_id_key.split("_", 1)[0]
|
|
||||||
user_id = fields.get(base_key)
|
|
||||||
|
|
||||||
if username and user_id is not None:
|
|
||||||
return f"{_clean_username(username)} (user id {user_id})"
|
|
||||||
if username:
|
|
||||||
return _clean_username(username)
|
|
||||||
if user_id is not None:
|
|
||||||
return f"user id {user_id}"
|
|
||||||
return "unknown user"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_actor(fields: Dict[str, Any], prefix: str) -> str:
|
|
||||||
return _format_user(fields, f"{prefix}_username", f"{prefix}_id")
|
|
||||||
|
|
||||||
|
|
||||||
def _plural(label: str, count: int) -> str:
|
|
||||||
return f"{count} {label if count == 1 else label + 's'}"
|
|
||||||
|
|
||||||
|
|
||||||
def _yes_no(flag: Any) -> str:
|
|
||||||
return "yes" if flag else "no"
|
|
||||||
|
|
||||||
|
|
||||||
def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
|
|
||||||
if action == "login_success":
|
|
||||||
lines = [f"Login approved for {_format_user(fields)}"]
|
|
||||||
session = fields.get("session_id")
|
|
||||||
if session:
|
|
||||||
lines.append(f"Session: {session}")
|
|
||||||
client_bits: List[str] = []
|
|
||||||
if fields.get("device"):
|
|
||||||
client_bits.append(fields["device"])
|
|
||||||
if fields.get("os"):
|
|
||||||
client_bits.append(fields["os"])
|
|
||||||
if fields.get("browser"):
|
|
||||||
client_bits.append(fields["browser"])
|
|
||||||
if client_bits:
|
|
||||||
lines.append(f"Client: {', '.join(client_bits)}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
return lines
|
|
||||||
if action == "login_failed":
|
|
||||||
lines = [f"Login denied for {_format_user(fields)}"]
|
|
||||||
if fields.get("reason"):
|
|
||||||
lines.append(f"Reason: {fields['reason']}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
return lines
|
|
||||||
if action == "auth_bruteforce_detected":
|
|
||||||
lines = ["Brute-force login pattern detected"]
|
|
||||||
lines.append(f"Target: {_format_user(fields)}")
|
|
||||||
failures = fields.get("failures")
|
|
||||||
if isinstance(failures, dict):
|
|
||||||
for key, value in failures.items():
|
|
||||||
lines.append(f"{key}: {value}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
if fields.get("window_seconds"):
|
|
||||||
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
|
||||||
return lines
|
|
||||||
if action == "registration_success":
|
|
||||||
ip_raw = fields.get("ip")
|
|
||||||
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
|
|
||||||
display_name = fields.get("display_name") or "Unknown"
|
|
||||||
username = fields.get("username")
|
|
||||||
user_id = fields.get("user_id")
|
|
||||||
user_agent = fields.get("user_agent") or "Unknown user agent"
|
|
||||||
lines = ["Account registered"]
|
|
||||||
lines.append(f"Display name: {display_name}")
|
|
||||||
lines.append(f"Username: {_clean_username(username) if username else 'unknown'}")
|
|
||||||
if ip_display:
|
|
||||||
lines.append(f"IP: {ip_display}")
|
|
||||||
if user_agent:
|
|
||||||
lines.append(f"User agent: {user_agent}")
|
|
||||||
if user_id is not None:
|
|
||||||
lines.append(f"User ID: {user_id}")
|
|
||||||
return lines
|
|
||||||
if action == "password_changed":
|
|
||||||
lines = [f"Password changed for {_format_user(fields)}"]
|
|
||||||
lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
return lines
|
|
||||||
if action == "logout":
|
|
||||||
lines = [f"Logout recorded for {_format_user(fields)}"]
|
|
||||||
if fields.get("session_id"):
|
|
||||||
lines.append(f"Session: {fields['session_id']}")
|
|
||||||
if fields.get("ip"):
|
|
||||||
lines.append(f"IP address: {fields['ip']}")
|
|
||||||
return lines
|
|
||||||
if action == "admin_delete_user":
|
|
||||||
return [
|
|
||||||
"Account removal",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
f"Target: {_format_actor(fields, 'target')}",
|
|
||||||
]
|
|
||||||
if action == "admin_suspend_user":
|
|
||||||
lines = [
|
|
||||||
"User suspension",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
f"Target: {_format_actor(fields, 'target')}",
|
|
||||||
]
|
|
||||||
if fields.get("reason"):
|
|
||||||
lines.append(f"Reason: {fields.get('reason')}")
|
|
||||||
return lines
|
|
||||||
if action == "admin_unsuspend_user":
|
|
||||||
return [
|
|
||||||
"User unsuspension",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
f"Target: {_format_actor(fields, 'target')}",
|
|
||||||
]
|
|
||||||
if action == "admin_verify_toggle":
|
|
||||||
return [
|
|
||||||
"User verification",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
f"Target: {_format_actor(fields, 'target')}",
|
|
||||||
f"Verified: {_yes_no(fields.get('verified'))}",
|
|
||||||
]
|
|
||||||
if action == "self_delete_account":
|
|
||||||
return [f"User {_format_user(fields)} deleted their account"]
|
|
||||||
if action == "auto_suspension_public_spam":
|
|
||||||
lines = [
|
|
||||||
f"Automatic suspension triggered for {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
match_type = fields.get("match_type")
|
|
||||||
if match_type:
|
|
||||||
lines.append(f"Match type: {match_type}")
|
|
||||||
similar = fields.get("similar_messages")
|
|
||||||
occurrences = fields.get("occurrences")
|
|
||||||
if similar:
|
|
||||||
lines.append(f"Similar messages detected: {similar}")
|
|
||||||
if occurrences and not similar:
|
|
||||||
lines.append(f"Occurrences: {occurrences}")
|
|
||||||
if fields.get("window_seconds"):
|
|
||||||
lines.append(f"Observation window: {fields['window_seconds']} seconds")
|
|
||||||
if fields.get("reason"):
|
|
||||||
lines.append(f"Reason: {fields['reason']}")
|
|
||||||
return lines
|
|
||||||
if action == "auto_suspension_public_burst":
|
|
||||||
lines = [
|
|
||||||
f"Automatic suspension triggered for {_format_user(fields)}",
|
|
||||||
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
|
|
||||||
]
|
|
||||||
if fields.get("reason"):
|
|
||||||
lines.append(f"Reason: {fields['reason']}")
|
|
||||||
return lines
|
|
||||||
if action == "public_message_burst":
|
|
||||||
return [
|
|
||||||
f"Rapid messaging spike for {_format_user(fields)}",
|
|
||||||
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
|
|
||||||
]
|
|
||||||
if action == "blocklist_add":
|
|
||||||
added = fields.get("added") or []
|
|
||||||
lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"]
|
|
||||||
if added:
|
|
||||||
lines.append(f"Added entries: {', '.join(added)}")
|
|
||||||
total = len(fields.get("words") or [])
|
|
||||||
lines.append(f"Total entries: {total}")
|
|
||||||
return lines
|
|
||||||
if action == "blocklist_remove":
|
|
||||||
removed = fields.get("removed") or []
|
|
||||||
lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"]
|
|
||||||
if removed:
|
|
||||||
lines.append(f"Removed entries: {', '.join(removed)}")
|
|
||||||
total = len(fields.get("words") or [])
|
|
||||||
lines.append(f"Total entries: {total}")
|
|
||||||
return lines
|
|
||||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
|
||||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
|
||||||
for key, value in fields.items()
|
|
||||||
if value is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]:
|
|
||||||
if action == "message_created":
|
|
||||||
lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"]
|
|
||||||
if fields.get("reply_to"):
|
|
||||||
lines.append(f"In reply to message #{fields['reply_to']}")
|
|
||||||
attachments = fields.get("attachments")
|
|
||||||
if attachments:
|
|
||||||
lines.append(f"Attachments: {_plural('file', attachments)}")
|
|
||||||
|
|
||||||
# If content was censored, log both raw and censored versions
|
|
||||||
if fields.get("raw_content") is not None:
|
|
||||||
lines.append("Raw content (before censoring):")
|
|
||||||
for line in unescape(fields["raw_content"]).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
lines.append("Censored content (stored):")
|
|
||||||
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
elif fields.get("content"):
|
|
||||||
lines.append("Content:")
|
|
||||||
for line in unescape(fields["content"]).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
return lines
|
|
||||||
if action == "message_edited":
|
|
||||||
lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"]
|
|
||||||
if fields.get("reply_to"):
|
|
||||||
lines.append(f"Reply to #{fields['reply_to']}")
|
|
||||||
if fields.get("previous_content"):
|
|
||||||
lines.append("Previous content:")
|
|
||||||
for line in unescape(fields["previous_content"] or "").splitlines() or [""]:
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
|
|
||||||
# If content was censored, log both raw and censored versions
|
|
||||||
if fields.get("raw_content") is not None:
|
|
||||||
lines.append("Raw content (before censoring):")
|
|
||||||
for line in unescape(fields["raw_content"]).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
lines.append("Censored content (stored):")
|
|
||||||
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
elif fields.get("content"):
|
|
||||||
lines.append("New content:")
|
|
||||||
for line in unescape(fields["content"] or "").splitlines() or [""]:
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
|
|
||||||
return lines
|
|
||||||
if action == "message_deleted":
|
|
||||||
lines = [
|
|
||||||
f"Message #{fields.get('message_id')} deleted",
|
|
||||||
f"Actor: {_format_actor(fields, 'actor')}",
|
|
||||||
]
|
|
||||||
if fields.get("original_author_id") is not None:
|
|
||||||
lines.append(f"Original author: user #{fields['original_author_id']}")
|
|
||||||
if fields.get("content"):
|
|
||||||
lines.append("Previous content:")
|
|
||||||
for line in unescape(fields["content"]).splitlines():
|
|
||||||
lines.append(f"| {line}")
|
|
||||||
return lines
|
|
||||||
if action == "reaction_update":
|
|
||||||
lines = [
|
|
||||||
f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}",
|
|
||||||
f"User: {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
if fields.get("emoji"):
|
|
||||||
lines.append(f"Emoji: {fields['emoji']}")
|
|
||||||
return lines
|
|
||||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
|
||||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
|
||||||
for key, value in fields.items()
|
|
||||||
if value is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]:
|
|
||||||
if action in {"message_sent", "message_sent_ws"}:
|
|
||||||
lines = [
|
|
||||||
f"Direct message #{fields.get('dm_envelope_id')} sent",
|
|
||||||
f"Sender: {_format_actor(fields, 'sender')}",
|
|
||||||
]
|
|
||||||
if fields.get("recipient_id") is not None:
|
|
||||||
lines.append(f"Recipient: user id {fields['recipient_id']}")
|
|
||||||
attachments = fields.get("attachment_count")
|
|
||||||
if attachments:
|
|
||||||
lines.append(f"Attachments: {_plural('file', attachments)}")
|
|
||||||
if fields.get("reply_to"):
|
|
||||||
lines.append(f"In reply to DM #{fields['reply_to']}")
|
|
||||||
return lines
|
|
||||||
if action == "message_edited":
|
|
||||||
return [
|
|
||||||
f"Direct message #{fields.get('dm_envelope_id')} edited",
|
|
||||||
f"Author: {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
if action == "message_deleted":
|
|
||||||
lines = [
|
|
||||||
f"Direct message #{fields.get('dm_envelope_id')} deleted",
|
|
||||||
f"Actor: {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
if fields.get("recipient_id") is not None:
|
|
||||||
lines.append(f"Recipient: user id {fields['recipient_id']}")
|
|
||||||
return lines
|
|
||||||
if action == "reaction_update":
|
|
||||||
lines = [
|
|
||||||
f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}",
|
|
||||||
f"User: {_format_user(fields)}",
|
|
||||||
]
|
|
||||||
if fields.get("emoji"):
|
|
||||||
lines.append(f"Emoji: {fields['emoji']}")
|
|
||||||
return lines
|
|
||||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
|
||||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
|
||||||
for key, value in fields.items()
|
|
||||||
if value is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _render_access(action: str, fields: Dict[str, Any]) -> List[str]:
|
|
||||||
ip_raw = fields.get("ip")
|
|
||||||
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
|
|
||||||
if action == "http_request":
|
|
||||||
first_line = f"{fields.get('method')} {fields.get('path')}"
|
|
||||||
if ip_display:
|
|
||||||
first_line += f" from {ip_display}"
|
|
||||||
first_line += f" -> {fields.get('status')}"
|
|
||||||
lines = [first_line]
|
|
||||||
if fields.get("user"):
|
|
||||||
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
|
|
||||||
return lines
|
|
||||||
if action == "http_error":
|
|
||||||
first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}"
|
|
||||||
if ip_display:
|
|
||||||
first_line += f" from {ip_display}"
|
|
||||||
lines = [first_line]
|
|
||||||
if fields.get("error"):
|
|
||||||
lines.append(f"Exception: {fields['error']}")
|
|
||||||
if fields.get("user"):
|
|
||||||
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
|
|
||||||
return lines
|
|
||||||
if action == "ws_connect":
|
|
||||||
lines = ["WebSocket connected"]
|
|
||||||
if fields.get("path"):
|
|
||||||
lines.append(f"Endpoint: {fields['path']}")
|
|
||||||
if ip_display:
|
|
||||||
lines.append(f"IP: {ip_display}")
|
|
||||||
return lines
|
|
||||||
if action == "ws_disconnect":
|
|
||||||
lines = ["WebSocket disconnected"]
|
|
||||||
if fields.get("path"):
|
|
||||||
lines.append(f"Endpoint: {fields['path']}")
|
|
||||||
if fields.get("code") is not None:
|
|
||||||
reason = fields.get("reason") or "no reason"
|
|
||||||
lines.append(f"Code {fields['code']} ({reason})")
|
|
||||||
if ip_display:
|
|
||||||
lines.append(f"IP: {ip_display}")
|
|
||||||
return lines
|
|
||||||
if action == "ws_event":
|
|
||||||
event_name = fields.get("event")
|
|
||||||
path = fields.get("path")
|
|
||||||
first_line = "WS"
|
|
||||||
if path:
|
|
||||||
first_line += f" {path}"
|
|
||||||
if ip_display:
|
|
||||||
first_line += f" from {ip_display}"
|
|
||||||
if event_name:
|
|
||||||
first_line += f" -> {event_name}"
|
|
||||||
lines = [first_line]
|
|
||||||
if fields.get("user"):
|
|
||||||
lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}")
|
|
||||||
for key, value in fields.items():
|
|
||||||
if key in {"path", "event", "user", "user_id", "ip"} or value is None:
|
|
||||||
continue
|
|
||||||
lines.append(f"{key.replace('_', ' ').capitalize()}: {value}")
|
|
||||||
return lines
|
|
||||||
return [f"{action.replace('_', ' ').capitalize()}"] + [
|
|
||||||
f"{key.replace('_', ' ').capitalize()}: {value}"
|
|
||||||
for key, value in fields.items()
|
|
||||||
if value is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _log_event(
|
|
||||||
logger: logging.Logger,
|
|
||||||
renderer: Callable[[str, Dict[str, Any]], List[str]],
|
|
||||||
action: str,
|
|
||||||
severity: str,
|
|
||||||
fields: Dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
lines = renderer(action, fields)
|
|
||||||
if not lines:
|
|
||||||
return
|
|
||||||
level = getattr(logging, severity.upper(), logging.INFO)
|
|
||||||
logger.log(level, "\n".join(lines))
|
|
||||||
|
|
||||||
|
|
||||||
def log_security(action: str, severity: str = "info", **fields: Any) -> None:
|
|
||||||
_log_event(security_logger, _render_security, action, severity, fields)
|
|
||||||
|
|
||||||
|
|
||||||
def log_public_chat(action: str, severity: str = "info", **fields: Any) -> None:
|
|
||||||
_log_event(public_chat_logger, _render_public_chat, action, severity, fields)
|
|
||||||
|
|
||||||
|
|
||||||
def log_dm(action: str, severity: str = "info", **fields: Any) -> None:
|
|
||||||
sanitized_fields = {key: value for key, value in fields.items() if key != "content"}
|
|
||||||
_log_event(dm_logger, _render_dm, action, severity, sanitized_fields)
|
|
||||||
|
|
||||||
|
|
||||||
def log_access(action: str, severity: str = "info", **fields: Any) -> None:
|
|
||||||
_log_event(access_logger, _render_access, action, severity, fields)
|
|
||||||
|
|
||||||
@@ -1,655 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import unicodedata
|
|
||||||
from pathlib import Path
|
|
||||||
from threading import RLock
|
|
||||||
from typing import Iterable, List, Set, Tuple
|
|
||||||
|
|
||||||
from better_profanity import Profanity
|
|
||||||
|
|
||||||
BLOCKLIST_PATH = Path("data/profanity/blocklist.json")
|
|
||||||
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
_CUSTOM_RU_TERMS: Set[str] = {
|
|
||||||
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
|
||||||
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
|
|
||||||
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
|
|
||||||
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
|
||||||
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
|
|
||||||
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
|
||||||
}
|
|
||||||
|
|
||||||
_ADULT_TERMS: Set[str] = {
|
|
||||||
"порно", "порнуха", "эротика", "эротический", "секс", "сексуальный",
|
|
||||||
"инцест", "порнография", "порностудия", "порновидео", "порносайт",
|
|
||||||
"сексчат", "сексчатик", "секслайв", "сексвидео",
|
|
||||||
}
|
|
||||||
|
|
||||||
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
|
|
||||||
|
|
||||||
# Words that should never be flagged as profanity (whitelist)
|
|
||||||
_WHITELIST: Set[str] = {
|
|
||||||
"говно", # Allow this word
|
|
||||||
}
|
|
||||||
|
|
||||||
# Phrase patterns - these will be applied to normalized text (without special chars)
|
|
||||||
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
|
||||||
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Map for normalizing homoglyphs (similar-looking characters)
|
|
||||||
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
|
|
||||||
# Also includes Greek, full-width, and other Unicode variants
|
|
||||||
_LEET_MAP = {
|
|
||||||
# Numbers to letters
|
|
||||||
"0": "о",
|
|
||||||
"1": "и",
|
|
||||||
"3": "е",
|
|
||||||
"4": "а",
|
|
||||||
# Latin to Cyrillic (lowercase)
|
|
||||||
"a": "а",
|
|
||||||
"c": "с",
|
|
||||||
"e": "е",
|
|
||||||
"f": "ф",
|
|
||||||
"g": "г",
|
|
||||||
"i": "и",
|
|
||||||
"m": "м",
|
|
||||||
"n": "н",
|
|
||||||
"o": "о",
|
|
||||||
"p": "п",
|
|
||||||
"s": "с",
|
|
||||||
"t": "т",
|
|
||||||
"u": "у",
|
|
||||||
"v": "в",
|
|
||||||
"x": "х",
|
|
||||||
"y": "у",
|
|
||||||
"z": "з", # English 'z' to Cyrillic 'з'
|
|
||||||
# Latin to Cyrillic (uppercase)
|
|
||||||
"A": "а",
|
|
||||||
"C": "с",
|
|
||||||
"E": "е",
|
|
||||||
"F": "ф",
|
|
||||||
"G": "г",
|
|
||||||
"I": "и",
|
|
||||||
"M": "м",
|
|
||||||
"N": "н",
|
|
||||||
"O": "о",
|
|
||||||
"P": "п",
|
|
||||||
"S": "с",
|
|
||||||
"T": "т",
|
|
||||||
"U": "у",
|
|
||||||
"V": "в",
|
|
||||||
"X": "х",
|
|
||||||
"Y": "у",
|
|
||||||
"Z": "з", # English 'Z' to Cyrillic 'з'
|
|
||||||
# Greek letters that look like Cyrillic/Latin
|
|
||||||
"α": "а", # Greek alpha
|
|
||||||
"Α": "а",
|
|
||||||
"ο": "о", # Greek omicron
|
|
||||||
"Ο": "о",
|
|
||||||
"ρ": "р", # Greek rho (looks like Cyrillic р)
|
|
||||||
"Ρ": "р",
|
|
||||||
"υ": "у", # Greek upsilon
|
|
||||||
"Υ": "у",
|
|
||||||
"χ": "х", # Greek chi
|
|
||||||
"Χ": "х",
|
|
||||||
"ε": "е", # Greek epsilon
|
|
||||||
"Ε": "е",
|
|
||||||
"ι": "и", # Greek iota
|
|
||||||
"Ι": "и",
|
|
||||||
"ν": "н", # Greek nu
|
|
||||||
"Ν": "н",
|
|
||||||
"μ": "м", # Greek mu
|
|
||||||
"Μ": "м",
|
|
||||||
"π": "п", # Greek pi
|
|
||||||
"Π": "п",
|
|
||||||
"τ": "т", # Greek tau
|
|
||||||
"Τ": "т",
|
|
||||||
"γ": "г", # Greek gamma
|
|
||||||
"Γ": "г",
|
|
||||||
"σ": "с", # Greek sigma
|
|
||||||
"Σ": "с",
|
|
||||||
"φ": "ф", # Greek phi
|
|
||||||
"Φ": "ф",
|
|
||||||
# Full-width Latin characters
|
|
||||||
"a": "а",
|
|
||||||
"A": "а",
|
|
||||||
"c": "с",
|
|
||||||
"C": "с",
|
|
||||||
"e": "е",
|
|
||||||
"E": "е",
|
|
||||||
"f": "ф",
|
|
||||||
"F": "ф",
|
|
||||||
"g": "г",
|
|
||||||
"G": "г",
|
|
||||||
"i": "и",
|
|
||||||
"I": "и",
|
|
||||||
"m": "м",
|
|
||||||
"M": "м",
|
|
||||||
"n": "н",
|
|
||||||
"N": "н",
|
|
||||||
"o": "о",
|
|
||||||
"O": "о",
|
|
||||||
"p": "п",
|
|
||||||
"P": "п",
|
|
||||||
"s": "с",
|
|
||||||
"S": "с",
|
|
||||||
"t": "т",
|
|
||||||
"T": "т",
|
|
||||||
"u": "у",
|
|
||||||
"U": "у",
|
|
||||||
"v": "в",
|
|
||||||
"V": "в",
|
|
||||||
"x": "х",
|
|
||||||
"X": "х",
|
|
||||||
"y": "у",
|
|
||||||
"Y": "у",
|
|
||||||
"z": "з", # Full-width 'z' to Cyrillic 'з'
|
|
||||||
"Z": "з",
|
|
||||||
# Cyrillic to canonical Cyrillic (identity mappings)
|
|
||||||
"а": "а",
|
|
||||||
"с": "с",
|
|
||||||
"е": "е",
|
|
||||||
"ё": "е",
|
|
||||||
"ф": "ф",
|
|
||||||
"г": "г",
|
|
||||||
"и": "и",
|
|
||||||
"м": "м",
|
|
||||||
"н": "н",
|
|
||||||
"о": "о",
|
|
||||||
"п": "п",
|
|
||||||
"т": "т",
|
|
||||||
"у": "у",
|
|
||||||
"ү": "у", # Cyrillic capital U (U+04AE)
|
|
||||||
"Ү": "у", # Cyrillic capital U (U+04AE)
|
|
||||||
"в": "в",
|
|
||||||
"х": "х",
|
|
||||||
"р": "р",
|
|
||||||
"з": "з", # Cyrillic 'з'
|
|
||||||
"д": "д", # Cyrillic 'д'
|
|
||||||
"б": "б", # Cyrillic 'б'
|
|
||||||
"л": "л", # Cyrillic 'л'
|
|
||||||
"я": "я", # Cyrillic 'я'
|
|
||||||
"н": "н", # Already mapped, but explicit
|
|
||||||
# Special characters
|
|
||||||
"@": "а",
|
|
||||||
}
|
|
||||||
|
|
||||||
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
|
||||||
("generic", ("айфон", "топ")),
|
|
||||||
("generic", ("самсунг", "говно")),
|
|
||||||
)
|
|
||||||
|
|
||||||
_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
|
|
||||||
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_char(ch: str) -> str:
|
|
||||||
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
|
||||||
# First try direct mapping (preserves case for non-mapped chars)
|
|
||||||
if ch in _LEET_MAP:
|
|
||||||
return _LEET_MAP[ch]
|
|
||||||
# Then try lowercase mapping
|
|
||||||
lower = ch.lower()
|
|
||||||
if lower in _LEET_MAP:
|
|
||||||
return _LEET_MAP[lower]
|
|
||||||
# If no mapping and character is ASCII letter, return lowercase
|
|
||||||
# This preserves English words like "fromchat" as-is
|
|
||||||
if ch.isascii() and ch.isalpha():
|
|
||||||
return lower
|
|
||||||
# For other characters, return lowercase for consistency
|
|
||||||
return lower
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_token(token: str) -> str:
|
|
||||||
"""Normalize a token by mapping all homoglyphs."""
|
|
||||||
return "".join(_normalize_char(ch) for ch in token)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_text_for_profanity(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Normalize entire text by mapping homoglyphs to canonical forms.
|
|
||||||
This prevents bypasses like using English 'u' instead of Russian 'у'.
|
|
||||||
"""
|
|
||||||
return "".join(_normalize_char(ch) for ch in text)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_zero_width_chars(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Remove zero-width characters that could be used to bypass filters.
|
|
||||||
"""
|
|
||||||
# Zero-width space, zero-width non-joiner, zero-width joiner, etc.
|
|
||||||
zero_width_chars = [
|
|
||||||
'\u200B', # Zero-width space
|
|
||||||
'\u200C', # Zero-width non-joiner
|
|
||||||
'\u200D', # Zero-width joiner
|
|
||||||
'\uFEFF', # Zero-width no-break space
|
|
||||||
'\u2060', # Word joiner
|
|
||||||
'\u2061', # Function application
|
|
||||||
'\u2062', # Invisible times
|
|
||||||
'\u2063', # Invisible separator
|
|
||||||
'\u2064', # Invisible plus
|
|
||||||
]
|
|
||||||
result = text
|
|
||||||
for zw_char in zero_width_chars:
|
|
||||||
result = result.replace(zw_char, '')
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]:
|
|
||||||
"""
|
|
||||||
Extract only alphanumeric characters from text and create a mapping
|
|
||||||
from normalized positions to original positions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(normalized_text, position_map) where position_map[i] is the original
|
|
||||||
position of the i-th character in normalized_text
|
|
||||||
"""
|
|
||||||
# First normalize Unicode (composed vs decomposed)
|
|
||||||
normalized_unicode = unicodedata.normalize('NFKC', text)
|
|
||||||
|
|
||||||
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
|
||||||
if preserve_spaces:
|
|
||||||
zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064']
|
|
||||||
for zw_char in zero_width_chars:
|
|
||||||
normalized_unicode = normalized_unicode.replace(zw_char, ' ')
|
|
||||||
else:
|
|
||||||
# Strip zero-width characters
|
|
||||||
normalized_unicode = _strip_zero_width_chars(normalized_unicode)
|
|
||||||
|
|
||||||
normalized = []
|
|
||||||
position_map = []
|
|
||||||
|
|
||||||
for i, ch in enumerate(normalized_unicode):
|
|
||||||
# Check if character is alphanumeric (including Cyrillic)
|
|
||||||
if ch.isalnum():
|
|
||||||
# For phrase matching, preserve ASCII letters as-is (just lowercase)
|
|
||||||
# to allow English words in patterns to match
|
|
||||||
if preserve_spaces and ch.isascii() and ch.isalpha():
|
|
||||||
normalized.append(ch.lower())
|
|
||||||
else:
|
|
||||||
# Normalize this character (homoglyphs, Cyrillic, etc.)
|
|
||||||
normalized.append(_normalize_char(ch))
|
|
||||||
position_map.append(i)
|
|
||||||
elif preserve_spaces:
|
|
||||||
# For phrase matching, treat any whitespace or non-alphanumeric as word separator
|
|
||||||
if ch.isspace() or not ch.isalnum():
|
|
||||||
# Normalize to single space to allow patterns to match
|
|
||||||
if normalized and normalized[-1] != ' ': # Don't add consecutive spaces
|
|
||||||
normalized.append(' ')
|
|
||||||
position_map.append(i)
|
|
||||||
|
|
||||||
return "".join(normalized), position_map
|
|
||||||
|
|
||||||
|
|
||||||
def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]:
|
|
||||||
"""
|
|
||||||
Check for profane words as substrings or subsequences in normalized text.
|
|
||||||
This catches cases like "хуй" in "хууй" (with extra characters).
|
|
||||||
Returns list of (start, end) positions where profanity is found.
|
|
||||||
"""
|
|
||||||
spans = []
|
|
||||||
normalized_lower = normalized_text.lower()
|
|
||||||
|
|
||||||
for word in profane_words:
|
|
||||||
word_lower = word.lower()
|
|
||||||
|
|
||||||
# First try exact substring match
|
|
||||||
start = 0
|
|
||||||
while True:
|
|
||||||
pos = normalized_lower.find(word_lower, start)
|
|
||||||
if pos == -1:
|
|
||||||
break
|
|
||||||
spans.append((pos, pos + len(word_lower)))
|
|
||||||
start = pos + 1
|
|
||||||
|
|
||||||
# Also check if profane word appears as a subsequence (allowing extra chars)
|
|
||||||
# This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй"
|
|
||||||
# Only do subsequence matching for words of length 4 or more to avoid false positives
|
|
||||||
# Use stricter span limits for shorter words to prevent false matches in long legitimate words
|
|
||||||
if len(word_lower) >= 4:
|
|
||||||
word_chars = list(word_lower)
|
|
||||||
text_chars = list(normalized_lower)
|
|
||||||
# Stricter ratio for shorter words, more lenient for longer words
|
|
||||||
if len(word_lower) <= 5:
|
|
||||||
max_span_ratio = 1.5 # Very strict for short words
|
|
||||||
else:
|
|
||||||
max_span_ratio = 2.0 # Slightly more lenient for longer words
|
|
||||||
|
|
||||||
# Try to find the word as a subsequence
|
|
||||||
i = 0 # position in text
|
|
||||||
j = 0 # position in word
|
|
||||||
seq_start = None
|
|
||||||
|
|
||||||
while i < len(text_chars) and j < len(word_chars):
|
|
||||||
if text_chars[i] == word_chars[j]:
|
|
||||||
if seq_start is None:
|
|
||||||
seq_start = i
|
|
||||||
j += 1
|
|
||||||
if j == len(word_chars):
|
|
||||||
# Found the word as subsequence
|
|
||||||
seq_end = i + 1
|
|
||||||
# Check if the span is reasonable (not too long)
|
|
||||||
span_length = seq_end - seq_start
|
|
||||||
max_allowed_span = int(len(word_lower) * max_span_ratio)
|
|
||||||
if span_length <= max_allowed_span:
|
|
||||||
# Only add if it's not already covered by exact match
|
|
||||||
if (seq_start, seq_end) not in spans:
|
|
||||||
spans.append((seq_start, seq_end))
|
|
||||||
# Reset to find next occurrence - continue from after the end of this match
|
|
||||||
next_start = seq_start + 1
|
|
||||||
seq_start = None
|
|
||||||
j = 0
|
|
||||||
i = next_start
|
|
||||||
continue
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return spans
|
|
||||||
|
|
||||||
|
|
||||||
def _check_profanity_in_normalized(normalized_text: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if normalized text contains profanity.
|
|
||||||
Uses both better_profanity library and substring matching for better detection.
|
|
||||||
|
|
||||||
Returns True if profanity is found.
|
|
||||||
"""
|
|
||||||
if not normalized_text:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check normalized text for profanity using better_profanity
|
|
||||||
censored = _profanity.censor(normalized_text, censor_char="\\*")
|
|
||||||
|
|
||||||
# Check if better_profanity found anything
|
|
||||||
if "*" in censored:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня")
|
|
||||||
profane_words = _STATIC_TERMS
|
|
||||||
substring_spans = _check_profanity_substrings(normalized_text, profane_words)
|
|
||||||
|
|
||||||
# If we found any substring matches, there's profanity
|
|
||||||
if substring_spans:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
|
|
||||||
tokens: List[Tuple[int, int, str]] = []
|
|
||||||
start: int | None = None
|
|
||||||
buffer: List[str] = []
|
|
||||||
|
|
||||||
for idx, ch in enumerate(text):
|
|
||||||
if ch.isalnum() or ch in {"@", "#", "_"}:
|
|
||||||
if start is None:
|
|
||||||
start = idx
|
|
||||||
buffer.append(ch)
|
|
||||||
else:
|
|
||||||
if buffer and start is not None:
|
|
||||||
token_raw = "".join(buffer)
|
|
||||||
tokens.append((start, idx, _normalize_token(token_raw)))
|
|
||||||
buffer.clear()
|
|
||||||
start = None
|
|
||||||
if buffer and start is not None:
|
|
||||||
token_raw = "".join(buffer)
|
|
||||||
tokens.append((start, len(text), _normalize_token(token_raw)))
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
|
|
||||||
def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool:
|
|
||||||
if a == b:
|
|
||||||
return True
|
|
||||||
if max_distance <= 0:
|
|
||||||
return False
|
|
||||||
if abs(len(a) - len(b)) > max_distance:
|
|
||||||
return False
|
|
||||||
|
|
||||||
previous = list(range(len(b) + 1))
|
|
||||||
for i, ca in enumerate(a, 1):
|
|
||||||
current = [i]
|
|
||||||
best = current[0]
|
|
||||||
for j, cb in enumerate(b, 1):
|
|
||||||
insert_cost = current[j - 1] + 1
|
|
||||||
delete_cost = previous[j] + 1
|
|
||||||
replace_cost = previous[j - 1] + (0 if ca == cb else 1)
|
|
||||||
cost = min(insert_cost, delete_cost, replace_cost)
|
|
||||||
current.append(cost)
|
|
||||||
if cost < best:
|
|
||||||
best = cost
|
|
||||||
if best > max_distance:
|
|
||||||
return False
|
|
||||||
previous = current
|
|
||||||
return previous[-1] <= max_distance
|
|
||||||
|
|
||||||
|
|
||||||
def _load_sensitive_phrases() -> List[Tuple[str, ...]]:
|
|
||||||
if not _SENSITIVE_PHRASE_PATH.exists():
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8"))
|
|
||||||
phrases: List[Tuple[str, ...]] = []
|
|
||||||
if isinstance(payload, list):
|
|
||||||
for entry in payload:
|
|
||||||
if isinstance(entry, list) and entry:
|
|
||||||
normalized = tuple(str(part).strip() for part in entry if str(part).strip())
|
|
||||||
if normalized:
|
|
||||||
phrases.append(normalized)
|
|
||||||
return phrases
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]:
|
|
||||||
if group not in _PHRASE_CACHE:
|
|
||||||
base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group]
|
|
||||||
if group == "sensitive":
|
|
||||||
base.extend(_load_sensitive_phrases())
|
|
||||||
_PHRASE_CACHE[group] = tuple(
|
|
||||||
tuple(_normalize_token(part) for part in phrase)
|
|
||||||
for phrase in base
|
|
||||||
)
|
|
||||||
return _PHRASE_CACHE[group]
|
|
||||||
|
|
||||||
|
|
||||||
def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]:
|
|
||||||
tokens = _tokenize_with_spans(text)
|
|
||||||
if not tokens:
|
|
||||||
return []
|
|
||||||
|
|
||||||
spans: List[Tuple[int, int]] = []
|
|
||||||
normalized_phrases = _get_phrases(group)
|
|
||||||
|
|
||||||
for index in range(len(tokens)):
|
|
||||||
for phrase in normalized_phrases:
|
|
||||||
if index + len(phrase) > len(tokens):
|
|
||||||
continue
|
|
||||||
matches = True
|
|
||||||
for offset, target in enumerate(phrase):
|
|
||||||
token = tokens[index + offset][2]
|
|
||||||
if not _edit_distance_limited(token, target):
|
|
||||||
matches = False
|
|
||||||
break
|
|
||||||
if matches:
|
|
||||||
span_start = tokens[index][0]
|
|
||||||
span_end = tokens[index + len(phrase) - 1][1]
|
|
||||||
spans.append((span_start, span_end))
|
|
||||||
return spans
|
|
||||||
|
|
||||||
_dictionary_lock = RLock()
|
|
||||||
_blocklist_signature: Tuple[str, ...] | None = None
|
|
||||||
_profanity = Profanity()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_words(words: Iterable[str]) -> Set[str]:
|
|
||||||
normalized: Set[str] = set()
|
|
||||||
for raw in words:
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower()
|
|
||||||
if cleaned:
|
|
||||||
normalized.add(cleaned)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _load_blocklist() -> Set[str]:
|
|
||||||
if not BLOCKLIST_PATH.exists():
|
|
||||||
return set()
|
|
||||||
try:
|
|
||||||
data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8"))
|
|
||||||
if isinstance(data, list):
|
|
||||||
return _normalize_words(data)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def _write_blocklist(words: Iterable[str]) -> None:
|
|
||||||
BLOCKLIST_PATH.write_text(
|
|
||||||
json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n",
|
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rebuild_dictionary(force: bool = False) -> None:
|
|
||||||
global _profanity, _blocklist_signature
|
|
||||||
with _dictionary_lock:
|
|
||||||
blocklist_list = sorted(_load_blocklist())
|
|
||||||
signature = tuple(blocklist_list)
|
|
||||||
if not force and _blocklist_signature == signature and _blocklist_signature is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
profanity = Profanity()
|
|
||||||
profanity.load_censor_words()
|
|
||||||
# Remove whitelisted words from the default word list
|
|
||||||
try:
|
|
||||||
for word in _WHITELIST:
|
|
||||||
profanity.remove_censor_words([word])
|
|
||||||
except AttributeError:
|
|
||||||
# If remove_censor_words doesn't exist, we'll handle it in post-processing
|
|
||||||
pass
|
|
||||||
combined = set(_STATIC_TERMS)
|
|
||||||
combined.update(blocklist_list)
|
|
||||||
# Remove whitelisted words from our custom terms
|
|
||||||
combined -= _WHITELIST
|
|
||||||
if combined:
|
|
||||||
profanity.add_censor_words(list(combined))
|
|
||||||
|
|
||||||
_profanity = profanity
|
|
||||||
_blocklist_signature = signature
|
|
||||||
|
|
||||||
|
|
||||||
def _check_phrase_patterns(text: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if text matches any phrase patterns.
|
|
||||||
Returns True if any pattern matches.
|
|
||||||
"""
|
|
||||||
# Normalize text for phrase matching (remove special chars but preserve spaces)
|
|
||||||
normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True)
|
|
||||||
normalized_lower = normalized_text.lower()
|
|
||||||
|
|
||||||
# Check phrase patterns
|
|
||||||
for pattern in _PHRASE_PATTERNS:
|
|
||||||
if pattern.search(normalized_lower):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check fuzzy phrase spans
|
|
||||||
if _find_fuzzy_phrase_spans(normalized_lower, "generic"):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def contains_profanity(text: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if text contains profanity.
|
|
||||||
Returns True if profanity is detected.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
|
|
||||||
_rebuild_dictionary()
|
|
||||||
|
|
||||||
# Check phrase patterns first
|
|
||||||
if _check_phrase_patterns(text):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Normalize text for whitelist matching (to handle special characters)
|
|
||||||
normalized_for_whitelist, _ = _extract_alphanumeric_with_mapping(text)
|
|
||||||
normalized_for_whitelist_lower = normalized_for_whitelist.lower()
|
|
||||||
|
|
||||||
# Check if text contains whitelisted words - if the entire text is a whitelisted word, skip profanity check
|
|
||||||
for whitelist_word in _WHITELIST:
|
|
||||||
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
|
|
||||||
normalized_whitelist_lower = normalized_whitelist.lower()
|
|
||||||
|
|
||||||
# Check if the normalized text exactly matches a whitelisted word
|
|
||||||
if normalized_for_whitelist_lower == normalized_whitelist_lower:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Extract only alphanumeric characters and normalize homoglyphs
|
|
||||||
# This removes special characters, emojis, etc. that could be used to bypass the filter
|
|
||||||
normalized_text, _ = _extract_alphanumeric_with_mapping(text)
|
|
||||||
|
|
||||||
# Check profanity on normalized text (without special characters)
|
|
||||||
return _check_profanity_in_normalized(normalized_text)
|
|
||||||
|
|
||||||
|
|
||||||
def contains_sensitive_phrase(text: str) -> bool:
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
if _find_fuzzy_phrase_spans(text, "sensitive"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_blocklist() -> List[str]:
|
|
||||||
with _dictionary_lock:
|
|
||||||
return sorted(_load_blocklist())
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
|
||||||
normalized = _normalize_words(words)
|
|
||||||
if not normalized:
|
|
||||||
return [], get_blocklist()
|
|
||||||
|
|
||||||
with _dictionary_lock:
|
|
||||||
current = _load_blocklist()
|
|
||||||
added = sorted(normalized - current)
|
|
||||||
if not added:
|
|
||||||
return [], sorted(current)
|
|
||||||
|
|
||||||
updated = sorted(current | normalized)
|
|
||||||
_write_blocklist(updated)
|
|
||||||
_rebuild_dictionary(force=True)
|
|
||||||
return added, updated
|
|
||||||
|
|
||||||
|
|
||||||
def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
|
||||||
normalized = _normalize_words(words)
|
|
||||||
if not normalized:
|
|
||||||
return [], get_blocklist()
|
|
||||||
|
|
||||||
with _dictionary_lock:
|
|
||||||
current = _load_blocklist()
|
|
||||||
removed = sorted(word for word in normalized if word in current)
|
|
||||||
if not removed:
|
|
||||||
return [], sorted(current)
|
|
||||||
|
|
||||||
updated = sorted(current - normalized)
|
|
||||||
_write_blocklist(updated)
|
|
||||||
_rebuild_dictionary(force=True)
|
|
||||||
return removed, updated
|
|
||||||
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Callable
|
|
||||||
from fastapi import Request
|
|
||||||
from slowapi import Limiter
|
|
||||||
from slowapi.util import get_remote_address
|
|
||||||
|
|
||||||
from utils import get_client_ip
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
def get_ip_key(request: Request) -> str:
|
|
||||||
"""Get rate limit key based on IP address."""
|
|
||||||
return get_client_ip(request) or get_remote_address(request)
|
|
||||||
|
|
||||||
# Initialize limiter with IP-based key function
|
|
||||||
# Note: We don't set default_limits to avoid affecting all users if one IP is attacked.
|
|
||||||
# Each endpoint should have an explicit rate limit based on its sensitivity.
|
|
||||||
# Rate limits automatically expire after the time window - IPs are not permanently blocked.
|
|
||||||
limiter = Limiter(
|
|
||||||
key_func=get_ip_key,
|
|
||||||
default_limits=[], # No global default - each endpoint must have explicit limits
|
|
||||||
storage_uri="memory://", # In-memory storage (can be changed to Redis later)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Rate limit decorator for IP-based limiting
|
|
||||||
def rate_limit_per_ip(limit: str) -> Callable:
|
|
||||||
"""Rate limit based on IP address."""
|
|
||||||
return limiter.limit(limit, key_func=get_ip_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_storage_dict(storage) -> dict | None:
|
|
||||||
"""Get the internal storage dictionary from slowapi's memory storage."""
|
|
||||||
if hasattr(storage, "_storage") and isinstance(storage._storage, dict):
|
|
||||||
return storage._storage
|
|
||||||
elif hasattr(storage, "storage") and isinstance(storage.storage, dict):
|
|
||||||
return storage.storage
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def reset_all_rate_limits() -> int:
|
|
||||||
"""
|
|
||||||
Reset all rate limits by clearing the storage.
|
|
||||||
This should be called on startup to ensure a clean state.
|
|
||||||
Returns the number of entries cleared.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Access the private _storage attribute
|
|
||||||
storage = limiter._storage
|
|
||||||
storage_dict = _get_storage_dict(storage)
|
|
||||||
|
|
||||||
if storage_dict is None:
|
|
||||||
# Try using the storage's reset method if available
|
|
||||||
if hasattr(storage, "reset"):
|
|
||||||
try:
|
|
||||||
# Try reset() with no args first (clears all)
|
|
||||||
storage.reset()
|
|
||||||
logger.info("Reset all rate limits on startup using storage.reset()")
|
|
||||||
return 1 # Assume it worked
|
|
||||||
except TypeError:
|
|
||||||
# reset() might require arguments, try clearing differently
|
|
||||||
try:
|
|
||||||
# Some storage backends need explicit clearing
|
|
||||||
if hasattr(storage, "clear"):
|
|
||||||
storage.clear()
|
|
||||||
logger.info("Reset all rate limits on startup using storage.clear()")
|
|
||||||
return 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.warning("Could not reset rate limits: storage dict not accessible and no reset method")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
count = len(storage_dict)
|
|
||||||
if count > 0:
|
|
||||||
storage_dict.clear()
|
|
||||||
logger.info(f"Reset all rate limits on startup: cleared {count} entries")
|
|
||||||
return count
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def reset_rate_limit_for_ip(ip: str) -> bool:
|
|
||||||
"""
|
|
||||||
Manually reset rate limit for a specific IP address.
|
|
||||||
This clears all rate limit entries for the given IP.
|
|
||||||
Returns True if any entries were cleared, False otherwise.
|
|
||||||
"""
|
|
||||||
if not ip:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Access the private _storage attribute
|
|
||||||
storage = limiter._storage
|
|
||||||
storage_dict = _get_storage_dict(storage)
|
|
||||||
|
|
||||||
if storage_dict is None:
|
|
||||||
# Try alternative methods
|
|
||||||
if hasattr(storage, "reset"):
|
|
||||||
try:
|
|
||||||
storage.reset(ip)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
cleared = False
|
|
||||||
# slowapi stores entries with keys like "LIMITER:{ip}:{endpoint}"
|
|
||||||
# We need to find all keys that contain this IP
|
|
||||||
# Also handle cases where IP might be in different positions
|
|
||||||
keys_to_remove = []
|
|
||||||
|
|
||||||
for key in list(storage_dict.keys()):
|
|
||||||
if isinstance(key, str):
|
|
||||||
# Check multiple patterns:
|
|
||||||
# - "LIMITER:{ip}:{endpoint}"
|
|
||||||
# - Keys containing the IP anywhere
|
|
||||||
# - Keys starting with the IP
|
|
||||||
if (key.startswith(f"LIMITER:{ip}:") or
|
|
||||||
key.startswith(f"LIMITER:{ip}") or
|
|
||||||
f":{ip}:" in key or
|
|
||||||
key.endswith(f":{ip}") or
|
|
||||||
(ip in key and "LIMITER" in key)):
|
|
||||||
keys_to_remove.append(key)
|
|
||||||
|
|
||||||
for key in keys_to_remove:
|
|
||||||
try:
|
|
||||||
del storage_dict[key]
|
|
||||||
cleared = True
|
|
||||||
logger.info(f"Cleared rate limit key: {key}")
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if cleared:
|
|
||||||
logger.info(f"Successfully cleared rate limits for IP: {ip}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"No rate limit entries found for IP: {ip}")
|
|
||||||
|
|
||||||
return cleared
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to reset rate limit for IP {ip}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def clear_all_rate_limits() -> int:
|
|
||||||
"""
|
|
||||||
Clear all rate limit entries. Use with caution - this affects all IPs.
|
|
||||||
Returns the number of entries cleared.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Access the private _storage attribute
|
|
||||||
storage = limiter._storage
|
|
||||||
storage_dict = _get_storage_dict(storage)
|
|
||||||
|
|
||||||
if storage_dict is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
count = len(storage_dict)
|
|
||||||
storage_dict.clear()
|
|
||||||
logger.warning(f"Cleared all {count} rate limit entries")
|
|
||||||
return count
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to clear all rate limits: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired_rate_limits() -> int:
|
|
||||||
"""
|
|
||||||
Clean up expired rate limit entries from memory storage.
|
|
||||||
This helps prevent rate limits from being stuck indefinitely.
|
|
||||||
Returns the number of entries cleaned up.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Access the private _storage attribute
|
|
||||||
storage = limiter._storage
|
|
||||||
storage_dict = _get_storage_dict(storage)
|
|
||||||
|
|
||||||
if storage_dict is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# slowapi's memory storage stores entries as tuples: (count, reset_time)
|
|
||||||
# Entries should expire naturally, but we'll clean up any that are clearly expired
|
|
||||||
now = time.time()
|
|
||||||
cleaned = 0
|
|
||||||
keys_to_remove = []
|
|
||||||
|
|
||||||
for key, value in storage_dict.items():
|
|
||||||
if isinstance(value, (tuple, list)) and len(value) >= 2:
|
|
||||||
# Check if reset_time has passed (with some buffer)
|
|
||||||
reset_time = value[1] if isinstance(value[1], (int, float)) else 0
|
|
||||||
# Add 60 second buffer to ensure we don't remove active entries
|
|
||||||
if reset_time > 0 and now > (reset_time + 60):
|
|
||||||
keys_to_remove.append(key)
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
# Some storage formats use dicts with 'expiry' or 'reset' fields
|
|
||||||
expiry = value.get("expiry") or value.get("reset") or value.get("reset_time")
|
|
||||||
if expiry and isinstance(expiry, (int, float)) and now > (expiry + 60):
|
|
||||||
keys_to_remove.append(key)
|
|
||||||
|
|
||||||
for key in keys_to_remove:
|
|
||||||
try:
|
|
||||||
del storage_dict[key]
|
|
||||||
cleaned += 1
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if cleaned > 0:
|
|
||||||
logger.info(f"Cleaned up {cleaned} expired rate limit entries")
|
|
||||||
|
|
||||||
return cleaned
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to cleanup expired rate limits: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
async def start_rate_limit_cleanup_task() -> None:
|
|
||||||
"""Start a background task to periodically clean up expired rate limit entries."""
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(300) # Run every 5 minutes
|
|
||||||
cleanup_expired_rate_limits()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in rate limit cleanup task: {e}")
|
|
||||||
await asyncio.sleep(60) # Wait 1 minute before retrying
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"""
|
|
||||||
Similarity detection utilities for username and display name comparison.
|
|
||||||
Implements both edit distance and visual similarity detection.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def levenshtein_distance(s1: str, s2: str) -> int:
|
|
||||||
"""Calculate Levenshtein distance between two strings."""
|
|
||||||
if len(s1) < len(s2):
|
|
||||||
return levenshtein_distance(s2, s1)
|
|
||||||
|
|
||||||
if len(s2) == 0:
|
|
||||||
return len(s1)
|
|
||||||
|
|
||||||
previous_row = list(range(len(s2) + 1))
|
|
||||||
for i, c1 in enumerate(s1):
|
|
||||||
current_row = [i + 1]
|
|
||||||
for j, c2 in enumerate(s2):
|
|
||||||
insertions = previous_row[j + 1] + 1
|
|
||||||
deletions = current_row[j] + 1
|
|
||||||
substitutions = previous_row[j] + (c1 != c2)
|
|
||||||
current_row.append(min(insertions, deletions, substitutions))
|
|
||||||
previous_row = current_row
|
|
||||||
|
|
||||||
return previous_row[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def check_visual_similarity(s1: str, s2: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if two strings are visually similar using common homoglyphs.
|
|
||||||
Returns True if strings are visually similar.
|
|
||||||
"""
|
|
||||||
if len(s1) != len(s2):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Common homoglyph mappings
|
|
||||||
homoglyphs = {
|
|
||||||
'0': ['O', 'o', 'Q'],
|
|
||||||
'O': ['0', 'o', 'Q'],
|
|
||||||
'o': ['0', 'O', 'Q'],
|
|
||||||
'1': ['l', 'I', '|'],
|
|
||||||
'l': ['1', 'I', '|'],
|
|
||||||
'I': ['1', 'l', '|'],
|
|
||||||
'5': ['S', 's'],
|
|
||||||
'S': ['5', 's'],
|
|
||||||
's': ['5', 'S'],
|
|
||||||
'6': ['G', 'g'],
|
|
||||||
'G': ['6', 'g'],
|
|
||||||
'g': ['6', 'G'],
|
|
||||||
'8': ['B', 'b'],
|
|
||||||
'B': ['8', 'b'],
|
|
||||||
'b': ['8', 'B'],
|
|
||||||
'9': ['g', 'q'],
|
|
||||||
'g': ['9', 'q'],
|
|
||||||
'q': ['9', 'g'],
|
|
||||||
'2': ['Z', 'z'],
|
|
||||||
'Z': ['2', 'z'],
|
|
||||||
'z': ['2', 'Z'],
|
|
||||||
'3': ['E'],
|
|
||||||
'E': ['3'],
|
|
||||||
'4': ['A'],
|
|
||||||
'A': ['4'],
|
|
||||||
'7': ['T', 't'],
|
|
||||||
'T': ['7', 't'],
|
|
||||||
't': ['7', 'T'],
|
|
||||||
}
|
|
||||||
|
|
||||||
for i in range(len(s1)):
|
|
||||||
c1, c2 = s1[i], s2[i]
|
|
||||||
if c1 == c2:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if characters are homoglyphs
|
|
||||||
if (c1 in homoglyphs and c2 in homoglyphs[c1]) or \
|
|
||||||
(c2 in homoglyphs and c1 in homoglyphs[c2]):
|
|
||||||
continue
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def check_username_similarity(username1: str, username2: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if two usernames are similar using both edit distance and visual similarity.
|
|
||||||
Returns True if usernames are considered similar.
|
|
||||||
"""
|
|
||||||
if username1 == username2:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check edit distance (Levenshtein distance <= 2)
|
|
||||||
edit_distance = levenshtein_distance(username1.lower(), username2.lower())
|
|
||||||
if edit_distance <= 2:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check visual similarity
|
|
||||||
if check_visual_similarity(username1, username2):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_display_name_similarity(display_name1: str, display_name2: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if two display names are similar using both edit distance and visual similarity.
|
|
||||||
Returns True if display names are considered similar.
|
|
||||||
"""
|
|
||||||
if display_name1 == display_name2:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check edit distance (Levenshtein distance <= 2)
|
|
||||||
edit_distance = levenshtein_distance(display_name1.lower(), display_name2.lower())
|
|
||||||
if edit_distance <= 2:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check visual similarity
|
|
||||||
if check_visual_similarity(display_name1, display_name2):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_user_similar_to_verified(user_username: str, user_display_name: str,
|
|
||||||
verified_users: list[dict]) -> tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
Check if a user is similar to any verified user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_username: Username to check
|
|
||||||
user_display_name: Display name to check
|
|
||||||
verified_users: List of verified user dictionaries with 'username' and 'display_name' keys
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_similar, similar_to_username)
|
|
||||||
"""
|
|
||||||
for verified_user in verified_users:
|
|
||||||
verified_username = verified_user.get('username', '')
|
|
||||||
verified_display_name = verified_user.get('display_name', '')
|
|
||||||
|
|
||||||
# Check username similarity
|
|
||||||
if check_username_similarity(user_username, verified_username):
|
|
||||||
return True, verified_username
|
|
||||||
|
|
||||||
# Check display name similarity
|
|
||||||
if check_display_name_similarity(user_display_name, verified_display_name):
|
|
||||||
return True, verified_username
|
|
||||||
|
|
||||||
return False, ""
|
|
||||||
+5
-43
@@ -1,20 +1,17 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import Request
|
|
||||||
import jwt
|
import jwt
|
||||||
from typing import Optional, Any
|
from typing import Optional
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
from constants import MAX_TOKEN_LIFETIME_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
|
from constants import *
|
||||||
|
|
||||||
# JWT Helper Functions
|
# JWT Helper Functions
|
||||||
def create_token(user_id: int, username: str, session_id: str) -> str:
|
def create_token(user_id: int, username: str) -> str:
|
||||||
# Set a long expiration as safety net (actual expiration based on inactivity)
|
expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||||
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
|
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"username": username,
|
"username": username,
|
||||||
"session_id": session_id,
|
"exp": expire
|
||||||
"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)
|
||||||
|
|
||||||
@@ -34,38 +31,3 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|||||||
|
|
||||||
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
|
|
||||||
+1
-11
@@ -3,17 +3,7 @@ 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
|
||||||
# Only allow English letters, numbers, dashes and underscores
|
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', username):
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
from websocket.registry import WebSocketHandlerRegistry
|
|
||||||
|
|
||||||
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
|
|
||||||
# Import them directly from websocket.handlers when needed
|
|
||||||
|
|
||||||
__all__ = ["WebSocketHandlerRegistry"]
|
|
||||||
|
|
||||||
@@ -1,572 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
from fastapi import HTTPException, WebSocket
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from websocket.registry import WebSocketHandlerRegistry
|
|
||||||
from routes.messaging import (
|
|
||||||
MessaggingSocketManager,
|
|
||||||
_send_message_internal,
|
|
||||||
get_messages,
|
|
||||||
edit_message,
|
|
||||||
delete_message,
|
|
||||||
add_reaction,
|
|
||||||
add_dm_reaction,
|
|
||||||
)
|
|
||||||
from models import (
|
|
||||||
User,
|
|
||||||
SendMessageRequest,
|
|
||||||
EditMessageRequest,
|
|
||||||
DMEnvelope,
|
|
||||||
ReactionRequest,
|
|
||||||
DMReactionRequest,
|
|
||||||
UpdateLog,
|
|
||||||
)
|
|
||||||
from security.audit import log_access, log_dm
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
# Create global registry instance
|
|
||||||
handler_registry = WebSocketHandlerRegistry()
|
|
||||||
|
|
||||||
# Create decorator alias
|
|
||||||
websocket_handler = handler_registry.register
|
|
||||||
|
|
||||||
|
|
||||||
def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None:
|
|
||||||
"""Log WebSocket event."""
|
|
||||||
ws_path = getattr(getattr(websocket, "url", None), "path", None)
|
|
||||||
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
|
|
||||||
ws_path = websocket.scope.get("path")
|
|
||||||
ws_path = ws_path or "unknown"
|
|
||||||
headers = {}
|
|
||||||
if isinstance(getattr(websocket, "scope", None), dict):
|
|
||||||
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
|
|
||||||
xff = headers.get("x-forwarded-for")
|
|
||||||
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
|
|
||||||
|
|
||||||
log_access(
|
|
||||||
"ws_event",
|
|
||||||
path=ws_path,
|
|
||||||
event=event,
|
|
||||||
user=user.username if user else None,
|
|
||||||
user_id=user.id if user else None,
|
|
||||||
ip=client_ip,
|
|
||||||
**extra,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("getUpdates", authRequired=True)
|
|
||||||
async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Handle gap detection - client requests updates from a specific sequence number."""
|
|
||||||
last_seq = data.get("lastSeq", 0)
|
|
||||||
manager.last_seq_by_ws[websocket] = last_seq
|
|
||||||
current_seq = manager.sequence_numbers.get(user.id, 0)
|
|
||||||
|
|
||||||
# Query database for missed updates
|
|
||||||
missed_updates = []
|
|
||||||
if last_seq > 0 and last_seq < current_seq:
|
|
||||||
try:
|
|
||||||
# Get all updates between last_seq and current_seq
|
|
||||||
update_logs = db.query(UpdateLog).filter(
|
|
||||||
UpdateLog.user_id == user.id,
|
|
||||||
UpdateLog.sequence > last_seq,
|
|
||||||
UpdateLog.sequence <= current_seq
|
|
||||||
).order_by(UpdateLog.sequence.asc()).all()
|
|
||||||
|
|
||||||
# Each log entry contains a batch of updates with the same sequence number
|
|
||||||
for log_entry in update_logs:
|
|
||||||
updates = json.loads(log_entry.updates)
|
|
||||||
missed_updates.append({
|
|
||||||
"seq": log_entry.sequence,
|
|
||||||
"updates": updates
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to retrieve missed updates: {e}")
|
|
||||||
|
|
||||||
# Send missed updates directly (not through return value)
|
|
||||||
for batch in missed_updates:
|
|
||||||
await websocket.send_json({
|
|
||||||
"type": "updates",
|
|
||||||
"seq": batch["seq"],
|
|
||||||
"updates": batch["updates"]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Update the websocket's last sequence tracking
|
|
||||||
manager.last_seq_by_ws[websocket] = current_seq
|
|
||||||
log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"lastSeq": current_seq,
|
|
||||||
"missedCount": len(missed_updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("ping", authRequired=True)
|
|
||||||
async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Handle ping - authenticate and set user online."""
|
|
||||||
# Set user online in DB
|
|
||||||
user.online = True
|
|
||||||
user.last_seen = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
# Add to online users
|
|
||||||
manager.online_users.add(user.id)
|
|
||||||
# Broadcast status change
|
|
||||||
await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "ping")
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("getMessages", authRequired=True)
|
|
||||||
async def getMessages(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Get all public chat messages."""
|
|
||||||
result = await get_messages(user, db)
|
|
||||||
log(manager, websocket, user, "getMessages")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("sendMessage", authRequired=True)
|
|
||||||
async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Send a public chat message."""
|
|
||||||
message_request: SendMessageRequest = SendMessageRequest.model_validate(data)
|
|
||||||
|
|
||||||
# Call internal function directly (rate limiting is handled at infrastructure level via Caddy)
|
|
||||||
response = await _send_message_internal(message_request, user, db, [])
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "newMessage",
|
|
||||||
"data": response["message"]
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"])
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("dmSend", authRequired=True)
|
|
||||||
async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Send a direct message."""
|
|
||||||
payload = data
|
|
||||||
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
|
||||||
for key in required:
|
|
||||||
if key not in payload:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
|
||||||
|
|
||||||
env = DMEnvelope(
|
|
||||||
sender_id=user.id,
|
|
||||||
recipient_id=int(payload["recipientId"]),
|
|
||||||
iv_b64=payload["iv"],
|
|
||||||
ciphertext_b64=payload["ciphertext"],
|
|
||||||
salt_b64=payload["salt"],
|
|
||||||
iv2_b64=payload["iv2"],
|
|
||||||
wrapped_mk_b64=payload["wrappedMk"],
|
|
||||||
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
|
|
||||||
)
|
|
||||||
db.add(env)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(env)
|
|
||||||
|
|
||||||
payload_ws = {
|
|
||||||
"type": "dmNew",
|
|
||||||
"data": {
|
|
||||||
"id": env.id,
|
|
||||||
"senderId": env.sender_id,
|
|
||||||
"recipientId": env.recipient_id,
|
|
||||||
"iv": env.iv_b64,
|
|
||||||
"ciphertext": env.ciphertext_b64,
|
|
||||||
"salt": env.salt_b64,
|
|
||||||
"iv2": env.iv2_b64,
|
|
||||||
"wrappedMk": env.wrapped_mk_b64,
|
|
||||||
"timestamp": env.timestamp.isoformat(),
|
|
||||||
"replyToId": env.reply_to_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send push notification for DM
|
|
||||||
try:
|
|
||||||
from push_service import push_service
|
|
||||||
await push_service.send_dm_notification(db, env, user)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
|
||||||
|
|
||||||
await manager.send_update_to_user(env.recipient_id, "dmNew", payload_ws["data"], db)
|
|
||||||
await manager.send_update_to_user(env.sender_id, "dmNew", payload_ws["data"], db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id)
|
|
||||||
log_dm(
|
|
||||||
"message_sent_ws",
|
|
||||||
dm_envelope_id=env.id,
|
|
||||||
sender_id=user.id,
|
|
||||||
sender_username=user.username,
|
|
||||||
recipient_id=env.recipient_id,
|
|
||||||
reply_to=env.reply_to_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "ok", "id": env.id}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("editMessage", authRequired=True)
|
|
||||||
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Edit a public chat message."""
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
message_id = data["message_id"]
|
|
||||||
request: EditMessageRequest = EditMessageRequest.model_validate(data)
|
|
||||||
|
|
||||||
# Create a dummy request object for the HTTP endpoint function
|
|
||||||
dummy_request = SimpleNamespace()
|
|
||||||
response = await edit_message(dummy_request, message_id, request, user, db)
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "messageEdited",
|
|
||||||
"data": response["message"]
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "editMessage", message_id=message_id)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("dmEdit", authRequired=True)
|
|
||||||
async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Edit a direct message."""
|
|
||||||
payload = data
|
|
||||||
env_id = int(payload["id"])
|
|
||||||
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
|
||||||
if not env:
|
|
||||||
raise HTTPException(status_code=404, detail="DM not found")
|
|
||||||
if env.sender_id != user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
|
||||||
|
|
||||||
# Replace ciphertext and iv
|
|
||||||
env.iv_b64 = payload["iv"]
|
|
||||||
env.ciphertext_b64 = payload["ciphertext"]
|
|
||||||
env.iv2_b64 = payload["iv2"]
|
|
||||||
env.wrapped_mk_b64 = payload["wrappedMk"]
|
|
||||||
env.salt_b64 = payload["salt"]
|
|
||||||
db.commit()
|
|
||||||
db.refresh(env)
|
|
||||||
|
|
||||||
payload_ws = {
|
|
||||||
"type": "dmEdited",
|
|
||||||
"data": {
|
|
||||||
"id": env.id,
|
|
||||||
"senderId": env.sender_id,
|
|
||||||
"recipientId": env.recipient_id,
|
|
||||||
"iv": env.iv_b64,
|
|
||||||
"ciphertext": env.ciphertext_b64,
|
|
||||||
"iv2": env.iv2_b64,
|
|
||||||
"wrappedMk": env.wrapped_mk_b64,
|
|
||||||
"salt": env.salt_b64,
|
|
||||||
"timestamp": env.timestamp.isoformat(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await manager.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db)
|
|
||||||
await manager.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "dmEdit", dm_envelope_id=env.id)
|
|
||||||
log_dm(
|
|
||||||
"message_edited",
|
|
||||||
dm_envelope_id=env.id,
|
|
||||||
user_id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "ok", "id": env.id}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("dmDelete", authRequired=True)
|
|
||||||
async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Delete a direct message."""
|
|
||||||
payload = data
|
|
||||||
env_id = int(payload["id"])
|
|
||||||
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
|
||||||
if not env:
|
|
||||||
raise HTTPException(status_code=404, detail="DM not found")
|
|
||||||
if env.sender_id != user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
|
||||||
|
|
||||||
db.delete(env)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
payload_ws = {
|
|
||||||
"type": "dmDeleted",
|
|
||||||
"data": {
|
|
||||||
"id": env_id,
|
|
||||||
"senderId": user.id,
|
|
||||||
"recipientId": payload.get("recipientId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await manager.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db)
|
|
||||||
await manager.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "dmDelete", dm_envelope_id=env_id)
|
|
||||||
log_dm(
|
|
||||||
"message_deleted",
|
|
||||||
dm_envelope_id=env_id,
|
|
||||||
user_id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
recipient_id=env.recipient_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "ok", "id": env_id}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("deleteMessage", authRequired=True)
|
|
||||||
async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Delete a public chat message."""
|
|
||||||
message_id = data["message_id"]
|
|
||||||
response = await delete_message(message_id, user, db)
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "messageDeleted",
|
|
||||||
"data": {"message_id": message_id}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "deleteMessage", message_id=message_id)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("addReaction", authRequired=True)
|
|
||||||
async def addReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Add or remove a reaction to a public chat message."""
|
|
||||||
reaction_request = ReactionRequest(
|
|
||||||
message_id=data["message_id"],
|
|
||||||
emoji=data["emoji"]
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await add_reaction(reaction_request, user, db)
|
|
||||||
|
|
||||||
# Broadcast reaction update
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "reactionUpdate",
|
|
||||||
"data": {
|
|
||||||
"message_id": data["message_id"],
|
|
||||||
"emoji": data["emoji"],
|
|
||||||
"action": response["action"],
|
|
||||||
"user_id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"reactions": response["reactions"]
|
|
||||||
}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "addReaction", message_id=data["message_id"], emoji=data["emoji"], action=response["action"])
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("addDmReaction", authRequired=True)
|
|
||||||
async def addDmReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Add or remove a reaction to a direct message."""
|
|
||||||
reaction_request = DMReactionRequest(
|
|
||||||
dm_envelope_id=data["dm_envelope_id"],
|
|
||||||
emoji=data["emoji"]
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await add_dm_reaction(reaction_request, user, db)
|
|
||||||
|
|
||||||
# Broadcast reaction update
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "dmReactionUpdate",
|
|
||||||
"data": {
|
|
||||||
"dm_envelope_id": data["dm_envelope_id"],
|
|
||||||
"emoji": data["emoji"],
|
|
||||||
"action": response["action"],
|
|
||||||
"user_id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"reactions": response["reactions"]
|
|
||||||
}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "addDmReaction", dm_envelope_id=data["dm_envelope_id"], emoji=data["emoji"], action=response["action"])
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("call_signaling", authRequired=True)
|
|
||||||
async def call_signaling(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Forward WebRTC signaling between peers."""
|
|
||||||
payload = data or {}
|
|
||||||
to_user_id = int(payload.get("toUserId") or 0)
|
|
||||||
if not to_user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing toUserId")
|
|
||||||
|
|
||||||
# Ensure sender is set by the server
|
|
||||||
payload["fromUserId"] = user.id
|
|
||||||
payload["fromUsername"] = user.username
|
|
||||||
|
|
||||||
await manager.send_to_user(to_user_id, {
|
|
||||||
"type": "call_signaling",
|
|
||||||
"data": payload
|
|
||||||
})
|
|
||||||
|
|
||||||
log(manager, websocket, user, "call_signaling", to_user_id=to_user_id)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("call_video_toggle", authRequired=True)
|
|
||||||
async def call_video_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Forward video toggle state between peers."""
|
|
||||||
payload = data or {}
|
|
||||||
to_user_id = int(payload.get("toUserId") or 0)
|
|
||||||
if not to_user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing toUserId")
|
|
||||||
|
|
||||||
await manager.send_update_to_user(to_user_id, "call_signaling", {
|
|
||||||
"type": "call_video_toggle",
|
|
||||||
"fromUserId": user.id,
|
|
||||||
"toUserId": to_user_id,
|
|
||||||
"data": {"enabled": payload.get("enabled", False)}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "call_video_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("call_screen_share_toggle", authRequired=True)
|
|
||||||
async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Forward screen share toggle state between peers."""
|
|
||||||
payload = data or {}
|
|
||||||
to_user_id = int(payload.get("toUserId") or 0)
|
|
||||||
if not to_user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing toUserId")
|
|
||||||
|
|
||||||
await manager.send_update_to_user(to_user_id, "call_signaling", {
|
|
||||||
"type": "call_screen_share_toggle",
|
|
||||||
"fromUserId": user.id,
|
|
||||||
"toUserId": to_user_id,
|
|
||||||
"data": {"enabled": payload.get("enabled", False)}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "call_screen_share_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("subscribeStatus", authRequired=True)
|
|
||||||
async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Subscribe to status updates for a user."""
|
|
||||||
user_id_to_subscribe = int(data["userId"])
|
|
||||||
manager.ws_subscriptions[websocket].add(user_id_to_subscribe)
|
|
||||||
|
|
||||||
# Get current status of the user
|
|
||||||
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
|
|
||||||
if target_user:
|
|
||||||
# Send current status directly (not through return value)
|
|
||||||
await websocket.send_json({
|
|
||||||
"type": "statusUpdate",
|
|
||||||
"data": {
|
|
||||||
"userId": user_id_to_subscribe,
|
|
||||||
"online": target_user.online,
|
|
||||||
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe)
|
|
||||||
return {"status": "ok"}
|
|
||||||
else:
|
|
||||||
log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found")
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("unsubscribeStatus", authRequired=True)
|
|
||||||
async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
|
||||||
"""Unsubscribe from status updates for a user."""
|
|
||||||
user_id_to_unsubscribe = int(data["userId"])
|
|
||||||
manager.ws_subscriptions[websocket].discard(user_id_to_unsubscribe)
|
|
||||||
|
|
||||||
log(manager, websocket, user, "unsubscribeStatus", target_user_id=user_id_to_unsubscribe)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("typing", authRequired=True)
|
|
||||||
async def typing(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
|
||||||
"""Handle typing indicator start for public chat."""
|
|
||||||
was_typing = manager.typing_state.get(user.id, False)
|
|
||||||
manager.typing_users[user.id] = time.time()
|
|
||||||
|
|
||||||
# Only send update if state changed (started typing)
|
|
||||||
if not was_typing:
|
|
||||||
manager.typing_state[user.id] = True
|
|
||||||
# Broadcast to all connected users
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "typing",
|
|
||||||
"data": {
|
|
||||||
"userId": user.id,
|
|
||||||
"username": user.username
|
|
||||||
}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
# No confirmation response - privacy protection
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("stopTyping", authRequired=True)
|
|
||||||
async def stopTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
|
||||||
"""Handle typing indicator stop for public chat."""
|
|
||||||
was_typing = manager.typing_state.get(user.id, False)
|
|
||||||
if user.id in manager.typing_users:
|
|
||||||
del manager.typing_users[user.id]
|
|
||||||
|
|
||||||
# Only send update if state changed (stopped typing)
|
|
||||||
if was_typing:
|
|
||||||
manager.typing_state[user.id] = False
|
|
||||||
# Broadcast to all connected users
|
|
||||||
await manager.broadcast({
|
|
||||||
"type": "stopTyping",
|
|
||||||
"data": {
|
|
||||||
"userId": user.id,
|
|
||||||
"username": user.username
|
|
||||||
}
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
# No confirmation response - privacy protection
|
|
||||||
log(manager, websocket, user, "stopTyping")
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("dmTyping", authRequired=True)
|
|
||||||
async def dmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
|
||||||
"""Handle typing indicator start for DM."""
|
|
||||||
recipient_id = int(data["recipientId"])
|
|
||||||
|
|
||||||
if user.id not in manager.dm_typing_users:
|
|
||||||
manager.dm_typing_users[user.id] = {}
|
|
||||||
if user.id not in manager.dm_typing_state:
|
|
||||||
manager.dm_typing_state[user.id] = {}
|
|
||||||
|
|
||||||
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
|
|
||||||
manager.dm_typing_users[user.id][recipient_id] = time.time()
|
|
||||||
|
|
||||||
# Only send update if state changed (started typing)
|
|
||||||
if not was_typing:
|
|
||||||
manager.dm_typing_state[user.id][recipient_id] = True
|
|
||||||
# Send only to recipient
|
|
||||||
await manager.send_update_to_user(recipient_id, "dmTyping", {
|
|
||||||
"userId": user.id,
|
|
||||||
"username": user.username
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
# No confirmation response - privacy protection
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_handler("stopDmTyping", authRequired=True)
|
|
||||||
async def stopDmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
|
|
||||||
"""Handle typing indicator stop for DM."""
|
|
||||||
recipient_id = int(data["recipientId"])
|
|
||||||
|
|
||||||
was_typing = False
|
|
||||||
if user.id in manager.dm_typing_state:
|
|
||||||
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
|
|
||||||
|
|
||||||
if user.id in manager.dm_typing_users and recipient_id in manager.dm_typing_users[user.id]:
|
|
||||||
del manager.dm_typing_users[user.id][recipient_id]
|
|
||||||
if not manager.dm_typing_users[user.id]:
|
|
||||||
del manager.dm_typing_users[user.id]
|
|
||||||
|
|
||||||
# Only send update if state changed (stopped typing)
|
|
||||||
if was_typing:
|
|
||||||
if user.id in manager.dm_typing_state:
|
|
||||||
manager.dm_typing_state[user.id][recipient_id] = False
|
|
||||||
# Send only to recipient
|
|
||||||
await manager.send_update_to_user(recipient_id, "stopDmTyping", {
|
|
||||||
"userId": user.id,
|
|
||||||
"username": user.username
|
|
||||||
}, db)
|
|
||||||
|
|
||||||
# No confirmation response - privacy protection
|
|
||||||
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from typing import Callable
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketHandlerRegistry:
|
|
||||||
"""Registry for WebSocket message handlers with authentication support."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._handlers: dict[str, tuple[Callable, bool]] = {}
|
|
||||||
|
|
||||||
def register(self, message_type: str, authRequired: bool = True):
|
|
||||||
"""Register a handler for a message type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message_type: The WebSocket message type to handle
|
|
||||||
authRequired: If True, handler will receive authenticated User (not None) or raise 401
|
|
||||||
"""
|
|
||||||
def decorator(func: Callable):
|
|
||||||
self._handlers[message_type] = (func, authRequired)
|
|
||||||
return func
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
def get_handler(self, message_type: str) -> tuple[Callable, bool] | None:
|
|
||||||
"""Get handler and authRequired flag for a message type.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (handler function, authRequired flag) or None if not found
|
|
||||||
"""
|
|
||||||
return self._handlers.get(message_type)
|
|
||||||
|
|
||||||
def get_all_types(self) -> list[str]:
|
|
||||||
"""Get all registered message types for debugging/logging."""
|
|
||||||
return list(self._handlers.keys())
|
|
||||||
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
from fastapi import HTTPException
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from dependencies import get_current_user
|
|
||||||
from models import User
|
|
||||||
|
|
||||||
|
|
||||||
def extract_token_from_data(data: dict) -> str | None:
|
|
||||||
"""Extract authentication token from WebSocket message data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: WebSocket message data dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Token string or None if not present
|
|
||||||
"""
|
|
||||||
credentials = data.get("credentials")
|
|
||||||
if credentials and isinstance(credentials, dict):
|
|
||||||
return credentials.get("credentials")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_from_token(token: str, db: Session) -> User | None:
|
|
||||||
"""Get user from authentication token.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token: JWT token string
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User object or None if token is invalid
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Ensure session is in a usable state before querying
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
dummy_request = SimpleNamespace()
|
|
||||||
dummy_request.state = SimpleNamespace()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from fastapi.security import HTTPBearer
|
|
||||||
security = HTTPBearer()
|
|
||||||
# We need to create credentials manually
|
|
||||||
credentials = HTTPAuthorizationCredentials(
|
|
||||||
scheme="Bearer",
|
|
||||||
credentials=token
|
|
||||||
)
|
|
||||||
return get_current_user(dummy_request, credentials, db)
|
|
||||||
except HTTPException:
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None:
|
|
||||||
"""Authenticate user from WebSocket message data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: WebSocket message data dictionary
|
|
||||||
db: Database session
|
|
||||||
authRequired: If True, raises 401 on missing/invalid token
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User object (guaranteed not None if authRequired=True) or None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if authRequired=True and token is missing/invalid
|
|
||||||
"""
|
|
||||||
token = extract_token_from_data(data)
|
|
||||||
|
|
||||||
if authRequired:
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=401, detail="Missing credentials")
|
|
||||||
|
|
||||||
user = get_current_user_from_token(token, db)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
||||||
|
|
||||||
return user
|
|
||||||
else:
|
|
||||||
if token:
|
|
||||||
return get_current_user_from_token(token, db)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@@ -31,4 +31,3 @@ test_results/
|
|||||||
out
|
out
|
||||||
|
|
||||||
data
|
data
|
||||||
logs
|
|
||||||
@@ -14,16 +14,12 @@ 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 /app/logs && \
|
RUN mkdir -p /app/data
|
||||||
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
|
||||||
@@ -9,9 +9,7 @@ services:
|
|||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- data:/app/data
|
- "data:/app/data"
|
||||||
- logs:/app/logs
|
|
||||||
|
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- action: sync+restart
|
- action: sync+restart
|
||||||
@@ -44,5 +42,3 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
name: fromchat-data
|
name: fromchat-data
|
||||||
logs:
|
|
||||||
name: fromchat-logs
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
|
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
|
||||||
import path from "path";
|
import path from "node: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;
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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.svg" />
|
<link rel="icon" href="./src/images/logo.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { Plugin, UserConfig } from 'vite';
|
|
||||||
|
|
||||||
const DEFAULT_DICTIONARY = '_-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
||||||
|
|
||||||
function counter(dictionary: string = DEFAULT_DICTIONARY) {
|
|
||||||
const sequence: string[] = [dictionary[0]];
|
|
||||||
return () => {
|
|
||||||
const str = sequence.join('');
|
|
||||||
let carry = 0;
|
|
||||||
for (let i = 0; i < sequence.length; i++) {
|
|
||||||
const index = dictionary.indexOf(sequence[i]) + carry + 1;
|
|
||||||
if (index < dictionary.length) {
|
|
||||||
sequence[i] = dictionary[index];
|
|
||||||
/**
|
|
||||||
* Make sure the following rules are not violated:
|
|
||||||
* 1. The first character cannot be a number
|
|
||||||
* 2. The second character cannot be a number if the first is a dash
|
|
||||||
* 3. The dash cannot be the only character
|
|
||||||
*
|
|
||||||
* https://www.w3.org/TR/CSS21/syndata.html#characters
|
|
||||||
*/
|
|
||||||
const [c1, c2] = sequence;
|
|
||||||
if (
|
|
||||||
(c1 >= '0' && c1 <= '9') ||
|
|
||||||
(c1 === '-' && (c2 >= '0' && c2 <= '9')) ||
|
|
||||||
(c1 === '-' && sequence.length === 1)
|
|
||||||
) {
|
|
||||||
i--;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
carry = 0;
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
sequence[i] = dictionary[0];
|
|
||||||
carry = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (carry) {
|
|
||||||
sequence.push(dictionary[0]);
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OptimizeCssModuleOptions {
|
|
||||||
dictionary?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optimizeCssModules(options?: OptimizeCssModuleOptions): Plugin {
|
|
||||||
const next = counter(options?.dictionary);
|
|
||||||
const map: Map<string, string> = new Map();
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'optimize-css-modules',
|
|
||||||
apply: 'build',
|
|
||||||
config: () => ({
|
|
||||||
css: {
|
|
||||||
modules: {
|
|
||||||
generateScopedName: (name: string, fileName: string) => {
|
|
||||||
const key = fileName + name;
|
|
||||||
let hash = map.get(key);
|
|
||||||
if (!hash) {
|
|
||||||
map.set(key, (hash = next()));
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import type { Plugin } from 'vite';
|
|
||||||
import { optimize } from 'svgo';
|
|
||||||
|
|
||||||
export interface OptimizeSvgOptions {
|
|
||||||
/**
|
|
||||||
* Whether to enable SVG optimization
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const svgoConfig: Parameters<typeof optimize>[1] = {
|
|
||||||
multipass: true,
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
name: 'preset-default',
|
|
||||||
params: {
|
|
||||||
overrides: {
|
|
||||||
// Keep IDs if they might be referenced (minify instead of remove)
|
|
||||||
cleanupIds: {
|
|
||||||
remove: false,
|
|
||||||
minify: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optimizes SVG files during build by:
|
|
||||||
* - Minifying SVG code
|
|
||||||
* - Removing metadata and comments
|
|
||||||
* - Removing unnecessary attributes
|
|
||||||
* - Optimizing paths and shapes
|
|
||||||
*/
|
|
||||||
export function optimizeSvg(options?: OptimizeSvgOptions): Plugin {
|
|
||||||
const enabled = options?.enabled !== false;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'optimize-svg',
|
|
||||||
apply: 'build',
|
|
||||||
enforce: 'post',
|
|
||||||
async generateBundle(options, bundle) {
|
|
||||||
if (!enabled) return;
|
|
||||||
|
|
||||||
// Optimize SVGs in the bundle
|
|
||||||
for (const [fileName, chunk] of Object.entries(bundle)) {
|
|
||||||
if (fileName.endsWith('.svg') && chunk.type === 'asset') {
|
|
||||||
try {
|
|
||||||
const svgContent = typeof chunk.source === 'string'
|
|
||||||
? chunk.source
|
|
||||||
: Buffer.from(chunk.source).toString('utf-8');
|
|
||||||
|
|
||||||
const result = optimize(svgContent, svgoConfig);
|
|
||||||
|
|
||||||
if (result.data && result.data !== svgContent) {
|
|
||||||
chunk.source = result.data;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to optimize SVG ${fileName}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+24
-123
@@ -1,146 +1,47 @@
|
|||||||
import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { ElectronTitleBar } from "./Electron";
|
import { ElectronTitleBar } from "./Electron";
|
||||||
import { useUserStore } from "./state/user";
|
import { useAppState } from "./pages/chat/state";
|
||||||
import { lazy, useEffect, useRef, useState } from "react";
|
import { useEffect, useState, lazy } from "react";
|
||||||
import { parseProfileLink } from "./core/profileLinks";
|
|
||||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
|
||||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||||
|
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||||
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
|
|
||||||
import { AlertDialogProvider } from "./core/components/AlertDialog";
|
|
||||||
import { delay } from "./utils/utils";
|
|
||||||
|
|
||||||
// Lazy load route components
|
// Lazy load route components
|
||||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||||
const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
|
const LoginPage = lazy(() => import("./pages/auth/LoginPage"));
|
||||||
|
const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
|
||||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||||
|
|
||||||
const routeConfig: RouteObject[] = [
|
|
||||||
{ path: "/", element: <HomePage /> },
|
|
||||||
{ path: "/auth", element: <AuthPage /> },
|
|
||||||
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
|
|
||||||
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
|
|
||||||
{ path: "/download-app", element: <DownloadAppPage /> },
|
|
||||||
{
|
|
||||||
path: "/chat",
|
|
||||||
element: (
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ChatPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ path: "*", element: <SmartCatchAll /> }
|
|
||||||
];
|
|
||||||
|
|
||||||
function SmartCatchAll() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [showNotFound, setShowNotFound] = useState(false);
|
|
||||||
|
|
||||||
function isValidRoute(path: string): boolean {
|
|
||||||
const validRoutes = routeConfig.filter(route => route.path !== "*");
|
|
||||||
const matches = matchRoutes(validRoutes, path);
|
|
||||||
|
|
||||||
return Boolean(matches && matches.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isValidRoute(location.pathname)) {
|
|
||||||
setShowNotFound(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileInfo = parseProfileLink(); // No URL specified intentionally to let it use the current URL
|
|
||||||
|
|
||||||
if (profileInfo) {
|
|
||||||
setShowNotFound(false);
|
|
||||||
navigate("/chat", {
|
|
||||||
replace: true,
|
|
||||||
state: { profileInfo }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setShowNotFound(true);
|
|
||||||
}
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
// Show 404 page
|
|
||||||
if (showNotFound) {
|
|
||||||
return <NotFoundPage />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnimatedRoutes() {
|
|
||||||
const location = useLocation();
|
|
||||||
const prevPathnameRef = useRef(location.pathname);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence mode="sync" initial={false}>
|
|
||||||
<motion.div
|
|
||||||
key={location.pathname}
|
|
||||||
onAnimationStart={() => {
|
|
||||||
if (prevPathnameRef.current !== location.pathname) {
|
|
||||||
prevPathnameRef.current = location.pathname;
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onAnimationComplete={async () => {
|
|
||||||
await delay(500);
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 1, scale: 1.1 }}
|
|
||||||
transition={{
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 300,
|
|
||||||
damping: 30,
|
|
||||||
mass: 0.8
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
transformOrigin: "center center",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Routes location={location}>
|
|
||||||
{routeConfig.map((route, index) => (
|
|
||||||
<Route key={index} path={route.path} element={route.element} />
|
|
||||||
))}
|
|
||||||
</Routes>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { restoreFromStorage, user } = useUserStore();
|
const { restoreUserFromStorage } = useAppState();
|
||||||
const [authReady, setAuthReady] = useState(false);
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
|
||||||
|
// Restore user from localStorage on app initialization
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restoreFromStorage().finally(() => {
|
restoreUserFromStorage().finally(() => {
|
||||||
setAuthReady(true);
|
setAuthReady(true);
|
||||||
});
|
});
|
||||||
}, [restoreFromStorage]);
|
}, [restoreUserFromStorage]);
|
||||||
|
|
||||||
return authReady && (
|
return authReady && (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ElectronTitleBar />
|
<ElectronTitleBar />
|
||||||
<AlertDialogProvider />
|
|
||||||
<div id="main-wrapper">
|
<div id="main-wrapper">
|
||||||
<AnimatedRoutes />
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/download-app" element={<DownloadAppPage />} />
|
||||||
|
<Route path="/">
|
||||||
|
<Route path="chat" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ChatPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
{user.isSuspended && (
|
|
||||||
<SuspensionDialog
|
|
||||||
reason={user.suspensionReason || "No reason provided"}
|
|
||||||
open={true}
|
|
||||||
onOpenChange={() => {}} // Suspended users can't close the dialog
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { isElectron } from "./core/electron/electron";
|
|||||||
export function ElectronTitleBar() {
|
export function ElectronTitleBar() {
|
||||||
return isElectron && (
|
return isElectron && (
|
||||||
<div id="electron-title-bar">
|
<div id="electron-title-bar">
|
||||||
{window.electronInterface.platform == "darwin" && <div className="macos-padding"></div>}
|
{window.electronInterface.platform === "darwin" && <div className="macos-padding" />}
|
||||||
<div id="window-title">{PRODUCT_NAME}</div>
|
<div id="window-title">{PRODUCT_NAME}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./index";
|
|
||||||
|
|
||||||
export interface DeviceInfo {
|
|
||||||
session_id: string;
|
|
||||||
device_name?: string;
|
|
||||||
device_type?: string;
|
|
||||||
os_name?: string;
|
|
||||||
os_version?: string;
|
|
||||||
browser_name?: string;
|
|
||||||
browser_version?: string;
|
|
||||||
brand?: string;
|
|
||||||
model?: string;
|
|
||||||
created_at?: string;
|
|
||||||
last_seen?: string;
|
|
||||||
revoked?: boolean;
|
|
||||||
current?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listDevices(token: string): Promise<DeviceInfo[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch devices");
|
|
||||||
const data = await res.json();
|
|
||||||
return data.devices as DeviceInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revokeDevice(token: string, sessionId: string): Promise<void> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) throw new Error("Failed to revoke device");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logoutAllOtherDevices(token: string): Promise<void> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) throw new Error("Failed to logout all devices");
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
|
|
||||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
|
||||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
|
||||||
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
|
|
||||||
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
|
|
||||||
import type { Headers } from "@/core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates authentication headers for API requests
|
|
||||||
* @param {string | null} token - Authentication token
|
|
||||||
* @param {boolean} json - Whether to include JSON content type header
|
|
||||||
* @returns {Headers} Headers object with authentication and content type
|
|
||||||
*/
|
|
||||||
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
|
||||||
const headers: Headers = {};
|
|
||||||
|
|
||||||
if (json) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckAuthResponse {
|
|
||||||
authenticated: boolean;
|
|
||||||
username: string;
|
|
||||||
admin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogoutResponse {
|
|
||||||
status: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserKeyPairMemory {
|
|
||||||
publicKey: Uint8Array;
|
|
||||||
privateKey: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentPublicKey: Uint8Array | null = null;
|
|
||||||
let currentPrivateKey: Uint8Array | null = null;
|
|
||||||
|
|
||||||
export function getCurrentKeys(): UserKeyPairMemory | null {
|
|
||||||
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveKeys(
|
|
||||||
publicKey: Uint8Array<ArrayBufferLike>,
|
|
||||||
privateKey: Uint8Array<ArrayBufferLike>
|
|
||||||
) {
|
|
||||||
const encodedPublicKey = b64(publicKey);
|
|
||||||
const encodedPrivateKey = b64(privateKey);
|
|
||||||
|
|
||||||
localStorage.setItem("publicKey", encodedPublicKey);
|
|
||||||
localStorage.setItem("privateKey", encodedPrivateKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the current user is authenticated
|
|
||||||
*/
|
|
||||||
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/check_auth`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to check auth");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs in a user with username and password
|
|
||||||
*/
|
|
||||||
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(null, true),
|
|
||||||
body: JSON.stringify(request)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
|
||||||
throw new Error(error.detail || "Login failed");
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a new user
|
|
||||||
*/
|
|
||||||
export async function register(request: RegisterRequest): Promise<LoginResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/register`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(null, true),
|
|
||||||
body: JSON.stringify(request)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
|
||||||
throw new Error(error.detail || "Registration failed");
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs out the current user
|
|
||||||
*/
|
|
||||||
export async function logout(token: string): Promise<LogoutResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/logout`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to logout");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a client-side authentication secret so the raw password never leaves the client.
|
|
||||||
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
|
|
||||||
*/
|
|
||||||
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
|
|
||||||
// Use per-user salt derived from username; in future we can fetch a server-provided salt
|
|
||||||
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
|
|
||||||
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
|
|
||||||
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
|
|
||||||
return b64(derived);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
|
||||||
// Try to restore from backup
|
|
||||||
const blobJson = await fetchBackupBlob(token);
|
|
||||||
if (blobJson) {
|
|
||||||
const blob = decodeBlob(blobJson);
|
|
||||||
const bundle = await decryptBackupWithPassword(password, blob);
|
|
||||||
currentPrivateKey = bundle.privateKey;
|
|
||||||
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
|
|
||||||
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
|
|
||||||
const serverPub = await fetchPublicKey(token);
|
|
||||||
if (serverPub) {
|
|
||||||
currentPublicKey = serverPub;
|
|
||||||
} else {
|
|
||||||
// We don't have the corresponding public key from server; regenerate pair to resync
|
|
||||||
const pair = generateX25519KeyPair();
|
|
||||||
currentPublicKey = pair.publicKey;
|
|
||||||
currentPrivateKey = pair.privateKey;
|
|
||||||
await uploadPublicKey(currentPublicKey, token);
|
|
||||||
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
|
||||||
await uploadBackupBlob(encodeBlob(newBlob), token);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveKeys(currentPublicKey!, currentPrivateKey!);
|
|
||||||
|
|
||||||
return {
|
|
||||||
publicKey: currentPublicKey!,
|
|
||||||
privateKey: currentPrivateKey!
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-time setup: generate keys and upload
|
|
||||||
const pair = generateX25519KeyPair();
|
|
||||||
currentPublicKey = pair.publicKey;
|
|
||||||
currentPrivateKey = pair.privateKey;
|
|
||||||
await uploadPublicKey(currentPublicKey, token);
|
|
||||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
|
||||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
|
||||||
|
|
||||||
saveKeys(pair.publicKey, pair.privateKey);
|
|
||||||
|
|
||||||
return pair;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restoreKeys() {
|
|
||||||
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
|
||||||
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthToken(): string | null {
|
|
||||||
return localStorage.getItem("authToken");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Changes the user's password
|
|
||||||
*/
|
|
||||||
export async function changePassword(
|
|
||||||
token: string,
|
|
||||||
username: string,
|
|
||||||
currentPassword: string,
|
|
||||||
newPassword: string,
|
|
||||||
logoutAllExceptCurrent: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
const currentDerived = await deriveAuthSecret(username, currentPassword);
|
|
||||||
const newDerived = await deriveAuthSecret(username, newPassword);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/change-password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({
|
|
||||||
currentPasswordDerived: currentDerived,
|
|
||||||
newPasswordDerived: newDerived,
|
|
||||||
logoutAllExceptCurrent
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to change password");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the current user's account
|
|
||||||
*/
|
|
||||||
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/account/delete`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
|
|
||||||
throw new Error(error.detail || "Failed to delete account");
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import { getAuthHeaders } from ".";
|
|
||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import type { UserProfile } from "@/core/types";
|
|
||||||
|
|
||||||
export interface ProfileData {
|
|
||||||
profile_picture?: string;
|
|
||||||
username?: string;
|
|
||||||
display_name?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadResponse {
|
|
||||||
profile_picture_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads user profile data from the server
|
|
||||||
*/
|
|
||||||
export async function loadProfile(token: string): Promise<ProfileData | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
// Map backend fields to frontend fields
|
|
||||||
return {
|
|
||||||
profile_picture: data.profile_picture,
|
|
||||||
username: data.username,
|
|
||||||
display_name: data.display_name,
|
|
||||||
description: data.bio
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading profile:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads a profile picture to the server
|
|
||||||
*/
|
|
||||||
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: getAuthHeaders(token, false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates user profile information
|
|
||||||
*/
|
|
||||||
export async function updateProfile(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Map frontend fields to backend fields
|
|
||||||
const backendData = {
|
|
||||||
username: data.username,
|
|
||||||
display_name: data.display_name,
|
|
||||||
description: data.description
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(token, true),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(backendData)
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating profile:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates user bio
|
|
||||||
*/
|
|
||||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({ bio })
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating bio:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches user profile data by username
|
|
||||||
*/
|
|
||||||
export async function fetchUserProfile(token: string, username: string): Promise<UserProfile | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching user profile:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches user profile data by user ID
|
|
||||||
*/
|
|
||||||
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching user profile by ID:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles verification status for a user (owner only)
|
|
||||||
*/
|
|
||||||
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory cache for user similarity results
|
|
||||||
* Key: userId, Value: similarity result
|
|
||||||
*/
|
|
||||||
const similarityCache = new Map<number, {isSimilar: boolean, similarTo?: string} | null>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a user is similar to any verified user
|
|
||||||
* Results are cached in memory to avoid redundant API calls
|
|
||||||
*/
|
|
||||||
export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> {
|
|
||||||
// Check cache first
|
|
||||||
if (similarityCache.has(userId)) {
|
|
||||||
return similarityCache.get(userId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
let result: {isSimilar: boolean, similarTo?: string} | null = null;
|
|
||||||
if (response.ok) {
|
|
||||||
result = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result (even if null/error)
|
|
||||||
similarityCache.set(userId, result);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking user similarity:', error);
|
|
||||||
const result: null = null;
|
|
||||||
// Cache null result to avoid retrying on errors
|
|
||||||
similarityCache.set(userId, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suspends a user account (admin only)
|
|
||||||
*/
|
|
||||||
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({ reason })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error suspending user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsuspends a user account (admin only)
|
|
||||||
*/
|
|
||||||
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error unsuspending user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a user account (admin only)
|
|
||||||
*/
|
|
||||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||||
|
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
||||||
|
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
||||||
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates authentication headers for API requests
|
||||||
|
* @param {boolean} json - Whether to include JSON content type header
|
||||||
|
* @returns {Headers} Headers object with authentication and content type
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
||||||
|
const headers: Headers = {};
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPublicKey: Uint8Array | null = null;
|
||||||
|
let currentPrivateKey: Uint8Array | null = null;
|
||||||
|
|
||||||
|
async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.publicKey) return null;
|
||||||
|
return ub64(data.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||||
|
await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(token, true),
|
||||||
|
body: JSON.stringify({
|
||||||
|
publicKey: b64(publicKey)
|
||||||
|
} satisfies UploadPublicKeyRequest)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||||
|
method: "GET",
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const response: BackupBlob = await res.json();
|
||||||
|
return response.blob;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||||
|
const payload: BackupBlob = { blob: blobJson }
|
||||||
|
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserKeyPairMemory {
|
||||||
|
publicKey: Uint8Array;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentKeys(): UserKeyPairMemory | null {
|
||||||
|
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveKeys(
|
||||||
|
publicKey: Uint8Array<ArrayBufferLike>,
|
||||||
|
privateKey: Uint8Array<ArrayBufferLike>
|
||||||
|
) {
|
||||||
|
const encodedPublicKey = b64(publicKey);
|
||||||
|
const encodedPrivateKey = b64(privateKey);
|
||||||
|
|
||||||
|
localStorage.setItem("publicKey", encodedPublicKey);
|
||||||
|
localStorage.setItem("privateKey", encodedPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||||
|
// Try to restore from backup
|
||||||
|
const blobJson = await fetchBackupBlob(token);
|
||||||
|
if (blobJson) {
|
||||||
|
const blob = decodeBlob(blobJson);
|
||||||
|
const bundle = await decryptBackupWithPassword(password, blob);
|
||||||
|
currentPrivateKey = bundle.privateKey;
|
||||||
|
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
|
||||||
|
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
|
||||||
|
const serverPub = await fetchPublicKey(token);
|
||||||
|
if (serverPub) {
|
||||||
|
currentPublicKey = serverPub;
|
||||||
|
} else {
|
||||||
|
// We don't have the corresponding public key from server; regenerate pair to resync
|
||||||
|
const pair = generateX25519KeyPair();
|
||||||
|
currentPublicKey = pair.publicKey;
|
||||||
|
currentPrivateKey = pair.privateKey;
|
||||||
|
await uploadPublicKey(currentPublicKey, token);
|
||||||
|
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||||
|
await uploadBackupBlob(encodeBlob(newBlob), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveKeys(currentPublicKey!, currentPrivateKey!);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: currentPublicKey!,
|
||||||
|
privateKey: currentPrivateKey!
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-time setup: generate keys and upload
|
||||||
|
const pair = generateX25519KeyPair();
|
||||||
|
currentPublicKey = pair.publicKey;
|
||||||
|
currentPrivateKey = pair.privateKey;
|
||||||
|
await uploadPublicKey(currentPublicKey, token);
|
||||||
|
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||||
|
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||||
|
|
||||||
|
saveKeys(pair.publicKey, pair.privateKey);
|
||||||
|
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreKeys() {
|
||||||
|
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
||||||
|
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./user/auth";
|
|
||||||
import type { IceServersResponse } from "@/core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches ICE server configuration for WebRTC
|
|
||||||
*/
|
|
||||||
export async function iceServers(token: string): Promise<IceServersResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/webrtc/ice`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch ICE servers");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import api from "@/core/api";
|
|
||||||
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
|
||||||
import { request } from "@/core/websocket";
|
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
|
||||||
import { b64 } from "@/utils/utils";
|
|
||||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
|
||||||
import { useUserStore } from "@/state/user";
|
|
||||||
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
|
|
||||||
|
|
||||||
export async function decrypt(envelope: DmEnvelope, senderId: number): Promise<string> {
|
|
||||||
const user = useUserStore.getState().user.currentUser;
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("User not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!envelope.ciphertext) {
|
|
||||||
throw new Error("DM envelope missing ciphertext");
|
|
||||||
}
|
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
|
||||||
|
|
||||||
// Remove padding (backward compatible with old messages)
|
|
||||||
// Check if ciphertext is base64 (padded) or already JSON (unpadded)
|
|
||||||
let ciphertextStr: string = envelope.ciphertext;
|
|
||||||
|
|
||||||
// Check if it's base64 (padded messages are base64)
|
|
||||||
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
||||||
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
|
|
||||||
|
|
||||||
if (isBase64) {
|
|
||||||
// Try to remove padding
|
|
||||||
try {
|
|
||||||
const unpadded = removePadding(envelope.ciphertext);
|
|
||||||
// Verify it's valid JSON before using it
|
|
||||||
JSON.parse(unpadded);
|
|
||||||
ciphertextStr = unpadded;
|
|
||||||
} catch {
|
|
||||||
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
|
|
||||||
try {
|
|
||||||
JSON.parse(envelope.ciphertext);
|
|
||||||
ciphertextStr = envelope.ciphertext;
|
|
||||||
} catch {
|
|
||||||
// If both fail, throw an error
|
|
||||||
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Not base64, assume it's already JSON (unpadded message)
|
|
||||||
ciphertextStr = envelope.ciphertext;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Signal Protocol message
|
|
||||||
let signalCiphertext: { type: number; body: string };
|
|
||||||
try {
|
|
||||||
signalCiphertext = JSON.parse(ciphertextStr);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to parse ciphertext as JSON: ${error instanceof Error ? error.message : String(error)}. Ciphertext length: ${ciphertextStr.length}, first 100 chars: ${ciphertextStr.substring(0, 100)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signalCiphertext || typeof signalCiphertext !== "object") {
|
|
||||||
throw new Error("Invalid Signal Protocol message format: not an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof signalCiphertext.type !== "number") {
|
|
||||||
throw new Error("Invalid Signal Protocol message format: type is not a number");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
|
|
||||||
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if body contains non-printable characters (corrupted binary data from old encryption)
|
|
||||||
// This must be checked first, before any base64 validation
|
|
||||||
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
|
|
||||||
if (hasNonPrintable) {
|
|
||||||
// This is a corrupted message from before the base64 conversion fix
|
|
||||||
// It cannot be decrypted - the body contains raw binary data instead of base64
|
|
||||||
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
|
|
||||||
return "_This message is corrupted and cannot be displayed._";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if body contains Unicode escape sequences (from JSON.stringify escaping)
|
|
||||||
// If so, we need to unescape them to get the actual base64 string
|
|
||||||
let bodyToDecode = signalCiphertext.body;
|
|
||||||
|
|
||||||
// Check for literal backslash-u sequences (before JSON parsing, these would be "\\u")
|
|
||||||
// After JSON parsing, Unicode escapes are converted to actual characters, so we check for
|
|
||||||
// the pattern that indicates it might have been escaped
|
|
||||||
if (bodyToDecode.includes("\\u") || bodyToDecode.match(/\\u[0-9a-fA-F]{4}/)) {
|
|
||||||
// Try to unescape Unicode sequences by wrapping in JSON quotes
|
|
||||||
try {
|
|
||||||
bodyToDecode = JSON.parse(`"${bodyToDecode.replace(/\\/g, "\\\\")}"`);
|
|
||||||
} catch {
|
|
||||||
// If unescaping fails, use the original
|
|
||||||
bodyToDecode = signalCiphertext.body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that body is valid base64 before attempting decryption
|
|
||||||
// Check if it's a valid base64 string (only contains base64 characters and padding)
|
|
||||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
||||||
if (!base64Regex.test(bodyToDecode)) {
|
|
||||||
// Log for debugging - this should help identify the issue
|
|
||||||
console.error("Invalid base64 in body:", {
|
|
||||||
bodyType: typeof signalCiphertext.body,
|
|
||||||
bodyLength: signalCiphertext.body.length,
|
|
||||||
unescapedLength: bodyToDecode.length,
|
|
||||||
first50: signalCiphertext.body.substring(0, 50),
|
|
||||||
unescapedFirst50: bodyToDecode.substring(0, 50),
|
|
||||||
envelopeId: envelope.id
|
|
||||||
});
|
|
||||||
throw new Error(`Invalid base64 format in ciphertext body`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the unescaped body for decryption
|
|
||||||
signalCiphertext.body = bodyToDecode;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to decode a small portion to validate base64
|
|
||||||
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
|
|
||||||
} catch (error) {
|
|
||||||
// Log for debugging
|
|
||||||
console.error("Base64 decode failed:", {
|
|
||||||
bodyLength: signalCiphertext.body.length,
|
|
||||||
first50: signalCiphertext.body.substring(0, 50),
|
|
||||||
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
});
|
|
||||||
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
|
||||||
return plaintext;
|
|
||||||
} catch (error) {
|
|
||||||
// If decryption fails, check if it's a session issue
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
if (errorMessage.includes("No session exists") || errorMessage.includes("No record for device")) {
|
|
||||||
console.warn(`Session missing for sender ${senderId} (envelope ID: ${envelope.id}). This may happen after page reload if the session was not properly restored.`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
|
||||||
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
|
|
||||||
if (beforeId) {
|
|
||||||
url += `&before_id=${beforeId}`;
|
|
||||||
}
|
|
||||||
const response = await globalThis.fetch(url, {
|
|
||||||
headers: api.user.auth.getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!response.ok) return { messages: [], has_more: false };
|
|
||||||
const data = await response.json();
|
|
||||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function send(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
|
||||||
const user = useUserStore.getState().user.currentUser;
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("User not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
|
||||||
|
|
||||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
|
||||||
let hasSession = false;
|
|
||||||
try {
|
|
||||||
hasSession = await signalService.hasSession(recipientId);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to check session, will attempt to establish new one:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasSession) {
|
|
||||||
try {
|
|
||||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
|
||||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
|
||||||
} catch (error) {
|
|
||||||
// Re-throw PrekeyExhaustedError as-is for proper handling
|
|
||||||
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Log other errors for debugging
|
|
||||||
console.error("Failed to establish session:", {
|
|
||||||
recipientId,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
});
|
|
||||||
// Re-throw other errors
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt with Signal Protocol
|
|
||||||
let ciphertext: { type: number; body: string };
|
|
||||||
try {
|
|
||||||
ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to encrypt message:", {
|
|
||||||
recipientId,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the body is valid base64 before stringifying
|
|
||||||
if (ciphertext.body && typeof ciphertext.body === "string") {
|
|
||||||
try {
|
|
||||||
// Test that body is valid base64
|
|
||||||
atob(ciphertext.body.substring(0, Math.min(4, ciphertext.body.length)));
|
|
||||||
|
|
||||||
// Verify the entire body is valid base64
|
|
||||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
||||||
if (!base64Regex.test(ciphertext.body)) {
|
|
||||||
console.error("Invalid base64 characters in encrypted body:", {
|
|
||||||
bodyLength: ciphertext.body.length,
|
|
||||||
first100: ciphertext.body.substring(0, 100),
|
|
||||||
last100: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 100))
|
|
||||||
});
|
|
||||||
throw new Error("Encrypted body contains invalid base64 characters");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Encrypted body is not valid base64: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stringify the ciphertext - JSON.stringify should not escape base64 strings
|
|
||||||
const ciphertextJson = JSON.stringify(ciphertext);
|
|
||||||
|
|
||||||
// Verify the stringified JSON doesn't have escaped characters in the body field
|
|
||||||
const parsed = JSON.parse(ciphertextJson);
|
|
||||||
if (parsed.body !== ciphertext.body) {
|
|
||||||
console.error("Body was modified during JSON stringification:", {
|
|
||||||
original: ciphertext.body.substring(0, 50),
|
|
||||||
stringified: parsed.body.substring(0, 50),
|
|
||||||
originalLength: ciphertext.body.length,
|
|
||||||
stringifiedLength: parsed.body.length
|
|
||||||
});
|
|
||||||
throw new Error("Body was incorrectly escaped during JSON stringification");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add padding to obfuscate message size (anti-censorship)
|
|
||||||
const paddedCiphertext = addPadding(ciphertextJson);
|
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: "", // Not used for Signal Protocol
|
|
||||||
ciphertext: paddedCiphertext, // Padded Signal Protocol message
|
|
||||||
salt: "", // Not used for Signal Protocol
|
|
||||||
iv2: "", // Not used for Signal Protocol
|
|
||||||
wrappedMk: "" // Not used for Signal Protocol
|
|
||||||
};
|
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmSend",
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken
|
|
||||||
},
|
|
||||||
data: payload
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: We'll cache the message when we receive the dmNew confirmation via WebSocket
|
|
||||||
// which contains the actual message ID
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
|
|
||||||
const user = useUserStore.getState().user.currentUser;
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("User not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
|
||||||
|
|
||||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
|
||||||
const hasSession = await signalService.hasSession(recipientId);
|
|
||||||
if (!hasSession) {
|
|
||||||
try {
|
|
||||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
|
|
||||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
|
||||||
} catch (error) {
|
|
||||||
// Re-throw PrekeyExhaustedError as-is for proper handling
|
|
||||||
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Re-throw other errors
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate master key for file encryption
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
|
|
||||||
// Encrypt the master key using Signal Protocol
|
|
||||||
const mkBase64 = b64(mk);
|
|
||||||
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
|
||||||
|
|
||||||
// Add padding to obfuscate master key size
|
|
||||||
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
const names: string[] = [];
|
|
||||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
|
||||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
// Encrypt file with same mk
|
|
||||||
const data = new Uint8Array(await f.arrayBuffer());
|
|
||||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
|
||||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
|
||||||
const serverName = f.name; // server uses provided name
|
|
||||||
names.push(serverName);
|
|
||||||
form.append("files", new File([blob], serverName));
|
|
||||||
}
|
|
||||||
form.append("fileNames", JSON.stringify(names));
|
|
||||||
|
|
||||||
// Merge files metadata into plaintext JSON and encrypt
|
|
||||||
let obj: DmEncryptedJSON;
|
|
||||||
try {
|
|
||||||
obj = JSON.parse(plaintextJson);
|
|
||||||
} catch {
|
|
||||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
|
||||||
form.append("dm_payload", JSON.stringify({
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: "", // Not used for Signal Protocol
|
|
||||||
iv2: "", // Not used for Signal Protocol
|
|
||||||
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
|
|
||||||
} satisfies BaseDmEnvelope));
|
|
||||||
|
|
||||||
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: api.user.auth.getAuthHeaders(token, false),
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function edit(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
|
|
||||||
const user = useUserStore.getState().user.currentUser;
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("User not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
|
||||||
|
|
||||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
|
||||||
const hasSession = await signalService.hasSession(recipientId);
|
|
||||||
if (!hasSession) {
|
|
||||||
try {
|
|
||||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
|
||||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
|
||||||
} catch (error) {
|
|
||||||
// Re-throw PrekeyExhaustedError as-is for proper handling
|
|
||||||
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Re-throw other errors
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate fresh master key for the edited message
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
|
|
||||||
// Encrypt the master key using Signal Protocol
|
|
||||||
const mkBase64 = b64(mk);
|
|
||||||
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
|
||||||
|
|
||||||
// Add padding to obfuscate master key size
|
|
||||||
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
|
||||||
|
|
||||||
// Encrypt the message content with the master key
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmEdit",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
iv2: "", // Not used for Signal Protocol
|
|
||||||
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
|
|
||||||
salt: "" // Not used for Signal Protocol
|
|
||||||
}
|
|
||||||
} as DMEditRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
type: "dmDelete",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: { id, recipientId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversationResponse {
|
|
||||||
user: User;
|
|
||||||
lastMessage: DmEnvelope;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
|
||||||
headers: api.user.auth.getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.conversations || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a DM as read
|
|
||||||
*/
|
|
||||||
export async function markRead(id: number, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
type: "dmMarkRead",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: { id }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export user functions for convenience
|
|
||||||
export { fetchUsers, searchUsers } from "@/core/api/users";
|
|
||||||
export { fetchUserPublicKey } from "@/core/api/crypto/identity";
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "../user/auth";
|
|
||||||
import type { Message, Messages, SendMessageRequest } from "@/core/types";
|
|
||||||
import { request } from "@/core/websocket";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches public chat messages
|
|
||||||
*/
|
|
||||||
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<{ messages: Message[]; has_more: boolean }> {
|
|
||||||
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
|
|
||||||
if (beforeId) {
|
|
||||||
url += `&before_id=${beforeId}`;
|
|
||||||
}
|
|
||||||
const response = await globalThis.fetch(url, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!response.ok) return { messages: [], has_more: false };
|
|
||||||
const data: Messages & { has_more?: boolean } = await response.json();
|
|
||||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a public chat message via WebSocket
|
|
||||||
*/
|
|
||||||
export async function send(content: string, replyToId: number | null, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
data: {
|
|
||||||
content: content.trim(),
|
|
||||||
reply_to_id: replyToId ?? null
|
|
||||||
},
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken
|
|
||||||
},
|
|
||||||
type: "sendMessage"
|
|
||||||
} satisfies SendMessageRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a public chat message with files via HTTP
|
|
||||||
*/
|
|
||||||
export async function sendWithFiles(
|
|
||||||
content: string,
|
|
||||||
replyToId: number | null,
|
|
||||||
files: File[],
|
|
||||||
authToken: string
|
|
||||||
): Promise<void> {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("payload", JSON.stringify({
|
|
||||||
content: content.trim(),
|
|
||||||
reply_to_id: replyToId ?? null
|
|
||||||
} satisfies SendMessageRequest["data"]));
|
|
||||||
for (const f of files) form.append("files", f, f.name);
|
|
||||||
const res = await globalThis.fetch(`${API_BASE_URL}/send_message`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(authToken, false),
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.text();
|
|
||||||
throw new Error(error || "Failed to send message with files");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edits a public chat message
|
|
||||||
*/
|
|
||||||
export async function edit(messageId: number, newContent: string, authToken: string): Promise<void> {
|
|
||||||
const res = await globalThis.fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: getAuthHeaders(authToken, true),
|
|
||||||
body: JSON.stringify({ content: newContent })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to edit message");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a public chat message
|
|
||||||
*/
|
|
||||||
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
|
|
||||||
const res = await globalThis.fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: getAuthHeaders(authToken, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to delete message");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a message as read
|
|
||||||
*/
|
|
||||||
export async function markRead(messageId: number, authToken: string): Promise<void> {
|
|
||||||
const res = await globalThis.fetch(`${API_BASE_URL}/messages/mark_read`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(authToken, true),
|
|
||||||
body: JSON.stringify({ message_id: messageId })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to mark message as read");
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./account";
|
|
||||||
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
|
||||||
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current user's public key
|
|
||||||
*/
|
|
||||||
export async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = await res.json();
|
|
||||||
if (!data?.publicKey) return null;
|
|
||||||
return ub64(data.publicKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads the current user's public key
|
|
||||||
*/
|
|
||||||
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
|
||||||
const payload: UploadPublicKeyRequest = {
|
|
||||||
publicKey: b64(publicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to upload public key");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches another user's public key by user ID
|
|
||||||
*/
|
|
||||||
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = await res.json();
|
|
||||||
return data.publicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current user's backup blob
|
|
||||||
*/
|
|
||||||
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
|
||||||
method: "GET",
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const response: BackupBlob = await res.json();
|
|
||||||
return response.blob;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads the current user's backup blob
|
|
||||||
*/
|
|
||||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
|
||||||
const payload: BackupBlob = { blob: blobJson }
|
|
||||||
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads Signal Protocol prekey bundle for the current user
|
|
||||||
*/
|
|
||||||
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
|
|
||||||
// Re-export from prekeys.ts
|
|
||||||
const { uploadPreKeyBundle: upload } = await import("./crypto/prekeys");
|
|
||||||
return upload(bundle, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads all available prekeys to the server for rotation
|
|
||||||
*/
|
|
||||||
export async function uploadAllPreKeys(
|
|
||||||
baseBundle: Omit<PreKeyBundleData, "preKey">,
|
|
||||||
prekeys: Array<{ keyId: number; publicKey: string }>,
|
|
||||||
token: string
|
|
||||||
): Promise<void> {
|
|
||||||
// Re-export from prekeys.ts
|
|
||||||
const { uploadAllPreKeys: upload } = await import("./crypto/prekeys");
|
|
||||||
return upload(baseBundle, prekeys, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches Signal Protocol prekey bundle for another user
|
|
||||||
*/
|
|
||||||
export async function fetchPreKeyBundle(userId: number, token: string): Promise<any | null> {
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = await res.json();
|
|
||||||
return data.bundle || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "../user/auth";
|
|
||||||
import type { BackupBlob } from "@/core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current user's backup blob
|
|
||||||
*/
|
|
||||||
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
|
||||||
method: "GET",
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const response: BackupBlob = await res.json();
|
|
||||||
return response.blob;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads the current user's backup blob
|
|
||||||
*/
|
|
||||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
|
||||||
const payload: BackupBlob = { blob: blobJson }
|
|
||||||
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "../user/auth";
|
|
||||||
import type { UploadPublicKeyRequest } from "@/core/types";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current user's public key
|
|
||||||
*/
|
|
||||||
export async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = await res.json();
|
|
||||||
if (!data?.publicKey) return null;
|
|
||||||
return ub64(data.publicKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads the current user's public key
|
|
||||||
*/
|
|
||||||
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
|
||||||
const payload: UploadPublicKeyRequest = {
|
|
||||||
publicKey: b64(publicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to upload public key");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches another user's public key by user ID
|
|
||||||
*/
|
|
||||||
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = await res.json();
|
|
||||||
return data.publicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* API functions for managing encrypted message plaintexts on the server
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import api from "@/core/api";
|
|
||||||
|
|
||||||
export interface MessagePlaintextData {
|
|
||||||
messageId: number;
|
|
||||||
recipientId: number;
|
|
||||||
encryptedData: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessagePlaintextResponse {
|
|
||||||
messageId: number;
|
|
||||||
recipientId: number;
|
|
||||||
encryptedData: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload encrypted message plaintexts to the server
|
|
||||||
*/
|
|
||||||
export async function uploadMessagePlaintexts(
|
|
||||||
messages: MessagePlaintextData[],
|
|
||||||
token: string
|
|
||||||
): Promise<void> {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/crypto/signal/message-plaintexts`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...api.user.auth.getAuthHeaders(token, false)
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ messages })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json().catch(() => ({ detail: "Failed to upload message plaintexts" }));
|
|
||||||
throw new Error(error.detail || "Failed to upload message plaintexts");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch encrypted message plaintexts from the server
|
|
||||||
*/
|
|
||||||
export async function fetchMessagePlaintexts(
|
|
||||||
token: string,
|
|
||||||
recipientId?: number
|
|
||||||
): Promise<MessagePlaintextResponse[]> {
|
|
||||||
let url = `${API_BASE_URL}/crypto/signal/message-plaintexts`;
|
|
||||||
if (recipientId !== undefined) {
|
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
|
||||||
url = `${url}${separator}recipient_id=${recipientId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
headers: api.user.auth.getAuthHeaders(token, false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json().catch(() => ({ detail: "Failed to fetch message plaintexts" }));
|
|
||||||
throw new Error(error.detail || "Failed to fetch message plaintexts");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.messages || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "../user/auth";
|
|
||||||
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads Signal Protocol prekey bundle for the current user
|
|
||||||
* This uploads the base bundle (identity, signed prekey) and one prekey
|
|
||||||
*/
|
|
||||||
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
|
|
||||||
const payload = { bundle };
|
|
||||||
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to upload prekey bundle");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads all available prekeys to the server for rotation in a single request
|
|
||||||
*/
|
|
||||||
export async function uploadAllPreKeys(
|
|
||||||
baseBundle: Omit<PreKeyBundleData, "preKey">,
|
|
||||||
prekeys: Array<{ keyId: number; publicKey: string }>,
|
|
||||||
token: string
|
|
||||||
): Promise<void> {
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const payload = {
|
|
||||||
baseBundle,
|
|
||||||
prekeys
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekeys/bulk`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to upload prekeys: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom error for prekey exhaustion
|
|
||||||
*/
|
|
||||||
export class PrekeyExhaustedError extends Error {
|
|
||||||
constructor(public readonly recipientId: number) {
|
|
||||||
super("Recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys.");
|
|
||||||
this.name = "PrekeyExhaustedError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches Signal Protocol prekey bundle for another user
|
|
||||||
* @throws {PrekeyExhaustedError} If the recipient has no unused prekeys available
|
|
||||||
*/
|
|
||||||
export async function fetchPreKeyBundle(userId: number, token: string): Promise<PreKeyBundleData> {
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 404) {
|
|
||||||
throw new Error("Recipient has not set up encryption. They need to log in to initialize their encryption keys.");
|
|
||||||
}
|
|
||||||
throw new Error("Failed to fetch prekey bundle");
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
const bundle = data.bundle;
|
|
||||||
|
|
||||||
// Check if bundle exists but has no prekey (all prekeys exhausted)
|
|
||||||
if (!bundle) {
|
|
||||||
throw new PrekeyExhaustedError(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If bundle exists but has no preKey field, it means all prekeys are exhausted
|
|
||||||
// The backend returns bundle without preKey when no unused prekeys are available
|
|
||||||
if (!bundle.preKey) {
|
|
||||||
throw new PrekeyExhaustedError(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bundle;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* API functions for managing Signal Protocol sessions on the server
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "../user/auth";
|
|
||||||
|
|
||||||
export interface SessionData {
|
|
||||||
recipientId: number;
|
|
||||||
deviceId: number;
|
|
||||||
encryptedData: string; // JSON string of encrypted session
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload encrypted Signal Protocol sessions to the server
|
|
||||||
*/
|
|
||||||
export async function uploadSessions(sessions: SessionData[], token: string): Promise<void> {
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
sessions
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to upload sessions: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all encrypted Signal Protocol sessions from the server
|
|
||||||
*/
|
|
||||||
export async function fetchSessions(token: string): Promise<SessionData[]> {
|
|
||||||
console.log("[Session API] Fetching sessions from server...");
|
|
||||||
console.log("[Session API] URL:", `${API_BASE_URL}/crypto/signal/sessions`);
|
|
||||||
|
|
||||||
const headers = getAuthHeaders(token, true);
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
|
|
||||||
method: "GET",
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[Session API] Response status:", res.status, res.statusText);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorText = await res.text().catch(() => "Unknown error");
|
|
||||||
console.error("[Session API] Failed to fetch sessions:", {
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
errorText
|
|
||||||
});
|
|
||||||
throw new Error(`Failed to fetch sessions: ${res.status} ${res.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
console.log("[Session API] Response data:", {
|
|
||||||
hasSessions: !!data.sessions,
|
|
||||||
sessionCount: data.sessions?.length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
return data.sessions || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// Re-export from dmApi.ts which has Signal Protocol support
|
|
||||||
export {
|
|
||||||
decryptDm,
|
|
||||||
fetchDMHistory,
|
|
||||||
sendDMViaWebSocket,
|
|
||||||
sendDmWithFiles,
|
|
||||||
editDmEnvelope,
|
|
||||||
deleteDmEnvelope,
|
|
||||||
fetchDMConversations,
|
|
||||||
fetchUsers,
|
|
||||||
searchUsers,
|
|
||||||
fetchUserPublicKey
|
|
||||||
} from "./dmApi";
|
|
||||||
|
|
||||||
export type { DMConversationResponse } from "./dmApi";
|
|
||||||
|
|
||||||
+83
-215
@@ -1,163 +1,79 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import api from "@/core/api";
|
import { getAuthHeaders } from "./authApi";
|
||||||
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
|
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||||
|
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
import { randomBytes } from "@/utils/crypto/kdf";
|
||||||
|
import { getCurrentKeys } from "./authApi";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "@/core/types";
|
||||||
import { b64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
|
||||||
import { useUserStore } from "@/state/user";
|
|
||||||
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
|
|
||||||
|
|
||||||
export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
|
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||||
const user = useUserStore.getState().user.currentUser;
|
const keys = getCurrentKeys();
|
||||||
if (!user?.id) {
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
throw new Error("User not authenticated");
|
|
||||||
|
// Obtain the key
|
||||||
|
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||||
|
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||||
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||||
|
return new TextDecoder().decode(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!envelope.ciphertext) {
|
export async function fetchUsers(token: string): Promise<User[]> {
|
||||||
throw new Error("DM envelope missing ciphertext");
|
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.users || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
||||||
// Remove padding (backward compatible with old messages)
|
if (!res.ok) return null;
|
||||||
// Check if ciphertext is base64 (padded messages are base64)
|
const data = await res.json();
|
||||||
let ciphertextStr: string = envelope.ciphertext;
|
return data.publicKey;
|
||||||
|
|
||||||
// Check if it's base64 (padded messages are base64)
|
|
||||||
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
||||||
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
|
|
||||||
|
|
||||||
if (isBase64) {
|
|
||||||
// Try to remove padding
|
|
||||||
try {
|
|
||||||
const unpadded = removePadding(envelope.ciphertext);
|
|
||||||
// Verify it's valid JSON before using it
|
|
||||||
JSON.parse(unpadded);
|
|
||||||
ciphertextStr = unpadded;
|
|
||||||
} catch {
|
|
||||||
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
|
|
||||||
try {
|
|
||||||
JSON.parse(envelope.ciphertext);
|
|
||||||
ciphertextStr = envelope.ciphertext;
|
|
||||||
} catch {
|
|
||||||
// If both fail, throw an error
|
|
||||||
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Not base64, assume it's already JSON (unpadded message)
|
|
||||||
ciphertextStr = envelope.ciphertext;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Signal Protocol message
|
|
||||||
let signalCiphertext: { type: number; body: string };
|
|
||||||
try {
|
|
||||||
signalCiphertext = JSON.parse(ciphertextStr);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to parse Signal Protocol message: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signalCiphertext || typeof signalCiphertext !== "object") {
|
|
||||||
throw new Error("Invalid Signal Protocol message format: not an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof signalCiphertext.type !== "number") {
|
|
||||||
throw new Error("Invalid Signal Protocol message format: type is not a number");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
|
|
||||||
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that body is valid base64 before attempting decryption
|
|
||||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
||||||
if (!base64Regex.test(signalCiphertext.body)) {
|
|
||||||
// Check if body contains non-printable characters (corrupted binary data)
|
|
||||||
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
|
|
||||||
if (hasNonPrintable) {
|
|
||||||
// This is a corrupted message from before the base64 conversion fix
|
|
||||||
// It cannot be decrypted - the body contains raw binary data instead of base64
|
|
||||||
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
|
|
||||||
|
|
||||||
return "_This message is corrupted and cannot be displayed._";
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("Invalid base64 in body:", {
|
|
||||||
bodyType: typeof signalCiphertext.body,
|
|
||||||
bodyLength: signalCiphertext.body.length,
|
|
||||||
first50: signalCiphertext.body.substring(0, 50),
|
|
||||||
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
|
|
||||||
envelopeId: envelope.id
|
|
||||||
});
|
|
||||||
throw new Error(`Invalid base64 format in ciphertext body`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to decode a small portion to validate base64
|
|
||||||
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Base64 decode failed:", {
|
|
||||||
bodyLength: signalCiphertext.body.length,
|
|
||||||
first50: signalCiphertext.body.substring(0, 50),
|
|
||||||
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
});
|
|
||||||
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
|
||||||
return plaintext;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to decrypt DM: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||||
headers: api.user.auth.getAuthHeaders(token, true)
|
headers: getAuthHeaders(token, true)
|
||||||
});
|
});
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.messages || [];
|
return data.messages || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendDMViaWebSocket(
|
||||||
|
recipientId: number,
|
||||||
|
recipientPublicKeyB64: string,
|
||||||
|
plaintext: string,
|
||||||
|
authToken: string,
|
||||||
|
replyToId?: number
|
||||||
|
): Promise<void> {
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
export async function sendDMViaWebSocket(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
// Encryption key
|
||||||
const user = useUserStore.getState().user.currentUser;
|
const mk = randomBytes(32);
|
||||||
if (!user?.id) {
|
const wkSalt = randomBytes(16);
|
||||||
throw new Error("User not authenticated");
|
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||||
}
|
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||||
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
// Encrypt the message
|
||||||
|
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
const wrap = await aesGcmEncrypt(wk, mk);
|
||||||
const hasSession = await signalService.hasSession(recipientId);
|
|
||||||
if (!hasSession) {
|
|
||||||
// Fetch prekey bundle from server
|
|
||||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
|
||||||
if (!bundle) {
|
|
||||||
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
|
|
||||||
}
|
|
||||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt with Signal Protocol
|
|
||||||
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
|
||||||
|
|
||||||
// Add padding to obfuscate message size (anti-censorship)
|
|
||||||
const paddedCiphertext = addPadding(JSON.stringify(ciphertext));
|
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
const payload: SendDMRequest = {
|
||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: "", // Not used for Signal Protocol
|
iv: b64(encMsg.iv),
|
||||||
ciphertext: paddedCiphertext, // Padded Signal Protocol message
|
ciphertext: b64(encMsg.ciphertext),
|
||||||
salt: "", // Not used for Signal Protocol
|
salt: b64(wkSalt),
|
||||||
iv2: "", // Not used for Signal Protocol
|
iv2: b64(wrap.iv),
|
||||||
wrappedMk: "" // Not used for Signal Protocol
|
wrappedMk: b64(wrap.ciphertext)
|
||||||
};
|
};
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
if (replyToId) payload.replyToId = replyToId;
|
||||||
|
|
||||||
@@ -171,33 +87,23 @@ export async function sendDMViaWebSocket(recipientId: number, plaintext: string,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendDmWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
|
export async function sendDmWithFiles(
|
||||||
const user = useUserStore.getState().user.currentUser;
|
recipientId: number,
|
||||||
if (!user?.id) {
|
recipientPublicKeyB64: string,
|
||||||
throw new Error("User not authenticated");
|
plaintextJson: string,
|
||||||
}
|
files: File[],
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
|
||||||
|
|
||||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
|
||||||
const hasSession = await signalService.hasSession(recipientId);
|
|
||||||
if (!hasSession) {
|
|
||||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
|
|
||||||
if (!bundle) {
|
|
||||||
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
|
|
||||||
}
|
|
||||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate master key for file encryption
|
|
||||||
const mk = randomBytes(32);
|
const mk = randomBytes(32);
|
||||||
|
const wkSalt = randomBytes(16);
|
||||||
|
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||||
|
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||||
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
|
||||||
// Encrypt the master key using Signal Protocol
|
const wrap = await aesGcmEncrypt(wk, mk);
|
||||||
const mkBase64 = b64(mk);
|
|
||||||
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
|
||||||
|
|
||||||
// Add padding to obfuscate master key size
|
|
||||||
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
@@ -229,48 +135,30 @@ export async function sendDmWithFiles(recipientId: number, plaintextJson: string
|
|||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: b64(encMsg.iv),
|
iv: b64(encMsg.iv),
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
ciphertext: b64(encMsg.ciphertext),
|
||||||
salt: "", // Not used for Signal Protocol
|
salt: b64(wkSalt),
|
||||||
iv2: "", // Not used for Signal Protocol
|
iv2: b64(wrap.iv),
|
||||||
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
|
wrappedMk: b64(wrap.ciphertext)
|
||||||
} satisfies BaseDmEnvelope));
|
} satisfies BaseDmEnvelope));
|
||||||
|
|
||||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: api.user.auth.getAuthHeaders(token, false),
|
headers: getAuthHeaders(token, false),
|
||||||
body: form
|
body: form
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editDmEnvelope(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
|
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||||
const user = useUserStore.getState().user.currentUser;
|
const keys = getCurrentKeys();
|
||||||
if (!user?.id) {
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
throw new Error("User not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||||
|
|
||||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
|
||||||
const hasSession = await signalService.hasSession(recipientId);
|
|
||||||
if (!hasSession) {
|
|
||||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
|
||||||
if (!bundle) {
|
|
||||||
throw new Error("No Signal Protocol prekey bundle available for recipient");
|
|
||||||
}
|
|
||||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate fresh master key for the edited message
|
|
||||||
const mk = randomBytes(32);
|
const mk = randomBytes(32);
|
||||||
|
const wkSalt = randomBytes(16);
|
||||||
// Encrypt the master key using Signal Protocol
|
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||||
const mkBase64 = b64(mk);
|
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||||
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
|
||||||
// Add padding to obfuscate master key size
|
|
||||||
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
|
||||||
|
|
||||||
// Encrypt the message content with the master key
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||||
|
const wrap = await aesGcmEncrypt(wk, mk);
|
||||||
|
|
||||||
await request({
|
await request({
|
||||||
type: "dmEdit",
|
type: "dmEdit",
|
||||||
@@ -279,9 +167,9 @@ export async function editDmEnvelope(id: number, recipientId: number, newPlainte
|
|||||||
id,
|
id,
|
||||||
iv: b64(encMsg.iv),
|
iv: b64(encMsg.iv),
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
ciphertext: b64(encMsg.ciphertext),
|
||||||
iv2: "", // Not used for Signal Protocol
|
iv2: b64(wrap.iv),
|
||||||
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
|
wrappedMk: b64(wrap.ciphertext),
|
||||||
salt: "" // Not used for Signal Protocol
|
salt: b64(wkSalt)
|
||||||
}
|
}
|
||||||
} as DMEditRequest);
|
} as DMEditRequest);
|
||||||
}
|
}
|
||||||
@@ -293,23 +181,3 @@ export async function deleteDmEnvelope(id: number, recipientId: number, authToke
|
|||||||
data: { id, recipientId }
|
data: { id, recipientId }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DMConversationResponse {
|
|
||||||
user: User;
|
|
||||||
lastMessage: DmEnvelope;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export for convenience
|
|
||||||
export { fetchUsers, searchUsers } from "./users";
|
|
||||||
export { fetchUserPublicKey } from "./crypto/identity";
|
|
||||||
|
|
||||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
|
||||||
headers: api.user.auth.getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.conversations || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./user/auth";
|
|
||||||
|
|
||||||
export const normal = {
|
|
||||||
/**
|
|
||||||
* Gets the URL for a normal (unencrypted) file
|
|
||||||
*/
|
|
||||||
url(filename: string): string {
|
|
||||||
return `${API_BASE_URL}/uploads/files/normal/${filename}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a normal file (unencrypted)
|
|
||||||
*/
|
|
||||||
async fetch(filename: string, token: string): Promise<Blob> {
|
|
||||||
const res = await fetch(this.url(filename), {
|
|
||||||
headers: getAuthHeaders(token, false)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch file");
|
|
||||||
return await res.blob();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const encrypted = {
|
|
||||||
/**
|
|
||||||
* Gets the URL for an encrypted file
|
|
||||||
*/
|
|
||||||
url(filename: string): string {
|
|
||||||
return `${API_BASE_URL}/uploads/files/encrypted/${filename}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches an encrypted file
|
|
||||||
*/
|
|
||||||
async fetch(filename: string, token: string): Promise<Blob> {
|
|
||||||
const res = await fetch(this.url(filename), {
|
|
||||||
headers: getAuthHeaders(token, false)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch encrypted file");
|
|
||||||
return await res.blob();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import * as chatsGeneral from "./chats/general";
|
|
||||||
import * as chatsDm from "./chats/dm";
|
|
||||||
import * as userProfile from "./user/profile";
|
|
||||||
import * as userAuth from "./user/auth";
|
|
||||||
import * as userDevices from "./user/devices";
|
|
||||||
import * as userSearch from "./user/search";
|
|
||||||
import * as cryptoPrekeys from "./crypto/prekeys";
|
|
||||||
import * as cryptoIdentity from "./crypto/identity";
|
|
||||||
import * as cryptoBackup from "./crypto/backup";
|
|
||||||
import * as cryptoSessions from "./crypto/sessions";
|
|
||||||
import * as cryptoMessagePlaintexts from "./crypto/messagePlaintexts";
|
|
||||||
import * as moderationBlocklist from "./moderation/blocklist";
|
|
||||||
import * as moderationUsers from "./moderation/users";
|
|
||||||
import * as callsModule from "./calls";
|
|
||||||
import * as filesModule from "./files";
|
|
||||||
import * as pushModule from "./push";
|
|
||||||
|
|
||||||
const api = {
|
|
||||||
chats: {
|
|
||||||
general: chatsGeneral,
|
|
||||||
dm: chatsDm
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
profile: userProfile,
|
|
||||||
auth: userAuth,
|
|
||||||
devices: userDevices,
|
|
||||||
search: userSearch
|
|
||||||
},
|
|
||||||
crypto: {
|
|
||||||
prekeys: cryptoPrekeys,
|
|
||||||
identity: cryptoIdentity,
|
|
||||||
backup: cryptoBackup,
|
|
||||||
sessions: cryptoSessions,
|
|
||||||
messagePlaintexts: cryptoMessagePlaintexts
|
|
||||||
},
|
|
||||||
moderation: {
|
|
||||||
blocklist: moderationBlocklist,
|
|
||||||
users: moderationUsers
|
|
||||||
},
|
|
||||||
calls: callsModule,
|
|
||||||
files: filesModule,
|
|
||||||
push: pushModule
|
|
||||||
};
|
|
||||||
|
|
||||||
export default api;
|
|
||||||
|
|
||||||
export const chats = api.chats;
|
|
||||||
export const user = api.user;
|
|
||||||
export const crypto = api.crypto;
|
|
||||||
export const moderation = api.moderation;
|
|
||||||
export const calls = api.calls;
|
|
||||||
export const files = api.files;
|
|
||||||
export const push = api.push;
|
|
||||||
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./account";
|
|
||||||
import type { Message, Messages, SendMessageRequest } from "@/core/types";
|
|
||||||
import { request } from "@/core/websocket";
|
|
||||||
|
|
||||||
class HttpError extends Error {
|
|
||||||
status: number;
|
|
||||||
detail: string;
|
|
||||||
|
|
||||||
constructor(message: string, status: number, detail: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = "HttpError";
|
|
||||||
this.status = status;
|
|
||||||
this.detail = detail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches public chat messages
|
|
||||||
*/
|
|
||||||
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<Message[]> {
|
|
||||||
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
|
|
||||||
if (beforeId) {
|
|
||||||
url += `&before_id=${beforeId}`;
|
|
||||||
}
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const data: Messages = await response.json();
|
|
||||||
return data.messages || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a public chat message via WebSocket
|
|
||||||
*/
|
|
||||||
export async function sendMessage(content: string, replyToId: number | null, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
data: {
|
|
||||||
content: content.trim(),
|
|
||||||
reply_to_id: replyToId ?? null
|
|
||||||
},
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken
|
|
||||||
},
|
|
||||||
type: "sendMessage"
|
|
||||||
} satisfies SendMessageRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a public chat message with files via HTTP
|
|
||||||
*/
|
|
||||||
export async function sendMessageWithFiles(
|
|
||||||
content: string,
|
|
||||||
replyToId: number | null,
|
|
||||||
files: File[],
|
|
||||||
authToken: string
|
|
||||||
): Promise<void> {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("payload", JSON.stringify({
|
|
||||||
content: content.trim(),
|
|
||||||
reply_to_id: replyToId ?? null
|
|
||||||
} satisfies SendMessageRequest["data"]));
|
|
||||||
for (const f of files) form.append("files", f, f.name);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/send_message`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(authToken, false),
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
let errorDetail = "Failed to send message with files";
|
|
||||||
try {
|
|
||||||
const errorJson = await res.json();
|
|
||||||
errorDetail = errorJson.detail || errorDetail;
|
|
||||||
} catch {
|
|
||||||
const errorText = await res.text();
|
|
||||||
errorDetail = errorText || errorDetail;
|
|
||||||
}
|
|
||||||
throw new HttpError(errorDetail, res.status, errorDetail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edits a public chat message
|
|
||||||
*/
|
|
||||||
export async function editMessage(messageId: number, newContent: string, authToken: string): Promise<void> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: getAuthHeaders(authToken, true),
|
|
||||||
body: JSON.stringify({ content: newContent })
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
let errorDetail = "Failed to edit message";
|
|
||||||
try {
|
|
||||||
const errorJson = await res.json();
|
|
||||||
errorDetail = errorJson.detail || errorDetail;
|
|
||||||
} catch {
|
|
||||||
const errorText = await res.text();
|
|
||||||
errorDetail = errorText || errorDetail;
|
|
||||||
}
|
|
||||||
throw new HttpError(errorDetail, res.status, errorDetail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a public chat message
|
|
||||||
*/
|
|
||||||
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: getAuthHeaders(authToken, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to delete message");
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./account";
|
|
||||||
|
|
||||||
export interface BlocklistResponse {
|
|
||||||
words: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlocklistUpdateRequest {
|
|
||||||
words: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlocklistUpdateResponse {
|
|
||||||
added?: string[];
|
|
||||||
removed?: string[];
|
|
||||||
words: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current blocklist (admin only)
|
|
||||||
*/
|
|
||||||
export async function getBlocklist(token: string): Promise<BlocklistResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch blocklist");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds words to the blocklist (admin only)
|
|
||||||
*/
|
|
||||||
export async function addToBlocklist(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({ words })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to add to blocklist");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes words from the blocklist (admin only)
|
|
||||||
*/
|
|
||||||
export async function removeFromBlocklist(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({ words })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to remove from blocklist");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "../user/auth";
|
|
||||||
|
|
||||||
export interface BlocklistResponse {
|
|
||||||
words: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlocklistUpdateRequest {
|
|
||||||
words: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlocklistUpdateResponse {
|
|
||||||
added?: string[];
|
|
||||||
removed?: string[];
|
|
||||||
words: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current blocklist (admin only)
|
|
||||||
*/
|
|
||||||
export async function get(token: string): Promise<BlocklistResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch blocklist");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds words to the blocklist (admin only)
|
|
||||||
*/
|
|
||||||
export async function add(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({ words })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to add to blocklist");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes words from the blocklist (admin only)
|
|
||||||
*/
|
|
||||||
export async function remove(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({ words })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to remove from blocklist");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "../user/auth";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles verification status for a user (owner only)
|
|
||||||
*/
|
|
||||||
export async function verify(userId: number, token: string): Promise<{verified: boolean} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suspends a user account (admin only)
|
|
||||||
*/
|
|
||||||
export async function suspend(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({ reason })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error suspending user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsuspends a user account (admin only)
|
|
||||||
*/
|
|
||||||
export async function unsuspend(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error unsuspending user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a user account (admin only)
|
|
||||||
*/
|
|
||||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { getAuthHeaders } from "./account";
|
import { getAuthHeaders } from "./authApi";
|
||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import type { UserProfile } from "@/core/types";
|
import type { UserProfile } from "@/core/types";
|
||||||
|
|
||||||
export interface ProfileData {
|
export interface ProfileData {
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
username?: string;
|
nickname?: string;
|
||||||
display_name?: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,15 +26,14 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
|
|||||||
// Map backend fields to frontend fields
|
// Map backend fields to frontend fields
|
||||||
return {
|
return {
|
||||||
profile_picture: data.profile_picture,
|
profile_picture: data.profile_picture,
|
||||||
username: data.username,
|
nickname: data.username,
|
||||||
display_name: data.display_name,
|
|
||||||
description: data.bio
|
description: data.bio
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading profile:', error);
|
console.error("Error loading profile:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,10 +44,10 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
|
|||||||
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
formData.append("profile_picture", file, "profile_picture.jpg");
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: getAuthHeaders(token, false)
|
headers: getAuthHeaders(token, false)
|
||||||
});
|
});
|
||||||
@@ -59,7 +57,7 @@ export async function uploadProfilePicture(token: string, file: Blob): Promise<U
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error("Upload error:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,23 +69,22 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
|
|||||||
try {
|
try {
|
||||||
// Map frontend fields to backend fields
|
// Map frontend fields to backend fields
|
||||||
const backendData = {
|
const backendData = {
|
||||||
username: data.username,
|
nickname: data.nickname,
|
||||||
display_name: data.display_name,
|
|
||||||
description: data.description
|
description: data.description
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
...getAuthHeaders(token),
|
...getAuthHeaders(token),
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify(backendData)
|
body: JSON.stringify(backendData)
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating profile:', error);
|
console.error("Error updating profile:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,14 +95,14 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
|
|||||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
headers: getAuthHeaders(token),
|
headers: getAuthHeaders(token),
|
||||||
body: JSON.stringify({ bio })
|
body: JSON.stringify({ bio })
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating bio:', error);
|
console.error("Error updating bio:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,150 +122,7 @@ export async function fetchUserProfile(token: string, username: string): Promise
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching user profile:', error);
|
console.error("Error fetching user profile:", error);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches user profile data by user ID
|
|
||||||
*/
|
|
||||||
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
|
||||||
headers: getAuthHeaders(token)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching user profile by ID:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles verification status for a user (owner only)
|
|
||||||
*/
|
|
||||||
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory cache for user similarity results
|
|
||||||
* Key: userId, Value: similarity result
|
|
||||||
*/
|
|
||||||
const similarityCache = new Map<number, {isSimilar: boolean, similarTo?: string} | null>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a user is similar to any verified user
|
|
||||||
* Results are cached in memory to avoid redundant API calls
|
|
||||||
*/
|
|
||||||
export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> {
|
|
||||||
// Check cache first
|
|
||||||
if (similarityCache.has(userId)) {
|
|
||||||
return similarityCache.get(userId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, {
|
|
||||||
headers: getAuthHeaders(token)
|
|
||||||
});
|
|
||||||
|
|
||||||
let result: {isSimilar: boolean, similarTo?: string} | null = null;
|
|
||||||
if (response.ok) {
|
|
||||||
result = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result (even if null/error)
|
|
||||||
similarityCache.set(userId, result);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking user similarity:', error);
|
|
||||||
const result: null = null;
|
|
||||||
// Cache null result to avoid retrying on errors
|
|
||||||
similarityCache.set(userId, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suspends a user account (admin only)
|
|
||||||
*/
|
|
||||||
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token),
|
|
||||||
body: JSON.stringify({ reason })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error suspending user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsuspends a user account (admin only)
|
|
||||||
*/
|
|
||||||
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error unsuspending user:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a user account (admin only)
|
|
||||||
*/
|
|
||||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(token)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting user:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./user/auth";
|
|
||||||
|
|
||||||
export interface PushSubscriptionRequest {
|
|
||||||
endpoint: string;
|
|
||||||
keys: {
|
|
||||||
p256dh: string;
|
|
||||||
auth: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PushSubscriptionResponse {
|
|
||||||
status: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const subscription = {
|
|
||||||
/**
|
|
||||||
* Subscribes the current user to push notifications
|
|
||||||
*/
|
|
||||||
async subscribe(
|
|
||||||
subscription: PushSubscriptionRequest,
|
|
||||||
token: string
|
|
||||||
): Promise<PushSubscriptionResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify(subscription)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => ({ detail: "Failed to subscribe to push notifications" }));
|
|
||||||
throw new Error(error.detail || "Failed to subscribe to push notifications");
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsubscribes the current user from push notifications
|
|
||||||
*/
|
|
||||||
async unsubscribe(token: string): Promise<PushSubscriptionResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => ({ detail: "Failed to unsubscribe from push notifications" }));
|
|
||||||
throw new Error(error.detail || "Failed to unsubscribe from push notifications");
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
|
|
||||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
|
||||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
|
||||||
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
|
|
||||||
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
|
|
||||||
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates authentication headers for API requests
|
|
||||||
* @param {string | null} token - Authentication token
|
|
||||||
* @param {boolean} json - Whether to include JSON content type header
|
|
||||||
* @returns {Headers} Headers object with authentication and content type
|
|
||||||
*/
|
|
||||||
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
|
||||||
const headers: Headers = {};
|
|
||||||
|
|
||||||
if (json) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckAuthResponse {
|
|
||||||
authenticated: boolean;
|
|
||||||
username: string;
|
|
||||||
admin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogoutResponse {
|
|
||||||
status: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserKeyPairMemory {
|
|
||||||
publicKey: Uint8Array;
|
|
||||||
privateKey: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentPublicKey: Uint8Array | null = null;
|
|
||||||
let currentPrivateKey: Uint8Array | null = null;
|
|
||||||
|
|
||||||
export function getCurrentKeys(): UserKeyPairMemory | null {
|
|
||||||
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveKeys(
|
|
||||||
publicKey: Uint8Array<ArrayBufferLike>,
|
|
||||||
privateKey: Uint8Array<ArrayBufferLike>
|
|
||||||
) {
|
|
||||||
const encodedPublicKey = b64(publicKey);
|
|
||||||
const encodedPrivateKey = b64(privateKey);
|
|
||||||
|
|
||||||
localStorage.setItem("publicKey", encodedPublicKey);
|
|
||||||
localStorage.setItem("privateKey", encodedPrivateKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the current user is authenticated
|
|
||||||
*/
|
|
||||||
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/check_auth`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to check auth");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs in a user with username and password
|
|
||||||
*/
|
|
||||||
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(null, true),
|
|
||||||
body: JSON.stringify(request)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
|
||||||
throw new Error(error.detail || "Login failed");
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a new user
|
|
||||||
*/
|
|
||||||
export async function register(request: RegisterRequest): Promise<LoginResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/register`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(null, true),
|
|
||||||
body: JSON.stringify(request)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
|
||||||
throw new Error(error.detail || "Registration failed");
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs out the current user
|
|
||||||
*/
|
|
||||||
export async function logout(token: string): Promise<LogoutResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/logout`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to logout");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a client-side authentication secret so the raw password never leaves the client.
|
|
||||||
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
|
|
||||||
*/
|
|
||||||
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
|
|
||||||
// Use per-user salt derived from username; in future we can fetch a server-provided salt
|
|
||||||
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
|
|
||||||
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
|
|
||||||
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
|
|
||||||
return b64(derived);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
|
||||||
// Try to restore from backup
|
|
||||||
const blobJson = await fetchBackupBlob(token);
|
|
||||||
if (blobJson) {
|
|
||||||
const blob = decodeBlob(blobJson);
|
|
||||||
const bundle = await decryptBackupWithPassword(password, blob);
|
|
||||||
currentPrivateKey = bundle.privateKey;
|
|
||||||
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
|
|
||||||
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
|
|
||||||
const serverPub = await fetchPublicKey(token);
|
|
||||||
if (serverPub) {
|
|
||||||
currentPublicKey = serverPub;
|
|
||||||
} else {
|
|
||||||
// We don't have the corresponding public key from server; regenerate pair to resync
|
|
||||||
const pair = generateX25519KeyPair();
|
|
||||||
currentPublicKey = pair.publicKey;
|
|
||||||
currentPrivateKey = pair.privateKey;
|
|
||||||
await uploadPublicKey(currentPublicKey, token);
|
|
||||||
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
|
||||||
await uploadBackupBlob(encodeBlob(newBlob), token);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveKeys(currentPublicKey!, currentPrivateKey!);
|
|
||||||
|
|
||||||
return {
|
|
||||||
publicKey: currentPublicKey!,
|
|
||||||
privateKey: currentPrivateKey!
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-time setup: generate keys and upload
|
|
||||||
const pair = generateX25519KeyPair();
|
|
||||||
currentPublicKey = pair.publicKey;
|
|
||||||
currentPrivateKey = pair.privateKey;
|
|
||||||
await uploadPublicKey(currentPublicKey, token);
|
|
||||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
|
||||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
|
||||||
|
|
||||||
saveKeys(pair.publicKey, pair.privateKey);
|
|
||||||
|
|
||||||
return pair;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restoreKeys() {
|
|
||||||
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
|
||||||
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthToken(): string | null {
|
|
||||||
return localStorage.getItem("authToken");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Changes the user's password
|
|
||||||
*/
|
|
||||||
export async function changePassword(
|
|
||||||
token: string,
|
|
||||||
username: string,
|
|
||||||
currentPassword: string,
|
|
||||||
newPassword: string,
|
|
||||||
logoutAllExceptCurrent: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
const currentDerived = await deriveAuthSecret(username, currentPassword);
|
|
||||||
const newDerived = await deriveAuthSecret(username, newPassword);
|
|
||||||
const res = await fetch(`${API_BASE_URL}/change-password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({
|
|
||||||
currentPasswordDerived: currentDerived,
|
|
||||||
newPasswordDerived: newDerived,
|
|
||||||
logoutAllExceptCurrent
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to change password");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the current user's account
|
|
||||||
*/
|
|
||||||
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/account/delete`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
|
|
||||||
throw new Error(error.detail || "Failed to delete account");
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./auth";
|
|
||||||
|
|
||||||
export interface DeviceInfo {
|
|
||||||
session_id: string;
|
|
||||||
device_name?: string;
|
|
||||||
device_type?: string;
|
|
||||||
os_name?: string;
|
|
||||||
os_version?: string;
|
|
||||||
browser_name?: string;
|
|
||||||
browser_version?: string;
|
|
||||||
brand?: string;
|
|
||||||
model?: string;
|
|
||||||
created_at?: string;
|
|
||||||
last_seen?: string;
|
|
||||||
revoked?: boolean;
|
|
||||||
current?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list(token: string): Promise<DeviceInfo[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch devices");
|
|
||||||
const data = await res.json();
|
|
||||||
return data.devices as DeviceInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revoke(token: string, sessionId: string): Promise<void> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) throw new Error("Failed to revoke device");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revokeAll(token: string): Promise<void> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) throw new Error("Failed to logout all devices");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { getAuthHeaders } from "./auth";
|
|
||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import type { UserProfile } from "@/core/types";
|
|
||||||
|
|
||||||
export interface ProfileData {
|
|
||||||
profile_picture?: string;
|
|
||||||
username?: string;
|
|
||||||
display_name?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadResponse {
|
|
||||||
profile_picture_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads user profile data from the server
|
|
||||||
*/
|
|
||||||
export async function get(token: string): Promise<ProfileData | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
// Map backend fields to frontend fields
|
|
||||||
return {
|
|
||||||
profile_picture: data.profile_picture,
|
|
||||||
username: data.username,
|
|
||||||
display_name: data.display_name,
|
|
||||||
description: data.bio
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading profile:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads a profile picture to the server
|
|
||||||
*/
|
|
||||||
export async function uploadPicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: getAuthHeaders(token, false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates user profile information
|
|
||||||
*/
|
|
||||||
export async function update(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Map frontend fields to backend fields
|
|
||||||
const backendData = {
|
|
||||||
username: data.username,
|
|
||||||
display_name: data.display_name,
|
|
||||||
description: data.description
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(token, true),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(backendData)
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating profile:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates user bio
|
|
||||||
*/
|
|
||||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getAuthHeaders(token, true),
|
|
||||||
body: JSON.stringify({ bio })
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating bio:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches user profile data by username
|
|
||||||
*/
|
|
||||||
export async function fetchByUsername(token: string, username: string): Promise<UserProfile | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching user profile:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches user profile data by user ID
|
|
||||||
*/
|
|
||||||
export async function fetchById(token: string, userId: number): Promise<UserProfile | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching user profile by ID:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory cache for user similarity results
|
|
||||||
* Key: userId, Value: similarity result
|
|
||||||
*/
|
|
||||||
const similarityCache = new Map<number, {isSimilar: boolean, similarTo?: string} | null>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a user is similar to any verified user
|
|
||||||
* Results are cached in memory to avoid redundant API calls
|
|
||||||
*/
|
|
||||||
export async function checkSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> {
|
|
||||||
// Check cache first
|
|
||||||
if (similarityCache.has(userId)) {
|
|
||||||
return similarityCache.get(userId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
|
|
||||||
let result: {isSimilar: boolean, similarTo?: string} | null = null;
|
|
||||||
if (response.ok) {
|
|
||||||
result = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result (even if null/error)
|
|
||||||
similarityCache.set(userId, result);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking user similarity:', error);
|
|
||||||
const result: null = null;
|
|
||||||
// Cache null result to avoid retrying on errors
|
|
||||||
similarityCache.set(userId, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./auth";
|
|
||||||
import type { User } from "@/core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a list of all users (excluding current user)
|
|
||||||
*/
|
|
||||||
export async function fetchUsers(token: string): Promise<User[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.users || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Searches for users by username query
|
|
||||||
*/
|
|
||||||
export async function searchUsers(query: string, token: string): Promise<User[]> {
|
|
||||||
if (query.length < 2) return [];
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.users || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a user by ID
|
|
||||||
*/
|
|
||||||
export async function get(userId: number, token: string): Promise<User | null> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/users/${userId}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return null;
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./account";
|
|
||||||
import type { User } from "@/core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a list of all users (excluding current user)
|
|
||||||
*/
|
|
||||||
export async function fetchUsers(token: string): Promise<User[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.users || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Searches for users by username query
|
|
||||||
*/
|
|
||||||
export async function searchUsers(query: string, token: string): Promise<User[]> {
|
|
||||||
if (query.length < 2) return [];
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.users || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
|
||||||
import { getAuthHeaders } from "./account";
|
|
||||||
import type { IceServersResponse } from "@/core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches ICE server configuration for WebRTC
|
|
||||||
*/
|
|
||||||
export async function getIceServers(token: string): Promise<IceServersResponse> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/webrtc/ice`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch ICE servers");
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
/**
|
|
||||||
* E2EE Worker for WebRTC Insertable Streams
|
|
||||||
* Encrypts/decrypts encoded audio and video frames using AES-GCM
|
|
||||||
* Uses RTP timestamps for IVs to handle out-of-order and dropped frames
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface FrameMetadata {
|
|
||||||
contributingSources?: number[];
|
|
||||||
mimeType?: string;
|
|
||||||
payloadType?: number;
|
|
||||||
rtpTimestamp: number;
|
|
||||||
synchronizationSource: number;
|
|
||||||
dependencies?: number[];
|
|
||||||
frameId?: number;
|
|
||||||
spatialIndex?: number;
|
|
||||||
temporalIndex?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EncodedFrame {
|
|
||||||
data: Uint8Array | ArrayBuffer;
|
|
||||||
timestamp?: number;
|
|
||||||
type?: string;
|
|
||||||
getMetadata?: () => FrameMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkerOptions {
|
|
||||||
key: CryptoKey;
|
|
||||||
mode: 'encrypt' | 'decrypt';
|
|
||||||
sessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract sequence number from encoded frame
|
|
||||||
* For RTCEncodedVideoFrame/AudioFrame, we use the frame's metadata if available,
|
|
||||||
* otherwise fall back to extracting from RTP header
|
|
||||||
*/
|
|
||||||
function makeIV(encodedFrame: EncodedFrame): ArrayBuffer {
|
|
||||||
// Create IV using ONLY RTP metadata - this ensures sender and receiver use identical IVs
|
|
||||||
// Frame data can differ between sender/receiver due to encoding differences
|
|
||||||
const ivBuffer = new ArrayBuffer(12);
|
|
||||||
const view = new DataView(ivBuffer);
|
|
||||||
|
|
||||||
if (encodedFrame.getMetadata) {
|
|
||||||
try {
|
|
||||||
const metadata = encodedFrame.getMetadata();
|
|
||||||
if (metadata && typeof metadata.rtpTimestamp === 'number') {
|
|
||||||
// Use ONLY RTP timestamp + sync source - these are identical on both sides
|
|
||||||
view.setUint32(0, metadata.rtpTimestamp, false); // First 4 bytes
|
|
||||||
view.setUint32(4, metadata.synchronizationSource || 0, false); // Middle 4 bytes
|
|
||||||
view.setUint32(8, 0, false); // Last 4 bytes (padding for 12-byte IV)
|
|
||||||
|
|
||||||
return ivBuffer;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to get metadata:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: use timestamp only (no random to avoid desync)
|
|
||||||
view.setUint32(0, Date.now() & 0xFFFFFFFF, false);
|
|
||||||
view.setUint32(4, 0, false);
|
|
||||||
view.setUint32(8, 0, false);
|
|
||||||
return ivBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener("rtctransform", (event) => {
|
|
||||||
const { transformer } = event;
|
|
||||||
const { readable, writable } = transformer;
|
|
||||||
const { key, mode } = transformer.options as WorkerOptions;
|
|
||||||
|
|
||||||
const isEncrypting = mode === 'encrypt';
|
|
||||||
|
|
||||||
let frameCount = 0;
|
|
||||||
|
|
||||||
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
|
|
||||||
try {
|
|
||||||
const data = new Uint8Array(encodedFrame.data);
|
|
||||||
|
|
||||||
// Increment frame counter
|
|
||||||
frameCount++;
|
|
||||||
|
|
||||||
// Create IV using RTP timestamp from metadata (synchronized between peers)
|
|
||||||
const iv = makeIV(encodedFrame);
|
|
||||||
|
|
||||||
// Ensure IV is properly typed
|
|
||||||
const ivArray = new Uint8Array(iv);
|
|
||||||
const params: AesGcmParams = { name: 'AES-GCM', iv: ivArray };
|
|
||||||
|
|
||||||
// COMPROMISE: Encrypt most of the frame while preserving minimal codec compatibility
|
|
||||||
// This prevents most visual leakage while maintaining decodability
|
|
||||||
let headerSize = 0;
|
|
||||||
let payloadData: Uint8Array;
|
|
||||||
|
|
||||||
if (data.length > 20) {
|
|
||||||
// For video frames, preserve first 8 bytes for better codec compatibility
|
|
||||||
// This includes frame type, keyframe info, and basic header structure
|
|
||||||
headerSize = Math.min(8, Math.floor(data.length / 10));
|
|
||||||
payloadData = data.slice(headerSize);
|
|
||||||
} else {
|
|
||||||
// For small frames (likely audio), encrypt everything
|
|
||||||
payloadData = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt the payload data
|
|
||||||
const payloadBuffer = new ArrayBuffer(payloadData.byteLength);
|
|
||||||
new Uint8Array(payloadBuffer).set(payloadData);
|
|
||||||
|
|
||||||
let encryptedPayload: ArrayBuffer;
|
|
||||||
if (isEncrypting) {
|
|
||||||
encryptedPayload = await crypto.subtle.encrypt(params, key, payloadBuffer);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
encryptedPayload = await crypto.subtle.decrypt(params, key, payloadBuffer);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, error);
|
|
||||||
return; // Drop the frame
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconstruct frame: minimal headers + encrypted payload
|
|
||||||
const encryptedArray = new Uint8Array(encryptedPayload);
|
|
||||||
const result = new Uint8Array(headerSize + encryptedArray.length);
|
|
||||||
|
|
||||||
if (headerSize > 0) {
|
|
||||||
result.set(data.slice(0, headerSize), 0); // Copy minimal headers
|
|
||||||
result.set(encryptedArray, headerSize); // Add encrypted payload
|
|
||||||
} else {
|
|
||||||
result.set(encryptedArray, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRITICAL: Video frames need ArrayBuffer, not Uint8Array
|
|
||||||
encodedFrame.data = result.buffer;
|
|
||||||
controller.enqueue(encodedFrame);
|
|
||||||
} catch (e) {
|
|
||||||
// FAIL SECURELY: Never send unencrypted frames
|
|
||||||
const data = new Uint8Array(encodedFrame.data);
|
|
||||||
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, e);
|
|
||||||
return; // Drop the frame completely
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readable
|
|
||||||
.pipeThrough(new TransformStream({ transform }))
|
|
||||||
.pipeTo(writable);
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
|
||||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
|
||||||
import { useUserStore } from "@/state/user";
|
|
||||||
import { fetchPreKeyBundle } from "@/core/api/crypto";
|
|
||||||
import { getAuthToken } from "@/core/api/account";
|
|
||||||
|
|
||||||
export interface CallSessionKey {
|
|
||||||
key: Uint8Array;
|
|
||||||
hash: string; // For emoji display
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a new call session key for end-to-end encryption
|
|
||||||
* @returns Promise that resolves to a session key with its hash for display
|
|
||||||
*/
|
|
||||||
export async function generateCallSessionKey(): Promise<CallSessionKey> {
|
|
||||||
// Generate session key material
|
|
||||||
const sessionKeyMaterial = randomBytes(32);
|
|
||||||
|
|
||||||
// Generate hash for emoji display (first 4 bytes of SHA-256 hash)
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", sessionKeyMaterial.buffer as ArrayBuffer);
|
|
||||||
const hash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: sessionKeyMaterial,
|
|
||||||
hash
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotate a session key by generating a completely new key
|
|
||||||
* This provides forward secrecy for long-running calls
|
|
||||||
*/
|
|
||||||
export async function rotateCallSessionKey(): Promise<CallSessionKey> {
|
|
||||||
// Generate new session key material (completely independent of current key)
|
|
||||||
const newSessionKeyMaterial = randomBytes(32);
|
|
||||||
|
|
||||||
// Generate new hash for emoji display
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", newSessionKeyMaterial.buffer as ArrayBuffer);
|
|
||||||
const newHash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: newSessionKeyMaterial,
|
|
||||||
hash: newHash
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate 4 emojis representing the call session key
|
|
||||||
*/
|
|
||||||
export function generateCallEmojis(sessionKeyHash: string): string[] {
|
|
||||||
// Convert hash to numbers and map to emoji ranges
|
|
||||||
const hashBytes = new Uint8Array(ub64(sessionKeyHash));
|
|
||||||
const emojis: string[] = [];
|
|
||||||
|
|
||||||
// Different emoji categories for variety
|
|
||||||
const emojiSets = [
|
|
||||||
["🎵", "🎶", "🎤", "🎧", "🎼", "🎹", "🥁", "🎺", "🎸", "🎻"], // Music
|
|
||||||
["🔥", "💫", "⭐", "✨", "🌟", "💥", "⚡", "🌈", "🎆", "🎇"], // Energy
|
|
||||||
["🚀", "🛸", "🛰️", "🌌", "🔭", "⚙️", "🔧", "⚡", "💡", "🔬"], // Tech/Space
|
|
||||||
["🎭", "🎪", "🎨", "🎬", "📷", "🎥", "📺", "🎮", "🕹️", "🎯"] // Entertainment
|
|
||||||
];
|
|
||||||
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
const set = emojiSets[i % emojiSets.length];
|
|
||||||
const index = hashBytes[i % hashBytes.length] % set.length;
|
|
||||||
emojis.push(set[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return emojis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypts a call session key using Signal Protocol
|
|
||||||
* @param recipientId - The recipient's user ID
|
|
||||||
* @param sessionKey - The session key to encrypt
|
|
||||||
* @returns Promise that resolves to encrypted session key data
|
|
||||||
*/
|
|
||||||
export async function encryptCallSessionKey(recipientId: number, sessionKey: Uint8Array): Promise<{ type: number; body: string }> {
|
|
||||||
const user = useUserStore.getState().user.currentUser;
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("User not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
|
||||||
|
|
||||||
// Ensure we have a session with the recipient
|
|
||||||
const hasSession = await signalService.hasSession(recipientId);
|
|
||||||
if (!hasSession) {
|
|
||||||
// Fetch prekey bundle and establish session
|
|
||||||
const token = getAuthToken();
|
|
||||||
if (!token) {
|
|
||||||
throw new Error("No auth token");
|
|
||||||
}
|
|
||||||
|
|
||||||
const bundle = await fetchPreKeyBundle(recipientId, token);
|
|
||||||
if (!bundle) {
|
|
||||||
throw new Error("No prekey bundle available for recipient");
|
|
||||||
}
|
|
||||||
|
|
||||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt the session key using Signal Protocol
|
|
||||||
const sessionKeyString = b64(sessionKey);
|
|
||||||
const encrypted = await signalService.encryptMessage(recipientId, sessionKeyString);
|
|
||||||
|
|
||||||
return encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypts a call session key using Signal Protocol
|
|
||||||
* @param senderId - The sender's user ID
|
|
||||||
* @param encryptedKey - The encrypted session key data
|
|
||||||
* @returns Promise that resolves to the decrypted session key
|
|
||||||
*/
|
|
||||||
export async function decryptCallSessionKey(senderId: number, encryptedKey: { type: number; body: string }): Promise<Uint8Array> {
|
|
||||||
const user = useUserStore.getState().user.currentUser;
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("User not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const signalService = new SignalProtocolService(user.id.toString());
|
|
||||||
|
|
||||||
// Decrypt using Signal Protocol
|
|
||||||
const decryptedString = await signalService.decryptMessage(senderId, encryptedKey);
|
|
||||||
|
|
||||||
// Convert back to Uint8Array
|
|
||||||
const sessionKey = new Uint8Array(
|
|
||||||
atob(decryptedString).split("").map(c => c.charCodeAt(0))
|
|
||||||
);
|
|
||||||
|
|
||||||
return sessionKey;
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData, CallSessionKeyData } from "@/core/types";
|
|
||||||
import * as WebRTC from "./webrtc";
|
|
||||||
|
|
||||||
export interface CallState {
|
|
||||||
receiveCall: (userId: number, username: string) => void;
|
|
||||||
endCall: () => void;
|
|
||||||
setCallSessionKeyHash: (sessionKeyHash: string) => void;
|
|
||||||
setRemoteVideoEnabled: (enabled: boolean) => void;
|
|
||||||
setRemoteScreenSharing: (enabled: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles incoming WebSocket messages related to call signaling
|
|
||||||
*/
|
|
||||||
export class CallSignalingHandler {
|
|
||||||
private getState: () => CallState;
|
|
||||||
|
|
||||||
constructor(getState: () => CallState) {
|
|
||||||
this.getState = getState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Routes incoming call signaling messages to appropriate handlers
|
|
||||||
*/
|
|
||||||
handleWebSocketMessage(message: CallSignalingMessage) {
|
|
||||||
const { data } = message;
|
|
||||||
if (!data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case "call_invite":
|
|
||||||
this.handleCallInvite(message, data as CallInviteMessageData);
|
|
||||||
break;
|
|
||||||
case "call_accept":
|
|
||||||
this.handleCallAccept(data as CallAcceptData);
|
|
||||||
break;
|
|
||||||
case "call_reject":
|
|
||||||
this.handleCallReject(data as CallRejectData);
|
|
||||||
break;
|
|
||||||
case "call_offer":
|
|
||||||
this.handleCallOffer(message, data as CallOfferData);
|
|
||||||
break;
|
|
||||||
case "call_answer":
|
|
||||||
this.handleCallAnswer(message, data as CallAnswerData);
|
|
||||||
break;
|
|
||||||
case "call_ice_candidate":
|
|
||||||
this.handleIceCandidate(message, data as CallIceCandidateData);
|
|
||||||
break;
|
|
||||||
case "call_end":
|
|
||||||
this.handleCallEnd(data as CallEndData);
|
|
||||||
break;
|
|
||||||
case "call_session_key":
|
|
||||||
this.handleCallSessionKey(message);
|
|
||||||
break;
|
|
||||||
case "call_video_toggle":
|
|
||||||
this.handleVideoToggle(message, data as CallVideoToggleData);
|
|
||||||
break;
|
|
||||||
case "call_screen_share_toggle":
|
|
||||||
this.handleScreenShareToggle(message, data as CallScreenShareToggleData);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles incoming call invitation
|
|
||||||
*/
|
|
||||||
private async handleCallInvite(message: CallSignalingMessage, data: CallInviteMessageData) {
|
|
||||||
const { fromUsername } = data;
|
|
||||||
const fromUserId = message.fromUserId;
|
|
||||||
const state = this.getState();
|
|
||||||
|
|
||||||
// First, create the peer connection in WebRTC service
|
|
||||||
await WebRTC.handleIncomingCall(fromUserId, fromUsername);
|
|
||||||
|
|
||||||
// Then show incoming call UI
|
|
||||||
state.receiveCall(fromUserId, fromUsername);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles call acceptance from remote peer
|
|
||||||
*/
|
|
||||||
private async handleCallAccept(data: CallAcceptData) {
|
|
||||||
const { fromUserId } = data;
|
|
||||||
// Initiator should create and send offer now
|
|
||||||
try {
|
|
||||||
await WebRTC.onRemoteAccepted(fromUserId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to proceed after accept:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles call rejection from remote peer
|
|
||||||
*/
|
|
||||||
private handleCallReject(data: CallRejectData) {
|
|
||||||
const state = this.getState();
|
|
||||||
const { fromUserId } = data;
|
|
||||||
|
|
||||||
// Clean up WebRTC connection first
|
|
||||||
if (fromUserId) {
|
|
||||||
WebRTC.cleanupCall(fromUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// End the call
|
|
||||||
state.endCall();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCallOffer(message: CallSignalingMessage, data: CallOfferData) {
|
|
||||||
await WebRTC.handleCallOffer(message.fromUserId, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCallAnswer(message: CallSignalingMessage, data: CallAnswerData) {
|
|
||||||
await WebRTC.handleCallAnswer(message.fromUserId, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleIceCandidate(message: CallSignalingMessage, data: CallIceCandidateData) {
|
|
||||||
await WebRTC.handleIceCandidate(message.fromUserId, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleCallEnd(data: CallEndData) {
|
|
||||||
const state = this.getState();
|
|
||||||
const { fromUserId } = data;
|
|
||||||
|
|
||||||
// Clean up WebRTC connection first
|
|
||||||
if (fromUserId) {
|
|
||||||
WebRTC.cleanupCall(fromUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// End the call
|
|
||||||
state.endCall();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleCallSessionKey(message: CallSignalingMessage) {
|
|
||||||
const state = this.getState();
|
|
||||||
const { sessionKeyHash } = message;
|
|
||||||
const data = message.data as CallSessionKeyData;
|
|
||||||
|
|
||||||
if (sessionKeyHash) {
|
|
||||||
state.setCallSessionKeyHash(sessionKeyHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if data is CallSessionKeyData and has encryptedSessionKey
|
|
||||||
if (data && data.encryptedSessionKey && message.fromUserId) {
|
|
||||||
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.encryptedSessionKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleVideoToggle(message: CallSignalingMessage, data: CallVideoToggleData) {
|
|
||||||
const state = this.getState();
|
|
||||||
|
|
||||||
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
|
|
||||||
// Update Zustand state (for UI)
|
|
||||||
state.setRemoteVideoEnabled(data.enabled);
|
|
||||||
// Update WebRTC internal state (for track routing)
|
|
||||||
WebRTC.setRemoteVideoEnabled(message.fromUserId, data.enabled);
|
|
||||||
} else {
|
|
||||||
console.warn("Invalid toggle data:", data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleScreenShareToggle(message: CallSignalingMessage, data: CallScreenShareToggleData) {
|
|
||||||
const state = this.getState();
|
|
||||||
|
|
||||||
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
|
|
||||||
// Update Zustand state (for UI)
|
|
||||||
state.setRemoteScreenSharing(data.enabled);
|
|
||||||
// Update WebRTC internal state (for track routing)
|
|
||||||
WebRTC.setRemoteScreenSharing(message.fromUserId, data.enabled);
|
|
||||||
} else {
|
|
||||||
console.warn("Invalid toggle data:", data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,94 +0,0 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
|
||||||
import { StyledDialog } from "./StyledDialog";
|
|
||||||
import { MaterialButton } from "@/utils/material";
|
|
||||||
import styles from "./css/alert-dialog.module.scss";
|
|
||||||
|
|
||||||
interface AlertDialogState {
|
|
||||||
open: boolean;
|
|
||||||
message: string;
|
|
||||||
resolve: (() => void) | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let alertState: AlertDialogState = {
|
|
||||||
open: false,
|
|
||||||
message: "",
|
|
||||||
resolve: null
|
|
||||||
};
|
|
||||||
|
|
||||||
const listeners = new Set<() => void>();
|
|
||||||
|
|
||||||
function notifyListeners() {
|
|
||||||
listeners.forEach(listener => listener());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop-in replacement for window.alert() using StyledDialog
|
|
||||||
* @param message - The message to display
|
|
||||||
* @returns Promise that resolves when the dialog is closed
|
|
||||||
*/
|
|
||||||
export function alert(message: string): Promise<void> {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
alertState = {
|
|
||||||
open: true,
|
|
||||||
message,
|
|
||||||
resolve: () => {
|
|
||||||
alertState.open = false;
|
|
||||||
alertState.message = "";
|
|
||||||
alertState.resolve = null;
|
|
||||||
notifyListeners();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
notifyListeners();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal component that renders the alert dialog
|
|
||||||
*/
|
|
||||||
export function AlertDialogProvider() {
|
|
||||||
const [, setUpdateKey] = useState(0);
|
|
||||||
|
|
||||||
const update = useCallback(() => {
|
|
||||||
setUpdateKey(prev => prev + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
listeners.add(update);
|
|
||||||
return () => {
|
|
||||||
listeners.delete(update);
|
|
||||||
};
|
|
||||||
}, [update]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (alertState.resolve) {
|
|
||||||
alertState.resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledDialog
|
|
||||||
open={alertState.open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBackdropClick={handleClose}
|
|
||||||
className={styles.alertDialog}
|
|
||||||
contentClassName={styles.alertDialogContent}
|
|
||||||
>
|
|
||||||
<div className={styles.alertDialogMessage}>
|
|
||||||
{alertState.message}
|
|
||||||
</div>
|
|
||||||
<div className={styles.alertDialogActions}>
|
|
||||||
<MaterialButton
|
|
||||||
variant="filled"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</MaterialButton>
|
|
||||||
</div>
|
|
||||||
</StyledDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Dialog as MduiDialog } from "mdui/components/dialog";
|
||||||
|
import { useEffect, type Ref } from "react"
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { id } from "@/utils/utils";
|
||||||
|
import useCombinedRefs from "@/core/hooks/useCombinedRefs";
|
||||||
|
|
||||||
|
export interface BaseDialogProps {
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
ref?: Ref<MduiDialog & HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
|
||||||
|
|
||||||
|
export function MaterialDialog(props: FullDialogProps) {
|
||||||
|
// eslint-disable-next-line react-hooks/refs
|
||||||
|
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
|
||||||
|
const { open, onOpenChange } = props;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === "attributes" && mutation.attributeName === "open") {
|
||||||
|
const isOpen = dialog.hasAttribute("open");
|
||||||
|
if (isOpen !== open) {
|
||||||
|
onOpenChange(isOpen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing the dialog element for attribute changes
|
||||||
|
observer.observe(dialog, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["open"]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup observer
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [open, onOpenChange, dialogRef]);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/refs
|
||||||
|
return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
|
||||||
|
}
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import useCombinedRefs from '@/core/hooks/useCombinedRefs';
|
|
||||||
import { id } from '@/utils/utils';
|
|
||||||
|
|
||||||
interface AutoResizeInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
autoresizing?: true;
|
|
||||||
placeholderMinWidth?: boolean;
|
|
||||||
onAutosize?: (width: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
autoresizing?: false;
|
|
||||||
placeholderMinWidth?: false;
|
|
||||||
onAutosize?: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Input({
|
|
||||||
autoresizing = false,
|
|
||||||
placeholderMinWidth = false,
|
|
||||||
onAutosize,
|
|
||||||
style: inputStyle,
|
|
||||||
...inputProps
|
|
||||||
}: AutoResizeInputProps | InputProps) {
|
|
||||||
const [inputWidth, setInputWidth] = useState(0);
|
|
||||||
|
|
||||||
const sizerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const placeholderSizerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [inputRef, inputElement] = useCombinedRefs<HTMLInputElement>();
|
|
||||||
|
|
||||||
const sizerStyle: React.CSSProperties = {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
visibility: 'hidden',
|
|
||||||
height: 0,
|
|
||||||
overflow: 'scroll',
|
|
||||||
whiteSpace: 'pre',
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyStyles = useCallback((styles: CSSStyleDeclaration, node: HTMLElement) => {
|
|
||||||
node.style.fontSize = styles.fontSize;
|
|
||||||
node.style.fontFamily = styles.fontFamily;
|
|
||||||
node.style.fontWeight = styles.fontWeight;
|
|
||||||
node.style.fontStyle = styles.fontStyle;
|
|
||||||
node.style.letterSpacing = styles.letterSpacing;
|
|
||||||
node.style.textTransform = styles.textTransform;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateInputWidth = useCallback(() => {
|
|
||||||
if (!sizerRef.current || typeof sizerRef.current.scrollWidth === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let newInputWidth: number;
|
|
||||||
|
|
||||||
if (inputProps.placeholder && (!inputProps.value || (inputProps.value && placeholderMinWidth))) {
|
|
||||||
const sizerWidth = sizerRef.current.scrollWidth;
|
|
||||||
const placeholderWidth = placeholderSizerRef.current?.scrollWidth || 0;
|
|
||||||
newInputWidth = Math.max(sizerWidth, placeholderWidth) + 2;
|
|
||||||
} else {
|
|
||||||
newInputWidth = sizerRef.current.scrollWidth + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (newInputWidth !== inputWidth) {
|
|
||||||
setInputWidth(newInputWidth);
|
|
||||||
onAutosize?.(newInputWidth);
|
|
||||||
}
|
|
||||||
}, [inputProps.placeholder, inputProps.value, inputProps.type, placeholderMinWidth, inputWidth, onAutosize]);
|
|
||||||
|
|
||||||
const copyInputStyles = useCallback(() => {
|
|
||||||
if (!inputElement.current || !window.getComputedStyle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputStyles = window.getComputedStyle(inputElement.current);
|
|
||||||
if (!inputStyles) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
copyStyles(inputStyles, sizerRef.current!);
|
|
||||||
if (placeholderSizerRef.current) {
|
|
||||||
copyStyles(inputStyles, placeholderSizerRef.current);
|
|
||||||
}
|
|
||||||
}, [inputElement]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoresizing) {
|
|
||||||
copyInputStyles();
|
|
||||||
updateInputWidth();
|
|
||||||
}
|
|
||||||
}, [autoresizing, copyInputStyles, updateInputWidth]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoresizing) {
|
|
||||||
updateInputWidth();
|
|
||||||
}
|
|
||||||
}, [inputProps.value, inputProps.placeholder, autoresizing, updateInputWidth]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
{...inputProps}
|
|
||||||
ref={inputRef}
|
|
||||||
style={{
|
|
||||||
boxSizing: 'content-box',
|
|
||||||
width: autoresizing ? `${inputWidth}px` : undefined,
|
|
||||||
...inputStyle,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{autoresizing && createPortal(
|
|
||||||
<>
|
|
||||||
<div ref={sizerRef} style={sizerStyle}>
|
|
||||||
{inputProps.defaultValue || inputProps.value || ''}
|
|
||||||
</div>
|
|
||||||
{inputProps.placeholder && (
|
|
||||||
<div ref={placeholderSizerRef} style={sizerStyle}>
|
|
||||||
{inputProps.placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>,
|
|
||||||
id("root")
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ interface RichTextAreaProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
readOnly?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RichTextArea({
|
export function RichTextArea({
|
||||||
@@ -21,8 +20,7 @@ export function RichTextArea({
|
|||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
rows = 1,
|
rows = 1,
|
||||||
autoComplete = "off",
|
autoComplete = "off"
|
||||||
readOnly = false
|
|
||||||
}: RichTextAreaProps) {
|
}: RichTextAreaProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
@@ -30,7 +28,7 @@ export function RichTextArea({
|
|||||||
|
|
||||||
function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
|
function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
|
||||||
const raw = computedStyle[prop] as string | number | undefined;
|
const raw = computedStyle[prop] as string | number | undefined;
|
||||||
if (raw == null) return 0;
|
if (raw === null) return 0;
|
||||||
const str = String(raw);
|
const str = String(raw);
|
||||||
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
|
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
|
||||||
}
|
}
|
||||||
@@ -185,10 +183,10 @@ export function RichTextArea({
|
|||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
autoComplete={readOnly ? "off" : autoComplete}
|
autoComplete={autoComplete}
|
||||||
onChange={readOnly ? undefined : handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={readOnly ? undefined : handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
readOnly={readOnly} />
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
aria-hidden
|
aria-hidden
|
||||||
readOnly
|
readOnly
|
||||||
@@ -204,7 +202,7 @@ export function RichTextArea({
|
|||||||
height: "auto",
|
height: "auto",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
maxHeight: "none",
|
maxHeight: "none",
|
||||||
overflow: "hidden",
|
overflow: "hidden"
|
||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import styles from "./css/searchBar.module.scss";
|
|
||||||
import { MaterialIcon, type MDUIBottomAppBar } from "@/utils/material";
|
|
||||||
|
|
||||||
interface SearchBarProps {
|
|
||||||
placeholder: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
searchQuery: string;
|
|
||||||
onQueryChange: (query: string) => void;
|
|
||||||
isExpanded: boolean;
|
|
||||||
onToggleExpanded: () => void;
|
|
||||||
leftIcon?: string | React.ReactNode;
|
|
||||||
rightIcon?: string | React.ReactNode;
|
|
||||||
containerRef: React.RefObject<HTMLElement | null>;
|
|
||||||
headerRef?: React.RefObject<HTMLElement | null>;
|
|
||||||
bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SearchBar({
|
|
||||||
placeholder,
|
|
||||||
children,
|
|
||||||
searchQuery,
|
|
||||||
onQueryChange,
|
|
||||||
isExpanded,
|
|
||||||
onToggleExpanded,
|
|
||||||
leftIcon = "search--outlined",
|
|
||||||
rightIcon = null,
|
|
||||||
containerRef,
|
|
||||||
headerRef,
|
|
||||||
bottomAppBarRef
|
|
||||||
}: SearchBarProps) {
|
|
||||||
const [dynamicHeight, setDynamicHeight] = useState<string>("48px");
|
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
||||||
const [showResults, setShowResults] = useState(false);
|
|
||||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const parentContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
|
|
||||||
// Focus input when expanded and manage height
|
|
||||||
useEffect(() => {
|
|
||||||
if (isExpanded && inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
// Set expanded height, subtracting both header and bottom app bar heights
|
|
||||||
if (containerRef.current) {
|
|
||||||
const panelHeight = containerRef.current.offsetHeight;
|
|
||||||
let headerHeight = 0;
|
|
||||||
let bottomBarHeight = 0;
|
|
||||||
|
|
||||||
// Get header height
|
|
||||||
if (headerRef?.current) {
|
|
||||||
headerHeight = headerRef.current.offsetHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get bottom app bar height
|
|
||||||
if (bottomAppBarRef?.current) {
|
|
||||||
bottomBarHeight = bottomAppBarRef.current.offsetHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate height by subtracting both header and bottom bar heights
|
|
||||||
const availableHeight = panelHeight - headerHeight - bottomBarHeight;
|
|
||||||
setDynamicHeight(`${availableHeight}px`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Set collapsed height
|
|
||||||
setDynamicHeight("48px");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide results and disable overflow during transition
|
|
||||||
if (isExpanded) {
|
|
||||||
setShowResults(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsTransitioning(true);
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setIsTransitioning(false);
|
|
||||||
if (!isExpanded) {
|
|
||||||
setShowResults(false);
|
|
||||||
}
|
|
||||||
}, 400); // Match transition duration (0.4s)
|
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [isExpanded, containerRef, headerRef, bottomAppBarRef]);
|
|
||||||
|
|
||||||
function handleToggle() {
|
|
||||||
onToggleExpanded();
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const query = e.target.value;
|
|
||||||
onQueryChange(query);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to render icon
|
|
||||||
function renderIcon(icon: string | React.ReactNode | undefined, defaultIcon?: string) {
|
|
||||||
if (icon === null) return null;
|
|
||||||
if (!icon) {
|
|
||||||
return defaultIcon ? <MaterialIcon name={defaultIcon} /> : null;
|
|
||||||
} else if (typeof icon === 'string') {
|
|
||||||
return <MaterialIcon name={icon} />;
|
|
||||||
} else {
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={parentContainerRef}
|
|
||||||
className={styles.searchParent}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={searchContainerRef}
|
|
||||||
className={`${styles.searchBarContainer} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
|
||||||
style={{ height: dynamicHeight }}
|
|
||||||
onClick={!isExpanded ? handleToggle : undefined}
|
|
||||||
>
|
|
||||||
{/* Single Search Bar Element */}
|
|
||||||
<div className={styles.searchBar}>
|
|
||||||
{/* Left Icon */}
|
|
||||||
<div className={styles.searchIcon}>
|
|
||||||
{renderIcon(leftIcon, "search--outlined")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input/Placeholder */}
|
|
||||||
<div className={styles.searchInputContainer}>
|
|
||||||
{isExpanded ? (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={styles.searchInput}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={handleQueryChange}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className={styles.searchPlaceholder}>{placeholder}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Icon */}
|
|
||||||
<div className={styles.searchClear}>
|
|
||||||
{renderIcon(rightIcon)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results Section - Visible during expansion and collapse transition */}
|
|
||||||
{showResults && (
|
|
||||||
<div
|
|
||||||
className={styles.searchResults}
|
|
||||||
style={{ overflowY: isTransitioning ? "hidden" : "auto" }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import api from "@/core/api";
|
|
||||||
import { useUserStore } from "@/state/user";
|
|
||||||
import { MaterialIcon } from "@/utils/material";
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
verified: boolean;
|
|
||||||
userId?: number;
|
|
||||||
size?: "small" | "medium" | "large";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) {
|
|
||||||
const [isSimilarToVerified, setIsSimilarToVerified] = useState(false);
|
|
||||||
const { user } = useUserStore();
|
|
||||||
|
|
||||||
const className = `status-badge ${size}`;
|
|
||||||
|
|
||||||
// Check similarity for unverified users
|
|
||||||
useEffect(() => {
|
|
||||||
if (!verified && userId && user.authToken) {
|
|
||||||
api.user.profile.checkSimilarity(userId, user.authToken)
|
|
||||||
.then(result => {
|
|
||||||
setIsSimilarToVerified(result?.isSimilar || false);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error checking similarity:', error);
|
|
||||||
setIsSimilarToVerified(false);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setIsSimilarToVerified(false);
|
|
||||||
}
|
|
||||||
}, [verified, userId, user.authToken]);
|
|
||||||
|
|
||||||
if (verified) {
|
|
||||||
return (
|
|
||||||
<span className={`${className} verified`} title="Подтверждённый аккаунт">
|
|
||||||
<MaterialIcon name="verified--filled" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSimilarToVerified) {
|
|
||||||
return (
|
|
||||||
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
|
|
||||||
<MaterialIcon name="warning--filled" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't show anything if not verified and not similar
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useEffect, type ReactNode } from "react";
|
|
||||||
import { motion, AnimatePresence, type Transition } from "motion/react";
|
|
||||||
import styles from "./css/styled-dialog.module.scss";
|
|
||||||
|
|
||||||
interface StyledDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
children: ReactNode;
|
|
||||||
onBackdropClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
contentClassName?: string;
|
|
||||||
afterChildren?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StyledDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
children,
|
|
||||||
onBackdropClick,
|
|
||||||
className = "",
|
|
||||||
contentClassName = "",
|
|
||||||
afterChildren
|
|
||||||
}: StyledDialogProps) {
|
|
||||||
const transition: Transition = { duration: 0.3, type: "tween", ease: "easeInOut" };
|
|
||||||
|
|
||||||
// Handle ESC key
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
function handleEsc(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleEsc);
|
|
||||||
return () => document.removeEventListener("keydown", handleEsc);
|
|
||||||
}
|
|
||||||
}, [open, onOpenChange]);
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<AnimatePresence>
|
|
||||||
{open && (
|
|
||||||
<motion.div
|
|
||||||
className={styles.styledDialogBackdrop}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
if (onBackdropClick) {
|
|
||||||
onBackdropClick();
|
|
||||||
} else {
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={transition}>
|
|
||||||
<motion.div
|
|
||||||
className={`${styles.styledDialog} ${className}`}
|
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0.9, opacity: 0 }}
|
|
||||||
transition={transition}>
|
|
||||||
<div className={`${styles.styledDialogContent} ${contentClassName}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
{afterChildren}
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>,
|
|
||||||
document.getElementById("root")!
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { TextField } from "mdui/components/text-field";
|
||||||
|
|
||||||
|
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
|
||||||
|
|
||||||
|
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
|
||||||
|
return (
|
||||||
|
<mdui-text-field
|
||||||
|
autocomplete="off"
|
||||||
|
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import api from "@/core/api";
|
|
||||||
import { useUserStore } from "@/state/user";
|
|
||||||
import { MaterialButton } from "@/utils/material";
|
|
||||||
|
|
||||||
interface VerifyButtonProps {
|
|
||||||
userId: number;
|
|
||||||
verified: boolean;
|
|
||||||
onVerificationChange?: (verified: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) {
|
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
|
||||||
const { user } = useUserStore();
|
|
||||||
|
|
||||||
// Only show for owner
|
|
||||||
if (user.currentUser?.id !== 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleVerifyToggle() {
|
|
||||||
if (!user.authToken || isVerifying) return;
|
|
||||||
|
|
||||||
setIsVerifying(true);
|
|
||||||
try {
|
|
||||||
const result = await api.moderation.users.verify(userId, user.authToken);
|
|
||||||
if (result) {
|
|
||||||
onVerificationChange?.(result.verified);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling verification:', error);
|
|
||||||
} finally {
|
|
||||||
setIsVerifying(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MaterialButton
|
|
||||||
variant="filled"
|
|
||||||
loading={isVerifying}
|
|
||||||
onClick={handleVerifyToggle}
|
|
||||||
title={verified ? "Снять подтверждение" : "Подтвердить аккаунт"}
|
|
||||||
>
|
|
||||||
{verified ? "Отменить подтверждение" : "Подтвердить"}
|
|
||||||
</MaterialButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import type { AnimatedPropertyProps } from "./types";
|
||||||
|
|
||||||
|
export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) {
|
||||||
|
const [height, setHeight] = useState("0px");
|
||||||
|
const [shouldRender, setShouldRender] = useState(!!visible);
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const measureRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setShouldRender(true);
|
||||||
|
setIsAnimating(true);
|
||||||
|
// Wait for content to render, then measure
|
||||||
|
setTimeout(() => {
|
||||||
|
if (measureRef.current) {
|
||||||
|
const contentHeight = measureRef.current.scrollHeight;
|
||||||
|
setHeight(`${contentHeight}px`);
|
||||||
|
}
|
||||||
|
// Animation complete
|
||||||
|
setTimeout(() => {
|
||||||
|
setHeight("auto");
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, duration * 1000);
|
||||||
|
}, 0);
|
||||||
|
} else if (shouldRender) {
|
||||||
|
setIsAnimating(true);
|
||||||
|
if (measureRef.current) {
|
||||||
|
const contentHeight = measureRef.current.scrollHeight;
|
||||||
|
setHeight(`${contentHeight}px`);
|
||||||
|
// Force a reflow before animating to 0
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// Read layout to ensure the previous height assignment is flushed
|
||||||
|
if (containerRef.current) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
containerRef.current.offsetHeight;
|
||||||
|
}
|
||||||
|
// Use a second frame to ensure the measured pixel height is applied before collapsing
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setHeight("0px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Hide content after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
setShouldRender(false);
|
||||||
|
setIsAnimating(false);
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
|
}, duration * 1000);
|
||||||
|
}
|
||||||
|
}, [visible, shouldRender, duration, onFinish]);
|
||||||
|
|
||||||
|
return (visible || shouldRender || isAnimating) && (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
transition: `height ${duration}s ease`,
|
||||||
|
overflow: "hidden",
|
||||||
|
...props.style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={measureRef} style={{ height: "auto" }}>
|
||||||
|
{shouldRender && children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { AnimatedPropertyProps } from "./types";
|
||||||
|
|
||||||
|
export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, children, ...props }: AnimatedPropertyProps) {
|
||||||
|
const [opacity, setOpacity] = useState(visible ? 1 : 0);
|
||||||
|
const [shouldRender, setShouldRender] = useState(visible);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setShouldRender(true);
|
||||||
|
setOpacity(0);
|
||||||
|
|
||||||
|
// Wait for content to render, then animate in
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
setOpacity(1);
|
||||||
|
}, 10);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
} else {
|
||||||
|
setOpacity(0);
|
||||||
|
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
setShouldRender(false);
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
|
}, duration * 1000);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}
|
||||||
|
}, [visible, duration, onFinish]);
|
||||||
|
|
||||||
|
return shouldRender && (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
opacity,
|
||||||
|
transition: `opacity ${duration}s ease`,
|
||||||
|
...props.style
|
||||||
|
}}
|
||||||
|
>{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface BaseAnimatedPropertyProps {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
visible: any;
|
||||||
|
duration?: number;
|
||||||
|
onFinish?: () => void
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div">
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
@use "../../../css/colors" as *;
|
|
||||||
@use "../../../css/material" as *;
|
|
||||||
|
|
||||||
.alertDialog {
|
|
||||||
.alertDialogContent {
|
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alertDialogMessage {
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.5;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alertDialogActions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
@use "../../../css/material" as *;
|
|
||||||
|
|
||||||
$font-size: 16px;
|
|
||||||
|
|
||||||
// Search container
|
|
||||||
.searchParent {
|
|
||||||
position: relative;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchBar component styles
|
|
||||||
.searchBarContainer {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1001;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
// All properties animate together simultaneously
|
|
||||||
transition:
|
|
||||||
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
top 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
left 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
right 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
|
||||||
// Initial background color for smooth transition
|
|
||||||
background-color: $color-dark-surface-container-high;
|
|
||||||
|
|
||||||
&.collapsed {
|
|
||||||
top: 8px;
|
|
||||||
left: 16px;
|
|
||||||
right: 16px;
|
|
||||||
border-radius: 24px;
|
|
||||||
// Height will be set dynamically by React (48px)
|
|
||||||
// background-color inherited from parent
|
|
||||||
}
|
|
||||||
|
|
||||||
&.expanded {
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
// bottom will be set dynamically by React to account for bottom app bar
|
|
||||||
border-radius: 0;
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
// Height will be set dynamically by React
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single search bar element
|
|
||||||
.searchBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 16px;
|
|
||||||
height: 48px;
|
|
||||||
gap: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.searchIcon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
|
|
||||||
mdui-icon {
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInputContainer {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.searchPlaceholder {
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
font-size: $font-size;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
font-size: $font-size;
|
|
||||||
padding: 8px 0;
|
|
||||||
pointer-events: auto;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchClear {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
|
|
||||||
mdui-icon {
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Results section
|
|
||||||
.searchResults {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
@use "../../../css/colors" as *;
|
|
||||||
@use "../../../css/material" as *;
|
|
||||||
@use "sass:color";
|
|
||||||
|
|
||||||
// Dialog padding variable
|
|
||||||
$dialog-padding: 30px;
|
|
||||||
|
|
||||||
// Base Styled Dialog Styles
|
|
||||||
.styledDialogBackdrop {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: $dialog-padding;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.styledDialog {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
max-height: calc(100vh - #{$dialog-padding} * 2);
|
|
||||||
background: rgba($color-dark-surface-container, 0.75);
|
|
||||||
border: 1px solid rgba($color-dark-outline-variant, 0.3);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14),
|
|
||||||
0 9px 46px 8px rgba(0, 0, 0, 0.12),
|
|
||||||
0 11px 15px -7px rgba(0, 0, 0, 0.2);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.styledDialogContent {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,28 @@
|
|||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base domain name for all requests in production
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
|
export const BASE_DOMAIN = import.meta.env.VITE_API_BASE_URL ?? "fromchat.ru";
|
||||||
|
|
||||||
export const BASE_DOMAIN = import.meta.env.VITE_API_BASE_URL || "fromchat.ru";
|
/**
|
||||||
|
* Base API endpoint for all backend requests
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
export const API_BASE_URL = `${location.host ? "" : `https://${BASE_DOMAIN}`}/api`;
|
export const API_BASE_URL = `${location.host ? "" : `https://${BASE_DOMAIN}`}/api`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full API URL including hostname and port for WebSocket connections
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
export const API_WS_BASE_URL = `${location.host || BASE_DOMAIN}/api`;
|
export const API_WS_BASE_URL = `${location.host || BASE_DOMAIN}/api`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application name displayed in UI and document title
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
export const PRODUCT_NAME = "FromChat";
|
export const PRODUCT_NAME = "FromChat";
|
||||||
|
|
||||||
export const MINIMUM_WIDTH = 800;
|
export const MINIMUM_WIDTH = 800;
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import "./electron.scss";
|
import "./electron.scss";
|
||||||
|
|
||||||
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface != undefined;
|
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface !== undefined;
|
||||||
|
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
console.log("Running in Electron");
|
console.log("Running in Electron");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user