mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Compare commits
3 Commits
@@ -1,40 +1,4 @@
|
||||
# Code Cleanup Command
|
||||
|
||||
## Overview
|
||||
Analyze git diff between the specified branch and HEAD (defaults to main if no branch specified) and clean up code quality issues without altering functionality.
|
||||
|
||||
## Process
|
||||
1. **Get diff**: Run `git diff <branch>..HEAD` to see changes
|
||||
2. **Identify issues**: Look for code quality problems in the diff
|
||||
3. **Clean up**: Remove only the identified issues
|
||||
4. **Verify**: Ensure no behavioral changes
|
||||
|
||||
## What to Clean Up
|
||||
- **Debug artifacts**: `console.log()`, `debugger`, `print()` statements
|
||||
- **Unused code**: Variables, imports, functions, parameters
|
||||
- **Commented code**: Dead code blocks, TODO comments (unless active)
|
||||
- **Formatting**: Inconsistent spacing, trailing whitespace
|
||||
- **Temporary code**: Test values, hardcoded strings meant to be dynamic
|
||||
- **Redundant code**: Duplicate logic, unnecessary intermediate variables
|
||||
|
||||
## What NOT to Touch
|
||||
- **Functional logic**: Don't change how features work
|
||||
- **API interfaces**: Keep method signatures intact
|
||||
- **Configuration**: Don't modify settings or constants
|
||||
- **Comments**: Keep documentation and explanatory comments
|
||||
- **Error handling**: Don't remove try-catch blocks or validation
|
||||
|
||||
## Safety Rules
|
||||
- ✅ Only modify code that appears in the git diff
|
||||
- ✅ Preserve all existing functionality
|
||||
- ✅ Maintain code readability and structure
|
||||
- ❌ Don't refactor or optimize beyond cleanup
|
||||
- ❌ Don't add new features or improvements
|
||||
- ❌ Don't change variable names or function signatures
|
||||
|
||||
## Example
|
||||
```bash
|
||||
# If user specifies: "/clean-up main"
|
||||
git diff main
|
||||
# Clean only the issues found in this diff
|
||||
```
|
||||
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,
|
||||
unused variables etc. Think twice before removing or adding code, because you
|
||||
mustn't alter the behavior.
|
||||
@@ -1,72 +0,0 @@
|
||||
# Security Audit Command
|
||||
|
||||
Perform a comprehensive security audit of the FromChat **Android application** only.
|
||||
|
||||
## Project Context
|
||||
|
||||
**FromChat Android** is a 100% open source secure messaging mobile application built with:
|
||||
|
||||
- Kotlin Multiplatform (KMP) shared code
|
||||
- Jetpack Compose UI framework
|
||||
- End-to-End Encryption (NaCl, AES-GCM)
|
||||
- WebSocket support for real-time features
|
||||
- LiveKit integration for calls
|
||||
- Local database storage (SQLite)
|
||||
|
||||
## Scope: Android Only
|
||||
|
||||
**OUT OF SCOPE:**
|
||||
|
||||
- Web backend (Python FastAPI, Caddy infrastructure)
|
||||
- React/TypeScript frontend
|
||||
|
||||
**IN SCOPE:**
|
||||
|
||||
- Android app code (`app/android`, `app/shared/src/androidMain`)
|
||||
- Shared cross-platform code (`app/shared/src/commonMain`)
|
||||
- Local encryption implementation (NaCl, AES-GCM)
|
||||
- Secure storage (Android Keystore, encrypted SharedPreferences)
|
||||
- WebSocket client security
|
||||
- Permission usage and handling
|
||||
- Call security (LiveKit integration)
|
||||
- Memory safety and injection attacks
|
||||
- Backend
|
||||
|
||||
## Important Design Decisions (NOT Vulnerabilities)
|
||||
|
||||
When auditing, remember these are **intentional design choices**:
|
||||
|
||||
1. **Local message caching** - Messages downloaded and stored locally (by design)
|
||||
- Messages are end-to-end encrypted at rest in local DB
|
||||
- Public DMs are not encrypted (messages are public)
|
||||
- Private DMs use NaCl encryption
|
||||
- Cache persists across app restarts for offline access
|
||||
2. **Local key storage** - Encryption keys stored on device (by design)
|
||||
- Keys protected by Android Keystore (hardware-backed when available)
|
||||
- Encrypted with device-specific secrets
|
||||
- User data never leaves device in plaintext
|
||||
- Do NOT flag key storage as critical (Keystore is production-ready)
|
||||
|
||||
## Output Format
|
||||
|
||||
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. **Architecture Review** - Data flow, encryption boundaries
|
||||
6. **Threat Analysis** - Current realistic threats (e.g., rooted device, malicious APK)
|
||||
7. **Recommendations** - Prioritized with time estimates
|
||||
8. **Conclusion** - Clear production readiness statement
|
||||
|
||||
**Keep it under 500 lines** - focus on actionable findings, not verbose explanations.
|
||||
|
||||
## Common False Positives to Avoid
|
||||
|
||||
❌ **DO NOT FLAG THESE AS ISSUES:**
|
||||
|
||||
- Local message caching (intentional for offline access)
|
||||
- Public message viewing without auth (intentional design)
|
||||
- Debuggable APK (only relevant if signed/released)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Run the command "npm run frontend:typecheck" and fix all errors listed in the command if there's any.
|
||||
@@ -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:
|
||||
|
||||
@@ -16,13 +16,10 @@ When working with this project, follow these rules:
|
||||
- Use double quotes ("") for strings consistently.
|
||||
- Prefer functional components over class components in React.
|
||||
- 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
|
||||
- If possible, try to update files in a single edit when making multiple changes.
|
||||
- Do NOT "cd" to the project directory.
|
||||
- NEVER edit/delete/regenerate .env files without explicit permission.
|
||||
- Don't use rg, it doesn't work anyway.
|
||||
|
||||
## Testing & Validation
|
||||
- Do NOT "test the implementation" when you are done. The only exception is when you
|
||||
@@ -33,14 +30,16 @@ When working with this project, follow these rules:
|
||||
- If the typecheck passed, there's no need for checking the linter errors.
|
||||
|
||||
## Async Operations
|
||||
- When you need a delay, use `await delay(millis);` from `@/utils/utils` in an async function. If the current function is not async, make it async.
|
||||
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async,
|
||||
make it async.
|
||||
|
||||
## Database
|
||||
- NEVER create database migrations, they are auto-generated.
|
||||
|
||||
## Project Structure Awareness
|
||||
- This is a React/TypeScript browser frontend (desktop is Compose Multiplatform in the Android/KMP repo)
|
||||
- This is a React/TypeScript frontend with Python FastAPI backend
|
||||
- Uses MDUI components for UI
|
||||
- Has Electron support for desktop app
|
||||
- Uses Zustand for state management
|
||||
- Uses use-immer for immutable state updates
|
||||
- Uses React Router for navigation
|
||||
@@ -51,16 +50,3 @@ When working with this project, follow these rules:
|
||||
- Batch tool calls when possible to reduce latency
|
||||
- Use semantic search before grep when looking for concepts
|
||||
- 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
|
||||
|
||||
## Debug Mode
|
||||
- When in debug mode and the issue is not yet fixed, ALWAYS end responses with `<reproduction_steps>` containing the steps to reproduce the issue and trigger logging
|
||||
- When NOT in debug mode or when the issue IS fixed, escape the tag as `<reproduction_steps>` to avoid triggering it
|
||||
- Never use other `<re>` tags, only `<reproduction_steps>`
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description: Do not filter cache, API, or DB fields by matching fixed UI/placeholder English strings
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# No magic-string “sanitization” of user or message data
|
||||
|
||||
- **Never** strip, null out, or rewrite stored or displayed values by comparing them to hard-coded UI strings (e.g. `"Direct messages"`, `"Direct message"`, `"User 123"`, etc.). Those strings can be legitimate **usernames, display names, or message text**.
|
||||
- **Prefer**: fix the source (don’t persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
|
||||
- Applies especially to: local storage/cache layers, list previews, and any code that “cleans” strings before show or read.
|
||||
@@ -3,11 +3,6 @@ alwaysApply: true
|
||||
---
|
||||
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,
|
||||
add it. Do NOT remove anything.
|
||||
1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML.
|
||||
3. The supporting text slot for MDUI lists is "description".
|
||||
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`.
|
||||
4. When working with lists/sets in states, use the "useImmer" hook.
|
||||
@@ -1,170 +0,0 @@
|
||||
---
|
||||
name: decrypt
|
||||
description: Decrypts a DM message on localhost by granting temporary compliance extract access, running extract and decrypt CLI commands, then reverting all temporary changes. Use when the user asks to decrypt a message or run the compliance decryption workflow on localhost.
|
||||
---
|
||||
|
||||
# Decrypt (local debug)
|
||||
|
||||
End-to-end workflow for extracting and decrypting one message on **localhost**. All temporary backend access must be removed when finished.
|
||||
|
||||
**After decrypt:** what to do with the plaintext and files (compare, inspect, report, etc.) comes from the **current conversation** — not from this skill.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Web dev server running at `http://localhost:8301` (browser rules default).
|
||||
- `compliance_keypair.txt` at the Web repo root (`Web/compliance_keypair.txt`).
|
||||
- Python deps for `scripts/compliance/decryption/` (`cryptography`, etc.).
|
||||
|
||||
## Step 0 — Read the compliance keypair
|
||||
|
||||
Read `Web/compliance_keypair.txt` before any decrypt step. Confirm the **private** key line exists (line after `PRIVATE KEY` header, base64).
|
||||
|
||||
The decrypt CLI loads `compliance_keypair.txt` from the **current working directory**. Always run extract/decrypt with `cd` to the Web repo root:
|
||||
|
||||
```bash
|
||||
cd /path/to/Web
|
||||
```
|
||||
|
||||
If `load_compliance_private_key` fails, the file format may need a `PRIVATE_KEY=<base64>` line (the loader expects that or a 43-char base64 line; 32-byte keys are often 44 chars with padding).
|
||||
|
||||
## Step 1 — Create a temporary user and capture token
|
||||
|
||||
Generate random credentials (username 3–20 chars: letters, digits, `-`, `_`; password 5–50 chars, no spaces):
|
||||
|
||||
```bash
|
||||
DEBUG_USER="decryptdbg$(openssl rand -hex 3)"
|
||||
DEBUG_PASS="$(openssl rand -base64 12 | tr -d '/+=' | head -c 16)"
|
||||
echo "user=$DEBUG_USER pass=$DEBUG_PASS"
|
||||
```
|
||||
|
||||
Register and save **`token`** from the JSON response (not the derived login secret):
|
||||
|
||||
```bash
|
||||
REGISTER_JSON=$(curl -sS -X POST "http://localhost:8301/api/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"${DEBUG_USER}\",\"display_name\":\"DecryptDebug\",\"password\":\"${DEBUG_PASS}\",\"confirm_password\":\"${DEBUG_PASS}\"}")
|
||||
echo "$REGISTER_JSON"
|
||||
DEBUG_TOKEN=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' <<< "$REGISTER_JSON")
|
||||
echo "token saved (length ${#DEBUG_TOKEN})"
|
||||
```
|
||||
|
||||
Alternatively export for the CLI: `export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"`
|
||||
|
||||
Save `user.id` from the same JSON if needed for debugging.
|
||||
|
||||
## Step 2 — Temporary compliance extract permission (marker blocks)
|
||||
|
||||
`GET /api/dm/compliance/extract/{message_id}` is restricted to **user id 1** in:
|
||||
|
||||
`backend/services/main/routes/envelope_messaging.py` (function `extract_message_for_compliance`).
|
||||
|
||||
Add a **temporary** bypass between searchable markers (replace `DEBUG_USERNAME` with the user from step 1):
|
||||
|
||||
```python
|
||||
# TEMP_COMPLIANCE_DEBUG_START — remove after decrypt debug run
|
||||
_TEMP_COMPLIANCE_DEBUG_USERNAMES = {"DEBUG_USERNAME"}
|
||||
# TEMP_COMPLIANCE_DEBUG_END
|
||||
|
||||
# Security check: only user ID 1 can access this
|
||||
if current_user.id != 1 and current_user.username not in _TEMP_COMPLIANCE_DEBUG_USERNAMES:
|
||||
```
|
||||
|
||||
Backend will auto-reload after this change, you don't need to do anything.
|
||||
|
||||
To remove later: search the repo for `TEMP_COMPLIANCE_DEBUG` and delete the marker block + revert the `if` to only `current_user.id != 1`.
|
||||
|
||||
## Step 3 — Extract bundle (online)
|
||||
|
||||
From Web repo root, set variables and run (user supplies `MESSAGE_ID`):
|
||||
|
||||
```bash
|
||||
MESSAGE_ID=<message_id>
|
||||
RANDOM_FOLDER="run_$(openssl rand -hex 4)"
|
||||
BUNDLE_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}"
|
||||
DECRYPT_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}_dec"
|
||||
mkdir -p /tmp/debug_decrypt
|
||||
|
||||
cd /path/to/Web
|
||||
python scripts/compliance/decryption/main.py extract \
|
||||
--server localhost:8301 \
|
||||
--http \
|
||||
--token "${DEBUG_TOKEN}" \
|
||||
--message-ids "${MESSAGE_ID}" \
|
||||
--out-dir "${BUNDLE_DIR}"
|
||||
```
|
||||
|
||||
Equivalent using env (no `--token` flag):
|
||||
|
||||
```bash
|
||||
export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"
|
||||
python scripts/compliance/decryption/main.py extract \
|
||||
--server localhost:8301 \
|
||||
--http \
|
||||
--message-ids "${MESSAGE_ID}" \
|
||||
--out-dir "${BUNDLE_DIR}"
|
||||
```
|
||||
|
||||
Confirm `${BUNDLE_DIR}/bundle.json` exists.
|
||||
|
||||
**Auth flags:** use `--token` (Bearer from register/login), or `--username` + `--password` (CLI derives the login secret). Do not pass both.
|
||||
|
||||
## Step 4 — Decrypt bundle (offline)
|
||||
|
||||
Still from Web repo root (`compliance_keypair.txt` must resolve):
|
||||
|
||||
```bash
|
||||
python scripts/compliance/decryption/main.py decrypt \
|
||||
--bundle-dir "${BUNDLE_DIR}" \
|
||||
--output-dir "${DECRYPT_DIR}"
|
||||
```
|
||||
|
||||
Outputs:
|
||||
|
||||
- `${DECRYPT_DIR}/messages/<message_id>/message.decrypted.txt` — message plaintext
|
||||
- `${DECRYPT_DIR}/messages/<message_id>/files/` — decrypted attachments
|
||||
- `${DECRYPT_DIR}/index.html` — HTML report
|
||||
|
||||
## Step 5 — Use decrypted output (conversation-driven)
|
||||
|
||||
Follow the **user’s request in the current chat** for what to do next (e.g. compare hashes, inspect text, verify a specific attachment). This skill stops at producing `${DECRYPT_DIR}`; do not assume a fixed post-decrypt task.
|
||||
|
||||
## Step 6 — Cleanup (required)
|
||||
|
||||
1. **Remove decrypt dirs:**
|
||||
```bash
|
||||
rm -rf "${BUNDLE_DIR}" "${DECRYPT_DIR}"
|
||||
```
|
||||
|
||||
2. **Delete temp user** (local SQLite default DB):
|
||||
```bash
|
||||
sqlite3 backend/data/database.db "DELETE FROM users WHERE username='${DEBUG_USER}';"
|
||||
```
|
||||
If your deployment uses another DB, delete the same username there.
|
||||
|
||||
3. **Remove temporary permission:** search `TEMP_COMPLIANCE_DEBUG` in the repo, delete the marker block, restore the original `if current_user.id != 1:` check, restart backend.
|
||||
|
||||
4. Do **not** commit `compliance_keypair.txt` or any decrypt output.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Read compliance_keypair.txt
|
||||
- [ ] Register DEBUG_USER; save DEBUG_TOKEN from response
|
||||
- [ ] Add TEMP_COMPLIANCE_DEBUG_* bypass; restart backend
|
||||
- [ ] extract --token → bundle.json present
|
||||
- [ ] decrypt → output under DECRYPT_DIR
|
||||
- [ ] Post-decrypt work per conversation context
|
||||
- [ ] rm -rf BUNDLE_DIR and DECRYPT_DIR
|
||||
- [ ] DELETE temp user from DB
|
||||
- [ ] Remove TEMP_COMPLIANCE_DEBUG markers; restart backend
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
- Extract API: `GET /api/dm/compliance/extract/{message_id}` (see `bundle_extract.py`).
|
||||
- Decrypt implementation: `scripts/compliance/decryption/bundle_decrypt.py`, `crypto.py`.
|
||||
- Extract auth: `--token` (Bearer), env `FROMCHAT_API_TOKEN` / `FROMCHAT_TOKEN`, or `--username` + plain `--password` (CLI calls `derive_auth_secret` for login only).
|
||||
|
||||
## Rules
|
||||
- NEVER use sleep in any command.
|
||||
- Set a timeout on EVERY command that may request user input.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Shared build context ignore (used by deployment images with context: ..)
|
||||
|
||||
# VCS / editor
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.DS_Store
|
||||
|
||||
# Secrets / local env
|
||||
.env
|
||||
deployment/.env
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.vite
|
||||
dist
|
||||
build
|
||||
out
|
||||
coverage
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ipynb_checkpoints
|
||||
.venv
|
||||
venv/
|
||||
|
||||
# App runtime data/logs (mounted, not baked)
|
||||
backend/data
|
||||
backend/files
|
||||
data
|
||||
logs
|
||||
**/logs
|
||||
**/logs/**
|
||||
*.log
|
||||
|
||||
# Deploy cache (hashes)
|
||||
.deploy-cache
|
||||
|
||||
# Firebase cert: bind-mounted at runtime; do not send to docker build context
|
||||
firebase-cert.json
|
||||
@@ -1,3 +0,0 @@
|
||||
# HTTP API host.
|
||||
# Example for local backend: http://localhost:8300
|
||||
VITE_API_BASE_URL=http://localhost:8300
|
||||
@@ -0,0 +1,171 @@
|
||||
name: Build Electron app
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
apiBaseUrl:
|
||||
description: "API base URL"
|
||||
required: false
|
||||
default: "fromchat.ru"
|
||||
type: string
|
||||
push:
|
||||
tags: ["v**"]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'package.json'
|
||||
- '.github/workflows/build.yml'
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Build Linux
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: npm-ubuntu-latest-node-24-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
npm-ubuntu-latest-node-24-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Cache Electron downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
key: electron-ubuntu-latest-${{ hashFiles('package.json', 'package.json') }}
|
||||
restore-keys: |
|
||||
electron-ubuntu-latest-
|
||||
|
||||
- name: Build Electron app
|
||||
shell: bash
|
||||
env:
|
||||
VITE_API_BASE_URL: ${{ inputs.apiBaseUrl }}
|
||||
run: npm run build:electron
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FromChat-linux
|
||||
path: |
|
||||
frontend/build/electron/forge/make/*/**
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
name: Build macOS
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: npm-macos-latest-node-24-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
npm-macos-latest-node-24-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
# - name: Cache Electron downloads
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: |
|
||||
# ~/.cache/electron
|
||||
# ~/.cache/electron-builder
|
||||
# key: electron-macos-latest-${{ hashFiles('package.json') }}
|
||||
# restore-keys: |
|
||||
# electron-macos-latest-
|
||||
|
||||
- name: Build Electron app
|
||||
shell: bash
|
||||
env:
|
||||
VITE_API_BASE_URL: ${{ inputs.apiBaseUrl }}
|
||||
run: npm run build:electron
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FromChat-macOS
|
||||
path: |
|
||||
frontend/build/electron/forge/make/zip/darwin/*/**.zip
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force npm to use Bash
|
||||
run: npm config set script-shell "C:\Program Files\Git\bin\bash.exe"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: npm-windows-latest-node-24-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
npm-windows-latest-node-24-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Cache Electron downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-windows-latest-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
electron-windows-latest-
|
||||
|
||||
- name: Build Electron app
|
||||
shell: bash
|
||||
env:
|
||||
VITE_API_BASE_URL: ${{ inputs.apiBaseUrl }}
|
||||
run: npm run build:electron
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FromChat-windows
|
||||
path: |
|
||||
frontend/build/electron/forge/make/zip/win32/x64
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,61 @@
|
||||
# Simple workflow for deploying to the self-hosted server
|
||||
name: Deploy to server
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "frontend/**"
|
||||
- "deployment/**"
|
||||
- "**/package.json"
|
||||
- ".nvmrc"
|
||||
- ".github/workflows/deploy.yml"
|
||||
- "!frontend/electron/**"
|
||||
- "!**.d.ts"
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
env:
|
||||
HOME: "/root"
|
||||
environment:
|
||||
name: production
|
||||
url: https://fromchat.ru
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: |
|
||||
mkdir -p deployment
|
||||
touch deployment/.env
|
||||
cat > deployment/.env << EOF
|
||||
JWT_SECRET=${{ secrets.JWT_SECRET }}
|
||||
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||
EOF
|
||||
- name: Build container
|
||||
run: |
|
||||
cd deployment
|
||||
docker compose build
|
||||
- name: Set up the service
|
||||
run: |
|
||||
cp -f deployment/fromchat.service /etc/systemd/system/fromchat.service
|
||||
systemctl daemon-reload
|
||||
- name: Start the server
|
||||
run: |
|
||||
if ! systemctl restart fromchat && sleep 10 && systemctl status fromchat; then
|
||||
journalctl --no-pager -xeu fromchat
|
||||
exit 1
|
||||
fi
|
||||
+1
-28
@@ -35,9 +35,6 @@ Temporary Items
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### FromChat local tools (downloaded LiveKit server binary) ###
|
||||
.tools/
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
@@ -115,18 +112,11 @@ web_modules/
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.prod
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# Firebase service account JSON (backend/firebase-cert.json; bind-mounted in docker-compose)
|
||||
firebase-cert.json
|
||||
**/firebase-cert.json
|
||||
firebase-adminsdk.json
|
||||
**/firebase-adminsdk.json
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
@@ -576,28 +566,11 @@ buck-out/
|
||||
|
||||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||
|
||||
backend/services/main/.fromchat_instance_id
|
||||
backend/data
|
||||
backend/files
|
||||
.vite
|
||||
*.db
|
||||
package-lock.json
|
||||
backend/alembic/**
|
||||
!backend/alembic/env.py
|
||||
!backend/alembic/script.py.mako
|
||||
!frontend/src/css/lib
|
||||
**/*.module.scss.d.ts
|
||||
|
||||
.cursor/plans
|
||||
tmp
|
||||
compliance_keypair.txt
|
||||
backend/files
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
.deploy-cache
|
||||
|
||||
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/
|
||||
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/lib
|
||||
|
||||
*.module.d.scss.ts
|
||||
!frontend/src/css/lib
|
||||
@@ -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
+4
-15
@@ -1,20 +1,9 @@
|
||||
{
|
||||
"npm.autoDetect": "off",
|
||||
"files.exclude": {
|
||||
".husky": true,
|
||||
"build": true
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/__pycache__": true,
|
||||
"**/package-lock.json": true,
|
||||
"**/*.module.scss.d.ts": true,
|
||||
"**/.husky/_": true,
|
||||
"**/.venv": true,
|
||||
"**/node_modules": true
|
||||
"**/package-lock.json": true
|
||||
},
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"python.terminal.activateEnvironment": false
|
||||
"github-actions.workflows.pinned.workflows": [],
|
||||
"github-actions.workflows.pinned.workflows.ignore": true,
|
||||
"github-actions.workflows.pinned.workflows.ignoreContextAccess": true
|
||||
}
|
||||
Vendored
+57
-5
@@ -2,9 +2,9 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Frontend (Web)",
|
||||
"type": "shell",
|
||||
"command": "npm run frontend:dev",
|
||||
"label": "Backend",
|
||||
"type": "npm",
|
||||
"script": "backend:run",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
@@ -19,9 +19,61 @@
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "Frontend (Web)",
|
||||
"type": "npm",
|
||||
"script": "frontend:dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build",
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "Frontend (Electron)",
|
||||
"type": "npm",
|
||||
"script": "frontend:electron:dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build"
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
|
||||
{
|
||||
"label": "Web",
|
||||
"dependsOn": ["Frontend (Web)"],
|
||||
"dependsOn": ["Backend", "Frontend (Web)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Electron",
|
||||
"dependsOn": ["Backend", "Frontend (Electron)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build"
|
||||
@@ -34,4 +86,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
# 1. Frontend production build
|
||||
FROM node:24 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 1.1. Install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 1.2. Copy sources and configure
|
||||
COPY . .
|
||||
|
||||
WORKDIR /app
|
||||
ARG NODE_ENV=production
|
||||
# Overridable at build time. Must be a real http(s) URL — never a compose ${...} stub.
|
||||
ARG VITE_API_BASE_URL=https://api.fromchat.ru
|
||||
|
||||
# Fail the build if the arg is not a real URL (e.g. unexpanded Compose ${...}).
|
||||
RUN if ! printf "%s" "${VITE_API_BASE_URL}" | grep -Eq '^https?://'; then \
|
||||
echo "ERROR: VITE_API_BASE_URL must be an http(s) URL, got: ${VITE_API_BASE_URL}" >&2; \
|
||||
exit 1; \
|
||||
fi && \
|
||||
export NODE_ENV=production VITE_API_BASE_URL && \
|
||||
npm run frontend:build
|
||||
|
||||
|
||||
# 2. Production web static server
|
||||
FROM joseluisq/static-web-server:latest AS production
|
||||
|
||||
# 2.1. Copy files to server root (/var/public -> /home/sws/public)
|
||||
COPY --from=builder /app/build/normal/dist /var/public
|
||||
|
||||
# 2.2. Configure (defaults serve the image's built-in landing page instead of our app)
|
||||
ENV SERVER_ROOT=/var/public
|
||||
ENV SERVER_FALLBACK_PAGE=/var/public/index.html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -1,5 +1,5 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,15 +7,17 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -60,7 +72,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
# FromChat Web Client — Messaging Web App
|
||||
|
||||
[Читать на других языках: Русский](./README.md)
|
||||
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/fromchat-messenger/android/main/app/android/src/main/ic_launcher-playstore.png" width="120" alt="FromChat Logo" />
|
||||
|
||||
**Web client for FromChat messenger**
|
||||
|
||||
[🌐 Web Client](https://github.com/fromchat-messenger/web) • [🖥️ Backend](https://github.com/fromchat-messenger/backend) • [📱 Android / Desktop](https://github.com/fromchat-messenger/android) • [🌍 Website](https://github.com/fromchat-messenger/site)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description
|
||||
|
||||
FromChat Web is a React/TypeScript browser client for the FromChat server. The desktop client is Compose Multiplatform in the [Android/KMP repository](https://github.com/fromchat-messenger/android).
|
||||
|
||||
**Note:** Landing pages and legal documents live in [fromchat-messenger/site](https://github.com/fromchat-messenger/site).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Client Comparison
|
||||
|
||||
| Feature | Android | Web | iOS |
|
||||
|---|---|---|---|
|
||||
| **Messaging & profiles** | ✅ | ✅ | ❌ |
|
||||
| **Voice/video calls** | ✅ | ✅ | ❌ |
|
||||
| **Screen sharing** | ✅ | ✅ | ❌ |
|
||||
| **Message reactions** | ❌ | ✅ | ❌ |
|
||||
| **Rich attachment support** | ✅ | ❌ | ❌ |
|
||||
|
||||
⚠️ **iOS is temporarily not supported.**
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Protected DMs (legal encryption scheme)
|
||||
- Voice/video calls and screen sharing
|
||||
- Message reactions
|
||||
- Public chats and profiles
|
||||
- Device management
|
||||
- WebSocket real-time updates
|
||||
- Dark mode
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
| Component | Notes |
|
||||
|---|---|
|
||||
| React 19 | UI |
|
||||
| TypeScript | strict typing |
|
||||
| Vite 7 | dev server & build |
|
||||
| MDUI | Material Design |
|
||||
| Zustand + use-immer | state |
|
||||
| Motion | animations |
|
||||
| TweetNaCl.js | cryptography |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js 20+ (Docker image uses Node 24)
|
||||
- npm
|
||||
- Backend API on `http://localhost:8300` (proxied as `/api`)
|
||||
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fromchat-messenger/web.git
|
||||
cd web
|
||||
npm install
|
||||
cp .env.example .env # if needed; install may copy it for you
|
||||
npm run frontend:dev
|
||||
```
|
||||
|
||||
Open `http://localhost:8301`.
|
||||
|
||||
`.env`:
|
||||
|
||||
```env
|
||||
# HTTP API host (Vite proxy target for /api)
|
||||
VITE_API_BASE_URL=http://localhost:8300
|
||||
```
|
||||
|
||||
In the browser the client uses same-origin `/api` (HTTP and WebSocket, e.g. `/api/chat/ws`).
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run frontend:dev # Vite on :8301
|
||||
npm run frontend:typecheck # TypeScript
|
||||
npm run frontend:build # typecheck + production build → build/normal
|
||||
npm run frontend:preview # preview built frontend
|
||||
```
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main/ # React app (@/)
|
||||
│ │ ├── pages/ # auth, chat, profile, …
|
||||
│ │ ├── core/ # API, websocket, calls, …
|
||||
│ │ ├── state/ # Zustand stores
|
||||
│ │ ├── utils/
|
||||
│ │ └── css/ # SCSS (Material Design)
|
||||
│ └── protocol/ # shared protocol (@fromchat/protocol)
|
||||
├── plugins/ # Vite plugins
|
||||
├── vite.config.ts
|
||||
├── compose.yml # production web image (:8301→80)
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
docker build -t fromchat-web:latest .
|
||||
# or via compose:
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
Container listens on port **8301** (static server on 80 inside).
|
||||
|
||||
Production edge (Caddy/HAProxy) is configured via the deployment repo / backend `compose.prod.yml`.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Branch for your change
|
||||
2. Open a PR with a description
|
||||
3. Ensure `npm run frontend:typecheck` passes
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU Affero General Public License v3.0 — see [LICENSE](./LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Repositories
|
||||
|
||||
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||
- [Android / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||
- [Website](https://github.com/fromchat-messenger/site)
|
||||
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: How do I run locally?**
|
||||
A: Start the backend on `:8300`, then `npm run frontend:dev` and open `http://localhost:8301`.
|
||||
|
||||
**Q: Which browsers?**
|
||||
A: Current Chrome, Firefox, Safari, Edge.
|
||||
|
||||
**Q: Do calls work on web?**
|
||||
A: Yes — voice/video and screen share (server needs LiveKit).
|
||||
|
||||
**Q: How do I report a bug?**
|
||||
A: GitHub Issues with reproduction steps.
|
||||
|
||||
---
|
||||
|
||||
**[⬆ back to top](#fromchat-web-client--messaging-web-app)**
|
||||
@@ -1,112 +1,30 @@
|
||||
# FromChat Web Client — веб-приложение для обмена сообщениями
|
||||
# FromChat
|
||||
|
||||
[Read in other languages: English](./README.en.md)
|
||||
FromChat - полностью открытый мессенджер.
|
||||
|
||||
_Написано ИИ. Могут быть ошибки._
|
||||
Демо версию можно попробовать на [сайте](http://95.165.0.162:8301).
|
||||
|
||||
## 📝 Описание
|
||||
## Содержание:
|
||||
- [Основные моменты](#highlights)
|
||||
- [Использование](#usage)
|
||||
- [Часто задаваемые вопросы](#faq)
|
||||
- [Внос вклада](#contributing)
|
||||
|
||||
Веб-клиент FromChat — React/TypeScript приложение для браузера. Десктоп-клиент — Compose Multiplatform в [Android/KMP репозитории](https://github.com/fromchat-messenger/android).
|
||||
## Основные моменты
|
||||
- Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд).
|
||||
- 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя.
|
||||
|
||||
## Развернуть в 1 клик
|
||||
## Использование
|
||||
_В разработке._
|
||||
|
||||
```bash
|
||||
docker run -d --restart always -p 8301:80 fromchat/web:latest
|
||||
```
|
||||
## Часто задаваемые вопросы
|
||||
<!--
|
||||
Вопрос: __Чему равно 2+2?__
|
||||
Ответ: __4__
|
||||
Вопрос: __Какая цитата Джейсона Стетхема на ваш взгляд является лучшей?__
|
||||
Ответ: __"Одна ошибка, и ты ошибся."__
|
||||
-->
|
||||
_В разработке._
|
||||
|
||||
## ✨ Возможности
|
||||
|
||||
- Защищённые личные сообщения (легальная схема шифрования)
|
||||
- Голосовые/видеозвонки и демонстрация экрана
|
||||
- Реакции на сообщения
|
||||
- Публичные чаты и профили
|
||||
- Управление устройствами
|
||||
- WebSocket для реал-тайма
|
||||
- Тёмный режим
|
||||
|
||||
## 🏗️ Технологический стек
|
||||
|
||||
| Компонент | Примечание |
|
||||
|---|---|
|
||||
| React 19 | UI |
|
||||
| TypeScript | строгая типизация |
|
||||
| Vite 7 | dev-сервер и сборка |
|
||||
| MDUI | Material Design |
|
||||
| Zustand + use-immer | состояние |
|
||||
| Motion | анимации |
|
||||
| TweetNaCl.js | криптография |
|
||||
|
||||
## 🔧 Разработка
|
||||
|
||||
### Требования
|
||||
|
||||
- Node.js 20+ (для Docker-образа используется Node 24)
|
||||
- npm
|
||||
- Backend API на `http://localhost:8300` (проксируется через `/api`)
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fromchat-messenger/web.git
|
||||
cd web
|
||||
npm install
|
||||
cp .env.example .env # при необходимости; install может скопировать сам
|
||||
npm run frontend:dev
|
||||
```
|
||||
|
||||
Откройте `http://localhost:8301`.
|
||||
|
||||
### Команды
|
||||
|
||||
```bash
|
||||
npm run frontend:dev # Vite на :8301
|
||||
npm run frontend:typecheck # TypeScript
|
||||
npm run frontend:build # typecheck + production build → build/normal
|
||||
npm run frontend:preview # preview собранного фронта
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main/ # React-приложение (@/)
|
||||
│ │ ├── pages/ # auth, chat, profile, …
|
||||
│ │ ├── core/ # API, websocket, calls, …
|
||||
│ │ ├── state/ # Zustand stores
|
||||
│ │ ├── utils/
|
||||
│ │ └── css/ # SCSS (Material Design)
|
||||
│ └── protocol/ # общий протокол (@fromchat/protocol)
|
||||
├── plugins/ # Vite-плагины
|
||||
├── vite.config.ts
|
||||
├── compose.yml # production-образ веба (:8301→80)
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Контейнер слушает порт **8301** (внутри nginx/static-server на 80).
|
||||
|
||||
## 🤝 Внесение вклада
|
||||
|
||||
1. Создайте ветку под изменение
|
||||
2. Отправьте PR с описанием
|
||||
3. Проверьте типы: `npm run frontend:typecheck`
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
GNU Affero General Public License v3.0 — см. [LICENSE](./LICENSE).
|
||||
|
||||
## 🔗 Связанные репозитории
|
||||
|
||||
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||
- [Android / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||
- [Website](https://github.com/fromchat-messenger/site)
|
||||
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||
## Внос вклада
|
||||
Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev).
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///./data/database.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,78 @@
|
||||
from logging.config import fileConfig
|
||||
import logging
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from models import Base
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,51 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
from routes import account, messaging, profile, push
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup - run migration in separate process to avoid logging interference
|
||||
try:
|
||||
print("Starting database migration check...")
|
||||
# Run migration in a separate process
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
||||
],
|
||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||
# No capture_output - let it stream to terminal in real-time
|
||||
# No text=True - let it use the terminal's encoding
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to run database migrations: {e}")
|
||||
raise
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown (if needed in the future)
|
||||
# logger.info("Application shutdown")
|
||||
|
||||
# Инициализация FastAPI
|
||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # В продакшене замените на нужные домены
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Routes
|
||||
app.include_router(account.router)
|
||||
app.include_router(messaging.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
|
||||
DATABASE_URL = "sqlite:///./data/database.db"
|
||||
JWT_ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
OWNER_USERNAME = "denis0001-dev"
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET")
|
||||
|
||||
if not JWT_SECRET_KEY:
|
||||
raise ValueError("JWT secret key empty")
|
||||
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
@@ -0,0 +1,38 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from utils import *
|
||||
from models import *
|
||||
from db import SessionLocal
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
# Зависимость для получения сессии БД
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Зависимость для получения текущего пользователя
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate VAPID keys for push notifications
|
||||
Run this script to generate new VAPID keys for your application
|
||||
"""
|
||||
|
||||
import sys
|
||||
import base64
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
def generate_vapid_keys():
|
||||
"""Generate VAPID keys for push notifications"""
|
||||
try:
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
public_key = private_key.public_key()
|
||||
|
||||
# Convert to base64 for web push
|
||||
private_key_b64 = base64.urlsafe_b64encode(
|
||||
private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
).decode('utf-8').rstrip('=')
|
||||
|
||||
# Get the raw uncompressed public key point (65 bytes: 0x04 + 32 bytes x + 32 bytes y)
|
||||
public_numbers = public_key.public_numbers()
|
||||
x_bytes = public_numbers.x.to_bytes(32, 'big')
|
||||
y_bytes = public_numbers.y.to_bytes(32, 'big')
|
||||
public_key_raw = b'\x04' + x_bytes + y_bytes
|
||||
|
||||
public_key_b64 = base64.urlsafe_b64encode(public_key_raw).decode('utf-8').rstrip('=')
|
||||
|
||||
print(f"VAPID_PRIVATE_KEY=\"{private_key_b64}\"")
|
||||
print(f"VAPID_PUBLIC_KEY=\"{public_key_b64}\"")
|
||||
|
||||
return private_key_b64, public_key_b64
|
||||
except Exception as e:
|
||||
print(f"Error generating VAPID keys: {e}", file=sys.stderr)
|
||||
return None, None
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_vapid_keys()
|
||||
@@ -0,0 +1,7 @@
|
||||
from constants import *
|
||||
from db import *
|
||||
from models import *
|
||||
from validation import *
|
||||
from utils import *
|
||||
from dependencies import *
|
||||
from app import *
|
||||
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
Database migration utility using Alembic.
|
||||
This module handles running database migrations on startup.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def run_migrations():
|
||||
"""
|
||||
Run database migrations using Alembic.
|
||||
This function will upgrade the database to the latest migration.
|
||||
Fully automated - handles all scenarios automatically.
|
||||
"""
|
||||
try:
|
||||
# Get the directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Create Alembic configuration
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
|
||||
# Disable Alembic's logging configuration to avoid interfering with FastAPI
|
||||
alembic_cfg.set_main_option("configure_logging", "false")
|
||||
|
||||
# Set the database URL in the config
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Check if any migration files exist
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
|
||||
if not os.path.exists(versions_dir):
|
||||
os.makedirs(versions_dir)
|
||||
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if not migration_files:
|
||||
logger.info("No migration files found. Creating initial migration...")
|
||||
# Check if database exists and has tables
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
|
||||
existing_tables = result.fetchall()
|
||||
|
||||
if existing_tables:
|
||||
logger.info("Found existing database with tables. Creating migration to match current schema...")
|
||||
# Create migration with autogenerate to detect differences
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration from existing database")
|
||||
|
||||
# Check if the generated migration is empty (common with existing databases)
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
# Check if migration is empty
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content:
|
||||
logger.info("Generated migration is empty. Creating complete schema migration...")
|
||||
# Remove the empty migration
|
||||
os.remove(migration_path)
|
||||
# Create a complete migration
|
||||
_create_complete_migration(alembic_cfg)
|
||||
else:
|
||||
logger.info("No existing tables found. Creating fresh migration...")
|
||||
# Create fresh migration
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
|
||||
logger.info("Initial migration created successfully.")
|
||||
else:
|
||||
# Migration files exist, check if we need to create a new migration for schema changes
|
||||
logger.info("Migration files exist. Checking for pending schema changes...")
|
||||
try:
|
||||
# Create a new migration to detect any schema changes
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration for schema changes")
|
||||
|
||||
# Check if the new migration is empty (no changes detected)
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
# Check if migration is empty
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content and 'op.drop_table' not in content and 'op.drop_column' not in content:
|
||||
logger.info("No schema changes detected. Removing empty migration...")
|
||||
# Remove the empty migration
|
||||
os.remove(migration_path)
|
||||
else:
|
||||
logger.info("Schema changes detected. New migration created.")
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"No new migrations needed or error creating migration: {e}")
|
||||
pass
|
||||
|
||||
# Run the upgrade command
|
||||
logger.info("Running database migrations...")
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully.")
|
||||
except Exception as upgrade_error:
|
||||
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
||||
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
||||
# Clear the alembic_version table and start fresh
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DELETE FROM alembic_version"))
|
||||
connection.commit()
|
||||
# Try upgrade again
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully after reset.")
|
||||
else:
|
||||
raise upgrade_error
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running database migrations: {e}")
|
||||
# Fully automated recovery - handle ALL error scenarios
|
||||
logger.info("Attempting automated recovery...")
|
||||
try:
|
||||
# Clear the alembic_version table to reset state
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
||||
connection.commit()
|
||||
|
||||
# Remove any existing migration files to start fresh
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
for file in os.listdir(versions_dir):
|
||||
if file.endswith('.py') and not file.startswith('__'):
|
||||
os.remove(os.path.join(versions_dir, file))
|
||||
|
||||
# Create a completely fresh migration with full schema
|
||||
logger.info("Creating fresh migration with complete schema...")
|
||||
_create_complete_migration(alembic_cfg)
|
||||
|
||||
# Run the migration
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Automated recovery completed successfully.")
|
||||
|
||||
except Exception as recovery_error:
|
||||
logger.error(f"Automated recovery failed: {recovery_error}")
|
||||
# Last resort: create database using SQLAlchemy directly
|
||||
logger.info("Using fallback: creating database directly...")
|
||||
_create_database_directly()
|
||||
logger.info("Database created successfully using fallback method.")
|
||||
|
||||
|
||||
def _create_complete_migration(alembic_cfg):
|
||||
"""Create a complete migration file with all database schema."""
|
||||
# Create a new migration file
|
||||
command.revision(alembic_cfg, message="Complete schema migration")
|
||||
|
||||
# Get the latest migration file
|
||||
versions_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
latest_migration = max(migration_files) if migration_files else None
|
||||
|
||||
if latest_migration:
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
_populate_migration_file(migration_path)
|
||||
|
||||
|
||||
def _populate_migration_file(migration_path):
|
||||
"""Populate a migration file with the complete database schema from models."""
|
||||
# Generate the migration content dynamically from models
|
||||
migration_content = _generate_migration_from_models()
|
||||
|
||||
# Read the current migration file
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Add datetime import if needed
|
||||
if "datetime.now" in migration_content and "from datetime import datetime" not in content:
|
||||
# Insert the import after the existing imports
|
||||
import re
|
||||
content = re.sub(
|
||||
r'(from alembic import op\nimport sqlalchemy as sa\n)',
|
||||
r'\1from datetime import datetime\n',
|
||||
content
|
||||
)
|
||||
|
||||
# Replace the empty upgrade/downgrade functions
|
||||
import re
|
||||
# More flexible regex to match the actual content
|
||||
content = re.sub(
|
||||
r'def upgrade\(\) -> None:.*?pass.*?(?=\n\ndef downgrade|\n\nif __name__|\Z)',
|
||||
migration_content,
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
# Write the updated content back
|
||||
with open(migration_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _generate_migration_from_models():
|
||||
"""Generate migration content dynamically from SQLAlchemy models."""
|
||||
from models import Base
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime
|
||||
|
||||
# Generate migration content using Alembic's op functions
|
||||
upgrade_statements = []
|
||||
downgrade_statements = []
|
||||
|
||||
# Get all tables from Base metadata
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
if table_name != 'alembic_version': # Skip alembic_version table
|
||||
# Check if table exists and compare schema
|
||||
schema_diff = _detect_schema_differences(table_name, table)
|
||||
|
||||
if schema_diff['table_exists']:
|
||||
if schema_diff['needs_update']:
|
||||
# Generate ALTER TABLE statements for existing table
|
||||
upgrade_statements.append(f" # Update {table_name} table schema")
|
||||
for statement in schema_diff['alter_statements']:
|
||||
upgrade_statements.append(f" {statement}")
|
||||
else:
|
||||
# Table exists and is up to date - skip creating it
|
||||
upgrade_statements.append(f" # Table {table_name} already exists and is up to date")
|
||||
else:
|
||||
# Generate CREATE TABLE for new table
|
||||
table_code = _generate_table_creation_code(table_name, table)
|
||||
upgrade_statements.append(f" # Create {table_name} table")
|
||||
upgrade_statements.append(table_code)
|
||||
|
||||
# Only add to downgrade if table actually exists
|
||||
if schema_diff['table_exists']:
|
||||
downgrade_statements.append(f" # op.drop_table('{table_name}') # Skipped - table exists")
|
||||
else:
|
||||
downgrade_statements.append(f" op.drop_table('{table_name}')")
|
||||
|
||||
# Combine all statements
|
||||
upgrade_content = "def upgrade() -> None:\n \"\"\"Upgrade schema.\"\"\"\n" + "\n".join(upgrade_statements)
|
||||
downgrade_content = "def downgrade() -> None:\n \"\"\"Downgrade schema.\"\"\"\n" + "\n".join(downgrade_statements)
|
||||
|
||||
return upgrade_content + "\n\n" + downgrade_content
|
||||
|
||||
|
||||
def _detect_schema_differences(table_name, expected_table):
|
||||
"""Detect differences between existing table and expected schema."""
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
# Check if table exists
|
||||
inspector = inspect(connection)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return {
|
||||
'table_exists': False,
|
||||
'needs_update': False,
|
||||
'alter_statements': []
|
||||
}
|
||||
|
||||
# Get existing columns
|
||||
existing_columns = inspector.get_columns(table_name)
|
||||
existing_column_names = {col['name'] for col in existing_columns}
|
||||
|
||||
# Get expected columns
|
||||
expected_column_names = {col.name for col in expected_table.columns}
|
||||
|
||||
# Check for missing columns
|
||||
missing_columns = expected_column_names - existing_column_names
|
||||
extra_columns = existing_column_names - expected_column_names
|
||||
|
||||
alter_statements = []
|
||||
|
||||
# Add missing columns
|
||||
for column in expected_table.columns:
|
||||
if column.name in missing_columns:
|
||||
column_def = _generate_column_definition(column)
|
||||
alter_statements.append(f"op.add_column('{table_name}', {column_def})")
|
||||
|
||||
# Add missing indexes
|
||||
for index in expected_table.indexes:
|
||||
if not index.unique:
|
||||
cols = "', '".join([col.name for col in index.columns])
|
||||
alter_statements.append(f"op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
||||
|
||||
return {
|
||||
'table_exists': True,
|
||||
'needs_update': len(alter_statements) > 0,
|
||||
'alter_statements': alter_statements
|
||||
}
|
||||
|
||||
|
||||
def _generate_column_definition(column):
|
||||
"""Generate column definition for ALTER TABLE."""
|
||||
type_def = _get_column_type(column)
|
||||
nullable = "nullable=True" if column.nullable else "nullable=False"
|
||||
|
||||
definition = f"sa.Column('{column.name}', {type_def}, {nullable}"
|
||||
|
||||
# Handle default values properly
|
||||
if column.default is not None:
|
||||
if hasattr(column.default, 'arg'):
|
||||
# Handle callable defaults
|
||||
if callable(column.default.arg):
|
||||
definition += f", default=datetime.now"
|
||||
else:
|
||||
definition += f", default={repr(column.default.arg)}"
|
||||
else:
|
||||
definition += f", default={repr(column.default)}"
|
||||
|
||||
definition += ")"
|
||||
return definition
|
||||
|
||||
|
||||
def _generate_table_creation_code(table_name, table):
|
||||
"""Generate op.create_table code for a SQLAlchemy table."""
|
||||
lines = [f" op.create_table('{table_name}',"]
|
||||
|
||||
# Collect all table items (columns + constraints)
|
||||
all_items = []
|
||||
|
||||
# Add columns
|
||||
for column in table.columns:
|
||||
column_def = f" sa.Column('{column.name}', {_get_column_type(column)}, nullable={column.nullable}"
|
||||
if column.default is not None:
|
||||
# Handle callable defaults properly
|
||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
||||
column_def += f", default=datetime.now"
|
||||
else:
|
||||
column_def += f", default={repr(column.default)}"
|
||||
column_def += ")"
|
||||
all_items.append(column_def)
|
||||
|
||||
# Add constraints
|
||||
for constraint in table.constraints:
|
||||
if hasattr(constraint, 'columns'):
|
||||
if constraint.__class__.__name__ == 'PrimaryKeyConstraint':
|
||||
all_items.append(f" sa.PrimaryKeyConstraint('{constraint.columns.keys()[0]}')")
|
||||
elif constraint.__class__.__name__ == 'UniqueConstraint':
|
||||
cols = "', '".join(constraint.columns.keys())
|
||||
all_items.append(f" sa.UniqueConstraint('{cols}')")
|
||||
|
||||
# Add foreign key constraints
|
||||
for fk in table.foreign_keys:
|
||||
all_items.append(f" sa.ForeignKeyConstraint(['{fk.parent.name}'], ['{fk.column.table.name}.{fk.column.name}'], )")
|
||||
|
||||
# Add all items with commas (except the last one)
|
||||
for i, item in enumerate(all_items):
|
||||
if i < len(all_items) - 1:
|
||||
item += ","
|
||||
lines.append(item)
|
||||
|
||||
lines.append(" )")
|
||||
|
||||
# Add indexes with IF NOT EXISTS equivalent using try/except
|
||||
for index in table.indexes:
|
||||
if not index.unique:
|
||||
cols = "', '".join([col.name for col in index.columns])
|
||||
lines.append(f" # Create index for {table_name}")
|
||||
lines.append(f" try:")
|
||||
lines.append(f" op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
||||
lines.append(f" except Exception:")
|
||||
lines.append(f" pass # Index may already exist")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _get_column_type(column):
|
||||
"""Get SQLAlchemy column type string."""
|
||||
type_name = column.type.__class__.__name__
|
||||
|
||||
if type_name == 'String':
|
||||
return f"sa.String(length={column.type.length})"
|
||||
elif type_name == 'Integer':
|
||||
return "sa.Integer()"
|
||||
elif type_name == 'Text':
|
||||
return "sa.Text()"
|
||||
elif type_name == 'Boolean':
|
||||
return "sa.Boolean()"
|
||||
elif type_name == 'DateTime':
|
||||
return "sa.DateTime()"
|
||||
else:
|
||||
return f"sa.{type_name}()"
|
||||
|
||||
|
||||
def _create_database_directly():
|
||||
"""Fallback method: create database directly using SQLAlchemy."""
|
||||
from models import Base
|
||||
from db import engine
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
# Check existing tables and update schema
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# For each model table, check if it needs updates
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
if table_name != 'alembic_version':
|
||||
if table_name in existing_tables:
|
||||
# Table exists, check for missing columns
|
||||
existing_columns = {col['name'] for col in inspector.get_columns(table_name)}
|
||||
expected_columns = {col.name for col in table.columns}
|
||||
missing_columns = expected_columns - existing_columns
|
||||
|
||||
# Add missing columns
|
||||
for column in table.columns:
|
||||
if column.name in missing_columns:
|
||||
# Convert to raw SQL for direct execution
|
||||
sql_type = _get_sql_type(column)
|
||||
nullable = "NULL" if column.nullable else "NOT NULL"
|
||||
|
||||
# Handle datetime columns without default (SQLite limitation)
|
||||
if column.type.__class__.__name__ == 'DateTime':
|
||||
# Add column without default, then update existing rows
|
||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}"
|
||||
try:
|
||||
connection.execute(text(alter_sql))
|
||||
logger.info(f"Added column {column.name} to {table_name}")
|
||||
|
||||
# Update existing rows with current timestamp
|
||||
update_sql = f"UPDATE {table_name} SET {column.name} = CURRENT_TIMESTAMP WHERE {column.name} IS NULL"
|
||||
connection.execute(text(update_sql))
|
||||
logger.info(f"Updated {column.name} with current timestamp")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not add column {column.name}: {e}")
|
||||
else:
|
||||
# Handle other column types with defaults
|
||||
default_clause = ""
|
||||
if column.default is not None:
|
||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
||||
# Skip callable defaults for SQLite compatibility
|
||||
pass
|
||||
elif hasattr(column.default, 'arg'):
|
||||
default_clause = f" DEFAULT {repr(column.default.arg)}"
|
||||
|
||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}{default_clause}"
|
||||
try:
|
||||
connection.execute(text(alter_sql))
|
||||
logger.info(f"Added column {column.name} to {table_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not add column {column.name}: {e}")
|
||||
else:
|
||||
# Table doesn't exist, create it
|
||||
logger.info(f"Creating table {table_name}")
|
||||
|
||||
# Create alembic_version table manually
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS alembic_version (
|
||||
version_num VARCHAR(32) NOT NULL,
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)
|
||||
"""))
|
||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _get_sql_type(column):
|
||||
"""Get SQL type for direct SQL execution."""
|
||||
type_name = column.type.__class__.__name__
|
||||
|
||||
if type_name == 'String':
|
||||
return f"VARCHAR({column.type.length})"
|
||||
elif type_name == 'Integer':
|
||||
return "INTEGER"
|
||||
elif type_name == 'Text':
|
||||
return "TEXT"
|
||||
elif type_name == 'Boolean':
|
||||
return "BOOLEAN"
|
||||
elif type_name == 'DateTime':
|
||||
return "DATETIME"
|
||||
else:
|
||||
return "TEXT" # fallback
|
||||
|
||||
|
||||
def check_migration_status():
|
||||
"""
|
||||
Check if the database needs migrations.
|
||||
Returns True if migrations are needed, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Create engine
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
# Check if alembic_version table exists
|
||||
with engine.connect() as connection:
|
||||
# Check if alembic_version table exists
|
||||
from sqlalchemy import text
|
||||
result = connection.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'")
|
||||
)
|
||||
alembic_table_exists = result.fetchone() is not None
|
||||
|
||||
if not alembic_table_exists:
|
||||
return True
|
||||
|
||||
# Get current migration context
|
||||
context = MigrationContext.configure(connection)
|
||||
current_rev = context.get_current_revision()
|
||||
|
||||
# Get the latest revision from alembic
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
script_dir = command.ScriptDirectory.from_config(alembic_cfg)
|
||||
head_rev = script_dir.get_current_head()
|
||||
|
||||
return current_rev != head_rev
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking migration status: {e}")
|
||||
return True # Assume migrations are needed if we can't check
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# This allows running migrations directly
|
||||
run_migrations()
|
||||
@@ -0,0 +1,239 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# Модели базы данных
|
||||
class User(Base):
|
||||
__tablename__ = "user"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(200), nullable=False)
|
||||
profile_picture = Column(String(255), nullable=True)
|
||||
bio = Column(Text, nullable=True)
|
||||
online = Column(Boolean, default=False)
|
||||
last_seen = Column(DateTime, default=datetime.now)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
messages = relationship("Message", back_populates="author", lazy="select")
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "message"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
content = Column(Text, nullable=False)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
is_read = Column(Boolean, default=False)
|
||||
reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True)
|
||||
is_edited = Column(Boolean, default=False)
|
||||
|
||||
author = relationship("User", back_populates="messages")
|
||||
reply_to = relationship("Message", remote_side=[id])
|
||||
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
|
||||
class MessageFile(Base):
|
||||
__tablename__ = "message_file"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
path = Column(Text, nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("Message", back_populates="files")
|
||||
|
||||
|
||||
class CryptoPublicKey(Base):
|
||||
__tablename__ = "crypto_public_key"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
public_key_b64 = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class CryptoBackup(Base):
|
||||
__tablename__ = "crypto_backup"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
blob_json = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class DMEnvelope(Base):
|
||||
__tablename__ = "dm_envelope"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
iv_b64 = Column(Text, nullable=False)
|
||||
ciphertext_b64 = Column(Text, nullable=False)
|
||||
salt_b64 = Column(Text, nullable=False)
|
||||
iv2_b64 = Column(Text, nullable=False)
|
||||
wrapped_mk_b64 = Column(Text, nullable=False)
|
||||
reply_to_id = Column(Integer, nullable=True)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
|
||||
class DMFile(Base):
|
||||
__tablename__ = "dm_file"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
path = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("DMEnvelope", back_populates="files")
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
__tablename__ = "push_subscription"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
endpoint = Column(Text, nullable=False)
|
||||
p256dh_key = Column(Text, nullable=False)
|
||||
auth_key = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
|
||||
# Ensure unique combination of message, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
|
||||
|
||||
|
||||
class DMReaction(Base):
|
||||
__tablename__ = "dm_reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
|
||||
|
||||
# Ensure unique combination of dm_envelope, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
||||
|
||||
|
||||
# Pydantic модели
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
confirm_password: str
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
content: str
|
||||
reply_to_id: int | None = None
|
||||
|
||||
|
||||
class EditMessageRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class DeleteMessageRequest(BaseModel):
|
||||
message_id: int
|
||||
|
||||
|
||||
class UpdateBioRequest(BaseModel):
|
||||
bio: str
|
||||
|
||||
|
||||
class PushSubscriptionRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: dict
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
profile_picture: str | None
|
||||
bio: str | None
|
||||
online: bool
|
||||
last_seen: datetime
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
timestamp: datetime
|
||||
is_author: bool
|
||||
is_read: bool
|
||||
username: str
|
||||
profile_picture: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReactionRequest(BaseModel):
|
||||
message_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class ReactionResponse(BaseModel):
|
||||
id: int
|
||||
message_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DMReactionRequest(BaseModel):
|
||||
dm_envelope_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class DMReactionResponse(BaseModel):
|
||||
id: int
|
||||
dm_envelope_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Tables are now created through Alembic migrations
|
||||
# Base.metadata.create_all(bind=engine)
|
||||
@@ -0,0 +1,152 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from pywebpush import webpush, WebPushException
|
||||
from models import PushSubscription, User, Message, DMEnvelope
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
class PushNotificationService:
|
||||
def __init__(self):
|
||||
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
|
||||
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
|
||||
|
||||
if (not self.vapid_public_key) or (not self.vapid_private_key):
|
||||
raise ValueError("VAPID public or private key is None")
|
||||
|
||||
self.vapid_claims = {
|
||||
"sub": "mailto:support@fromchat.ru",
|
||||
"aud": "https://fcm.googleapis.com"
|
||||
}
|
||||
|
||||
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
|
||||
"""Subscribe a user to push notifications"""
|
||||
try:
|
||||
# Check if user already has a subscription
|
||||
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
existing_sub.endpoint = endpoint
|
||||
existing_sub.p256dh_key = p256dh_key
|
||||
existing_sub.auth_key = auth_key
|
||||
else:
|
||||
# Create new subscription
|
||||
new_sub = PushSubscription(
|
||||
user_id=user_id,
|
||||
endpoint=endpoint,
|
||||
p256dh_key=p256dh_key,
|
||||
auth_key=auth_key
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Push subscription saved for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
|
||||
"""Send push notification for a new public chat message"""
|
||||
try:
|
||||
# Get all users except the sender
|
||||
users = db.query(User).filter(User.id != message.user_id)
|
||||
if exclude_user_id:
|
||||
users = users.filter(User.id != exclude_user_id)
|
||||
|
||||
for user in users:
|
||||
# Check if user has push subscription before trying to send
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
||||
if not subscription:
|
||||
continue
|
||||
|
||||
await self._send_notification_to_user(
|
||||
db, user.id,
|
||||
f"New message from {message.author.username}",
|
||||
message.content[:100] + ("..." if len(message.content) > 100 else ""),
|
||||
message.author.profile_picture,
|
||||
{
|
||||
"type": "public_message",
|
||||
"message_id": message.id,
|
||||
"sender_id": message.user_id,
|
||||
"sender_username": message.author.username
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send public message notifications: {e}")
|
||||
|
||||
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
||||
"""Send push notification for a new DM"""
|
||||
try:
|
||||
await self._send_notification_to_user(
|
||||
db, dm_envelope.recipient_id,
|
||||
f"New message from {sender.username}",
|
||||
"You have a new direct message",
|
||||
sender.profile_picture,
|
||||
{
|
||||
"type": "dm",
|
||||
"dm_id": dm_envelope.id,
|
||||
"sender_id": sender.id,
|
||||
"sender_username": sender.username
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send DM notification: {e}")
|
||||
|
||||
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
|
||||
"""Send a push notification to a specific user"""
|
||||
try:
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"icon": icon or "/logo.png",
|
||||
"tag": f"message_{user_id}",
|
||||
"data": data
|
||||
}
|
||||
|
||||
subscription_info = {
|
||||
"endpoint": subscription.endpoint,
|
||||
"keys": {
|
||||
"p256dh": subscription.p256dh_key,
|
||||
"auth": subscription.auth_key
|
||||
}
|
||||
}
|
||||
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=self.vapid_private_key,
|
||||
vapid_claims=self.vapid_claims
|
||||
)
|
||||
|
||||
except WebPushException as e:
|
||||
logger.error(f"WebPush error for user {user_id}: {e}")
|
||||
# If the subscription is invalid, remove it
|
||||
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
||||
|
||||
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
||||
"""Unsubscribe a user from push notifications"""
|
||||
try:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
logger.info(f"Push subscription removed for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
# Global instance
|
||||
push_service = PushNotificationService()
|
||||
@@ -0,0 +1,12 @@
|
||||
PyJWT>=2.8.0
|
||||
fastapi[standard]>=0.116.1
|
||||
pydantic>=2.11.7
|
||||
sqlalchemy>=2.0.43
|
||||
bcrypt>=4.3.0
|
||||
websockets>=15.0.1
|
||||
Pillow>=10.0.0
|
||||
python-multipart>=0.0.6
|
||||
pywebpush>=1.14.0
|
||||
cryptography>=41.0.0
|
||||
alembic>=1.13.2
|
||||
better-profanity>=0.7.0
|
||||
@@ -0,0 +1,225 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
|
||||
from utils import create_token, get_password_hash, verify_password
|
||||
from validation import is_valid_password, is_valid_username
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def convert_user(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_seen": user.last_seen.isoformat(),
|
||||
"online": user.online,
|
||||
"username": user.username,
|
||||
"profile_picture": user.profile_picture,
|
||||
"bio": user.bio,
|
||||
"admin": user.username == OWNER_USERNAME
|
||||
}
|
||||
|
||||
@router.get("/check_auth")
|
||||
def check_auth(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"authenticated": True,
|
||||
"username": current_user.username,
|
||||
"admin": current_user.username == OWNER_USERNAME
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == request.username.strip()).first()
|
||||
|
||||
if not user or not verify_password(request.password.strip(), user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверное имя пользователя или пароль"
|
||||
)
|
||||
|
||||
user.online = True
|
||||
user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
token = create_token(user.id, user.username)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Login successful",
|
||||
"token": token,
|
||||
"user": convert_user(user)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||
username = request.username.strip()
|
||||
password = request.password.strip()
|
||||
confirm_password = request.confirm_password.strip()
|
||||
|
||||
# Determine if owner already exists
|
||||
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
||||
|
||||
# If owner not yet registered, only allow the owner to register
|
||||
if not owner_exists and username != OWNER_USERNAME:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Регистрация временно закрыта до регистрации владельца"
|
||||
)
|
||||
|
||||
# Validate input
|
||||
if not is_valid_username(username):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Имя пользователя должно быть от 3 до 20 символов и не содержать пробелов"
|
||||
)
|
||||
|
||||
if not is_valid_password(password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пароль должен быть от 5 до 50 символов и не содержать пробелов"
|
||||
)
|
||||
|
||||
if password != confirm_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пароли не совпадают"
|
||||
)
|
||||
|
||||
# After owner exists, disallow registering the reserved owner username via public registration
|
||||
if owner_exists and username == OWNER_USERNAME:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Это имя пользователя зарезервировано"
|
||||
)
|
||||
|
||||
existing_user = db.query(User).filter(User.username == username).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Это имя пользователя уже занято"
|
||||
)
|
||||
|
||||
hashed_password = get_password_hash(password)
|
||||
new_user = User(
|
||||
username=username,
|
||||
password_hash=hashed_password,
|
||||
online=True,
|
||||
last_seen=datetime.now()
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
token = create_token(new_user.id, new_user.username)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Регистрация прошла успешно",
|
||||
"token": token,
|
||||
"user": convert_user(new_user)
|
||||
}
|
||||
|
||||
@router.get("/crypto/public-key")
|
||||
def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||
return {"publicKey": row.public_key_b64 if row else None}
|
||||
|
||||
|
||||
@router.post("/crypto/public-key")
|
||||
def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
pk = payload.get("publicKey")
|
||||
if not pk:
|
||||
raise HTTPException(status_code=400, detail="publicKey required")
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.public_key_b64 = pk
|
||||
else:
|
||||
row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/crypto/backup")
|
||||
def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||
return {"blob": row.blob_json if row else None}
|
||||
|
||||
|
||||
@router.post("/crypto/backup")
|
||||
def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
blob = payload.get("blob")
|
||||
if not blob:
|
||||
raise HTTPException(status_code=400, detail="blob required")
|
||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.blob_json = blob
|
||||
else:
|
||||
row = CryptoBackup(user_id=current_user.id, blob_json=blob)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/admin/user/{user_id}")
|
||||
def delete_user_as_owner(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Only owner can delete users
|
||||
if current_user.username != OWNER_USERNAME:
|
||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deleting the owner account via API
|
||||
if user.username == OWNER_USERNAME:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete owner account")
|
||||
|
||||
# Manually delete user's messages to satisfy FK constraints
|
||||
from models import Message # local import to avoid circular
|
||||
db.query(Message).filter(Message.user_id == user.id).delete()
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "deleted_user_id": user_id}
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
current_user.online = False
|
||||
current_user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Logged out successfully"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
users = db.query(User).order_by(User.username.asc()).all()
|
||||
return {
|
||||
"users": [
|
||||
convert_user(u) for u in users if u.id != current_user.id
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crypto/public-key/of/{user_id}")
|
||||
def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
|
||||
return {"publicKey": row.public_key_b64 if row else None}
|
||||
@@ -0,0 +1,983 @@
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from constants import OWNER_USERNAME
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse
|
||||
from push_service import push_service
|
||||
from PIL import Image
|
||||
import io
|
||||
import json
|
||||
from better_profanity import profanity as _bp
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
|
||||
|
||||
FILES_BASE_DIR = Path("data/uploads/files")
|
||||
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
|
||||
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||
|
||||
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
|
||||
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def convert_message(msg: Message) -> dict:
|
||||
# Group reactions by emoji
|
||||
reactions_dict = {}
|
||||
if msg.reactions:
|
||||
for reaction in msg.reactions:
|
||||
emoji = reaction.emoji
|
||||
if emoji not in reactions_dict:
|
||||
reactions_dict[emoji] = {
|
||||
"emoji": emoji,
|
||||
"count": 0,
|
||||
"users": []
|
||||
}
|
||||
reactions_dict[emoji]["count"] += 1
|
||||
reactions_dict[emoji]["users"].append({
|
||||
"id": reaction.user_id,
|
||||
"username": reaction.user.username
|
||||
})
|
||||
|
||||
return {
|
||||
"id": msg.id,
|
||||
"content": msg.content,
|
||||
"timestamp": msg.timestamp.isoformat(),
|
||||
"is_read": msg.is_read,
|
||||
"is_edited": msg.is_edited,
|
||||
"username": msg.author.username,
|
||||
"profile_picture": msg.author.profile_picture,
|
||||
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
|
||||
"reactions": list(reactions_dict.values()),
|
||||
"files": [
|
||||
{
|
||||
"path": f"/api/uploads/files/normal/{Path(f.path).name}",
|
||||
"id": f.id,
|
||||
"name": f.name,
|
||||
"message_id": f.message_id
|
||||
}
|
||||
for f in (msg.files or [])
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
||||
# Group reactions by emoji
|
||||
reactions_dict = {}
|
||||
if envelope.reactions:
|
||||
for reaction in envelope.reactions:
|
||||
emoji = reaction.emoji
|
||||
if emoji not in reactions_dict:
|
||||
reactions_dict[emoji] = {
|
||||
"emoji": emoji,
|
||||
"count": 0,
|
||||
"users": []
|
||||
}
|
||||
reactions_dict[emoji]["count"] += 1
|
||||
reactions_dict[emoji]["users"].append({
|
||||
"id": reaction.user_id,
|
||||
"username": reaction.user.username
|
||||
})
|
||||
|
||||
return {
|
||||
"id": envelope.id,
|
||||
"senderId": envelope.sender_id,
|
||||
"recipientId": envelope.recipient_id,
|
||||
"iv": envelope.iv_b64,
|
||||
"ciphertext": envelope.ciphertext_b64,
|
||||
"salt": envelope.salt_b64,
|
||||
"iv2": envelope.iv2_b64,
|
||||
"wrappedMk": envelope.wrapped_mk_b64,
|
||||
"timestamp": envelope.timestamp.isoformat(),
|
||||
"reactions": list(reactions_dict.values()),
|
||||
"files": [
|
||||
{
|
||||
"path": f"/api/uploads/files/encrypted/{Path(f.path).name}",
|
||||
"id": f.id,
|
||||
"name": f.name,
|
||||
"dm_envelope_id": f.dm_envelope_id
|
||||
}
|
||||
for f in (envelope.files or [])
|
||||
]
|
||||
}
|
||||
|
||||
# для тех кто читает этот код я эти маты не писал
|
||||
# мат писал ии а я сам не матерюсь))
|
||||
# - denis0001-dev
|
||||
_RU_EXTRA = [
|
||||
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
||||
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
|
||||
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
|
||||
"долбоёб", "долбоеб", "дебил"
|
||||
]
|
||||
|
||||
_bp.load_censor_words()
|
||||
_bp.add_censor_words(_RU_EXTRA)
|
||||
|
||||
# Additional phrase-level filters (case-insensitive)
|
||||
_PHRASE_PATTERNS: list[re.Pattern] = [
|
||||
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),
|
||||
]
|
||||
|
||||
def _mask_span(text: str, start: int, end: int) -> str:
|
||||
return text[:start] + ("\\*" * (end - start)) + text[end:]
|
||||
|
||||
def _apply_phrase_filters(text: str) -> str:
|
||||
result = text
|
||||
for pattern in _PHRASE_PATTERNS:
|
||||
# Replace all occurrences; iterate until no more matches to avoid overlapping issues
|
||||
while True:
|
||||
m = pattern.search(result)
|
||||
if not m:
|
||||
break
|
||||
result = _mask_span(result, m.start(), m.end())
|
||||
return result
|
||||
|
||||
def filter_profanity(text: str) -> str:
|
||||
preprocessed = _apply_phrase_filters(text)
|
||||
return _bp.censor(preprocessed, censor_char="\\*")
|
||||
|
||||
|
||||
@router.post("/send_message")
|
||||
async def send_message(
|
||||
request: SendMessageRequest | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
# Optional multipart form support
|
||||
payload: str | None = Form(default=None),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
):
|
||||
# If payload is provided, prefer it for multipart requests
|
||||
if payload and request is None:
|
||||
# Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null}
|
||||
try:
|
||||
obj = json.loads(payload)
|
||||
content = obj.get("content", "")
|
||||
reply_to_id = obj.get("reply_to_id", None)
|
||||
request = SendMessageRequest(content=content, reply_to_id=reply_to_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid payload JSON")
|
||||
|
||||
if request.reply_to_id:
|
||||
# Check if the message being replied to exists
|
||||
original_message = db.query(Message).filter(Message.id == request.reply_to_id).first()
|
||||
if not original_message:
|
||||
raise HTTPException(status_code=404, detail="Original message not found")
|
||||
|
||||
if not request.content.strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No content provided"
|
||||
)
|
||||
|
||||
# Apply profanity filter before storing
|
||||
filtered_content = filter_profanity(request.content.strip())
|
||||
|
||||
if len(filtered_content) > 4096:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Message too long"
|
||||
)
|
||||
|
||||
new_message = Message(
|
||||
content=filtered_content,
|
||||
user_id=current_user.id,
|
||||
reply_to_id=request.reply_to_id,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
db.add(new_message)
|
||||
db.commit()
|
||||
db.refresh(new_message)
|
||||
|
||||
# Handle files if provided (normal, not encrypted)
|
||||
if files:
|
||||
total_size = 0
|
||||
for up in files:
|
||||
# Accumulate size if available
|
||||
if hasattr(up, "size") and up.size is not None:
|
||||
total_size += int(up.size)
|
||||
else:
|
||||
# If size unknown, read into memory to determine
|
||||
data = await up.read()
|
||||
up.file.seek(0)
|
||||
total_size += len(data)
|
||||
if total_size > MAX_TOTAL_SIZE:
|
||||
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
|
||||
|
||||
for up in files:
|
||||
# Sanitize filename
|
||||
original_name = Path(up.filename or "file").name
|
||||
ext = Path(original_name).suffix.lower()
|
||||
uid = uuid.uuid4().hex
|
||||
safe_name = f"{new_message.id}_{uid}{ext or ''}"
|
||||
out_path = FILES_NORMAL_DIR / safe_name
|
||||
|
||||
content = await up.read()
|
||||
up.file.seek(0)
|
||||
|
||||
# If image, try lossless optimization
|
||||
try:
|
||||
if up.content_type and up.content_type.startswith("image/"):
|
||||
image = Image.open(io.BytesIO(content))
|
||||
img_format = image.format or ("PNG" if ext == ".png" else "JPEG")
|
||||
buf = io.BytesIO()
|
||||
save_kwargs = {"optimize": True}
|
||||
if img_format.upper() == "JPEG":
|
||||
# Use quality=95 with optimize to keep high quality (not truly lossless but near)
|
||||
save_kwargs["quality"] = 95
|
||||
image.save(buf, format=img_format, **save_kwargs)
|
||||
buf.seek(0)
|
||||
content = buf.read()
|
||||
except Exception:
|
||||
# Fallback to original content
|
||||
pass
|
||||
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
mf = MessageFile(
|
||||
message_id=new_message.id,
|
||||
name=original_name,
|
||||
path=str(out_path)
|
||||
)
|
||||
db.add(mf)
|
||||
db.commit()
|
||||
db.refresh(new_message)
|
||||
|
||||
# Send push notifications for public messages
|
||||
try:
|
||||
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
|
||||
|
||||
# Realtime broadcast for HTTP uploads as well
|
||||
try:
|
||||
from .messaging import messagingManager # self import safe here
|
||||
await messagingManager.broadcast({
|
||||
"type": "newMessage",
|
||||
"data": convert_message(new_message)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "message": convert_message(new_message)}
|
||||
|
||||
|
||||
@router.get("/get_messages")
|
||||
async def get_messages(db: Session = Depends(get_db)):
|
||||
messages = db.query(Message).order_by(Message.timestamp.asc()).all()
|
||||
|
||||
messages_data = []
|
||||
for msg in messages:
|
||||
messages_data.append(convert_message(msg))
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"messages": messages_data
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dm/send")
|
||||
async def dm_send(
|
||||
payload: dict | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
# Multipart support
|
||||
dm_payload: str | None = Form(default=None),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
fileNames: str | None = Form(default=None), # JSON array of filenames corresponding to files
|
||||
):
|
||||
if dm_payload and payload is None:
|
||||
try:
|
||||
payload = json.loads(dm_payload)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid dm_payload JSON")
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=400, detail="Missing payload")
|
||||
|
||||
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=current_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)
|
||||
|
||||
# Save encrypted files if any (no processing)
|
||||
if files:
|
||||
# Validate total size
|
||||
total_size = 0
|
||||
for file in files:
|
||||
if hasattr(file, "size") and file.size is not None:
|
||||
total_size += int(file.size)
|
||||
else:
|
||||
data = await file.read()
|
||||
file.file.seek(0)
|
||||
total_size += len(data)
|
||||
if total_size > MAX_TOTAL_SIZE:
|
||||
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
|
||||
|
||||
names: list[str] = []
|
||||
if fileNames:
|
||||
try:
|
||||
decoded = json.loads(fileNames)
|
||||
if isinstance(decoded, list):
|
||||
names = [str(x) for x in decoded]
|
||||
except Exception:
|
||||
names = []
|
||||
|
||||
for i, file in enumerate(files):
|
||||
provided = names[i] if i < len(names) else None
|
||||
# Sanitize provided name to avoid path traversal
|
||||
if provided and not re.match(r"^[A-Za-z0-9._-]{1,200}$", provided):
|
||||
provided = None
|
||||
original_name = provided or Path(file.filename or "file").name
|
||||
# Save using provided/original name to allow client to reference path directly
|
||||
safe_name = uid = uuid.uuid4().hex
|
||||
out_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}"
|
||||
out_path = FILES_ENCRYPTED_DIR / out_name
|
||||
|
||||
content = await file.read()
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Save DM file record
|
||||
df = DMFile(
|
||||
message_id=env.id,
|
||||
sender_id=current_user.id,
|
||||
recipient_id=env.recipient_id,
|
||||
path=f"/api/uploads/files/encrypted/{out_name}",
|
||||
name=original_name
|
||||
)
|
||||
db.add(df)
|
||||
db.commit()
|
||||
|
||||
# Send push notification for DM
|
||||
try:
|
||||
await push_service.send_dm_notification(db, env, current_user)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||
|
||||
# Realtime notify both users for HTTP requests
|
||||
try:
|
||||
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,
|
||||
}
|
||||
}
|
||||
await messagingManager.send_to_user(env.recipient_id, payload_ws)
|
||||
await messagingManager.send_to_user(env.sender_id, payload_ws)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "ok", "id": env.id}
|
||||
|
||||
def convert_envelopes(envs: list[DMEnvelope]):
|
||||
return {
|
||||
"status": "ok",
|
||||
"messages": [
|
||||
{
|
||||
"id": e.id,
|
||||
"senderId": e.sender_id,
|
||||
"recipientId": e.recipient_id,
|
||||
"iv": e.iv_b64,
|
||||
"ciphertext": e.ciphertext_b64,
|
||||
"salt": e.salt_b64,
|
||||
"iv2": e.iv2_b64,
|
||||
"wrappedMk": e.wrapped_mk_b64,
|
||||
"timestamp": e.timestamp.isoformat(),
|
||||
"files": [{"name": file.name, "path": file.path, "id": file.id} for file in e.files]
|
||||
}
|
||||
for e in envs
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/dm/fetch")
|
||||
async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id)
|
||||
if since:
|
||||
q = q.filter(DMEnvelope.id > since)
|
||||
return convert_envelopes(q.order_by(DMEnvelope.id.asc()).all())
|
||||
|
||||
|
||||
@router.get("/dm/history/{other_user_id}")
|
||||
async def dm_history(other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
return convert_envelopes(
|
||||
db.query(DMEnvelope)
|
||||
.filter(
|
||||
((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id))
|
||||
| ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id))
|
||||
)
|
||||
.order_by(DMEnvelope.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.put("/edit_message/{message_id}")
|
||||
async def edit_message(
|
||||
message_id: int,
|
||||
request: EditMessageRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
message = db.query(Message).filter(Message.id == message_id).first()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
if message.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
||||
|
||||
if not request.content.strip():
|
||||
raise HTTPException(status_code=400, detail="Message content cannot be empty")
|
||||
|
||||
message.content = request.content.strip()
|
||||
message.is_edited = True
|
||||
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
return {"status": "success", "message": convert_message(message)}
|
||||
|
||||
|
||||
@router.delete("/delete_message/{message_id}")
|
||||
async def delete_message(
|
||||
message_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
message = db.query(Message).filter(Message.id == message_id).first()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
# Allow owner to delete any message
|
||||
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
||||
|
||||
db.delete(message)
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message_id": message_id}
|
||||
|
||||
|
||||
@router.post("/add_reaction")
|
||||
async def add_reaction(
|
||||
request: ReactionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Check if message exists
|
||||
message = db.query(Message).filter(Message.id == request.message_id).first()
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
# Check if reaction already exists
|
||||
existing_reaction = db.query(Reaction).filter(
|
||||
Reaction.message_id == request.message_id,
|
||||
Reaction.user_id == current_user.id,
|
||||
Reaction.emoji == request.emoji
|
||||
).first()
|
||||
|
||||
if existing_reaction:
|
||||
# Remove existing reaction (toggle off)
|
||||
db.delete(existing_reaction)
|
||||
action = "removed"
|
||||
else:
|
||||
# Add new reaction
|
||||
new_reaction = Reaction(
|
||||
message_id=request.message_id,
|
||||
user_id=current_user.id,
|
||||
emoji=request.emoji
|
||||
)
|
||||
db.add(new_reaction)
|
||||
action = "added"
|
||||
|
||||
db.commit()
|
||||
|
||||
# Refresh message to get updated reactions
|
||||
db.refresh(message)
|
||||
|
||||
# Broadcast reaction update
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.broadcast({
|
||||
"type": "reactionUpdate",
|
||||
"data": {
|
||||
"message_id": request.message_id,
|
||||
"emoji": request.emoji,
|
||||
"action": action,
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": convert_message(message)["reactions"]
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]}
|
||||
|
||||
|
||||
@router.post("/dm/add_reaction")
|
||||
async def add_dm_reaction(
|
||||
request: DMReactionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Check if DM envelope exists
|
||||
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first()
|
||||
if not envelope:
|
||||
raise HTTPException(status_code=404, detail="DM envelope not found")
|
||||
|
||||
# Check if user is part of this DM conversation
|
||||
if current_user.id not in [envelope.sender_id, envelope.recipient_id]:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to react to this message")
|
||||
|
||||
# Check if reaction already exists
|
||||
existing_reaction = db.query(DMReaction).filter(
|
||||
DMReaction.dm_envelope_id == request.dm_envelope_id,
|
||||
DMReaction.user_id == current_user.id,
|
||||
DMReaction.emoji == request.emoji
|
||||
).first()
|
||||
|
||||
if existing_reaction:
|
||||
# Remove existing reaction (toggle off)
|
||||
db.delete(existing_reaction)
|
||||
action = "removed"
|
||||
else:
|
||||
# Add new reaction
|
||||
new_reaction = DMReaction(
|
||||
dm_envelope_id=request.dm_envelope_id,
|
||||
user_id=current_user.id,
|
||||
emoji=request.emoji
|
||||
)
|
||||
db.add(new_reaction)
|
||||
action = "added"
|
||||
|
||||
db.commit()
|
||||
|
||||
# Refresh envelope to get updated reactions
|
||||
db.refresh(envelope)
|
||||
|
||||
# Broadcast reaction update to both participants
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.broadcast({
|
||||
"type": "dmReactionUpdate",
|
||||
"data": {
|
||||
"dm_envelope_id": request.dm_envelope_id,
|
||||
"emoji": request.emoji,
|
||||
"action": action,
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": convert_dm_envelope(envelope)["reactions"]
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
|
||||
|
||||
|
||||
class MessaggingSocketManager:
|
||||
def __init__(self) -> None:
|
||||
self.connections: list[WebSocket] = []
|
||||
self.user_by_ws: dict[WebSocket, int] = {}
|
||||
|
||||
async def send_error(self, websocket: WebSocket, type: str, e: HTTPException):
|
||||
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
|
||||
|
||||
async def handle_connection(self, websocket: WebSocket, db: Session):
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
type = data["type"]
|
||||
|
||||
def get_current_user_inner() -> User | None:
|
||||
if data["credentials"]:
|
||||
return get_current_user(
|
||||
HTTPAuthorizationCredentials(
|
||||
scheme=data["credentials"]["scheme"],
|
||||
credentials=data["credentials"]["credentials"]
|
||||
),
|
||||
db
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
if type == "ping":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if current_user:
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "ping",
|
||||
"data": {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"detail": "Failed to authorize",
|
||||
"code": 401
|
||||
}
|
||||
}
|
||||
})
|
||||
except HTTPException:
|
||||
await websocket.send_json({
|
||||
"type": "ping",
|
||||
"data": {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"detail": "Failed to authorize",
|
||||
"code": 401
|
||||
}
|
||||
}
|
||||
})
|
||||
await websocket.send_json({"type": "ping", "data": {"status": "success"}})
|
||||
elif type == "getMessages":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
|
||||
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "sendMessage":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
|
||||
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
|
||||
|
||||
response = await send_message(request, current_user, db, None, [])
|
||||
await self.broadcast({
|
||||
"type": "newMessage",
|
||||
"data": response["message"]
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "dmSend":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
payload = data["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=current_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 = {
|
||||
"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:
|
||||
await push_service.send_dm_notification(db, env, current_user)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||
|
||||
await self.send_to_user(env.recipient_id, payload);
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
|
||||
await self.send_to_user(env.sender_id, payload);
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "editMessage":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
message_id = data["data"]["message_id"]
|
||||
request: EditMessageRequest = EditMessageRequest.model_validate(data["data"])
|
||||
|
||||
response = await edit_message(message_id, request, current_user, db)
|
||||
await self.broadcast({
|
||||
"type": "messageEdited",
|
||||
"data": response["message"]
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "dmEdit":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
payload = data["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 != current_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,
|
||||
"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 self.send_to_user(env.recipient_id, payload_ws)
|
||||
await self.send_to_user(env.sender_id, payload_ws)
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "dmDelete":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
payload = data["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 != current_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": current_user.id,
|
||||
"recipientId": payload.get("recipientId")
|
||||
}
|
||||
}
|
||||
await self.send_to_user(env.recipient_id, payload_ws)
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}})
|
||||
await self.send_to_user(env.sender_id, payload_ws)
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "deleteMessage":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
message_id = data["data"]["message_id"]
|
||||
response = await delete_message(message_id, current_user, db)
|
||||
await self.broadcast({
|
||||
"type": "messageDeleted",
|
||||
"data": {"message_id": message_id}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "addReaction":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
request_data = data["data"]
|
||||
reaction_request = ReactionRequest(
|
||||
message_id=request_data["message_id"],
|
||||
emoji=request_data["emoji"]
|
||||
)
|
||||
|
||||
response = await add_reaction(reaction_request, current_user, db)
|
||||
|
||||
# Broadcast reaction update
|
||||
await self.broadcast({
|
||||
"type": "reactionUpdate",
|
||||
"data": {
|
||||
"message_id": request_data["message_id"],
|
||||
"emoji": request_data["emoji"],
|
||||
"action": response["action"],
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": response["reactions"]
|
||||
}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "addDmReaction":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
request_data = data["data"]
|
||||
reaction_request = DMReactionRequest(
|
||||
dm_envelope_id=request_data["dm_envelope_id"],
|
||||
emoji=request_data["emoji"]
|
||||
)
|
||||
|
||||
response = await add_dm_reaction(reaction_request, current_user, db)
|
||||
|
||||
# Broadcast reaction update
|
||||
await self.broadcast({
|
||||
"type": "dmReactionUpdate",
|
||||
"data": {
|
||||
"dm_envelope_id": request_data["dm_envelope_id"],
|
||||
"emoji": request_data["emoji"],
|
||||
"action": response["action"],
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": response["reactions"]
|
||||
}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
else:
|
||||
await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}})
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, code: int = 1000, message: str | None = None):
|
||||
try:
|
||||
await websocket.close(code=code, reason=message)
|
||||
finally:
|
||||
self.connections.remove(websocket)
|
||||
|
||||
async def connect(self, websocket: WebSocket, db: Session):
|
||||
await websocket.accept()
|
||||
self.connections.append(websocket)
|
||||
try:
|
||||
await self.handle_connection(websocket, db)
|
||||
except WebSocketDisconnect as e:
|
||||
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
||||
finally:
|
||||
self.connections.remove(websocket)
|
||||
if websocket in self.user_by_ws:
|
||||
del self.user_by_ws[websocket]
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
for websocket in self.connections:
|
||||
await websocket.send_json(message)
|
||||
|
||||
async def send_to_user(self, user_id: int, message: dict):
|
||||
for websocket in self.connections:
|
||||
if self.user_by_ws.get(websocket) == user_id:
|
||||
await websocket.send_json(message)
|
||||
|
||||
messagingManager = MessaggingSocketManager()
|
||||
|
||||
@router.websocket("/chat/ws")
|
||||
async def chat_websocket(
|
||||
websocket: WebSocket,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
await messagingManager.connect(websocket, db)
|
||||
|
||||
|
||||
# File serving endpoints
|
||||
@router.get("/uploads/files/normal/{filename}")
|
||||
async def get_file_normal(filename: str):
|
||||
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
path = FILES_NORMAL_DIR / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(str(path))
|
||||
|
||||
|
||||
@router.get("/uploads/files/encrypted/{filename}")
|
||||
async def get_file_encrypted(filename: str, current_user: User = Depends(get_current_user)):
|
||||
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
path = FILES_ENCRYPTED_DIR / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
match = re.match(r"^(\d+)_(\d+)_(\d+)_.*$", path.resolve().name)
|
||||
if match:
|
||||
sender_id = int(match.group(1))
|
||||
recipient_id = int(match.group(2))
|
||||
|
||||
if not current_user.id in [sender_id, recipient_id]:
|
||||
raise HTTPException(403)
|
||||
else:
|
||||
raise HTTPException(500)
|
||||
|
||||
return FileResponse(str(path))
|
||||
@@ -0,0 +1,205 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from PIL import Image
|
||||
import os
|
||||
import uuid
|
||||
import io
|
||||
|
||||
from dependencies import get_db, get_current_user
|
||||
from models import User, UpdateBioRequest, UserProfileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Request models
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
nickname: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
# Create uploads directory if it doesn't exist
|
||||
PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
|
||||
|
||||
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
|
||||
|
||||
@router.post("/upload-profile-picture")
|
||||
async def upload_profile_picture(
|
||||
profile_picture: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Upload and process a profile picture
|
||||
"""
|
||||
# Validate file type
|
||||
if not profile_picture.content_type.startswith('image/'):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
# Validate file size (max 5MB)
|
||||
if profile_picture.size > 5 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="File size must be less than 5MB")
|
||||
|
||||
try:
|
||||
# Read and process the image
|
||||
image_data = await profile_picture.read()
|
||||
|
||||
# Open image with PIL
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Convert to RGB if necessary
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# Resize to a reasonable size (200x200)
|
||||
image.thumbnail((200, 200), Image.Resampling.LANCZOS)
|
||||
|
||||
# Generate unique filename
|
||||
filename = f"{current_user.id}_{uuid.uuid4().hex}.jpg"
|
||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
||||
|
||||
# Save the processed image
|
||||
image.save(filepath, 'JPEG', quality=85)
|
||||
|
||||
# Update user's profile picture in database
|
||||
profile_picture_url = f"/api/profile-picture/{filename}"
|
||||
current_user.profile_picture = profile_picture_url
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Profile picture uploaded successfully",
|
||||
"profile_picture_url": profile_picture_url
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
|
||||
|
||||
@router.get("/profile-picture/{filename}")
|
||||
async def get_profile_picture(filename: str):
|
||||
"""
|
||||
Serve profile picture files
|
||||
"""
|
||||
|
||||
if not re.match(r"^\d+_[0-9a-z]+\.jpg$", filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
|
||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="Profile picture not found")
|
||||
|
||||
return FileResponse(filepath, media_type="image/jpeg")
|
||||
|
||||
@router.get("/user/profile")
|
||||
async def get_user_profile(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get current user's profile information
|
||||
"""
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"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
|
||||
}
|
||||
|
||||
@router.put("/user/profile")
|
||||
async def update_user_profile(
|
||||
request: UpdateProfileRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update current user's profile information
|
||||
"""
|
||||
updated = False
|
||||
|
||||
# Update username if provided
|
||||
if request.nickname is not None:
|
||||
nickname = request.nickname.strip()
|
||||
if len(nickname) < 3:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 3 characters long")
|
||||
if len(nickname) > 50:
|
||||
raise HTTPException(status_code=400, detail="Username must be 50 characters or less")
|
||||
|
||||
# Check if username is already taken by another user
|
||||
existing_user = db.query(User).filter(User.username == nickname, User.id != current_user.id).first()
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
current_user.username = nickname
|
||||
updated = True
|
||||
|
||||
# Update bio if provided
|
||||
if request.description is not None:
|
||||
bio = request.description.strip()
|
||||
if len(bio) > 500:
|
||||
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
|
||||
|
||||
current_user.bio = bio
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
db.commit()
|
||||
return {
|
||||
"message": "Profile updated successfully",
|
||||
"username": current_user.username,
|
||||
"bio": current_user.bio
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"message": "No changes made",
|
||||
"username": current_user.username,
|
||||
"bio": current_user.bio
|
||||
}
|
||||
|
||||
|
||||
@router.put("/user/bio")
|
||||
async def update_user_bio(
|
||||
request: UpdateBioRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update current user's bio
|
||||
"""
|
||||
if len(request.bio) > 500: # Limit bio to 500 characters
|
||||
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
|
||||
|
||||
current_user.bio = request.bio.strip()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Bio updated successfully",
|
||||
"bio": current_user.bio
|
||||
}
|
||||
|
||||
|
||||
@router.get("/user/{username}")
|
||||
async def get_user_by_username(
|
||||
username: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get user profile by username
|
||||
"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return UserProfileResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
profile_picture=user.profile_picture,
|
||||
bio=user.bio,
|
||||
online=user.online,
|
||||
last_seen=user.last_seen,
|
||||
created_at=user.created_at
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, PushSubscriptionRequest
|
||||
from push_service import push_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_to_push_notifications(
|
||||
request: PushSubscriptionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Subscribe user to push notifications"""
|
||||
try:
|
||||
success = await push_service.subscribe_user(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
endpoint=request.endpoint,
|
||||
p256dh_key=request.keys["p256dh"],
|
||||
auth_key=request.keys["auth"]
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"status": "success", "message": "Push notifications enabled"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to enable push notifications")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/unsubscribe")
|
||||
async def unsubscribe_from_push_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Unsubscribe user from push notifications"""
|
||||
try:
|
||||
success = await push_service.unsubscribe_user(db=db, user_id=current_user.id)
|
||||
|
||||
if success:
|
||||
return {"status": "success", "message": "Push notifications disabled"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime, timedelta
|
||||
import jwt
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
|
||||
from constants import *
|
||||
|
||||
# JWT Helper Functions
|
||||
def create_token(user_id: int, username: str) -> str:
|
||||
expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"exp": expire
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
@@ -0,0 +1,16 @@
|
||||
import re
|
||||
|
||||
def is_valid_username(username: str) -> bool:
|
||||
if len(username) < 3 or len(username) > 20:
|
||||
return False
|
||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', username):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_password(password: str) -> bool:
|
||||
if len(password) < 5 or len(password) > 50:
|
||||
return False
|
||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
|
||||
return False
|
||||
return True
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.fromchat.ru}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8301:80"
|
||||
restart: always
|
||||
@@ -0,0 +1,33 @@
|
||||
# Node.js
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ipynb_checkpoints
|
||||
.venv
|
||||
venv/
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Common
|
||||
# Exclude editor and OS files, as well as test results
|
||||
dist
|
||||
dist-electron
|
||||
build
|
||||
coverage
|
||||
test_results/
|
||||
out
|
||||
|
||||
data
|
||||
@@ -0,0 +1,25 @@
|
||||
# 1. Install pip dependencies
|
||||
FROM python:3.12 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
RUN python3 -m venv .venv
|
||||
COPY backend/requirements.txt .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
./.venv/bin/pip3 install -r requirements.txt
|
||||
|
||||
# 2. Runtime stage
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
# 2.1. Non-root user
|
||||
WORKDIR /app
|
||||
RUN useradd -u 1000 app && \
|
||||
chown -R app /app
|
||||
USER app
|
||||
|
||||
# 2.2. Copy content and create dirs
|
||||
COPY --chown=app backend .
|
||||
COPY --from=builder --chown=app /app/.venv .venv
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# 3. Final command
|
||||
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
|
||||
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
dockerfile: deployment/Dockerfile.backend
|
||||
context: ..
|
||||
environment:
|
||||
PORT: 8300
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
volumes:
|
||||
- "data:/app/data"
|
||||
develop:
|
||||
watch:
|
||||
- action: sync+restart
|
||||
path: ../backend
|
||||
target: /app
|
||||
- action: rebuild
|
||||
path: ../backend/requirements.txt
|
||||
|
||||
frontend:
|
||||
build:
|
||||
dockerfile: deployment/frontend/Dockerfile
|
||||
context: ..
|
||||
environment:
|
||||
PORT: 8301
|
||||
BACKEND_HOST: http://backend:8300
|
||||
ports:
|
||||
- "8301:8301"
|
||||
depends_on:
|
||||
- backend
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ../frontend
|
||||
- action: sync+restart
|
||||
path: server.js
|
||||
target: /server/server.js
|
||||
- action: rebuild
|
||||
path: package.json
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: fromchat-data
|
||||
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=FromChat server
|
||||
After=multi-user.target
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=/bin/docker compose up
|
||||
ExecStop=/bin/docker compose down
|
||||
WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/log
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
StandardInput=tty-force
|
||||
SyslogIdentifier=fromchat
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,52 @@
|
||||
# 1. Build the frontend
|
||||
FROM node:24 AS frontend
|
||||
|
||||
# 1.1. Install npm dependencies
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 1.2. Build
|
||||
COPY frontend frontend
|
||||
RUN npm run frontend:build
|
||||
|
||||
|
||||
# 2. Build the static file server
|
||||
FROM node:24 AS server
|
||||
|
||||
# 2.1. Install npm dependencies
|
||||
WORKDIR /server
|
||||
COPY deployment/frontend/package.json .
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install
|
||||
|
||||
# 2.2. Copy the code
|
||||
COPY deployment/frontend/ .
|
||||
|
||||
# 2.3. Build
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# 3. Put it all together
|
||||
FROM node:24-slim
|
||||
|
||||
# 3.1. Non-root user
|
||||
RUN useradd -u 1001 app && \
|
||||
mkdir -p /app && \
|
||||
chown -R app /app && \
|
||||
mkdir /server && \
|
||||
chown -R app /server
|
||||
USER app
|
||||
|
||||
# 3.1. Frontend static files
|
||||
WORKDIR /app
|
||||
COPY --from=frontend --chown=app /app/frontend/build/normal/dist .
|
||||
|
||||
# 3.2. Static file server
|
||||
WORKDIR /server
|
||||
COPY --from=server --chown=app /server .
|
||||
|
||||
# 4. Final command
|
||||
ENV STATIC_FILE_PATH=/app
|
||||
ENTRYPOINT ["npm", "run", "start:prod"]
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "frontend-server",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node server.ts",
|
||||
"build": "tsc -b",
|
||||
"start:prod": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0",
|
||||
"ts-node": "^10.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import express from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
||||
const filePath = process.env.STATIC_FILE_PATH || ".";
|
||||
|
||||
// API proxy middleware
|
||||
app.use('/api', createProxyMiddleware({
|
||||
target: backendHost,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
ws: true
|
||||
}));
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(resolve(filePath)));
|
||||
|
||||
// SPA routing - catch all handler for client-side routing
|
||||
app.use((_req, res) => {
|
||||
res.sendFile(resolve(filePath, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server launched on http://localhost:${port}`);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"server.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
export type Platform = "win32" | "darwin" | "linux"
|
||||
|
||||
export interface NotificationShowOptions {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface ElectronNotifications {
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
show: (options: NotificationShowOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ElectronInterface {
|
||||
desktop: true,
|
||||
platform: Platform,
|
||||
notifications: ElectronNotifications
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronInterface: ElectronInterface
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
|
||||
import path from "node:path";
|
||||
import type { NotificationShowOptions } from '../electron.d.ts';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
app.whenReady().then(() => {
|
||||
mainWindow = new BrowserWindow({
|
||||
title: 'Main window',
|
||||
minWidth: 800,
|
||||
minHeight: 420,
|
||||
webPreferences: {
|
||||
preload: path.join(import.meta.dirname, "preload.mjs")
|
||||
},
|
||||
titleBarStyle: "hidden",
|
||||
trafficLightPosition: {
|
||||
x: 16 - 4,
|
||||
y: 16 - 4
|
||||
},
|
||||
titleBarOverlay: process.platform !== "darwin"
|
||||
});
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile('frontend/build/electron/dist/index.html');
|
||||
}
|
||||
|
||||
// Handle notification permission requests
|
||||
ipcMain.handle('request-notification-permission', async () => {
|
||||
if (Notification.isSupported()) {
|
||||
return 'granted';
|
||||
}
|
||||
return 'denied';
|
||||
});
|
||||
|
||||
// Handle showing notifications
|
||||
ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => {
|
||||
if (Notification.isSupported()) {
|
||||
try {
|
||||
const notification = new Notification({
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
icon: options.icon,
|
||||
silent: false,
|
||||
urgency: 'normal'
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
notification.show();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error creating notification:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { ElectronInterface, Platform } from "../electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("electronInterface", {
|
||||
desktop: true,
|
||||
platform: process.platform as Platform,
|
||||
notifications: {
|
||||
requestPermission: () => ipcRenderer.invoke('request-notification-permission'),
|
||||
show: (options) => ipcRenderer.invoke('show-notification', options)
|
||||
}
|
||||
} satisfies ElectronInterface);
|
||||
@@ -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/**"
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
|
||||
export default {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
},
|
||||
outDir: "frontend/build/electron/forge",
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
config: {},
|
||||
platforms: ['win32', 'darwin'],
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
config: {},
|
||||
platforms: ['linux'],
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-rpm',
|
||||
config: {},
|
||||
platforms: ['linux'],
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
||||
config: {},
|
||||
},
|
||||
// Fuses are used to enable/disable various Electron functionality
|
||||
// at package time, before code signing the application
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
} satisfies ForgeConfig;
|
||||
@@ -4,11 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Loading...</title>
|
||||
<link rel="icon" href="./images/logo.svg" />
|
||||
<link rel="icon" href="./src/images/logo.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="main/main.tsx" type="module"></script>
|
||||
<script src="src/main.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { ElectronTitleBar } from "./Electron";
|
||||
import { useAppState } from "./pages/chat/state";
|
||||
import { useEffect, useState, lazy } from "react";
|
||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||
|
||||
// Lazy load route components
|
||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||
const LoginPage = lazy(() => import("./pages/auth/LoginPage"));
|
||||
const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
|
||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||
|
||||
export default function App() {
|
||||
const { restoreUserFromStorage } = useAppState();
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
|
||||
// Restore user from localStorage on app initialization
|
||||
useEffect(() => {
|
||||
restoreUserFromStorage().finally(() => {
|
||||
setAuthReady(true);
|
||||
});
|
||||
}, [restoreUserFromStorage]);
|
||||
|
||||
return authReady && (
|
||||
<BrowserRouter>
|
||||
<ElectronTitleBar />
|
||||
<div id="main-wrapper">
|
||||
<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>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PRODUCT_NAME } from "./core/config";
|
||||
import { isElectron } from "./core/electron/electron";
|
||||
|
||||
export function ElectronTitleBar() {
|
||||
return isElectron && (
|
||||
<div id="electron-title-bar">
|
||||
{window.electronInterface.platform === "darwin" && <div className="macos-padding" />}
|
||||
<div id="window-title">{PRODUCT_NAME}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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")!);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { getCurrentKeys } from "./authApi";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Obtain the key
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||
|
||||
// Decrypt
|
||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||
return new TextDecoder().decode(msg);
|
||||
}
|
||||
|
||||
export async function 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 || [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
export async function sendDMViaWebSocket(
|
||||
recipientId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
plaintext: string,
|
||||
authToken: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(
|
||||
recipientId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
plaintextJson: string,
|
||||
files: File[],
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// Encrypt file with same mk
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name; // server uses provided name
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
try {
|
||||
obj = JSON.parse(plaintextJson);
|
||||
} catch {
|
||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
||||
}
|
||||
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import { getAuthHeaders } from "./authApi";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
nickname?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -16,10 +15,10 @@ export interface UploadResponse {
|
||||
/**
|
||||
* Loads user profile data from the server
|
||||
*/
|
||||
export async function get(token: string): Promise<ProfileData | null> {
|
||||
export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -27,15 +26,14 @@ export async function get(token: string): Promise<ProfileData | null> {
|
||||
// Map backend fields to frontend fields
|
||||
return {
|
||||
profile_picture: data.profile_picture,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
nickname: data.username,
|
||||
description: data.bio
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
console.error("Error loading profile:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -43,13 +41,13 @@ export async function get(token: string): Promise<ProfileData | null> {
|
||||
/**
|
||||
* Uploads a profile picture to the server
|
||||
*/
|
||||
export async function uploadPicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||
try {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
@@ -59,7 +57,7 @@ export async function uploadPicture(token: string, file: Blob): Promise<UploadRe
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
console.error("Upload error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -67,27 +65,26 @@ export async function uploadPicture(token: string, file: Blob): Promise<UploadRe
|
||||
/**
|
||||
* Updates user profile information
|
||||
*/
|
||||
export async function update(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
||||
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,
|
||||
nickname: data.nickname,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...getAuthHeaders(token, true),
|
||||
'Content-Type': 'application/json'
|
||||
...getAuthHeaders(token),
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(backendData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
console.error("Error updating profile:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -98,14 +95,14 @@ export async function update(token: string, data: Partial<ProfileData>): Promise
|
||||
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),
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(token),
|
||||
body: JSON.stringify({ bio })
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating bio:', error);
|
||||
console.error("Error updating bio:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -113,40 +110,19 @@ export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||
/**
|
||||
* Fetches user profile data by username
|
||||
*/
|
||||
export async function fetchByUsername(token: string, username: string): Promise<UserProfile | null> {
|
||||
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)
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} 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 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
+7
-9
@@ -10,7 +10,6 @@ interface RichTextAreaProps {
|
||||
className?: string;
|
||||
rows?: number;
|
||||
autoComplete?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function RichTextArea({
|
||||
@@ -21,8 +20,7 @@ export function RichTextArea({
|
||||
placeholder,
|
||||
className,
|
||||
rows = 1,
|
||||
autoComplete = "off",
|
||||
readOnly = false
|
||||
autoComplete = "off"
|
||||
}: RichTextAreaProps) {
|
||||
const textareaRef = 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 {
|
||||
const raw = computedStyle[prop] as string | number | undefined;
|
||||
if (raw == null) return 0;
|
||||
if (raw === null) return 0;
|
||||
const str = String(raw);
|
||||
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
|
||||
}
|
||||
@@ -185,10 +183,10 @@ export function RichTextArea({
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
autoComplete={readOnly ? "off" : autoComplete}
|
||||
onChange={readOnly ? undefined : handleChange}
|
||||
onKeyDown={readOnly ? undefined : handleKeyDown}
|
||||
readOnly={readOnly} />
|
||||
autoComplete={autoComplete}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<textarea
|
||||
aria-hidden
|
||||
readOnly
|
||||
@@ -204,7 +202,7 @@ export function RichTextArea({
|
||||
height: "auto",
|
||||
minHeight: 0,
|
||||
maxHeight: "none",
|
||||
overflow: "hidden",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
@@ -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> })} />
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @fileoverview Application configuration constants
|
||||
* @description Contains all configuration values used throughout the application
|
||||
* @author Cursor
|
||||
* @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";
|
||||
|
||||
/**
|
||||
* Base API endpoint for all backend requests
|
||||
* @constant
|
||||
*/
|
||||
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`;
|
||||
|
||||
/**
|
||||
* Application name displayed in UI and document title
|
||||
* @constant
|
||||
*/
|
||||
export const PRODUCT_NAME = "FromChat";
|
||||
|
||||
export const MINIMUM_WIDTH = 800;
|
||||
@@ -0,0 +1,41 @@
|
||||
@use "../../css/material" as *;
|
||||
|
||||
#electron-title-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.electron {
|
||||
#electron-title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
background-color: $color-dark-surface-container;
|
||||
width: 100%;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
transition: background-color 0.5s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.color-surface {
|
||||
background-color: $color-dark-surface;
|
||||
}
|
||||
}
|
||||
|
||||
#window-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#main-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&.platform-darwin .macos-padding {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @fileoverview Electron-specific code
|
||||
* @description This module initializes Electron-specific functionality.
|
||||
* @author denis0001-dev
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import "./electron.scss";
|
||||
|
||||
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface !== undefined;
|
||||
|
||||
if (isElectron) {
|
||||
console.log("Running in Electron");
|
||||
document.documentElement.classList.add("electron", `platform-${window.electronInterface.platform}`);
|
||||
} else {
|
||||
console.log("Running in normal browser");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useCallback, type RefCallback, type Ref } from 'react';
|
||||
import { useRef, useCallback, type RefCallback, type Ref } from "react";
|
||||
|
||||
// Определяем тип для ref, который может быть либо функцией, либо объектом
|
||||
type PossibleRef<T> = Ref<T> | undefined;
|
||||
@@ -14,7 +14,7 @@ export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallb
|
||||
refs.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
if (typeof ref === "function") {
|
||||
// Если ref - это функция, вызываем её
|
||||
ref(node);
|
||||
} else {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { MINIMUM_WIDTH } from "@/core/config";
|
||||
import { MINIMUM_WIDTH } from "../config";
|
||||
import useWindowSize from "./useWindowSize";
|
||||
|
||||
export default function useDownloadAppScreen() {
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @fileoverview Application initialization logic
|
||||
* @description Handles initial application setup and state
|
||||
* @author FromChat Team
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { PRODUCT_NAME } from "./config";
|
||||
import { enableMapSet } from "immer";
|
||||
|
||||
document.title = PRODUCT_NAME;
|
||||
enableMapSet();
|
||||
@@ -0,0 +1,257 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import type { Message, NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
|
||||
import serviceWorker from "./service-worker?worker&url";
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
// Global state
|
||||
let isInitialized = false;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let subscription: PushSubscription | null = null;
|
||||
let isElectronReceiverRunning = false;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
// Helper functions
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
async function subscribeToWebPush(): Promise<PushSubscription | null> {
|
||||
if (!registration) {
|
||||
throw new Error("Service Worker not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(
|
||||
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
|
||||
).slice().buffer
|
||||
});
|
||||
|
||||
console.log("Push subscription successful");
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error("Push subscription failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSubscriptionToServer(token: string): Promise<boolean> {
|
||||
if (!subscription) {
|
||||
throw new Error("No push subscription available");
|
||||
}
|
||||
|
||||
const subscriptionData: PushSubscriptionData = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(subscription.getKey("p256dh")!),
|
||||
auth: arrayBufferToBase64(subscription.getKey("auth")!)
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(subscriptionData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("Failed to send subscription to server:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function showMessageNotification(message: Message): Promise<void> {
|
||||
try {
|
||||
await showNotification({
|
||||
title: `New message from ${message.username}`,
|
||||
body: message.content.length > 100
|
||||
? message.content.substring(0, 100) + "..."
|
||||
: message.content,
|
||||
icon: message.profile_picture || "/logo.png",
|
||||
tag: `message_${message.id}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to show message notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebSocketMessage(response: WebSocketMessage<object>): Promise<void> {
|
||||
// Handle notifications for new messages
|
||||
if (response.type === "newMessage" && response.data) {
|
||||
const newResponse = response as NewMessageWebSocketMessage;
|
||||
await showMessageNotification(newResponse.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API functions
|
||||
export async function initialize(): Promise<boolean> {
|
||||
if (isInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isElectron) {
|
||||
// For Electron, we just need to request permission
|
||||
const permission = await window.electronInterface.notifications.requestPermission();
|
||||
isInitialized = permission === "granted";
|
||||
return isInitialized;
|
||||
} else {
|
||||
// For web browsers, initialize service worker and push manager
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
console.log("Push messaging is not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
|
||||
console.log("Service Worker registered successfully");
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === "granted") {
|
||||
await subscribeToWebPush();
|
||||
isInitialized = true;
|
||||
}
|
||||
return isInitialized;
|
||||
} catch (error) {
|
||||
console.error("Service Worker registration failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize notification service:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribe(token: string): Promise<boolean> {
|
||||
if (!isInitialized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need server-side subscription
|
||||
return true;
|
||||
}
|
||||
|
||||
return await sendSubscriptionToServer(token);
|
||||
}
|
||||
|
||||
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
try {
|
||||
return await window.electronInterface.notifications.show(payload);
|
||||
} catch (error) {
|
||||
console.error("Failed to show Electron notification:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For web browsers, notifications are handled by the service worker
|
||||
// when push messages are received from the server
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function unsubscribe(): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need to unsubscribe from server
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to unsubscribe:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSupported(): boolean {
|
||||
if (isElectron) {
|
||||
return true; // Electron always supports notifications
|
||||
}
|
||||
return "serviceWorker" in navigator && "PushManager" in window;
|
||||
}
|
||||
|
||||
// Electron-specific functions
|
||||
export async function startElectronReceiver(): Promise<void> {
|
||||
if (!isElectron || isElectronReceiverRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = true;
|
||||
|
||||
// Add our own message listener to the existing WebSocket
|
||||
messageListener = (event: MessageEvent) => {
|
||||
try {
|
||||
const response: WebSocketMessage<object> = JSON.parse(event.data);
|
||||
handleWebSocketMessage(response);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener("message", messageListener);
|
||||
}
|
||||
|
||||
export function stopElectronReceiver(): void {
|
||||
if (!isElectron) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = false;
|
||||
|
||||
// Remove our message listener
|
||||
if (messageListener) {
|
||||
websocket.removeEventListener("message", messageListener);
|
||||
messageListener = null;
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -1,7 +1,5 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import logo from "@/images/logo.svg";
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
interface NotificationPayload {
|
||||
@@ -10,7 +8,7 @@ interface NotificationPayload {
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
data?: any;
|
||||
data?: object;
|
||||
}
|
||||
|
||||
interface NotificationAction {
|
||||
@@ -24,7 +22,7 @@ interface NotificationOptions {
|
||||
badge: string;
|
||||
image?: string;
|
||||
tag: string;
|
||||
data?: any;
|
||||
data?: object;
|
||||
actions: NotificationAction[];
|
||||
requireInteraction: boolean;
|
||||
silent: boolean;
|
||||
@@ -35,11 +33,11 @@ self.addEventListener("push", function(event: ExtendableEvent) {
|
||||
const pushEvent = event as PushEvent;
|
||||
if (pushEvent.data) {
|
||||
const data: NotificationPayload = pushEvent.data.json();
|
||||
|
||||
|
||||
const options: NotificationOptions = {
|
||||
body: data.body,
|
||||
icon: data.icon || logo,
|
||||
badge: logo,
|
||||
icon: data.icon || "/logo.png",
|
||||
badge: "/logo.png",
|
||||
image: data.image,
|
||||
tag: data.tag || "message",
|
||||
data: data.data,
|
||||
+48
-253
@@ -39,8 +39,6 @@ export interface Rect extends Size2D {
|
||||
|
||||
// App types
|
||||
|
||||
export type VerificationStatus = "verified" | "warning" | "blocked" | "none";
|
||||
|
||||
/**
|
||||
* Chat message structure
|
||||
* @interface Message
|
||||
@@ -64,15 +62,12 @@ export interface Reaction {
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
is_edited: boolean;
|
||||
timestamp: string;
|
||||
profile_picture?: string;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
reply_to?: Message;
|
||||
files?: Attachment[];
|
||||
reactions?: Reaction[];
|
||||
@@ -80,7 +75,7 @@ export interface Message {
|
||||
runtimeData?: {
|
||||
dmEnvelope?: DmEnvelope;
|
||||
sendingState?: {
|
||||
status: 'sending' | 'sent' | 'failed';
|
||||
status: "sending" | "sent" | "failed";
|
||||
tempId?: string; // Temporary ID for tracking until server confirms
|
||||
retryData?: {
|
||||
content: string;
|
||||
@@ -116,15 +111,9 @@ export interface User {
|
||||
last_seen: string;
|
||||
online: boolean;
|
||||
username: string;
|
||||
display_name: string;
|
||||
admin?: boolean;
|
||||
bio?: string;
|
||||
profile_picture: string;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
suspended?: boolean;
|
||||
suspension_reason?: string | null;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,16 +130,11 @@ export interface User {
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
profile_picture?: string;
|
||||
bio?: string;
|
||||
online: boolean;
|
||||
last_seen: string;
|
||||
created_at: string;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
deleted?: boolean;
|
||||
suspended?: boolean;
|
||||
}
|
||||
|
||||
// ----------
|
||||
@@ -179,10 +163,8 @@ export interface LoginRequest {
|
||||
*/
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
display_name: string;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
export interface UploadPublicKeyRequest {
|
||||
@@ -191,9 +173,11 @@ export interface UploadPublicKeyRequest {
|
||||
|
||||
export interface SendDMRequest {
|
||||
recipientId: number;
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
replyToId?: number;
|
||||
}
|
||||
|
||||
@@ -215,9 +199,11 @@ export interface BackupBlob {
|
||||
}
|
||||
|
||||
export interface BaseDmEnvelope {
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
@@ -227,29 +213,25 @@ export interface DmEnvelope extends BaseDmEnvelope {
|
||||
files?: DmFile[];
|
||||
timestamp: string;
|
||||
reactions?: Reaction[];
|
||||
replyToId?: number;
|
||||
}
|
||||
|
||||
export interface DmFile {
|
||||
name: string;
|
||||
id: number;
|
||||
path: string;
|
||||
dm_envelope_id?: number;
|
||||
wrapped_mek_b64?: string;
|
||||
nonce_b64?: string;
|
||||
}
|
||||
|
||||
export interface DmEditedPayload {
|
||||
id: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
timestamp: string
|
||||
export interface DmEditedPayload {
|
||||
id: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface DmDeletedPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number
|
||||
export interface DmDeletedPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number
|
||||
}
|
||||
|
||||
export interface FetchDMResponse {
|
||||
@@ -257,7 +239,7 @@ export interface FetchDMResponse {
|
||||
}
|
||||
|
||||
export interface DmEncryptedJSON {
|
||||
type: "text",
|
||||
type: "text";
|
||||
data: {
|
||||
content: string;
|
||||
reply_to_id?: number;
|
||||
@@ -310,8 +292,6 @@ export interface Attachment {
|
||||
path: string;
|
||||
encrypted: boolean;
|
||||
name: string;
|
||||
wrapped_mek_b64?: string;
|
||||
nonce_b64?: string;
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
@@ -321,23 +301,22 @@ export interface Attachment {
|
||||
// Utils
|
||||
export interface DMEditPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number;
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
timestamp: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
salt: string;
|
||||
}
|
||||
|
||||
// Requests
|
||||
export interface DMEditRequest extends WebSocketMessage {
|
||||
type: "dmEdit",
|
||||
type: "dmEdit";
|
||||
credentials: WebSocketCredentials;
|
||||
data: DMEditPayload
|
||||
}
|
||||
|
||||
export interface SendMessageRequest extends WebSocketMessage {
|
||||
type: "sendMessage",
|
||||
type: "sendMessage";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
content: string;
|
||||
@@ -346,7 +325,7 @@ export interface SendMessageRequest extends WebSocketMessage {
|
||||
}
|
||||
|
||||
export interface AddReactionRequest extends WebSocketMessage {
|
||||
type: "addReaction",
|
||||
type: "addReaction";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
message_id: number;
|
||||
@@ -355,7 +334,7 @@ export interface AddReactionRequest extends WebSocketMessage {
|
||||
}
|
||||
|
||||
export interface AddDmReactionRequest extends WebSocketMessage {
|
||||
type: "addDmReaction",
|
||||
type: "addDmReaction";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
dm_envelope_id: number;
|
||||
@@ -365,41 +344,41 @@ export interface AddDmReactionRequest extends WebSocketMessage {
|
||||
|
||||
// Messages
|
||||
export interface DMNewWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmNew",
|
||||
type: "dmNew";
|
||||
data: DmEnvelope
|
||||
}
|
||||
|
||||
export interface DMEditedWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmEdited",
|
||||
type: "dmEdited";
|
||||
data: DMEditPayload
|
||||
}
|
||||
|
||||
export interface DMDeletedWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmDeleted",
|
||||
type: "dmDeleted";
|
||||
data: {
|
||||
id: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessageEditedWebSocketMessage extends WebSocketMessage {
|
||||
type: "messageEdited",
|
||||
type: "messageEdited";
|
||||
data: Partial<Message> & { id: number }
|
||||
}
|
||||
|
||||
export interface MessageDeletedWebSocketMessage extends WebSocketMessage {
|
||||
type: "messageDeleted",
|
||||
type: "messageDeleted";
|
||||
data: {
|
||||
message_id: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewMessageWebSocketMessage extends WebSocketMessage {
|
||||
type: "newMessage",
|
||||
type: "newMessage";
|
||||
data: Message
|
||||
}
|
||||
|
||||
export interface ReactionUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "reactionUpdate",
|
||||
type: "reactionUpdate";
|
||||
data: {
|
||||
message_id: number;
|
||||
emoji: string;
|
||||
@@ -423,8 +402,16 @@ export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage {
|
||||
}
|
||||
|
||||
// Shared types
|
||||
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage | DMReactionUpdateWebSocketMessage
|
||||
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage | ReactionUpdateWebSocketMessage
|
||||
export type DMWebSocketMessage =
|
||||
DMNewWebSocketMessage |
|
||||
DMEditedWebSocketMessage |
|
||||
DMDeletedWebSocketMessage |
|
||||
DMReactionUpdateWebSocketMessage;
|
||||
export type ChatWebSocketMessage =
|
||||
MessageEditedWebSocketMessage |
|
||||
MessageDeletedWebSocketMessage |
|
||||
NewMessageWebSocketMessage |
|
||||
ReactionUpdateWebSocketMessage;
|
||||
|
||||
// -----------
|
||||
// Encrypted message JSON (plaintext structure before encryption)
|
||||
@@ -449,196 +436,4 @@ export interface EncryptedMessageJson {
|
||||
export interface DialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
// Call types
|
||||
export interface CallSignalingData {
|
||||
fromUserId: number;
|
||||
toUserId: number;
|
||||
}
|
||||
|
||||
export interface CallInviteData {
|
||||
fromUsername: string;
|
||||
}
|
||||
|
||||
export interface CallInviteMessageData {
|
||||
fromUsername: string;
|
||||
}
|
||||
|
||||
export type CallSignalingDataType = "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject" | "call_session_key" | "call_signaling" | "call_video_toggle" | "call_screen_share_toggle";
|
||||
|
||||
export interface CallSignalingMessage extends WebSocketMessage {
|
||||
type: CallSignalingDataType;
|
||||
fromUserId: number;
|
||||
toUserId: number;
|
||||
sessionKeyHash?: string;
|
||||
data: CallSignalingMessageData;
|
||||
}
|
||||
|
||||
export type CallSignalingMessageData =
|
||||
| CallInviteMessageData
|
||||
| CallAcceptData
|
||||
| CallRejectData
|
||||
| CallOfferData
|
||||
| CallAnswerData
|
||||
| CallIceCandidateData
|
||||
| CallEndData
|
||||
| CallSessionKeyData
|
||||
| CallVideoToggleData
|
||||
| CallScreenShareToggleData;
|
||||
|
||||
export interface CallAcceptData {
|
||||
fromUserId: number;
|
||||
}
|
||||
|
||||
export interface CallRejectData {
|
||||
fromUserId: number;
|
||||
}
|
||||
|
||||
export interface CallOfferData extends RTCSessionDescriptionInit {
|
||||
}
|
||||
|
||||
export interface CallAnswerData extends RTCSessionDescriptionInit {
|
||||
}
|
||||
|
||||
export interface CallIceCandidateData extends RTCIceCandidateInit {
|
||||
}
|
||||
|
||||
export interface CallEndData {
|
||||
fromUserId: number;
|
||||
}
|
||||
|
||||
export interface CallSessionKeyData {
|
||||
wrappedSessionKey?: WrappedSessionKeyPayload;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CallScreenShareToggleData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleMessageData {
|
||||
fromUserId: number;
|
||||
data: CallVideoToggleData;
|
||||
}
|
||||
|
||||
export interface CallScreenShareToggleMessageData {
|
||||
fromUserId: number;
|
||||
data: CallScreenShareToggleData;
|
||||
}
|
||||
|
||||
export interface WrappedSessionKeyPayload {
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrapped: string;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleMessage extends CallSignalingMessage {
|
||||
type: "call_video_toggle";
|
||||
data: CallVideoToggleData;
|
||||
}
|
||||
|
||||
export interface CallScreenShareToggleMessage extends CallSignalingMessage {
|
||||
type: "call_screen_share_toggle";
|
||||
data: CallScreenShareToggleData;
|
||||
}
|
||||
|
||||
// -----------
|
||||
// Online Status & Typing WebSocket Messages
|
||||
// -----------
|
||||
|
||||
export interface StatusUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "statusUpdate";
|
||||
data: {
|
||||
userId: number;
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubscribeStatusWebSocketMessage extends WebSocketMessage {
|
||||
type: "subscribeStatus";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
userId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UnsubscribeStatusWebSocketMessage extends WebSocketMessage {
|
||||
type: "unsubscribeStatus";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
userId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "typing";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "stopTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DmTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopDmTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "stopDmTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Request types for sending typing/status messages
|
||||
export interface TypingRequest extends WebSocketMessage {
|
||||
type: "typing";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {};
|
||||
}
|
||||
|
||||
export interface StopTypingRequest extends WebSocketMessage {
|
||||
type: "stopTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {};
|
||||
}
|
||||
|
||||
export interface DmTypingRequest extends WebSocketMessage {
|
||||
type: "dmTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
recipientId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopDmTypingRequest extends WebSocketMessage {
|
||||
type: "stopDmTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
recipientId: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// -------------
|
||||
// Utility types
|
||||
// -------------
|
||||
|
||||
export type Override<TBase, TExt> = Omit<TBase, keyof TExt> & TExt;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @fileoverview WebSocket connection management for real-time chat
|
||||
* @description Handles WebSocket connections, message processing, and auto-reconnection
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { API_WS_BASE_URL } from "./config";
|
||||
import type { WebSocketMessage } from "./types";
|
||||
import { delay } from "@/utils/utils";
|
||||
|
||||
/**
|
||||
* Creates a new WebSocket connection to the chat server
|
||||
* @returns {WebSocket} New WebSocket instance
|
||||
* @private
|
||||
*/
|
||||
function create(): WebSocket {
|
||||
let prefix = "ws://";
|
||||
if (location.protocol.includes("https")) {
|
||||
prefix = "wss://";
|
||||
}
|
||||
|
||||
return new WebSocket(`${prefix}${API_WS_BASE_URL}/chat/ws`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global WebSocket instance
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
export let websocket: WebSocket = create();
|
||||
|
||||
/**
|
||||
* Global WebSocket message handler reference
|
||||
* This will be set by the active panel to handle incoming messages
|
||||
*/
|
||||
let globalMessageHandler: ((response: WebSocketMessage<object>) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Set the global WebSocket message handler
|
||||
* @param handler - Function to handle WebSocket messages
|
||||
*/
|
||||
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<object>) => void) | null): void {
|
||||
globalMessageHandler = handler;
|
||||
}
|
||||
|
||||
export function request<Request, Response = object>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
|
||||
console.log("WebSocket request:", payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
function requestInner() {
|
||||
let listener: ((e: MessageEvent) => void) | null = null;
|
||||
listener = (e) => {
|
||||
resolve(JSON.parse(e.data));
|
||||
websocket.removeEventListener("message", listener!);
|
||||
}
|
||||
websocket.addEventListener("message", listener);
|
||||
websocket.send(JSON.stringify(payload))
|
||||
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
}
|
||||
|
||||
if (websocket.readyState === 0) {
|
||||
websocket.addEventListener("open", requestInner);
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
} else {
|
||||
requestInner();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
|
||||
* If it fails, tries again in an endless loop until the connection is established
|
||||
* again.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async function onError() {
|
||||
console.warn("WebSocket disconnected, retrying in 3 seconds...");
|
||||
await delay(3000);
|
||||
websocket = create();
|
||||
|
||||
let listener: (() => void) | null = null;
|
||||
listener = () => {
|
||||
console.log("WebSocket successfully reconnected!");
|
||||
websocket.removeEventListener("open", listener!);
|
||||
}
|
||||
|
||||
websocket.addEventListener("open", listener);
|
||||
websocket.addEventListener("error", onError);
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Initialization
|
||||
// --------------
|
||||
|
||||
websocket.addEventListener("message", (e) => {
|
||||
try {
|
||||
const response: WebSocketMessage<object> = JSON.parse(e.data);
|
||||
|
||||
// Route message to global handler if set
|
||||
if (globalMessageHandler) {
|
||||
globalMessageHandler(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
});
|
||||
websocket.addEventListener("error", onError);
|
||||
@@ -0,0 +1,119 @@
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOutUp {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUpLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutDownRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-switch-out {
|
||||
animation: fadeOutUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.chat-switch-in {
|
||||
animation: fadeInDown 0.2s ease forwards;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@use "material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&.alert-success {
|
||||
background-color: #C6F6D5;
|
||||
color: #22543D;
|
||||
}
|
||||
|
||||
&.alert-danger {
|
||||
background-color: #FED7D7;
|
||||
color: #742A2A;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: $color-dark-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button, input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
// Dialog content styles
|
||||
.dialog-content {
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
mdui-text-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.rich-text-area {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
transition: height 0.2s ease;
|
||||
overflow-y: hidden;
|
||||
background-color: transparent;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quote {
|
||||
background-color: $color-dark-surface-primary-container-lightened;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
|
||||
&.bg-surfaceContainer {
|
||||
background-color: $color-dark-secondary-container;
|
||||
|
||||
.quote-inner {
|
||||
border-left: 3px solid $color-dark-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.quote-inner {
|
||||
border-left: 3px solid $color-dark-primary;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,63 @@
|
||||
@use "sass:color";
|
||||
|
||||
// Dark
|
||||
// Generated from base color #9333EA (rgb(147, 51, 234))
|
||||
$color-dark-primary: rgb(219 185 249);
|
||||
$color-dark-surface-tint: rgb(219 185 249);
|
||||
$color-dark-on-primary: rgb(62 36 88);
|
||||
$color-dark-primary-container: rgb(86 59 113);
|
||||
$color-dark-on-primary-container: rgb(240 219 255);
|
||||
$color-dark-secondary: rgb(208 193 218);
|
||||
$color-dark-on-secondary: rgb(54 44 63);
|
||||
$color-dark-secondary-container: rgb(77 67 86);
|
||||
$color-dark-on-secondary-container: rgb(237 221 246);
|
||||
$color-dark-tertiary: rgb(243 183 190);
|
||||
$color-dark-on-tertiary: rgb(75 37 43);
|
||||
$color-dark-tertiary-container: rgb(101 58 64);
|
||||
$color-dark-on-tertiary-container: rgb(255 217 221);
|
||||
$color-dark-primary: rgb(145 206 244);
|
||||
$color-dark-surface-tint: rgb(145 206 244);
|
||||
$color-dark-on-primary: rgb(0 52 74);
|
||||
$color-dark-primary-container: rgb(0 76 106);
|
||||
$color-dark-on-primary-container: rgb(197 231 255);
|
||||
$color-dark-secondary: rgb(182 201 216);
|
||||
$color-dark-on-secondary: rgb(32 51 62);
|
||||
$color-dark-secondary-container: rgb(55 73 85);
|
||||
$color-dark-on-secondary-container: rgb(210 229 244);
|
||||
$color-dark-tertiary: rgb(203 193 233);
|
||||
$color-dark-on-tertiary: rgb(51 44 76);
|
||||
$color-dark-tertiary-container: rgb(73 66 99);
|
||||
$color-dark-on-tertiary-container: rgb(231 222 255);
|
||||
$color-dark-error: rgb(255 180 171);
|
||||
$color-dark-on-error: rgb(105 0 5);
|
||||
$color-dark-error-container: rgb(147 0 10);
|
||||
$color-dark-on-error-container: rgb(255 218 214);
|
||||
$color-dark-background: rgb(21 18 24);
|
||||
$color-dark-on-background: rgb(232 224 232);
|
||||
$color-dark-surface: rgb(21 18 24);
|
||||
$color-dark-on-surface: rgb(232 224 232);
|
||||
$color-dark-surface-variant: rgb(74 69 78);
|
||||
$color-dark-on-surface-variant: rgb(204 196 206);
|
||||
$color-dark-outline: rgb(150 142 152);
|
||||
$color-dark-outline-variant: rgb(74 69 78);
|
||||
$color-dark-background: rgb(15 20 23);
|
||||
$color-dark-on-background: rgb(223 227 231);
|
||||
$color-dark-surface: rgb(15 20 23);
|
||||
$color-dark-on-surface: rgb(223 227 231);
|
||||
$color-dark-surface-variant: rgb(65 72 77);
|
||||
$color-dark-on-surface-variant: rgb(193 199 206);
|
||||
$color-dark-outline: rgb(139 146 151);
|
||||
$color-dark-outline-variant: rgb(65 72 77);
|
||||
$color-dark-shadow: rgb(0 0 0);
|
||||
$color-dark-scrim: rgb(0 0 0);
|
||||
$color-dark-inverse-surface: rgb(232 224 232);
|
||||
$color-dark-inverse-on-surface: rgb(51 47 53);
|
||||
$color-dark-inverse-primary: rgb(111 82 138);
|
||||
$color-dark-primary-fixed: rgb(240 219 255);
|
||||
$color-dark-on-primary-fixed: rgb(40 13 66);
|
||||
$color-dark-primary-fixed-dim: rgb(219 185 249);
|
||||
$color-dark-on-primary-fixed-variant: rgb(86 59 113);
|
||||
$color-dark-secondary-fixed: rgb(237 221 246);
|
||||
$color-dark-on-secondary-fixed: rgb(33 24 41);
|
||||
$color-dark-secondary-fixed-dim: rgb(208 193 218);
|
||||
$color-dark-on-secondary-fixed-variant: rgb(77 67 86);
|
||||
$color-dark-tertiary-fixed: rgb(255 217 221);
|
||||
$color-dark-on-tertiary-fixed: rgb(50 16 22);
|
||||
$color-dark-tertiary-fixed-dim: rgb(243 183 190);
|
||||
$color-dark-on-tertiary-fixed-variant: rgb(101 58 64);
|
||||
$color-dark-surface-dim: rgb(21 18 24);
|
||||
$color-dark-surface-bright: rgb(60 56 62);
|
||||
$color-dark-surface-container-lowest: rgb(16 13 18);
|
||||
$color-dark-surface-container-low: rgb(30 26 32);
|
||||
$color-dark-surface-container: rgb(34 30 36);
|
||||
$color-dark-surface-container-high: rgb(44 41 46);
|
||||
$color-dark-surface-container-highest: rgb(55 51 57);
|
||||
$color-dark-inverse-surface: rgb(223 227 231);
|
||||
$color-dark-inverse-on-surface: rgb(44 49 52);
|
||||
$color-dark-inverse-primary: rgb(31 101 134);
|
||||
$color-dark-primary-fixed: rgb(197 231 255);
|
||||
$color-dark-on-primary-fixed: rgb(0 30 45);
|
||||
$color-dark-primary-fixed-dim: rgb(145 206 244);
|
||||
$color-dark-on-primary-fixed-variant: rgb(0 76 106);
|
||||
$color-dark-secondary-fixed: rgb(210 229 244);
|
||||
$color-dark-on-secondary-fixed: rgb(10 30 40);
|
||||
$color-dark-secondary-fixed-dim: rgb(182 201 216);
|
||||
$color-dark-on-secondary-fixed-variant: rgb(55 73 85);
|
||||
$color-dark-tertiary-fixed: rgb(231 222 255);
|
||||
$color-dark-on-tertiary-fixed: rgb(29 23 53);
|
||||
$color-dark-tertiary-fixed-dim: rgb(203 193 233);
|
||||
$color-dark-on-tertiary-fixed-variant: rgb(73 66 99);
|
||||
$color-dark-surface-dim: rgb(15 20 23);
|
||||
$color-dark-surface-bright: rgb(53 58 61);
|
||||
$color-dark-surface-container-lowest: rgb(10 15 18);
|
||||
$color-dark-surface-container-low: rgb(24 28 31);
|
||||
$color-dark-surface-container: rgb(28 32 36);
|
||||
$color-dark-surface-container-high: rgb(38 43 46);
|
||||
$color-dark-surface-container-highest: rgb(49 53 57);
|
||||
$color-dark-surface-primary-container-lightened: color.adjust($color-dark-primary-container, $lightness: 5%);
|
||||
$color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%);
|
||||
|
||||
// custom colors
|
||||
$color-1: rgb(82, 109, 246);
|
||||
$color-2: rgb(65, 11, 113);
|
||||
$color-4: rgb(95, 26, 198);
|
||||
$color-3: rgb(49, 71, 179);
|
||||
// Light
|
||||
$color-light-primary: rgb(31 101 134);
|
||||
$color-light-surface-tint: rgb(31 101 134);
|
||||
@@ -1,3 +1,4 @@
|
||||
@use "animations";
|
||||
@use "components";
|
||||
@use "colors" as *;
|
||||
@use "material" as *;
|
||||
@@ -31,6 +32,12 @@ body, #root {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
mdui-icon {
|
||||
user-select: none;
|
||||
mdui-dialog {
|
||||
> *:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -5,13 +5,14 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import './css/style.scss';
|
||||
import "./css/style.scss";
|
||||
|
||||
import "./utils/material";
|
||||
import "./core/init";
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { StrictMode } from 'react';
|
||||
import "./core/electron/electron";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { StrictMode } from "react";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppState } from "./chat/state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { user } = useAppState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user.authToken) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
}, [user.authToken, user.currentUser, navigate]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type React from "react";
|
||||
|
||||
export function AuthContainer({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card fade-in">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type IconType = "filled" | "outlined";
|
||||
|
||||
export interface AuthHeaderIcon {
|
||||
name: string;
|
||||
type: IconType
|
||||
}
|
||||
|
||||
export interface AuthHeaderProps {
|
||||
title: string;
|
||||
icon: string | AuthHeaderIcon;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
|
||||
const iconType = typeof icon === "string" ? "filled" : icon.type;
|
||||
const iconName = typeof icon === "string" ? icon : icon.name;
|
||||
|
||||
return (
|
||||
<div className="auth-header">
|
||||
<h2>
|
||||
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
|
||||
{title}
|
||||
</h2>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type AlertType = "success" | "danger"
|
||||
|
||||
export interface Alert {
|
||||
type: AlertType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
|
||||
return (
|
||||
<div>
|
||||
{alerts.slice(-3).map((alert, i) => {
|
||||
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useImmer } from "use-immer";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "./Auth";
|
||||
import { AuthContainer, AuthHeader } from "./Auth";
|
||||
import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types";
|
||||
import { ensureKeysOnLogin } from "@/core/api/authApi";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { useRef } from "react";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialTextField } from "@/core/components/TextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./auth.scss";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => {
|
||||
alerts.push({ type: type, message: message });
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
|
||||
<div className="auth-body">
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: LoginRequest = {
|
||||
username: username,
|
||||
password: password
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: LoginResponse = await response.json();
|
||||
// Store the JWT token first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
try {
|
||||
await ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
|
||||
// Initialize notifications
|
||||
try {
|
||||
if (isSupported()) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(data.token);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
|
||||
console.log("Notifications enabled");
|
||||
} else {
|
||||
console.log("Notification permission denied");
|
||||
}
|
||||
} else {
|
||||
console.log("Notifications not supported");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Notification setup failed:", e);
|
||||
}
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
|
||||
}
|
||||
} catch {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
id="login-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
required
|
||||
ref={usernameElement} />
|
||||
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
id="login-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
|
||||
<mdui-button type="submit">Войти</mdui-button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Ещё нет аккаунта?
|
||||
<a
|
||||
href="#"
|
||||
className="link"
|
||||
onClick={() => navigate("/register")}>
|
||||
Зарегистрируйтесь
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useImmer } from "use-immer";
|
||||
import { AuthContainer, AuthHeader } from "./Auth";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "./Auth";
|
||||
import { useRef } from "react";
|
||||
import { TextField } from "mdui/components/text-field";
|
||||
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialTextField } from "@/core/components/TextField";
|
||||
import { ensureKeysOnLogin } from "@/core/api/authApi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./auth.scss";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
const confirmPasswordElement = useRef<TextField>(null);
|
||||
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => {
|
||||
alerts.push({ type: type, message: message });
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
|
||||
<div className="auth-body">
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password || !confirmPassword) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showAlert("danger", "Пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 5 || password.length > 50) {
|
||||
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: RegisterRequest = {
|
||||
username: username,
|
||||
password: password,
|
||||
confirm_password: confirmPassword
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: LoginResponse = await response.json();
|
||||
// Store the JWT token first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
try {
|
||||
await ensureKeysOnLogin(password, data.token);
|
||||
} catch {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Ошибка при регистрации");
|
||||
}
|
||||
} catch {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
id="register-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
maxlength={20}
|
||||
counter
|
||||
required
|
||||
ref={usernameElement} />
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
id="register-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
<MaterialTextField
|
||||
label="Подтвердите пароль"
|
||||
id="register-confirm-password"
|
||||
name="confirm_password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={confirmPasswordElement} />
|
||||
|
||||
<mdui-button type="submit">Зарегистрироваться</mdui-button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Уже есть аккаунт?
|
||||
<a
|
||||
href="#"
|
||||
id="login-link"
|
||||
className="link"
|
||||
onClick={() => navigate("/login")}>
|
||||
Войдите
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@use "../../css/colors" as *;
|
||||
@use "../../css/material" as *;
|
||||
|
||||
.auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 2rem;
|
||||
background-color: $color-dark-surface;
|
||||
|
||||
.auth-card {
|
||||
background-color: $color-dark-surface-container;
|
||||
color: $color-dark-on-surface;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
padding-bottom: 0;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-body {
|
||||
padding: 25px;
|
||||
padding-bottom: 16px;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Animation for reactions appearing/disappearing
|
||||
@keyframes messageReactionsFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reactionFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reactionFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu wrapper animations
|
||||
@keyframes contextMenuEnter {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuEnterLeft {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuEnterUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuEnterUpLeft {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuClose {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuCloseLeft {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuCloseUp {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuCloseUpLeft {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) translateY(20px) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiMenuEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user