diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f288212 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Server +PORT=3001 +JWT_SECRET=your-secure-random-secret-key-here +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# Database (PostgreSQL) +DATABASE_URL=postgresql://user:password@localhost:5432/vortex + +# Message encryption (AES-256-GCM) — 64-char hex key (32 bytes) +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# WARNING: if you lose this key, all encrypted messages become unreadable! +ENCRYPTION_KEY= + +# TURN server for voice/video calls (optional, recommended for production) +# Install coturn: sudo apt install coturn +# TURN_URL=turn:your-domain.com:3478 +# TURN_SECRET=your-coturn-shared-secret +# STUN_URLS=stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acbe4c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build output +apps/server/dist/ +apps/web/dist/ + +# Environment files +.env +apps/server/.env + +# Uploads (user data) +apps/server/uploads/avatars/* +!apps/server/uploads/avatars/.gitkeep + +# Deploy artifacts +vortex-deploy/ +vortex-deploy.tar.gz + +# Credentials +CREDENTIALS.txt + +# Security audit (personal) +SECURITY_AUDIT.md + +# OS files +Thumbs.db +.DS_Store + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 0000000..e7bbb75 --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,108 @@ +# Vortex Messenger — Настройка конфигурации + +> Если вы используете `deploy.sh` — всё ниже подставляется автоматически. +> Этот файл нужен для ручной настройки или если вы хотите понимать, что где лежит. + +--- + +## 1. Файл `update-local.bat` (на вашем ПК) + +Откройте файл и замените IP на ваш: + +``` +set SERVER_IP= +``` + +--- + +## 2. Файл `apps/server/.env` (на сервере) + +Скопируйте `.env.example` и заполните: + +```env +# Порт HTTP-сервера (обычно менять не нужно) +PORT=3001 + +# Секрет для подписи JWT-токенов — длинная случайная строка +# Сгенерировать: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" +JWT_SECRET=<ВАШ_СЛУЧАЙНЫЙ_СЕКРЕТ> + +# Строка подключения к PostgreSQL +# Формат: postgresql://ПОЛЬЗОВАТЕЛЬ:ПАРОЛЬ@localhost:5432/ИМЯ_БАЗЫ +DATABASE_URL=postgresql://vortex:<ПАРОЛЬ_БД>@localhost:5432/vortex + +# Ключ шифрования сообщений (64 hex-символа = 32 байта) +# Сгенерировать: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# ⚠ ВАЖНО: при утере ключа все зашифрованные сообщения станут нечитаемы! +ENCRYPTION_KEY=<64_HEX_СИМВОЛА> + +# Режим работы +NODE_ENV=production + +# Разрешённые домены для CORS (ваш домен с https://) +CORS_ORIGINS=https://<ВАШ_ДОМЕН> + +# Лимит регистраций с одного IP +MAX_REGISTRATIONS_PER_IP=5 + +# TURN-сервер для звонков (coturn) +TURN_URL=turn:<ВАШ_ДОМЕН>:3478 +TURN_SECRET=<СЕКРЕТ_TURN_СЕРВЕРА> +STUN_URLS=stun:stun.l.google.com:19302,stun:<ВАШ_ДОМЕН>:3478 +``` + +--- + +## 3. Конфигурация coturn (на сервере) + +Файл `/etc/turnserver.conf`: + +``` +realm=<ВАШ_ДОМЕН> +server-name=<ВАШ_ДОМЕН> +static-auth-secret=<СЕКРЕТ_TURN_СЕРВЕРА> # тот же что TURN_SECRET в .env +``` + +--- + +## 4. Nginx (на сервере) + +Файл `/etc/nginx/sites-available/vortex` — замените `server_name`: + +``` +server_name <ВАШ_ДОМЕН>; +``` + +--- + +## 5. Генерация ключей + +```bash +# JWT-секрет (64 символа) +node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" + +# Ключ шифрования сообщений (64 hex-символа) +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + +# Пароль для базы данных (32 символа) +node -e "console.log(require('crypto').randomBytes(24).toString('base64url').slice(0,32))" + +# TURN-секрет (32 символа) +node -e "console.log(require('crypto').randomBytes(24).toString('base64url').slice(0,32))" +``` + +--- + +## 6. Сводная таблица + +| Где | Что заменить | Пример значения | +|-----|-------------|-----------------| +| `update-local.bat` строка 9 | `SERVER_IP` | `203.0.113.50` | +| `.env` → `JWT_SECRET` | Случайная строка 64+ символов | см. раздел 5 | +| `.env` → `DATABASE_URL` | Пароль пользователя БД | `postgresql://vortex:MyPass123@localhost:5432/vortex` | +| `.env` → `ENCRYPTION_KEY` | 64 hex-символа | см. раздел 5 | +| `.env` → `CORS_ORIGINS` | Ваш домен с `https://` | `https://chat.example.com` | +| `.env` → `TURN_URL` | Ваш домен | `turn:chat.example.com:3478` | +| `.env` → `TURN_SECRET` | Случайная строка | см. раздел 5 | +| `/etc/turnserver.conf` | `realm`, `server-name`, `static-auth-secret` | Ваш домен + TURN_SECRET | +| Nginx → `server_name` | Ваш домен | `chat.example.com` | diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..b356aa2 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,215 @@ +# Vortex Messenger — Руководство по развёртыванию + +## Что понадобится + +| Что | Детали | +|-----|--------| +| **VPS** | Ubuntu 22.04 / 24.04, от 1 ГБ RAM, публичный IP | +| **Домен** | С A-записью, направленной на IP вашего VPS | +| **Email** | Для получения SSL-сертификата (Let's Encrypt) | +| **ПК** | Windows / macOS / Linux с SSH-клиентом | + +--- + +## 1. Подготовка сервера + +### 1.1 Покупка VPS +Купите VPS у провайдера (Timeweb Cloud, Hetzner, DigitalOcean и т.д.). +Запишите **IP** и **root-пароль**. + +### 1.2 Покупка домена +Купите домен и в DNS-настройках реестра создайте A-запись: + +``` +Тип: A +Хост: @ +Значение: +``` + +> Может потребоваться до 24 часов для распространения DNS (обычно 5–15 минут). + +--- + +## 2. Загрузка проекта на сервер + +С вашего ПК (из папки с проектом): + +```bash +# Создать папку на сервере +ssh root@ "mkdir -p /var/www/vortex" + +# Скопировать файлы проекта +scp -r ./* root@:/var/www/vortex/ +``` + +> Замените `` на IP вашего VPS. При первом подключении подтвердите fingerprint (`yes`) и введите root-пароль. + +--- + +## 3. Запуск автоматического деплоя + +```bash +# Подключиться к серверу +ssh root@ + +# Дать права на запуск +chmod +x /var/www/vortex/deploy.sh + +# Запустить деплой +/var/www/vortex/deploy.sh +``` + +Скрипт спросит: +- **Домен** — ваш домен (например `messenger.example.com`) +- **Email** — для SSL-сертификата + +Всё остальное (Node.js, PostgreSQL, Nginx, Certbot, coturn, PM2) установится и настроится автоматически. + +--- + +## 4. Что делает скрипт + +| Шаг | Действие | +|-----|----------| +| 1/10 | Обновление системы, установка базовых утилит | +| 2/10 | Установка Node.js 20 и PM2 | +| 3/10 | Установка PostgreSQL, создание БД и пользователя | +| 4/10 | Установка Nginx | +| 5/10 | Установка Certbot для SSL | +| 6/10 | Установка coturn (TURN-сервер для звонков) | +| 7/10 | Создание `.env` с автогенерированными ключами | +| 8/10 | `npm install`, Prisma миграции, сборка бэкенда и фронтенда | +| 9/10 | Настройка Nginx (reverse proxy, SSL, блокировка доступа по IP) | +| 10/10 | Запуск сервера через PM2 с автозапуском | + +По завершении: +- Кредитные данные сохраняются в `/var/www/vortex/CREDENTIALS.txt` (доступ только root) +- Скрипт обновления создаётся в `/var/www/vortex/update.sh` + +--- + +## 5. Настройка конфигурации + +Подробная инструкция по всем параметрам, которые нужно подставить под себя — в отдельном файле **[CONFIGURATION.md](CONFIGURATION.md)**. + +> При использовании `deploy.sh` всё настраивается автоматически. `CONFIGURATION.md` нужен только для ручной настройки или понимания структуры. + +--- + +## 6. После установки + +Откройте в браузере: + +``` +https://ваш-домен.ru +``` + +--- + +## 7. Полезные команды на сервере + +```bash +# ─── Подключение к серверу ─── +ssh root@ + +# ─── Логи приложения ─── +pm2 logs vortex-server # все логи +pm2 logs vortex-server --lines 50 # последние 50 строк + +# ─── Управление сервером ─── +pm2 restart vortex-server # перезапуск +pm2 stop vortex-server # остановка +pm2 start vortex-server # запуск +pm2 monit # мониторинг в реальном времени + +# ─── Статус ─── +pm2 status # список процессов +pm2 info vortex-server # детальная информация + +# ─── Nginx ─── +nginx -t # проверить конфигурацию +systemctl reload nginx # перезагрузить Nginx +systemctl status nginx # статус Nginx + +# ─── Логи Nginx ─── +tail -f /var/log/nginx/access.log # логи входящих запросов +tail -f /var/log/nginx/error.log # логи ошибок + +# ─── PostgreSQL ─── +sudo -u postgres psql -d vortex # подключиться к БД +\dt # список таблиц (внутри psql) +\q # выход из psql + +# ─── coturn (TURN-сервер) ─── +systemctl status coturn # статус TURN +systemctl restart coturn # перезапуск TURN + +# ─── SSL сертификат ─── +certbot renew --dry-run # проверка обновления сертификата +certbot certificates # список сертификатов + +# ─── Файрвол ─── +ufw status # статус файрвола +ufw allow 22/tcp # SSH +ufw allow 80/tcp # HTTP +ufw allow 443/tcp # HTTPS +ufw allow 3478/tcp # TURN TCP +ufw allow 3478/udp # TURN UDP + +# ─── Файлы проекта ─── +ls /var/www/vortex/ # корень проекта +cat /var/www/vortex/apps/server/.env # посмотреть env +cat /var/www/vortex/CREDENTIALS.txt # все сгенерированные пароли +``` + +--- + +## 8. Обновление проекта + +### С Windows (update-local.bat) + +1. Внесите изменения в код на ПК +2. Отредактируйте `update-local.bat` — впишите IP вашего сервера в переменную `SERVER_IP` +3. Запустите `update-local.bat` — он соберёт архив и загрузит на сервер +4. Подключитесь к серверу и запустите: + +```bash +ssh root@ +/var/www/vortex/update.sh +``` + +### Вручную + +```bash +# На ПК: заархивировать проект (без node_modules, dist, .env) +tar -czf vortex-deploy.tar.gz --exclude='node_modules' --exclude='dist' --exclude='.env' --exclude='uploads/avatars/*' -C . . + +# Загрузить на сервер +scp vortex-deploy.tar.gz root@:/root/ + +# На сервере: запустить обновление +ssh root@ +/var/www/vortex/update.sh +``` + +Скрипт `update.sh` автоматически: +- Остановит сервер +- Сделает бэкап `.env` и аватаров +- Распакует обновление +- Восстановит `.env` и аватары +- Установит зависимости, применит миграции, пересоберёт +- Запустит сервер + +--- + +## 9. Устранение проблем + +| Проблема | Решение | +|----------|---------| +| Сайт не открывается | `pm2 logs vortex-server --lines 30` — проверить ошибки | +| 502 Bad Gateway | `pm2 restart vortex-server` — бэкенд упал | +| SSL не получен | Проверить DNS: `dig +short ваш-домен.ru` должен показать IP сервера. Повторить: `certbot --nginx -d ваш-домен.ru` | +| Звонки не работают | `systemctl status coturn` — проверить TURN-сервер. Порты 3478 TCP/UDP должны быть открыты | +| БД недоступна | `systemctl status postgresql` → `systemctl start postgresql` | +| Сервер не стартует после ребута | `pm2 startup` и `pm2 save` | +| Нет места на диске | `df -h` → очистить логи: `pm2 flush` | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bad54e3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vortex Messenger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..7618e3f --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,39 @@ +{ + "name": "@vortex/server", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:push": "prisma db push", + "db:seed": "tsx prisma/seed.ts", + "db:studio": "prisma studio" + }, + "dependencies": { + "@prisma/client": "^6.3.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^17.3.1", + "express": "^4.21.2", + "express-rate-limit": "^8.2.1", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "socket.io": "^4.8.1", + "uuid": "^11.0.5" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/express-rate-limit": "^5.1.3", + "@types/jsonwebtoken": "^9.0.7", + "@types/mime-types": "^3.0.1", + "@types/multer": "^1.4.12", + "@types/node": "^22.10.0", + "@types/uuid": "^10.0.0", + "prisma": "^6.3.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } +} diff --git a/apps/server/prisma/clean-db.ts b/apps/server/prisma/clean-db.ts new file mode 100644 index 0000000..7edf767 --- /dev/null +++ b/apps/server/prisma/clean-db.ts @@ -0,0 +1,113 @@ +/** + * Полная очистка базы данных от тестовых данных. + * Удаляет ВСЕ: пользователей, чаты, сообщения, истории, дружбы. + * Таблицы и схема остаются на месте. + * + * Запуск: npx tsx prisma/clean-db.ts + */ + +import { PrismaClient } from '@prisma/client'; +import fs from 'fs'; +import path from 'path'; + +const prisma = new PrismaClient(); + +async function cleanDatabase() { + console.log('⚠️ ВНИМАНИЕ: Полная очистка базы данных!\n'); + + // Удаляем в правильном порядке (зависимости → родители) + const counts: Record = {}; + + // 1. Зависимые таблицы + const r1 = await prisma.hiddenMessage.deleteMany(); + counts['HiddenMessage'] = r1.count; + + const r2 = await prisma.readReceipt.deleteMany(); + counts['ReadReceipt'] = r2.count; + + const r3 = await prisma.reaction.deleteMany(); + counts['Reaction'] = r3.count; + + const r4 = await prisma.pinnedMessage.deleteMany(); + counts['PinnedMessage'] = r4.count; + + const r5 = await prisma.media.deleteMany(); + counts['Media'] = r5.count; + + const r6 = await prisma.storyView.deleteMany(); + counts['StoryView'] = r6.count; + + const r7 = await prisma.story.deleteMany(); + counts['Story'] = r7.count; + + // 2. Сообщения + const r8 = await prisma.message.deleteMany(); + counts['Message'] = r8.count; + + // 3. Чаты + const r9 = await prisma.chatMember.deleteMany(); + counts['ChatMember'] = r9.count; + + const r10 = await prisma.chat.deleteMany(); + counts['Chat'] = r10.count; + + // 4. Дружбы + const r11 = await prisma.friendship.deleteMany(); + counts['Friendship'] = r11.count; + + // 5. Пользователи + const r12 = await prisma.user.deleteMany(); + counts['User'] = r12.count; + + // 6. Чистка папки uploads (кроме avatars/.gitkeep) + const uploadsDir = path.join(__dirname, '..', 'uploads'); + let filesDeleted = 0; + + if (fs.existsSync(uploadsDir)) { + const entries = fs.readdirSync(uploadsDir); + for (const entry of entries) { + const fullPath = path.join(uploadsDir, entry); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + // Для папки avatars — очистить содержимое, но оставить папку + if (entry === 'avatars') { + const avatarFiles = fs.readdirSync(fullPath); + for (const f of avatarFiles) { + if (f === '.gitkeep') continue; + fs.unlinkSync(path.join(fullPath, f)); + filesDeleted++; + } + } + } else { + // Файлы в корне uploads + if (entry !== '.gitkeep') { + fs.unlinkSync(fullPath); + filesDeleted++; + } + } + } + } + + // Вывод результатов + console.log('┌──────────────────────────────────────┐'); + console.log('│ 🧹 База данных очищена! │'); + console.log('├──────────────────────────────────────┤'); + for (const [table, count] of Object.entries(counts)) { + if (count > 0) { + console.log(`│ ${table.padEnd(20)} ${String(count).padStart(6)} удалено │`); + } + } + if (filesDeleted > 0) { + console.log(`│ ${'Файлы (uploads)'.padEnd(20)} ${String(filesDeleted).padStart(6)} удалено │`); + } + console.log('└──────────────────────────────────────┘'); + console.log('\n✅ Готово. БД чистая, можно начинать с нуля.'); +} + +cleanDatabase() + .catch((e) => { + console.error('❌ Ошибка очистки:', e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/server/prisma/dev.db b/apps/server/prisma/dev.db new file mode 100644 index 0000000..628ffee Binary files /dev/null and b/apps/server/prisma/dev.db differ diff --git a/apps/server/prisma/encrypt-existing-files.ts b/apps/server/prisma/encrypt-existing-files.ts new file mode 100644 index 0000000..7ba1f39 --- /dev/null +++ b/apps/server/prisma/encrypt-existing-files.ts @@ -0,0 +1,69 @@ +/** + * Encrypt existing unencrypted files in the uploads directory. + * + * Run once after enabling ENCRYPTION_KEY: + * npx ts-node prisma/encrypt-existing-files.ts + * + * Safe to re-run — skips already-encrypted files (decryption test). + */ +import '../src/config'; // loads .env & initialises encryption +import path from 'path'; +import fs from 'fs'; +import { isEncryptionEnabled, encryptFileInPlace, decryptFileToBuffer } from '../src/encrypt'; + +const UPLOADS_ROOT = path.join(__dirname, '../uploads'); + +function walkDir(dir: string): string[] { + const files: string[] = []; + if (!fs.existsSync(dir)) return files; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walkDir(full)); + } else { + files.push(full); + } + } + return files; +} + +async function main() { + if (!isEncryptionEnabled()) { + console.error('❌ ENCRYPTION_KEY не задан в .env — сначала укажите ключ шифрования.'); + process.exit(1); + } + + console.log('🔒 Начало шифрования файлов в uploads/…\n'); + + const allFiles = walkDir(UPLOADS_ROOT); + console.log(`📁 Найдено ${allFiles.length} файлов`); + + let encrypted = 0; + let skipped = 0; + + for (const filePath of allFiles) { + const relPath = path.relative(UPLOADS_ROOT, filePath); + try { + // Try to decrypt — if it works, file is already encrypted + const decrypted = decryptFileToBuffer(filePath); + if (decrypted !== null) { + skipped++; + continue; + } + + // File is not encrypted — encrypt it + encryptFileInPlace(filePath); + encrypted++; + process.stdout.write(` ✔ ${encrypted} зашифровано, ${skipped} пропущено\r`); + } catch (e) { + console.error(`\n ❌ Ошибка с файлом ${relPath}:`, e); + } + } + + console.log(`\n\n✅ Готово! Зашифровано ${encrypted} файлов, пропущено ${skipped} (уже зашифрованы).`); +} + +main().catch((e) => { + console.error('Ошибка:', e); + process.exit(1); +}); diff --git a/apps/server/prisma/encrypt-existing.ts b/apps/server/prisma/encrypt-existing.ts new file mode 100644 index 0000000..8acc996 --- /dev/null +++ b/apps/server/prisma/encrypt-existing.ts @@ -0,0 +1,67 @@ +/** + * Migrate existing plain-text messages to encrypted form. + * + * Run once after enabling ENCRYPTION_KEY: + * npx ts-node prisma/encrypt-existing.ts + * + * Safe to re-run — skips already-encrypted messages ("enc:v1:" prefix). + */ +import '../src/config'; // loads .env & initialises encryption +import { PrismaClient } from '@prisma/client'; +import { encryptText, isEncryptionEnabled } from '../src/encrypt'; + +// Use raw PrismaClient to bypass the encryption middleware (avoid double-encryption) +const rawPrisma = new PrismaClient(); + +async function main() { + if (!isEncryptionEnabled()) { + console.error('❌ ENCRYPTION_KEY не задан в .env — сначала укажите ключ шифрования.'); + process.exit(1); + } + + console.log('🔒 Начало шифрования существующих сообщений…\n'); + + // We bypass the Prisma middleware by using $queryRawUnsafe for the SELECT, + // then use raw UPDATE to avoid double-encryption via middleware. + const messages: Array<{ id: string; content: string | null; quote: string | null }> = + await rawPrisma.$queryRaw` + SELECT id, content, quote FROM "Message" + WHERE (content IS NOT NULL AND content != '' AND content NOT LIKE 'enc:v1:%') + OR (quote IS NOT NULL AND quote != '' AND quote NOT LIKE 'enc:v1:%') + `; + + console.log(`📝 Найдено ${messages.length} незашифрованных сообщений`); + + let encrypted = 0; + const BATCH_SIZE = 500; + + for (let i = 0; i < messages.length; i += BATCH_SIZE) { + const batch = messages.slice(i, i + BATCH_SIZE); + await rawPrisma.$transaction( + batch.map((msg) => { + const newContent = msg.content && !msg.content.startsWith('enc:v1:') + ? encryptText(msg.content) : null; + const newQuote = msg.quote && !msg.quote.startsWith('enc:v1:') + ? encryptText(msg.quote) : null; + + return rawPrisma.$executeRaw` + UPDATE "Message" + SET content = COALESCE(${newContent}::text, content), + quote = COALESCE(${newQuote}::text, quote) + WHERE id = ${msg.id} + `; + }) + ); + encrypted += batch.length; + process.stdout.write(` ✔ ${encrypted}/${messages.length}\r`); + } + + console.log(`\n\n✅ Готово! Зашифровано ${encrypted} сообщений.`); + console.log('⚠ СОХРАНИТЕ КЛЮЧ ENCRYPTION_KEY В НАДЁЖНОМ МЕСТЕ — без него данные не восстановить!'); + await rawPrisma.$disconnect(); +} + +main().catch((e) => { + console.error('Ошибка миграции:', e); + process.exit(1); +}); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma new file mode 100644 index 0000000..ac42cca --- /dev/null +++ b/apps/server/prisma/schema.prisma @@ -0,0 +1,195 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + username String @unique + displayName String @default("") + email String? @unique + phone String? + password String + avatar String? + bio String? + birthday String? + createdAt DateTime @default(now()) + lastSeen DateTime @default(now()) + isOnline Boolean @default(false) + hideStoryViews Boolean @default(false) + registrationIp String? + + messages Message[] + chatMembers ChatMember[] + reactions Reaction[] + readReceipts ReadReceipt[] + forwardedMessages Message[] @relation("ForwardedFrom") + stories Story[] + storyViews StoryView[] + friendshipsSent Friendship[] @relation("FriendshipsSent") + friendshipsReceived Friendship[] @relation("FriendshipsReceived") +} + +model Chat { + id String @id @default(uuid()) + type String @default("personal") + name String? + avatar String? + createdAt DateTime @default(now()) + + members ChatMember[] + messages Message[] + pinnedMessages PinnedMessage[] +} + +model ChatMember { + id String @id @default(uuid()) + chatId String + userId String + role String @default("member") + joinedAt DateTime @default(now()) + isMuted Boolean @default(false) + isArchived Boolean @default(false) + isPinned Boolean @default(false) + clearedAt DateTime? + + chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([chatId, userId]) +} + +model Message { + id String @id @default(uuid()) + chatId String + senderId String + content String? + type String @default("text") + replyToId String? + quote String? + forwardedFromId String? + isEdited Boolean @default(false) + isDeleted Boolean @default(false) + scheduledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) + sender User @relation(fields: [senderId], references: [id]) + replyTo Message? @relation("Reply", fields: [replyToId], references: [id]) + replies Message[] @relation("Reply") + forwardedFrom User? @relation("ForwardedFrom", fields: [forwardedFromId], references: [id]) + + media Media[] + reactions Reaction[] + readBy ReadReceipt[] + pinnedIn PinnedMessage[] + hiddenBy HiddenMessage[] +} + +model Media { + id String @id @default(uuid()) + messageId String + type String + url String + filename String? + thumbnail String? + size Int? + duration Float? + width Int? + height Int? + + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) +} + +model Reaction { + id String @id @default(uuid()) + messageId String + userId String + emoji String + createdAt DateTime @default(now()) + + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([messageId, userId, emoji]) +} + +model ReadReceipt { + id String @id @default(uuid()) + messageId String + userId String + readAt DateTime @default(now()) + + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([messageId, userId]) +} + +model PinnedMessage { + id String @id @default(uuid()) + chatId String + messageId String + pinnedAt DateTime @default(now()) + + chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@unique([chatId, messageId]) +} + +model Story { + id String @id @default(uuid()) + userId String + type String @default("text") + mediaUrl String? + content String? + bgColor String? + createdAt DateTime @default(now()) + expiresAt DateTime + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + views StoryView[] +} + +model StoryView { + id String @id @default(uuid()) + storyId String + userId String + viewedAt DateTime @default(now()) + + story Story @relation(fields: [storyId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([storyId, userId]) +} + +model HiddenMessage { + id String @id @default(uuid()) + messageId String + userId String + hiddenAt DateTime @default(now()) + + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@unique([messageId, userId]) +} + +model Friendship { + id String @id @default(uuid()) + userId String + friendId String + status String @default("pending") // pending | accepted | declined + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation("FriendshipsSent", fields: [userId], references: [id], onDelete: Cascade) + friend User @relation("FriendshipsReceived", fields: [friendId], references: [id], onDelete: Cascade) + + @@unique([userId, friendId]) +} diff --git a/apps/server/prisma/seed.ts b/apps/server/prisma/seed.ts new file mode 100644 index 0000000..bfda346 --- /dev/null +++ b/apps/server/prisma/seed.ts @@ -0,0 +1,48 @@ +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Заполнение базы данных...\n'); + + const password = await bcrypt.hash('demo123', 10); + + const usersData = [ + { username: 'evgeniy', displayName: 'Евгений', bio: 'Создатель Vortex' }, + { username: 'anastasia', displayName: 'Анастасия', bio: 'Дизайнер интерфейсов' }, + { username: 'artem', displayName: 'Артём', bio: 'Frontend разработчик' }, + { username: 'polina', displayName: 'Полина', bio: 'Backend разработчик' }, + { username: 'daniil', displayName: 'Даниил', bio: 'DevOps инженер' }, + { username: 'vladimir', displayName: 'Владимир', bio: 'Product Manager' }, + ]; + + const users = await Promise.all( + usersData.map((u) => + prisma.user.upsert({ + where: { username: u.username }, + update: { displayName: u.displayName, bio: u.bio }, + create: { + username: u.username, + displayName: u.displayName, + password, + bio: u.bio, + isOnline: false, + }, + }) + ) + ); + + console.log(`Создано ${users.length} пользователей`); + + console.log('\n--- Тестовые аккаунты ---'); + console.log('Пароль для всех: demo123\n'); + for (const user of users) { + console.log(` ${user.username} (${user.displayName})`); + } + console.log('\nЗаполнение завершено!'); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000..491592d --- /dev/null +++ b/apps/server/src/config.ts @@ -0,0 +1,40 @@ +import dotenv from 'dotenv'; +import path from 'path'; +import { initEncryption } from './encrypt'; + +dotenv.config({ path: path.join(__dirname, '../.env') }); + +if (!process.env.JWT_SECRET) { + if (process.env.NODE_ENV === 'production') { + throw new Error('JWT_SECRET не задан в .env — нельзя запускать в production без секрета!'); + } + console.error(' ⚠ JWT_SECRET не задан в .env — используется dev-значение. Укажите безопасный секрет в продакшене!'); +} + +// Initialise message encryption (AES-256-GCM) +if (process.env.ENCRYPTION_KEY) { + initEncryption(process.env.ENCRYPTION_KEY); + console.log(' 🔒 Шифрование сообщений включено (AES-256-GCM)'); +} else { + console.warn(' ⚠ ENCRYPTION_KEY не задан — сообщения хранятся без шифрования. Для продакшена задайте 64-символьный hex-ключ.'); +} + +export const config = { + port: Number(process.env.PORT) || 3001, + jwtSecret: process.env.JWT_SECRET || 'vortex-dev-fallback-not-for-production', + corsOrigins: process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map(s => s.trim()) + : ['http://localhost:5173', 'http://localhost:3000'], + uploadsDir: 'uploads', + /** Minimum password length */ + minPasswordLength: 8, + /** Maximum registrations allowed from the same IP (permanent, DB-level) */ + maxRegistrationsPerIp: Number(process.env.MAX_REGISTRATIONS_PER_IP) || 2, + /** TURN server URL for WebRTC calls (e.g. turn:your-domain.com:3478) */ + turnUrl: process.env.TURN_URL || '', + /** Shared secret for TURN server (coturn static-auth-secret) */ + turnSecret: process.env.TURN_SECRET || '', + /** STUN server URLs */ + stunUrls: (process.env.STUN_URLS || 'stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302') + .split(',').map(s => s.trim()).filter(Boolean), +}; diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts new file mode 100644 index 0000000..324a1e7 --- /dev/null +++ b/apps/server/src/db.ts @@ -0,0 +1,153 @@ +import { PrismaClient } from '@prisma/client'; +import { encryptText, decryptText, isEncryptionEnabled } from './encrypt'; + +const basePrisma = new PrismaClient(); + +// ─── Prisma extension: transparent message encryption ─────────────── +// Encrypts `content` and `quote` before writing to DB, +// decrypts them after reading. This way the DB never stores plaintext. + +export const prisma = basePrisma.$extends({ + query: { + message: { + async create({ args, query }) { + if (args.data.content && typeof args.data.content === 'string') { + args.data.content = encryptText(args.data.content); + } + if (args.data.quote && typeof args.data.quote === 'string') { + args.data.quote = encryptText(args.data.quote); + } + const result = await query(args); + decryptMessageFields(result as Record); + return result; + }, + async update({ args, query }) { + if (args.data.content && typeof args.data.content === 'string') { + args.data.content = encryptText(args.data.content); + } + if (args.data.quote && typeof args.data.quote === 'string') { + args.data.quote = encryptText(args.data.quote); + } + const result = await query(args); + decryptMessageFields(result as Record); + return result; + }, + async upsert({ args, query }) { + if (args.create.content && typeof args.create.content === 'string') { + args.create.content = encryptText(args.create.content); + } + if (args.create.quote && typeof args.create.quote === 'string') { + args.create.quote = encryptText(args.create.quote); + } + if (args.update.content && typeof args.update.content === 'string') { + (args.update as Record).content = encryptText(args.update.content as string); + } + if (args.update.quote && typeof args.update.quote === 'string') { + (args.update as Record).quote = encryptText(args.update.quote as string); + } + const result = await query(args); + decryptMessageFields(result as Record); + return result; + }, + async findUnique({ args, query }) { + const result = await query(args); + if (result) decryptMessageFields(result as Record); + return result; + }, + async findFirst({ args, query }) { + const result = await query(args); + if (result) decryptMessageFields(result as Record); + return result; + }, + async findMany({ args, query }) { + const results = await query(args); + for (const item of results) { + decryptMessageFields(item as Record); + } + return results; + }, + }, + // Also decrypt messages nested inside Chat queries + chat: { + async findMany({ args, query }) { + const results = await query(args); + for (const chat of results) { + decryptChatMessages(chat as Record); + } + return results; + }, + async findFirst({ args, query }) { + const result = await query(args); + if (result) decryptChatMessages(result as Record); + return result; + }, + async findUnique({ args, query }) { + const result = await query(args); + if (result) decryptChatMessages(result as Record); + return result; + }, + async create({ args, query }) { + const result = await query(args); + decryptChatMessages(result as Record); + return result; + }, + }, + // Decrypt message inside PinnedMessage queries + pinnedMessage: { + async findFirst({ args, query }) { + const result = await query(args); + if (result) decryptNested(result as Record); + return result; + }, + async findMany({ args, query }) { + const results = await query(args); + for (const item of results) decryptNested(item as Record); + return results; + }, + }, + }, +}); + +/** Decrypt content/quote on a message-shaped object. */ +function decryptMessageFields(obj: Record | null): void { + if (!obj || typeof obj !== 'object' || !isEncryptionEnabled()) return; + + if (typeof obj.content === 'string') { + obj.content = decryptText(obj.content); + } + if (typeof obj.quote === 'string') { + obj.quote = decryptText(obj.quote); + } + // Nested replyTo + if (obj.replyTo && typeof obj.replyTo === 'object') { + decryptMessageFields(obj.replyTo as Record); + } +} + +/** Decrypt messages nested inside a chat object. */ +function decryptChatMessages(chat: Record): void { + if (!chat || !isEncryptionEnabled()) return; + if (Array.isArray(chat.messages)) { + for (const msg of chat.messages) { + decryptMessageFields(msg as Record); + } + } + // pinnedMessages[].message + if (Array.isArray(chat.pinnedMessages)) { + for (const pm of chat.pinnedMessages) { + const pmo = pm as Record; + if (pmo.message && typeof pmo.message === 'object') { + decryptMessageFields(pmo.message as Record); + } + } + } +} + +/** Decrypt nested message field on any object (e.g. PinnedMessage.message). */ +function decryptNested(obj: Record): void { + if (!obj || !isEncryptionEnabled()) return; + if (obj.message && typeof obj.message === 'object') { + decryptMessageFields(obj.message as Record); + } +} + diff --git a/apps/server/src/encrypt.ts b/apps/server/src/encrypt.ts new file mode 100644 index 0000000..eaf5911 --- /dev/null +++ b/apps/server/src/encrypt.ts @@ -0,0 +1,154 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +/** + * AES-256-GCM encryption for message content and files. + * + * Text format: "enc:v1:::" + * File format: [12 bytes IV][16 bytes AuthTag][...ciphertext...] + * + * The "enc:v1:" prefix allows detecting encrypted vs plain-text content + * for backward compatibility with existing unencrypted messages. + */ + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 96 bits — recommended for GCM +const AUTH_TAG_LENGTH = 16; +const FILE_HEADER_LENGTH = IV_LENGTH + AUTH_TAG_LENGTH; // 28 bytes +const PREFIX = 'enc:v1:'; + +let encryptionKey: Buffer | null = null; + +/** Initialise encryption with a 64-char hex key (32 bytes). */ +export function initEncryption(hexKey: string): void { + if (!hexKey || hexKey.length !== 64) { + throw new Error( + 'ENCRYPTION_KEY must be a 64-character hex string (32 bytes). ' + + 'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"' + ); + } + encryptionKey = Buffer.from(hexKey, 'hex'); +} + +/** Returns true if encryption is enabled (key configured). */ +export function isEncryptionEnabled(): boolean { + return encryptionKey !== null; +} + +/** Encrypt a plain-text string. Returns the encrypted string or the original if encryption is disabled. */ +export function encryptText(plaintext: string): string { + if (!encryptionKey || !plaintext) return plaintext; + + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return `${PREFIX}${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; +} + +/** Decrypt an encrypted string. Returns the plain text, or the original string if it's not encrypted. */ +export function decryptText(ciphertext: string): string { + if (!ciphertext || !ciphertext.startsWith(PREFIX)) { + // Not encrypted (legacy data or null) — return as-is + return ciphertext; + } + if (!encryptionKey) { + console.error('Cannot decrypt: ENCRYPTION_KEY not configured'); + return '[зашифровано]'; + } + + try { + const payload = ciphertext.slice(PREFIX.length); + const [ivHex, tagHex, dataHex] = payload.split(':'); + if (!ivHex || !tagHex || !dataHex) return '[повреждённые данные]'; + + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(tagHex, 'hex'); + const encrypted = Buffer.from(dataHex, 'hex'); + + const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); + } catch (e) { + console.error('Decryption failed:', e); + return '[ошибка расшифровки]'; + } +} + +// ─── File encryption ───────────────────────────────────────────────── + +/** + * Encrypt a file in-place on disk. + * Replaces the original file with: [IV 12B][AuthTag 16B][ciphertext...] + */ +export function encryptFileInPlace(filePath: string): void { + if (!encryptionKey) return; + + const plainData = fs.readFileSync(filePath); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey, iv); + const encrypted = Buffer.concat([cipher.update(plainData), cipher.final()]); + const authTag = cipher.getAuthTag(); + + // Write: IV + AuthTag + Ciphertext + const output = Buffer.concat([iv, authTag, encrypted]); + fs.writeFileSync(filePath, output); +} + +/** + * Check if a file appears to be encrypted (has valid header size). + * This is a heuristic — not 100% reliable on tiny files, but good enough. + */ +export function isFileEncrypted(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + // Encrypted files must be at least 28 bytes (header). + // We also check if decryption with the key succeeds on the first chunk. + if (stat.size < FILE_HEADER_LENGTH) return false; + // If encryption is disabled, treat all files as plain + if (!encryptionKey) return false; + return true; // Assume encrypted if key is configured and file is large enough + } catch { + return false; + } +} + +/** + * Decrypt a file and return the plain-text Buffer. + * Returns null if decryption fails (file may be unencrypted). + */ +export function decryptFileToBuffer(filePath: string): Buffer | null { + if (!encryptionKey) return null; + + try { + const data = fs.readFileSync(filePath); + if (data.length < FILE_HEADER_LENGTH) return null; + + const iv = data.subarray(0, IV_LENGTH); + const authTag = data.subarray(IV_LENGTH, FILE_HEADER_LENGTH); + const ciphertext = data.subarray(FILE_HEADER_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return decrypted; + } catch { + // Decryption failed — file is likely not encrypted (legacy) + return null; + } +} + +/** + * Resolve a URL path like '/uploads/avatars/abc.jpg' to an absolute file path. + */ +export function resolveUploadPath(urlPath: string, uploadsRoot: string): string | null { + if (!urlPath) return null; + const filename = urlPath.replace(/^\/uploads\//, ''); + const filePath = path.resolve(uploadsRoot, filename); + // Path containment check + if (!filePath.startsWith(uploadsRoot)) return null; + return filePath; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..7482f55 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,188 @@ +import express from 'express'; +import { createServer } from 'http'; +import { Server } from 'socket.io'; +import cors from 'cors'; +import rateLimit from 'express-rate-limit'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import mime from 'mime-types'; +import { config } from './config'; +import { prisma } from './db'; +import authRoutes from './routes/auth'; +import userRoutes from './routes/users'; +import chatRoutes from './routes/chats'; +import messageRoutes from './routes/messages'; +import storyRoutes from './routes/stories'; +import friendRoutes from './routes/friends'; +import { setupSocket } from './socket'; +import { authenticateToken, AuthRequest } from './middleware/auth'; +import { decryptFileToBuffer, isEncryptionEnabled } from './encrypt'; +import { UPLOADS_ROOT } from './shared'; + +const app = express(); +const server = createServer(app); +const io = new Server(server, { + cors: { + origin: config.corsOrigins, + methods: ['GET', 'POST', 'PUT', 'DELETE'], + }, +}); + +// Trust first proxy (Nginx) so req.ip returns real client IP from X-Forwarded-For +app.set('trust proxy', 1); + +app.use(cors({ origin: config.corsOrigins })); +app.use(express.json({ limit: '10mb' })); + +// Serve uploads — decrypts encrypted files on the fly +app.use('/uploads', (req, res, next) => { + // Security headers + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', "default-src 'none'"); + res.setHeader('Cache-Control', 'private, max-age=86400'); + + // Resolve file path safely + const urlPath = decodeURIComponent(req.path); + if (urlPath.includes('..')) { + res.status(400).end(); + return; + } + + const filePath = path.resolve(UPLOADS_ROOT, urlPath.replace(/^\//, '')); + if (!filePath.startsWith(UPLOADS_ROOT) || !fs.existsSync(filePath)) { + res.status(404).end(); + return; + } + + // Set Content-Type from extension + const contentType = mime.lookup(filePath) || 'application/octet-stream'; + res.setHeader('Content-Type', contentType); + + // If encryption is enabled, try to decrypt + if (isEncryptionEnabled()) { + const decrypted = decryptFileToBuffer(filePath); + if (decrypted) { + res.setHeader('Content-Length', decrypted.length); + res.end(decrypted); + return; + } + // Decryption failed — file is likely unencrypted (legacy), fall through to static + } + + // Serve unencrypted file as-is + next(); +}, express.static(UPLOADS_ROOT)); + +// Rate limiting for auth endpoints (prevent brute-force) +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, // max 20 attempts per window + message: { error: 'Слишком много попыток, попробуйте позже' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// General API rate limiter (100 req/min per IP) +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 100, + message: { error: 'Слишком много запросов, попробуйте позже' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// API маршруты — auth/me uses general limiter (called on every page load) +app.use('/api/auth/me', apiLimiter, authRoutes); +app.use('/api/auth', authLimiter, authRoutes); +app.use('/api/users', apiLimiter, authenticateToken, userRoutes); +app.use('/api/chats', apiLimiter, authenticateToken, chatRoutes); +app.use('/api/messages', apiLimiter, authenticateToken, messageRoutes); +app.use('/api/stories', apiLimiter, authenticateToken, storyRoutes); +app.use('/api/friends', apiLimiter, authenticateToken, friendRoutes); + +// Проверка здоровья +app.get('/api/health', (_req, res) => { + res.json({ status: 'ok', name: 'Vortex Server' }); +}); + +// ICE серверы для WebRTC звонков +app.get('/api/ice-servers', authenticateToken, (_req: AuthRequest, res) => { + const iceServers: Array<{ urls: string | string[]; username?: string; credential?: string }> = []; + + // STUN серверы + if (config.stunUrls.length > 0) { + iceServers.push({ urls: config.stunUrls }); + } + + // TURN сервер с временными credentials (coturn --use-auth-secret) + if (config.turnUrl && config.turnSecret) { + const ttl = 24 * 3600; // 24 часа + const timestamp = Math.floor(Date.now() / 1000) + ttl; + const username = `${timestamp}:vortex`; + const credential = crypto + .createHmac('sha1', config.turnSecret) + .update(username) + .digest('base64'); + + iceServers.push({ + urls: config.turnUrl, + username, + credential, + }); + } + + res.json({ iceServers }); +}); + +// Socket.io +setupSocket(io); + +// При старте сервера сбросить всех в offline +prisma.user.updateMany({ data: { isOnline: false, lastSeen: new Date() } }) + .then(() => console.log(' ✔ Все пользователи сброшены в offline')) + .catch((e: unknown) => console.error('Ошибка сброса онлайн-статусов:', e)); + +// Cleanup expired stories (every 10 minutes) +import { deleteUploadedFile } from './shared'; + +async function cleanupExpiredStories() { + try { + const expired = await prisma.story.findMany({ + where: { expiresAt: { lte: new Date() } }, + select: { id: true, mediaUrl: true }, + }); + + if (expired.length === 0) return; + + for (const story of expired) { + if (story.mediaUrl) deleteUploadedFile(story.mediaUrl); + } + + const ids = expired.map(s => s.id); + // Cascade handles StoryView deletion via schema onDelete: Cascade + await prisma.story.deleteMany({ where: { id: { in: ids } } }); + + console.log(` 🗑 Удалено ${expired.length} истёкших историй`); + } catch (e) { + console.error('Story cleanup error:', e); + } +} + +cleanupExpiredStories(); +setInterval(cleanupExpiredStories, 10 * 60 * 1000); + +server.listen(config.port, () => { + console.log(`\n ⚡ Vortex Server запущен на порту ${config.port}\n`); +}); + +// Graceful shutdown +const shutdown = async () => { + console.log('\n Завершение работы...'); + await prisma.$disconnect(); + server.close(() => { + process.exit(0); + }); +}; +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts new file mode 100644 index 0000000..5fe5c83 --- /dev/null +++ b/apps/server/src/middleware/auth.ts @@ -0,0 +1,26 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { config } from '../config'; + +export interface AuthRequest extends Request { + userId?: string; +} + +export function authenticateToken(req: AuthRequest, res: Response, next: NextFunction) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + res.status(401).json({ error: 'Требуется авторизация' }); + return; + } + + try { + const decoded = jwt.verify(token, config.jwtSecret) as { userId: string }; + req.userId = decoded.userId; + next(); + } catch { + res.status(403).json({ error: 'Недействительный токен' }); + return; + } +} diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts new file mode 100644 index 0000000..e34a921 --- /dev/null +++ b/apps/server/src/routes/auth.ts @@ -0,0 +1,169 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { prisma } from '../db'; +import { config } from '../config'; +import { USER_SELECT } from '../shared'; +import { authenticateToken, AuthRequest } from '../middleware/auth'; +import rateLimit from 'express-rate-limit'; + +const router = Router(); + +// ─── Strict registration rate limiter: 3 registrations per IP per hour ─── +const registerLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, + message: { error: 'Слишком много регистраций с этого IP. Попробуйте через час.' }, + standardHeaders: true, + legacyHeaders: false, + validate: false, + keyGenerator: (req) => req.ip || req.socket.remoteAddress || 'unknown', +}); + +// In-memory cooldown: track last registration timestamp per IP (prevents rapid-fire even within rate limit) +const registrationCooldowns = new Map(); +const REGISTRATION_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between registrations from same IP + +// Регистрация +router.post('/register', registerLimiter, async (req, res) => { + try { + const { username, displayName, password, bio } = req.body; + + // ── IP cooldown check ── + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; + const lastReg = registrationCooldowns.get(clientIp); + if (lastReg && Date.now() - lastReg < REGISTRATION_COOLDOWN_MS) { + const waitMinutes = Math.ceil((REGISTRATION_COOLDOWN_MS - (Date.now() - lastReg)) / 60000); + res.status(429).json({ error: `Подождите ${waitMinutes} мин. перед созданием нового аккаунта` }); + return; + } + + // ── Permanent IP limit (DB-level) ── + const accountsFromIp = await prisma.user.count({ where: { registrationIp: clientIp } }); + if (accountsFromIp >= config.maxRegistrationsPerIp) { + res.status(403).json({ error: `Максимум ${config.maxRegistrationsPerIp} аккаунта с одного IP. Лимит исчерпан.` }); + return; + } + + if (!username || !password) { + res.status(400).json({ error: 'Username и пароль обязательны' }); + return; + } + + if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) { + res.status(400).json({ error: 'Username: 3-20 символов, только латиница, цифры, _' }); + return; + } + + if (password.length < config.minPasswordLength) { + res.status(400).json({ error: `Пароль должен быть не менее ${config.minPasswordLength} символов` }); + return; + } + + // Password must contain at least one letter and one digit + if (!/[a-zA-Zа-яА-Я]/.test(password) || !/\d/.test(password)) { + res.status(400).json({ error: 'Пароль должен содержать буквы и цифры' }); + return; + } + + // Validate optional fields + if (displayName !== undefined && (typeof displayName !== 'string' || displayName.length > 50)) { + res.status(400).json({ error: 'Имя должно быть не длиннее 50 символов' }); + return; + } + if (bio !== undefined && (typeof bio !== 'string' || bio.length > 500)) { + res.status(400).json({ error: 'Био должно быть не длиннее 500 символов' }); + return; + } + + const existing = await prisma.user.findUnique({ where: { username: username.toLowerCase() } }); + if (existing) { + res.status(400).json({ error: 'Этот username уже занят' }); + return; + } + + const hashedPassword = await bcrypt.hash(password, 10); + const user = await prisma.user.create({ + data: { + username: username.toLowerCase(), + displayName: (displayName || username).slice(0, 50), + password: hashedPassword, + bio: bio ? bio.slice(0, 500) : null, + registrationIp: clientIp, + }, + select: USER_SELECT, + }); + + const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '30d' }); + + // Track registration for cooldown + registrationCooldowns.set(clientIp, Date.now()); + + res.json({ token, user: { ...user, isOnline: true } }); + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Вход +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + res.status(400).json({ error: 'Username и пароль обязательны' }); + return; + } + + const user = await prisma.user.findUnique({ + where: { username: username.toLowerCase() }, + select: { ...USER_SELECT, password: true }, + }); + + if (!user) { + res.status(400).json({ error: 'Неверный username или пароль' }); + return; + } + + const validPassword = await bcrypt.compare(password, user.password); + if (!validPassword) { + res.status(400).json({ error: 'Неверный username или пароль' }); + return; + } + + await prisma.user.update({ + where: { id: user.id }, + data: { isOnline: true, lastSeen: new Date() }, + }); + + const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '30d' }); + + const { password: _, ...userWithoutPassword } = user; + res.json({ token, user: { ...userWithoutPassword, isOnline: true } }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Текущий пользователь — uses authenticateToken middleware instead of duplicating JWT parsing +router.get('/me', authenticateToken, async (req: AuthRequest, res) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.userId }, + select: USER_SELECT, + }); + + if (!user) { + res.status(404).json({ error: 'Пользователь не найден' }); + return; + } + + res.json({ user }); + } catch { + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +export default router; diff --git a/apps/server/src/routes/chats.ts b/apps/server/src/routes/chats.ts new file mode 100644 index 0000000..5aab4c9 --- /dev/null +++ b/apps/server/src/routes/chats.ts @@ -0,0 +1,608 @@ +import { Router } from 'express'; +import { Prisma } from '@prisma/client'; +import { prisma } from '../db'; +import { AuthRequest } from '../middleware/auth'; +import { USER_SELECT, SENDER_SELECT, uploadGroupAvatar, deleteUploadedFile, encryptUploadedFile } from '../shared'; + +const router = Router(); + +// Compact user select for chat member lists (no bio/birthday) +const CHAT_USER_SELECT = { + id: true, + username: true, + displayName: true, + avatar: true, + isOnline: true, + lastSeen: true, +}; + +// Получить все чаты пользователя +router.get('/', async (req: AuthRequest, res) => { + try { + const chats = await prisma.chat.findMany({ + where: { + members: { some: { userId: req.userId } }, + }, + include: { + members: { + include: { user: { select: CHAT_USER_SELECT } }, + }, + messages: { + where: { + isDeleted: false, + OR: [ + { scheduledAt: null }, + { senderId: req.userId! }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + sender: { select: { id: true, username: true, displayName: true } }, + readBy: { select: { userId: true } }, + }, + }, + pinnedMessages: { + orderBy: { pinnedAt: 'desc' }, + take: 1, + include: { + message: { + include: { + sender: { select: SENDER_SELECT }, + media: true, + }, + }, + }, + }, + }, + }); + + // Batch unread counts in a single query to avoid N+1 + const chatIds = chats.map(c => c.id); + let unreadCounts: Array<{ chatId: string; count: bigint }> = []; + if (chatIds.length > 0) { + unreadCounts = await prisma.$queryRaw>( + Prisma.sql`SELECT m."chatId", COUNT(m.id) as count FROM "Message" m + LEFT JOIN "ReadReceipt" rr ON rr."messageId" = m.id AND rr."userId" = ${req.userId} + WHERE m."chatId" IN (${Prisma.join(chatIds)}) + AND m."senderId" != ${req.userId} AND m."isDeleted" = false AND rr.id IS NULL + AND m."scheduledAt" IS NULL + GROUP BY m."chatId"` + ).catch(() => [] as Array<{ chatId: string; count: bigint }>); + } + + const unreadMap = new Map(unreadCounts.map(r => [r.chatId, Number(r.count)])); + + // Filter last message by clearedAt per user + const chatsFiltered = chats.map((chat) => { + const member = chat.members.find((m) => m.userId === req.userId); + const clearedAt = member?.clearedAt; + if (clearedAt && chat.messages.length > 0) { + const filtered = chat.messages.filter((msg) => new Date(msg.createdAt) > new Date(clearedAt)); + return { ...chat, messages: filtered }; + } + return chat; + }); + + const sortedChats = chatsFiltered.sort((a, b) => { + const aPinned = a.members.find((m) => m.userId === req.userId)?.isPinned || false; + const bPinned = b.members.find((m) => m.userId === req.userId)?.isPinned || false; + if (aPinned && !bPinned) return -1; + if (!aPinned && bPinned) return 1; + + const aDate = a.messages[0]?.createdAt || a.createdAt; + const bDate = b.messages[0]?.createdAt || b.createdAt; + return new Date(bDate).getTime() - new Date(aDate).getTime(); + }); + + const chatsWithUnread = sortedChats.map((chat) => ({ + ...chat, + unreadCount: unreadMap.get(chat.id) || 0, + })); + + res.json(chatsWithUnread); + } catch (error) { + console.error('Get chats error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Создать личный чат +router.post('/personal', async (req: AuthRequest, res) => { + try { + const { userId } = req.body; + if (!userId) { + res.status(400).json({ error: 'ID пользователя обязателен' }); + return; + } + + const existingChat = await prisma.chat.findFirst({ + where: { + type: 'personal', + AND: [ + { members: { some: { userId: req.userId } } }, + { members: { some: { userId } } }, + ], + }, + include: { + members: { include: { user: { select: CHAT_USER_SELECT } } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + sender: { select: { id: true, username: true, displayName: true } }, + readBy: { select: { userId: true } }, + }, + }, + }, + }); + + if (existingChat) { + res.json({ ...existingChat, unreadCount: 0 }); + return; + } + + const chat = await prisma.chat.create({ + data: { + type: 'personal', + members: { + create: [{ userId: req.userId! }, { userId }], + }, + }, + include: { + members: { include: { user: { select: CHAT_USER_SELECT } } }, + messages: true, + }, + }); + + res.json({ ...chat, unreadCount: 0 }); + } catch (error) { + console.error('Create chat error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Создать или получить чат "Избранное" (saved messages) +router.post('/favorites', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + + // Check if favorites chat already exists + const existing = await prisma.chat.findFirst({ + where: { + type: 'favorites', + members: { some: { userId } }, + }, + include: { + members: { include: { user: { select: CHAT_USER_SELECT } } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + sender: { select: { id: true, username: true, displayName: true } }, + readBy: { select: { userId: true } }, + }, + }, + }, + }); + + if (existing) { + res.json({ ...existing, unreadCount: 0 }); + return; + } + + const chat = await prisma.chat.create({ + data: { + type: 'favorites', + name: null, + members: { + create: [{ userId, role: 'admin' }], + }, + }, + include: { + members: { include: { user: { select: CHAT_USER_SELECT } } }, + messages: true, + }, + }); + + res.json({ ...chat, unreadCount: 0 }); + } catch (error) { + console.error('Create favorites chat error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Создать групповой чат +router.post('/group', async (req: AuthRequest, res) => { + try { + const { name, memberIds } = req.body; + if (!name || !memberIds || !Array.isArray(memberIds)) { + res.status(400).json({ error: 'Название и участники обязательны' }); + return; + } + + // Validate group name length + if (typeof name !== 'string' || name.trim().length === 0 || name.length > 100) { + res.status(400).json({ error: 'Название группы должно быть от 1 до 100 символов' }); + return; + } + + // Limit max members + if (memberIds.length > 256) { + res.status(400).json({ error: 'Максимум 256 участников в группе' }); + return; + } + + const allMemberIds = [...new Set([req.userId!, ...memberIds])]; + + const chat = await prisma.chat.create({ + data: { + type: 'group', + name, + members: { + create: allMemberIds.map((uid) => ({ + userId: uid, + role: uid === req.userId ? 'admin' : 'member', + })), + }, + }, + include: { + members: { include: { user: { select: CHAT_USER_SELECT } } }, + messages: true, + }, + }); + + res.json({ ...chat, unreadCount: 0 }); + } catch (error) { + console.error('Create group error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Получить чат по ID +router.get('/:id', async (req: AuthRequest, res) => { + try { + const chat = await prisma.chat.findFirst({ + where: { + id: String(req.params.id), + members: { some: { userId: req.userId } }, + }, + include: { + members: { include: { user: { select: CHAT_USER_SELECT } } }, + }, + }); + + if (!chat) { + res.status(404).json({ error: 'Чат не найден' }); + return; + } + + res.json(chat); + } catch (error) { + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Обновить группу (только админ) +router.put('/:id', async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.id); + const { name } = req.body; + + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId, userId: req.userId! } }, + }); + + if (!member || member.role !== 'admin') { + res.status(403).json({ error: 'Только администратор может редактировать группу' }); + return; + } + + const chat = await prisma.chat.update({ + where: { id: chatId }, + data: { name }, + include: { + members: { include: { user: { select: USER_SELECT } } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + sender: { select: { id: true, username: true, displayName: true } }, + readBy: { select: { userId: true } }, + }, + }, + }, + }); + + res.json(chat); + } catch (error) { + console.error('Update group error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Загрузить аватар группы (только админ) +router.post('/:id/avatar', uploadGroupAvatar.single('avatar'), encryptUploadedFile, async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.id); + + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId, userId: req.userId! } }, + }); + + if (!member || member.role !== 'admin') { + res.status(403).json({ error: 'Только администратор может менять аватар группы' }); + return; + } + + if (!req.file) { + res.status(400).json({ error: 'Файл не загружен' }); + return; + } + + // Delete old avatar file + const currentChat = await prisma.chat.findUnique({ where: { id: chatId }, select: { avatar: true } }); + if (currentChat?.avatar) deleteUploadedFile(currentChat.avatar); + + const avatarUrl = `/uploads/avatars/${req.file.filename}`; + + const chat = await prisma.chat.update({ + where: { id: chatId }, + data: { avatar: avatarUrl }, + include: { + members: { include: { user: { select: USER_SELECT } } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + sender: { select: { id: true, username: true, displayName: true } }, + readBy: { select: { userId: true } }, + }, + }, + }, + }); + + res.json(chat); + } catch (error) { + console.error('Upload group avatar error:', error); + res.status(500).json({ error: 'Ошибка загрузки аватара' }); + } +}); + +// Удалить аватар группы (только админ) +router.delete('/:id/avatar', async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.id); + + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId, userId: req.userId! } }, + }); + + if (!member || member.role !== 'admin') { + res.status(403).json({ error: 'Только администратор может менять аватар группы' }); + return; + } + + // Delete file from disk + const currentChat = await prisma.chat.findUnique({ where: { id: chatId }, select: { avatar: true } }); + if (currentChat?.avatar) deleteUploadedFile(currentChat.avatar); + + const chat = await prisma.chat.update({ + where: { id: chatId }, + data: { avatar: null }, + include: { + members: { include: { user: { select: USER_SELECT } } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + sender: { select: { id: true, username: true, displayName: true } }, + readBy: { select: { userId: true } }, + }, + }, + }, + }); + + res.json(chat); + } catch (error) { + res.status(500).json({ error: 'Ошибка удаления аватара' }); + } +}); + +// Добавить участников в группу (только админ) +router.post('/:id/members', async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.id); + const { userIds } = req.body; + + if (!userIds || !Array.isArray(userIds) || userIds.length === 0) { + res.status(400).json({ error: 'Необходимо указать пользователей' }); + return; + } + + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId, userId: req.userId! } }, + }); + + if (!member || member.role !== 'admin') { + res.status(403).json({ error: 'Только администратор может добавлять участников' }); + return; + } + + const chat = await prisma.chat.findUnique({ where: { id: chatId } }); + if (!chat || chat.type !== 'group') { + res.status(400).json({ error: 'Чат не является группой' }); + return; + } + + for (const uid of userIds) { + await prisma.chatMember.upsert({ + where: { chatId_userId: { chatId, userId: uid } }, + create: { chatId, userId: uid, role: 'member' }, + update: {}, + }); + } + + const updatedChat = await prisma.chat.findUnique({ + where: { id: chatId }, + include: { + members: { include: { user: { select: USER_SELECT } } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + sender: { select: { id: true, username: true, displayName: true } }, + readBy: { select: { userId: true } }, + }, + }, + }, + }); + + res.json(updatedChat); + } catch (error) { + console.error('Add members error:', error); + res.status(500).json({ error: 'Ошибка добавления участников' }); + } +}); + +// Удалить участника из группы (только админ) +router.delete('/:id/members/:userId', async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.id); + const targetUserId = String(req.params.userId); + + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId, userId: req.userId! } }, + }); + + if (!member || member.role !== 'admin') { + res.status(403).json({ error: 'Только администратор может удалять участников' }); + return; + } + + if (targetUserId === req.userId) { + res.status(400).json({ error: 'Нельзя удалить себя из группы' }); + return; + } + + await prisma.chatMember.delete({ + where: { chatId_userId: { chatId, userId: targetUserId } }, + }); + + const updatedChat = await prisma.chat.findUnique({ + where: { id: chatId }, + include: { + members: { include: { user: { select: USER_SELECT } } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + sender: { select: { id: true, username: true, displayName: true } }, + readBy: { select: { userId: true } }, + }, + }, + }, + }); + + res.json(updatedChat); + } catch (error) { + console.error('Remove member error:', error); + res.status(500).json({ error: 'Ошибка удаления участника' }); + } +}); + +// Очистить чат для себя +router.post('/:id/clear', async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.id); + + await prisma.chatMember.update({ + where: { chatId_userId: { chatId, userId: req.userId! } }, + data: { clearedAt: new Date() }, + }); + + res.json({ success: true }); + } catch (error) { + console.error('Clear chat error:', error); + res.status(500).json({ error: 'Ошибка очистки чата' }); + } +}); + +// Удалить чат (для текущего пользователя — выйти из чата) +router.delete('/:id', async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.id); + const userId = req.userId!; + + const chat = await prisma.chat.findUnique({ + where: { id: chatId }, + include: { members: true }, + }); + + if (!chat) { + res.status(404).json({ error: 'Чат не найден' }); + return; + } + + // Membership check + const isMember = chat.members.some(m => m.userId === userId); + if (!isMember) { + res.status(403).json({ error: 'Нет доступа к этому чату' }); + return; + } + + if (chat.type === 'personal') { + // For personal chats, just remove the member (soft leave) instead of destroying for both + await prisma.chatMember.delete({ + where: { chatId_userId: { chatId, userId } }, + }); + // If both members have left, clean up the chat + const remaining = await prisma.chatMember.count({ where: { chatId } }); + if (remaining === 0) { + await prisma.chat.delete({ where: { id: chatId } }); + } + } else if (chat.members.length <= 1) { + // Last member — delete the group entirely + await prisma.chat.delete({ where: { id: chatId } }); + } else { + // For groups, just remove the member + await prisma.chatMember.delete({ + where: { chatId_userId: { chatId, userId } }, + }); + } + + res.json({ success: true }); + } catch (error) { + console.error('Delete chat error:', error); + res.status(500).json({ error: 'Ошибка удаления чата' }); + } +}); + +// Закрепить / открепить чат +router.post('/:id/pin', async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.id); + const userId = req.userId!; + + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId, userId } }, + }); + + if (!member) { + res.status(404).json({ error: 'Чат не найден' }); + return; + } + + await prisma.chatMember.update({ + where: { chatId_userId: { chatId, userId } }, + data: { isPinned: !member.isPinned }, + }); + + res.json({ isPinned: !member.isPinned }); + } catch (error) { + console.error('Pin chat error:', error); + res.status(500).json({ error: 'Ошибка закрепления чата' }); + } +}); + +export default router; diff --git a/apps/server/src/routes/friends.ts b/apps/server/src/routes/friends.ts new file mode 100644 index 0000000..20ae09a --- /dev/null +++ b/apps/server/src/routes/friends.ts @@ -0,0 +1,258 @@ +import { Router } from 'express'; +import { prisma } from '../db'; +import { AuthRequest } from '../middleware/auth'; +import { USER_SELECT } from '../shared'; + +const router = Router(); + +// ─── Get accepted friends list ─────────────────────────────────────── +router.get('/', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + + const friendships = await prisma.friendship.findMany({ + where: { + status: 'accepted', + OR: [{ userId }, { friendId: userId }], + }, + include: { + user: { select: USER_SELECT }, + friend: { select: USER_SELECT }, + }, + }); + + const friends = friendships.map(f => ({ + ...(f.userId === userId ? f.friend : f.user), + friendshipId: f.id, + })); + + res.json(friends); + } catch (error) { + console.error('Get friends error:', error); + res.status(500).json({ error: 'Ошибка получения друзей' }); + } +}); + +// ─── Get incoming friend requests ──────────────────────────────────── +router.get('/requests', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + + const requests = await prisma.friendship.findMany({ + where: { friendId: userId, status: 'pending' }, + include: { + user: { select: USER_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + }); + + res.json(requests.map(r => ({ id: r.id, user: r.user, createdAt: r.createdAt }))); + } catch (error) { + console.error('Get friend requests error:', error); + res.status(500).json({ error: 'Ошибка получения заявок' }); + } +}); + +// ─── Get outgoing friend requests ──────────────────────────────────── +router.get('/outgoing', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + + const requests = await prisma.friendship.findMany({ + where: { userId, status: 'pending' }, + include: { + friend: { select: USER_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + }); + + res.json(requests.map(r => ({ id: r.id, user: r.friend, createdAt: r.createdAt }))); + } catch (error) { + console.error('Get outgoing requests error:', error); + res.status(500).json({ error: 'Ошибка получения заявок' }); + } +}); + +// ─── Get friendship status with a user ─────────────────────────────── +router.get('/status/:userId', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const targetId = String(req.params.userId); + + if (userId === targetId) { + res.json({ status: 'self' }); + return; + } + + const friendship = await prisma.friendship.findFirst({ + where: { + OR: [ + { userId, friendId: targetId }, + { userId: targetId, friendId: userId }, + ], + }, + }); + + if (!friendship) { + res.json({ status: 'none', friendshipId: null }); + return; + } + + // Determine who sent the request to show correct action + const direction = friendship.userId === userId ? 'outgoing' : 'incoming'; + res.json({ status: friendship.status, friendshipId: friendship.id, direction }); + } catch (error) { + console.error('Get friend status error:', error); + res.status(500).json({ error: 'Ошибка получения статуса' }); + } +}); + +// ─── Send friend request ───────────────────────────────────────────── +router.post('/request', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const { friendId } = req.body; + + if (!friendId || typeof friendId !== 'string') { + res.status(400).json({ error: 'ID пользователя обязателен' }); + return; + } + + if (userId === friendId) { + res.status(400).json({ error: 'Нельзя добавить себя в друзья' }); + return; + } + + // Check if target user exists + const targetUser = await prisma.user.findUnique({ where: { id: friendId } }); + if (!targetUser) { + res.status(404).json({ error: 'Пользователь не найден' }); + return; + } + + // Check for existing friendship in either direction + const existing = await prisma.friendship.findFirst({ + where: { + OR: [ + { userId, friendId }, + { userId: friendId, friendId: userId }, + ], + }, + }); + + if (existing) { + if (existing.status === 'accepted') { + res.status(400).json({ error: 'Уже в друзьях' }); + return; + } + if (existing.status === 'pending') { + // If they already sent us a request, auto-accept + if (existing.userId === friendId) { + const updated = await prisma.friendship.update({ + where: { id: existing.id }, + data: { status: 'accepted' }, + include: { user: { select: USER_SELECT }, friend: { select: USER_SELECT } }, + }); + res.json({ status: 'accepted', friendship: updated }); + return; + } + res.status(400).json({ error: 'Заявка уже отправлена' }); + return; + } + if (existing.status === 'declined') { + // Allow re-sending if previously declined — keep existing direction to avoid @@unique conflict + const updated = await prisma.friendship.update({ + where: { id: existing.id }, + data: { status: 'pending' }, + }); + res.json({ status: 'pending', friendship: updated }); + return; + } + } + + const friendship = await prisma.friendship.create({ + data: { userId, friendId }, + include: { user: { select: USER_SELECT }, friend: { select: USER_SELECT } }, + }); + + res.json({ status: 'pending', friendship }); + } catch (error) { + console.error('Send friend request error:', error); + res.status(500).json({ error: 'Ошибка отправки заявки' }); + } +}); + +// ─── Accept friend request ─────────────────────────────────────────── +router.post('/:id/accept', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const friendshipId = String(req.params.id); + + const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId } }); + + if (!friendship || friendship.friendId !== userId || friendship.status !== 'pending') { + res.status(404).json({ error: 'Заявка не найдена' }); + return; + } + + const updated = await prisma.friendship.update({ + where: { id: friendshipId }, + data: { status: 'accepted' }, + include: { user: { select: USER_SELECT }, friend: { select: USER_SELECT } }, + }); + + res.json(updated); + } catch (error) { + console.error('Accept friend request error:', error); + res.status(500).json({ error: 'Ошибка принятия заявки' }); + } +}); + +// ─── Decline friend request ────────────────────────────────────────── +router.post('/:id/decline', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const friendshipId = String(req.params.id); + + const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId } }); + + if (!friendship || friendship.friendId !== userId || friendship.status !== 'pending') { + res.status(404).json({ error: 'Заявка не найдена' }); + return; + } + + await prisma.friendship.update({ + where: { id: friendshipId }, + data: { status: 'declined' }, + }); + + res.json({ success: true }); + } catch (error) { + console.error('Decline friend request error:', error); + res.status(500).json({ error: 'Ошибка отклонения заявки' }); + } +}); + +// ─── Remove friend ─────────────────────────────────────────────────── +router.delete('/:id', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const friendshipId = String(req.params.id); + + const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId } }); + + if (!friendship || (friendship.userId !== userId && friendship.friendId !== userId)) { + res.status(404).json({ error: 'Дружба не найдена' }); + return; + } + + await prisma.friendship.delete({ where: { id: friendshipId } }); + + res.json({ success: true }); + } catch (error) { + console.error('Remove friend error:', error); + res.status(500).json({ error: 'Ошибка удаления друга' }); + } +}); + +export default router; diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts new file mode 100644 index 0000000..050fece --- /dev/null +++ b/apps/server/src/routes/messages.ts @@ -0,0 +1,221 @@ +import { Router } from 'express'; +import { prisma } from '../db'; +import { Prisma } from '@prisma/client'; +import { AuthRequest } from '../middleware/auth'; +import { SENDER_SELECT, MESSAGE_INCLUDE, uploadFile, deleteUploadedFile, encryptUploadedFile } from '../shared'; + +const router = Router(); + +// Получить сообщения чата +router.get('/chat/:chatId', async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.chatId); + const { cursor, limit = '50' } = req.query; + const take = Math.min(Math.max(1, parseInt(limit as string) || 50), 200); + + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId, userId: req.userId! } }, + }); + + if (!member) { + res.status(403).json({ error: 'Нет доступа к этому чату' }); + return; + } + + const createdAtFilter: Record = {}; + if (cursor) createdAtFilter.lt = new Date(cursor as string); + if (member.clearedAt) createdAtFilter.gt = member.clearedAt; + + const messages = await prisma.message.findMany({ + where: { + chatId, + isDeleted: false, + hiddenBy: { none: { userId: req.userId! } }, + // Scheduled messages: only visible to the sender until delivered + OR: [ + { scheduledAt: null }, + { senderId: req.userId! }, + ], + ...(Object.keys(createdAtFilter).length > 0 ? { createdAt: createdAtFilter } : {}), + }, + include: MESSAGE_INCLUDE, + orderBy: { createdAt: 'desc' }, + take, + }); + + res.json(messages.reverse()); + } catch (error) { + console.error('Get messages error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Загрузка файла +router.post('/upload', uploadFile.single('file'), encryptUploadedFile, async (req: AuthRequest, res) => { + try { + if (!req.file) { + res.status(400).json({ error: 'Файл не загружен' }); + return; + } + + const fileUrl = `/uploads/${req.file.filename}`; + // multer decodes multipart filenames as latin1 — re-decode as UTF-8 + const originalName = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); + + res.json({ + url: fileUrl, + filename: originalName, + size: req.file.size, + mimetype: req.file.mimetype, + }); + } catch (error) { + console.error('Upload error:', error); + res.status(500).json({ error: 'Ошибка загрузки' }); + } +}); + +// Редактировать сообщение +router.put('/:id', async (req: AuthRequest, res) => { + try { + const { content } = req.body; + const id = String(req.params.id); + + if (!content || typeof content !== 'string' || content.length > 10000) { + res.status(400).json({ error: 'Содержимое обязательно и не должно превышать 10000 символов' }); + return; + } + + const message = await prisma.message.findUnique({ where: { id } }); + if (!message || message.senderId !== req.userId) { + res.status(403).json({ error: 'Нет прав для редактирования' }); + return; + } + + const updated = await prisma.message.update({ + where: { id }, + data: { content, isEdited: true }, + include: MESSAGE_INCLUDE, + }); + + res.json(updated); + } catch (error) { + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Удалить сообщение +router.delete('/:id', async (req: AuthRequest, res) => { + try { + const id = String(req.params.id); + + const message = await prisma.message.findUnique({ + where: { id }, + include: { media: true }, + }); + if (!message || message.senderId !== req.userId) { + res.status(403).json({ error: 'Нет прав для удаления' }); + return; + } + + // Delete media files from disk + if (message.media && message.media.length > 0) { + for (const m of message.media) { + if (m.url) deleteUploadedFile(m.url); + } + await prisma.media.deleteMany({ where: { messageId: id } }); + } + + await prisma.message.update({ + where: { id }, + data: { isDeleted: true, content: null }, + }); + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Получить общие медиа/файлы/ссылки чата +router.get('/chat/:chatId/shared', async (req: AuthRequest, res) => { + try { + const chatId = String(req.params.chatId); + const { type } = req.query; // 'media' | 'files' | 'links' + + // Check membership + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId, userId: req.userId! } }, + }); + if (!member) { + res.status(403).json({ error: 'Нет доступа' }); + return; + } + + const baseWhere: Prisma.MessageWhereInput = { + chatId, + isDeleted: false, + hiddenBy: { none: { userId: req.userId! } }, + ...(member.clearedAt ? { createdAt: { gt: member.clearedAt } } : {}), + }; + + if (type === 'media') { + // Images and videos + const messages = await prisma.message.findMany({ + where: { + ...baseWhere, + media: { some: { type: { in: ['image', 'video'] } } }, + }, + include: { + media: { where: { type: { in: ['image', 'video'] } } }, + sender: { select: SENDER_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + res.json(messages); + } else if (type === 'files') { + // Files (documents, archives, audio, etc.) + const messages = await prisma.message.findMany({ + where: { + ...baseWhere, + media: { some: { type: { notIn: ['image', 'video'] } } }, + }, + include: { + media: { where: { type: { notIn: ['image', 'video'] } } }, + sender: { select: SENDER_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + res.json(messages); + } else if (type === 'links') { + // Messages containing URLs + const messages = await prisma.message.findMany({ + where: { + ...baseWhere, + content: { contains: 'http' }, + }, + include: { + sender: { select: SENDER_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + // Filter to only messages with actual URLs + const withLinks = messages + .filter((m) => m.content && /https?:\/\/[^\s]+/i.test(m.content)) + .map((m) => { + const links = m.content!.match(/https?:\/\/[^\s]+/gi) || []; + return { ...m, links }; + }); + res.json(withLinks); + } else { + res.status(400).json({ error: 'Invalid type. Use: media, files, or links' }); + } + } catch (error) { + console.error('Shared media error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +export default router; diff --git a/apps/server/src/routes/stories.ts b/apps/server/src/routes/stories.ts new file mode 100644 index 0000000..4416283 --- /dev/null +++ b/apps/server/src/routes/stories.ts @@ -0,0 +1,244 @@ +import { Router } from 'express'; +import { prisma } from '../db'; +import { AuthRequest } from '../middleware/auth'; +import { deleteUploadedFile } from '../shared'; + +const router = Router(); + +// Get all active stories (grouped by user) +router.get('/', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const now = new Date(); + + // Get accepted friends + const friendships = await prisma.friendship.findMany({ + where: { + status: 'accepted', + OR: [{ userId }, { friendId: userId }], + }, + select: { userId: true, friendId: true }, + }); + + const friendIds = friendships.map(f => + f.userId === userId ? f.friendId : f.userId, + ); + // Include own userId to see own stories + friendIds.push(userId); + + const stories = await prisma.story.findMany({ + where: { + userId: { in: friendIds }, + expiresAt: { gt: now }, + }, + include: { + user: { + select: { id: true, username: true, displayName: true, avatar: true }, + }, + views: { + select: { userId: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + + // Group by user + interface StoryItem { + id: string; + type: string; + mediaUrl: string | null; + content: string | null; + bgColor: string | null; + createdAt: Date; + expiresAt: Date; + viewCount: number; + viewed: boolean; + } + interface StoryGroupResult { + user: typeof stories[number]['user']; + stories: StoryItem[]; + hasUnviewed: boolean; + } + const grouped: Record = {}; + for (const story of stories) { + if (!grouped[story.userId]) { + grouped[story.userId] = { + user: story.user, + stories: [], + hasUnviewed: false, + }; + } + const viewed = story.views.some(v => v.userId === userId); + grouped[story.userId].stories.push({ + id: story.id, + type: story.type, + mediaUrl: story.mediaUrl, + content: story.content, + bgColor: story.bgColor, + createdAt: story.createdAt, + expiresAt: story.expiresAt, + viewCount: story.views.length, + viewed, + }); + if (!viewed && story.userId !== userId) { + grouped[story.userId].hasUnviewed = true; + } + } + + // Own stories first, then unviewed, then viewed + const result = Object.values(grouped).sort((a, b) => { + if (a.user.id === userId) return -1; + if (b.user.id === userId) return 1; + if (a.hasUnviewed && !b.hasUnviewed) return -1; + if (!a.hasUnviewed && b.hasUnviewed) return 1; + return 0; + }); + + res.json(result); + } catch (error) { + console.error('Get stories error:', error); + res.status(500).json({ error: 'Ошибка получения историй' }); + } +}); + +// Create a story +router.post('/', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const { type, mediaUrl, content, bgColor } = req.body; + + // Validate mediaUrl to prevent path traversal + if (mediaUrl) { + if (typeof mediaUrl !== 'string' || !mediaUrl.startsWith('/uploads/') || mediaUrl.includes('..')) { + res.status(400).json({ error: 'Недопустимый URL медиафайла' }); + return; + } + } + + const story = await prisma.story.create({ + data: { + userId, + type: type || 'text', + mediaUrl, + content, + bgColor: bgColor || '#6366f1', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + }, + include: { + user: { + select: { id: true, username: true, displayName: true, avatar: true }, + }, + views: true, + }, + }); + + res.json(story); + } catch (error) { + console.error('Create story error:', error); + res.status(500).json({ error: 'Ошибка создания истории' }); + } +}); + +// View a story +router.post('/:storyId/view', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const storyId = req.params.storyId as string; + + // Verify story exists and viewer is the owner or a friend + const story = await prisma.story.findUnique({ where: { id: storyId }, select: { userId: true } }); + if (!story) { + res.status(404).json({ error: 'История не найдена' }); + return; + } + if (story.userId !== userId) { + const friendship = await prisma.friendship.findFirst({ + where: { + status: 'accepted', + OR: [ + { userId, friendId: story.userId }, + { userId: story.userId, friendId: userId }, + ], + }, + }); + if (!friendship) { + res.status(403).json({ error: 'Нет доступа' }); + return; + } + } + + await prisma.storyView.upsert({ + where: { storyId_userId: { storyId, userId } }, + create: { storyId, userId }, + update: {}, + }); + + res.json({ ok: true }); + } catch (error) { + console.error('View story error:', error); + res.status(500).json({ error: 'Ошибка просмотра истории' }); + } +}); + +// Get story viewers +router.get('/:storyId/viewers', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const storyId = req.params.storyId as string; + + const story = await prisma.story.findUnique({ where: { id: storyId }, select: { userId: true } }); + if (!story || story.userId !== userId) { + res.status(403).json({ error: 'Только автор может просматривать аудиторию' }); + return; + } + + const views = await prisma.storyView.findMany({ + where: { + storyId, + user: { hideStoryViews: false }, + }, + include: { + user: { + select: { id: true, username: true, displayName: true, avatar: true }, + }, + }, + orderBy: { viewedAt: 'desc' }, + }); + + res.json(views.map(v => ({ + userId: v.userId, + username: v.user.username, + displayName: v.user.displayName, + avatar: v.user.avatar, + viewedAt: v.viewedAt, + }))); + } catch (error) { + console.error('Get story viewers error:', error); + res.status(500).json({ error: 'Ошибка получения просмотров' }); + } +}); + +// Delete own story +router.delete('/:storyId', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + const storyId = req.params.storyId as string; + + const story = await prisma.story.findUnique({ where: { id: storyId } }); + if (!story || story.userId !== userId) { + res.status(403).json({ error: 'Нет прав' }); + return; + } + + // Delete media file if present + if (story.mediaUrl) deleteUploadedFile(story.mediaUrl); + + await prisma.story.delete({ where: { id: storyId } }); + res.json({ ok: true }); + } catch (error) { + console.error('Delete story error:', error); + res.status(500).json({ error: 'Ошибка удаления истории' }); + } +}); + +export default router; diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts new file mode 100644 index 0000000..c85af3f --- /dev/null +++ b/apps/server/src/routes/users.ts @@ -0,0 +1,233 @@ +import { Router } from 'express'; +import { prisma } from '../db'; +import { AuthRequest } from '../middleware/auth'; +import { USER_SELECT, SENDER_SELECT, uploadUserAvatar, deleteUploadedFile, encryptUploadedFile } from '../shared'; + +const router = Router(); + +// Поиск пользователей +router.get('/search', async (req: AuthRequest, res) => { + try { + const { q } = req.query; + if (!q || typeof q !== 'string' || q.trim().length < 3) { + res.json([]); + return; + } + + const users = await prisma.user.findMany({ + where: { + OR: [ + { username: { contains: q } }, + { displayName: { contains: q } }, + ], + NOT: { id: req.userId }, + }, + select: USER_SELECT, + take: 20, + }); + + res.json(users); + } catch (error) { + console.error('Search users error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Профиль пользователя +router.get('/:id', async (req: AuthRequest, res) => { + try { + const user = await prisma.user.findUnique({ + where: { id: String(req.params.id) }, + select: USER_SELECT, + }); + + if (!user) { + res.status(404).json({ error: 'Пользователь не найден' }); + return; + } + + res.json(user); + } catch (error) { + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Загрузить аватар +router.post('/avatar', uploadUserAvatar.single('avatar'), encryptUploadedFile, async (req: AuthRequest, res) => { + try { + if (!req.file) { + res.status(400).json({ error: 'Файл не загружен' }); + return; + } + + // Delete old avatar file if exists + const currentUser = await prisma.user.findUnique({ where: { id: req.userId }, select: { avatar: true } }); + if (currentUser?.avatar) deleteUploadedFile(currentUser.avatar); + + const avatarUrl = `/uploads/avatars/${req.file.filename}`; + + const user = await prisma.user.update({ + where: { id: req.userId }, + data: { avatar: avatarUrl }, + select: USER_SELECT, + }); + + res.json(user); + } catch (error) { + res.status(500).json({ error: 'Ошибка загрузки аватара' }); + } +}); + +// Удалить аватар +router.delete('/avatar', async (req: AuthRequest, res) => { + try { + // Delete file from disk + const currentUser = await prisma.user.findUnique({ where: { id: req.userId }, select: { avatar: true } }); + if (currentUser?.avatar) deleteUploadedFile(currentUser.avatar); + + const user = await prisma.user.update({ + where: { id: req.userId }, + data: { avatar: null }, + select: USER_SELECT, + }); + res.json(user); + } catch (error) { + res.status(500).json({ error: 'Ошибка удаления аватара' }); + } +}); + +// Обновить профиль (username НЕ меняется!) +router.put('/profile', async (req: AuthRequest, res) => { + try { + const { displayName, bio, birthday } = req.body; + + // Validate field lengths + if (displayName !== undefined && (typeof displayName !== 'string' || displayName.length === 0 || displayName.length > 50)) { + res.status(400).json({ error: 'Имя должно быть от 1 до 50 символов' }); + return; + } + if (bio !== undefined && bio !== null && (typeof bio !== 'string' || bio.length > 500)) { + res.status(400).json({ error: 'Био должно быть не длиннее 500 символов' }); + return; + } + if (birthday !== undefined && birthday !== null) { + if (typeof birthday !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(birthday) || isNaN(Date.parse(birthday))) { + res.status(400).json({ error: 'Некорректный формат даты рождения (YYYY-MM-DD)' }); + return; + } + } + + const updateData: Record = {}; + if (displayName !== undefined) updateData.displayName = displayName; + if (bio !== undefined) updateData.bio = bio; + if (birthday !== undefined) updateData.birthday = birthday; + + const user = await prisma.user.update({ + where: { id: req.userId }, + data: updateData, + select: USER_SELECT, + }); + + res.json(user); + } catch (error) { + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Поиск сообщений +router.get('/messages/search', async (req: AuthRequest, res) => { + try { + const { q, chatId } = req.query; + if (!q || typeof q !== 'string') { + res.json([]); + return; + } + + const where: Record = { + content: { contains: q }, + isDeleted: false, + }; + + if (chatId) { + where.chatId = chatId; + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId: chatId as string, userId: req.userId! } }, + }); + if (member?.clearedAt) { + where.createdAt = { gt: member.clearedAt }; + } + } else { + where.chat = { + members: { some: { userId: req.userId } }, + }; + } + + const messages = await prisma.message.findMany({ + where, + include: { + sender: { select: SENDER_SELECT }, + chat: { + select: { + id: true, + name: true, + type: true, + members: { + include: { + user: { select: { id: true, username: true, displayName: true } }, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + + // For global search (no chatId filter), filter out messages before clearedAt per chat + let filtered = messages; + if (!chatId) { + const memberships = await prisma.chatMember.findMany({ + where: { userId: req.userId! }, + select: { chatId: true, clearedAt: true }, + }); + const clearedMap = new Map(); + for (const m of memberships) { + if (m.clearedAt) clearedMap.set(m.chatId, m.clearedAt); + } + if (clearedMap.size > 0) { + filtered = messages.filter((msg) => { + const cleared = clearedMap.get(msg.chatId); + if (!cleared) return true; + return new Date(msg.createdAt) > new Date(cleared); + }); + } + } + + res.json(filtered); + } catch (error) { + console.error('Search messages error:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Обновить настройки приватности +router.put('/settings', async (req: AuthRequest, res) => { + try { + const { hideStoryViews } = req.body; + + const updateData: Record = {}; + if (typeof hideStoryViews === 'boolean') updateData.hideStoryViews = hideStoryViews; + + const user = await prisma.user.update({ + where: { id: req.userId }, + data: updateData, + select: USER_SELECT, + }); + + res.json(user); + } catch (error) { + res.status(500).json({ error: 'Ошибка сохранения настроек' }); + } +}); + +export default router; diff --git a/apps/server/src/shared.ts b/apps/server/src/shared.ts new file mode 100644 index 0000000..89799ba --- /dev/null +++ b/apps/server/src/shared.ts @@ -0,0 +1,181 @@ +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { v4 as uuidv4 } from 'uuid'; +import { Request, Response, NextFunction } from 'express'; +import { encryptFileInPlace, isEncryptionEnabled } from './encrypt'; + +// ─── Prisma select objects ──────────────────────────────────────────── + +/** Standard user fields to include in API responses (excludes password) */ +export const USER_SELECT = { + id: true, + username: true, + displayName: true, + avatar: true, + bio: true, + birthday: true, + isOnline: true, + lastSeen: true, + createdAt: true, + hideStoryViews: true, +} as const; + +/** Compact user fields for message sender / forwarded-from */ +export const SENDER_SELECT = { + id: true, + username: true, + displayName: true, + avatar: true, +} as const; + +/** Full message include for API responses */ +export const MESSAGE_INCLUDE = { + sender: { select: SENDER_SELECT }, + forwardedFrom: { select: SENDER_SELECT }, + replyTo: { + include: { sender: { select: { id: true, username: true, displayName: true } } }, + }, + media: true, + reactions: { + include: { user: { select: { id: true, username: true, displayName: true } } }, + }, + readBy: { select: { userId: true } }, +} as const; + +// ─── File system helpers ────────────────────────────────────────────── + +const uploadsRoot = path.join(__dirname, '../uploads'); + +/** Ensure a directory exists (recursive). */ +export function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +/** Safely delete a file from the uploads directory given its URL path (e.g. '/uploads/avatars/abc.jpg'). */ +export function deleteUploadedFile(urlPath: string): void { + if (!urlPath) return; + try { + const filename = urlPath.replace(/^\/uploads\//, ''); + const filePath = path.resolve(uploadsRoot, filename); + + // Path containment check — prevent directory traversal + if (!filePath.startsWith(uploadsRoot)) { + console.error('Path traversal attempt blocked:', urlPath); + return; + } + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (e) { + console.error('Failed to delete file:', urlPath, e); + } +} + +// ─── Multer configurations ─────────────────────────────────────────── + +const avatarsDir = path.join(uploadsRoot, 'avatars'); +ensureDir(avatarsDir); +ensureDir(uploadsRoot); + +/** Allowed image extensions for avatars. */ +const ALLOWED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif']); + +function createAvatarStorage(prefix = '') { + return multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, avatarsDir), + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + cb(null, `${prefix}${uuidv4()}${ext}`); + }, + }); +} + +/** Multer middleware for user avatar uploads (max 5MB, images only). */ +export const uploadUserAvatar = multer({ + storage: createAvatarStorage(''), + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (file.mimetype.startsWith('image/') && ALLOWED_IMAGE_EXTENSIONS.has(ext)) cb(null, true); + else cb(new Error('Только изображения (jpg, png, gif, webp, avif)')); + }, +}); + +/** Multer middleware for group avatar uploads (max 5MB, images only). */ +export const uploadGroupAvatar = multer({ + storage: createAvatarStorage('group-'), + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (file.mimetype.startsWith('image/') && ALLOWED_IMAGE_EXTENSIONS.has(ext)) cb(null, true); + else cb(new Error('Только изображения (jpg, png, gif, webp, avif)')); + }, +}); + +/** Blocked file extensions that could be served as executable content. */ +const BLOCKED_EXTENSIONS = new Set([ + '.html', '.htm', '.svg', '.xml', '.xhtml', + '.php', '.jsp', '.asp', '.aspx', '.cgi', + '.exe', '.bat', '.cmd', '.com', '.msi', '.scr', '.pif', + '.sh', '.bash', '.ps1', '.psm1', '.vbs', '.vbe', '.js', '.jse', '.wsf', '.wsh', + '.dll', '.sys', '.drv', + '.hta', '.cpl', '.inf', '.reg', +]); + +/** Multer middleware for general file uploads (max 50MB). */ +export const uploadFile = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, uploadsRoot), + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + cb(null, `${uuidv4()}${ext}`); + }, + }), + limits: { fileSize: 50 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (BLOCKED_EXTENSIONS.has(ext)) { + cb(new Error('Этот тип файла не разрешён')); + } else { + cb(null, true); + } + }, +}); + +// ─── Post-upload file encryption middleware ─────────────────────────── + +/** + * Express middleware that encrypts an uploaded file in-place after multer + * has written it to disk. Use after any multer middleware. + */ +export function encryptUploadedFile(req: Request, _res: Response, next: NextFunction): void { + if (!isEncryptionEnabled()) return next(); + + try { + // Single file upload (req.file) + if (req.file) { + encryptFileInPlace(req.file.path); + } + // Multiple files (req.files) — handle both array and field-keyed forms + if (req.files) { + const files = Array.isArray(req.files) + ? req.files + : Object.values(req.files).flat(); + for (const file of files) { + encryptFileInPlace(file.path); + } + } + } catch (e) { + console.error('File encryption error:', e); + // Don't block the request — file is already saved, just unencrypted + } + + next(); +} + +/** Absolute path to the uploads root directory. */ +export const UPLOADS_ROOT = uploadsRoot; diff --git a/apps/server/src/socket/index.ts b/apps/server/src/socket/index.ts new file mode 100644 index 0000000..34b8c06 --- /dev/null +++ b/apps/server/src/socket/index.ts @@ -0,0 +1,1119 @@ +import { Server, Socket } from 'socket.io'; +import jwt from 'jsonwebtoken'; +import { prisma } from '../db'; +import { config } from '../config'; +import { SENDER_SELECT, deleteUploadedFile } from '../shared'; + +interface AuthSocket extends Socket { + userId?: string; +} + +const onlineUsers = new Map>(); + +// ─── Active group calls: chatId → Set ──────────────────────── +const activeGroupCalls = new Map>(); + +// ─── Socket rate limiting ──────────────────────────────────────────── +const rateLimitMap = new Map(); +const RATE_LIMIT_WINDOW = 1000; // 1 second +const RATE_LIMIT_MAX = 10; // max events per window + +const MAX_TIMEOUT = 2_147_483_647; // Max safe setTimeout delay (~24.8 days) + +function checkRateLimit(userId: string): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(userId); + if (!entry || now > entry.resetAt) { + rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }); + return true; + } + entry.count++; + return entry.count <= RATE_LIMIT_MAX; +} + +// Clean up stale rate-limit entries every 30s +setInterval(() => { + const now = Date.now(); + for (const [key, val] of rateLimitMap) { + if (now > val.resetAt) rateLimitMap.delete(key); + } +}, 30_000); + +async function isChatMember(chatId: string, userId: string): Promise { + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId, userId } }, + }); + return !!member; +} + +export function setupSocket(io: Server) { + // On startup, re-schedule any pending scheduled messages + rescheduleMessages(io); + + io.use((socket: AuthSocket, next) => { + const token = socket.handshake.auth.token; + if (!token) return next(new Error('Требуется авторизация')); + + try { + const decoded = jwt.verify(token, config.jwtSecret) as { userId: string }; + socket.userId = decoded.userId; + next(); + } catch { + next(new Error('Недействительный токен')); + } + }); + + io.on('connection', async (socket: AuthSocket) => { + const userId = socket.userId!; + console.log(`Пользователь подключился: ${userId}`); + + if (!onlineUsers.has(userId)) { + onlineUsers.set(userId, new Set()); + } + onlineUsers.get(userId)!.add(socket.id); + + await prisma.user.update({ + where: { id: userId }, + data: { isOnline: true, lastSeen: new Date() }, + }); + + socket.broadcast.emit('user_online', { userId }); + + const userChats = await prisma.chatMember.findMany({ + where: { userId }, + select: { chatId: true }, + }); + + for (const { chatId } of userChats) { + socket.join(`chat:${chatId}`); + } + + socket.on('join_chat', async (chatId: string) => { + if (await isChatMember(chatId, userId)) { + socket.join(`chat:${chatId}`); + } + }); + + socket.on('leave_chat', (chatId: string) => { + socket.leave(`chat:${chatId}`); + }); + + // Отправка сообщения + socket.on('send_message', async (data: { + chatId: string; + content?: string; + type?: string; + replyToId?: string; + quote?: string; + forwardedFromId?: string; + mediaUrl?: string; + mediaType?: string; + fileName?: string; + fileSize?: number; + duration?: number; + scheduledAt?: string; + }) => { + try { + // Rate limit + if (!checkRateLimit(userId)) { + socket.emit('error', { message: 'Слишком много сообщений, подождите' }); + return; + } + + // Validate payload + if (!data.chatId || typeof data.chatId !== 'string') return; + if (data.content && data.content.length > 10000) { + socket.emit('error', { message: 'Сообщение слишком длинное' }); + return; + } + + // Membership check + if (!(await isChatMember(data.chatId, userId))) { + socket.emit('error', { message: 'Нет доступа к этому чату' }); + return; + } + + // Validate message type + const VALID_TYPES = ['text', 'image', 'video', 'voice', 'file', 'gif']; + const msgType = data.type || 'text'; + if (!VALID_TYPES.includes(msgType)) { + socket.emit('error', { message: 'Недопустимый тип сообщения' }); + return; + } + + // Validate mediaUrl — only /uploads/ paths or https URLs allowed + if (data.mediaUrl) { + if (typeof data.mediaUrl !== 'string') { + socket.emit('error', { message: 'Некорректный mediaUrl' }); + return; + } + const isLocalUpload = data.mediaUrl.startsWith('/uploads/'); + const isExternalUrl = data.mediaUrl.startsWith('https://'); + if (!isLocalUpload && !isExternalUrl) { + socket.emit('error', { message: 'Недопустимый mediaUrl' }); + return; + } + if (isLocalUpload && data.mediaUrl.includes('..')) { + socket.emit('error', { message: 'Недопустимый путь в mediaUrl' }); + return; + } + } + + const scheduledAt = data.scheduledAt ? new Date(data.scheduledAt) : null; + + // Validate scheduledAt — must be in the future and within 7 days + if (scheduledAt) { + const now = Date.now(); + const maxSchedule = now + 7 * 24 * 60 * 60 * 1000; + if (isNaN(scheduledAt.getTime()) || scheduledAt.getTime() <= now || scheduledAt.getTime() > maxSchedule) { + socket.emit('error', { message: 'Некорректная дата отложенного сообщения' }); + return; + } + } + + // Validate forwardedFromId — must reference an existing user + let validForwardedFromId: string | null = null; + if (data.forwardedFromId) { + const forwardUser = await prisma.user.findUnique({ where: { id: data.forwardedFromId }, select: { id: true } }); + if (forwardUser) { + validForwardedFromId = forwardUser.id; + } + } + + const message = await prisma.message.create({ + data: { + chatId: data.chatId, + senderId: userId, + content: data.content || null, + type: msgType, + replyToId: data.replyToId || null, + quote: data.quote || null, + forwardedFromId: validForwardedFromId, + scheduledAt, + ...(data.mediaUrl + ? { + media: { + create: { + type: data.mediaType || 'file', + url: data.mediaUrl, + filename: data.fileName, + size: data.fileSize, + duration: data.duration, + }, + }, + } + : {}), + }, + include: { + sender: { select: SENDER_SELECT }, + forwardedFrom: { select: SENDER_SELECT }, + replyTo: { + include: { sender: { select: { id: true, username: true, displayName: true } } }, + }, + media: true, + reactions: true, + readBy: true, + }, + }); + + // Scheduled messages: only send to the sender immediately, deliver to chat at scheduled time + if (scheduledAt && scheduledAt.getTime() > Date.now()) { + socket.emit('new_message', { + ...message, + readBy: [{ userId }], + }); + + const delay = Math.min(scheduledAt.getTime() - Date.now(), MAX_TIMEOUT); + setTimeout(async () => { + try { + // Check if message was deleted while waiting + const current = await prisma.message.findUnique({ where: { id: message.id } }); + if (!current || current.isDeleted) return; + + // Clear scheduledAt and emit to all + await prisma.message.update({ + where: { id: message.id }, + data: { scheduledAt: null }, + }); + + await prisma.readReceipt.create({ + data: { messageId: message.id, userId }, + }); + + const members = await prisma.chatMember.findMany({ + where: { chatId: data.chatId }, + select: { userId: true }, + }); + for (const member of members) { + const memberSockets = onlineUsers.get(member.userId); + if (memberSockets) { + for (const sid of memberSockets) { + const memberSocket = io.sockets.sockets.get(sid); + if (memberSocket) memberSocket.join(`chat:${data.chatId}`); + } + } + } + + const updated = await prisma.message.findUnique({ + where: { id: message.id }, + include: { + sender: { select: SENDER_SELECT }, + forwardedFrom: { select: SENDER_SELECT }, + replyTo: { + include: { sender: { select: { id: true, username: true, displayName: true } } }, + }, + media: true, + reactions: true, + readBy: true, + }, + }); + if (updated) { + // Get chat details for notification + const chat = await prisma.chat.findUnique({ + where: { id: data.chatId }, + include: { + members: { + include: { user: { select: { id: true, username: true, displayName: true } } }, + }, + }, + }); + let recipientName = ''; + if (chat) { + if (chat.type === 'group') { + recipientName = chat.name || 'Group'; + } else if (chat.type === 'favorites') { + recipientName = 'Избранное'; + } else { + const otherMember = chat.members.find(m => m.userId !== userId); + recipientName = otherMember?.user.displayName || otherMember?.user.username || ''; + } + } + + io.to(`chat:${data.chatId}`).emit('scheduled_delivered', { + ...updated, + readBy: updated.readBy.map(r => ({ userId: r.userId })), + _recipientName: recipientName, + _deliveredAt: new Date().toISOString(), + }); + } + } catch (err) { + console.error('Scheduled delivery error:', err); + } + }, delay); + return; + } + + await prisma.readReceipt.create({ + data: { messageId: message.id, userId }, + }); + + const members = await prisma.chatMember.findMany({ + where: { chatId: data.chatId }, + select: { userId: true }, + }); + + for (const member of members) { + const memberSockets = onlineUsers.get(member.userId); + if (memberSockets) { + for (const sid of memberSockets) { + const memberSocket = io.sockets.sockets.get(sid); + if (memberSocket) { + memberSocket.join(`chat:${data.chatId}`); + } + } + } + } + + io.to(`chat:${data.chatId}`).emit('new_message', { + ...message, + readBy: [{ userId }], + }); + } catch (error) { + console.error('Send message error:', error); + socket.emit('error', { message: 'Ошибка отправки сообщения' }); + } + }); + + // Индикатор набора текста (with membership check) + socket.on('typing_start', async (chatId: string) => { + if (!chatId || typeof chatId !== 'string') return; + if (!(await isChatMember(chatId, userId))) return; + socket.to(`chat:${chatId}`).emit('user_typing', { chatId, userId }); + }); + + socket.on('typing_stop', async (chatId: string) => { + if (!chatId || typeof chatId !== 'string') return; + if (!(await isChatMember(chatId, userId))) return; + socket.to(`chat:${chatId}`).emit('user_stopped_typing', { chatId, userId }); + }); + + // Отметки о прочтении + socket.on('read_messages', async (data: { chatId: string; messageIds: string[] }) => { + try { + if (!data.chatId || !Array.isArray(data.messageIds) || data.messageIds.length === 0) return; + // Limit array size to prevent abuse + if (data.messageIds.length > 200) { + socket.emit('error', { message: 'Слишком много сообщений за раз (макс. 200)' }); + return; + } + if (!(await isChatMember(data.chatId, userId))) return; + + await prisma.$transaction( + data.messageIds.map(messageId => + prisma.readReceipt.upsert({ + where: { messageId_userId: { messageId, userId } }, + create: { messageId, userId }, + update: {}, + }) + ) + ); + + socket.to(`chat:${data.chatId}`).emit('messages_read', { + chatId: data.chatId, + userId, + messageIds: data.messageIds, + }); + } catch (error) { + console.error('Read receipts error:', error); + } + }); + + // Редактирование сообщения + socket.on('edit_message', async (data: { messageId: string; content: string; chatId: string }) => { + try { + if (!checkRateLimit(userId)) return; + if (!data.messageId || !data.content || data.content.length > 10000) return; + + const message = await prisma.message.findUnique({ where: { id: data.messageId } }); + if (!message || message.senderId !== userId) return; + + const updated = await prisma.message.update({ + where: { id: data.messageId }, + data: { content: data.content, isEdited: true }, + include: { + sender: { select: SENDER_SELECT }, + replyTo: { + include: { sender: { select: { id: true, username: true, displayName: true } } }, + }, + media: true, + reactions: { include: { user: { select: { id: true, username: true, displayName: true } } } }, + readBy: { select: { userId: true } }, + }, + }); + + io.to(`chat:${message.chatId}`).emit('message_edited', updated); + } catch (error) { + console.error('Edit message error:', error); + } + }); + + // Удаление сообщения + socket.on('delete_message', async (data: { messageId: string; chatId: string }) => { + try { + if (!checkRateLimit(userId)) return; + if (!data.messageId) return; + + const message = await prisma.message.findUnique({ + where: { id: data.messageId }, + include: { media: true }, + }); + if (!message) return; + + // Проверяем членство в чате + if (!(await isChatMember(message.chatId, userId))) return; + + // Delete media files from disk + if (message.media && message.media.length > 0) { + for (const m of message.media) { + if (m.url) deleteUploadedFile(m.url); + } + // Delete media records from DB + await prisma.media.deleteMany({ where: { messageId: data.messageId } }); + } + + await prisma.message.update({ + where: { id: data.messageId }, + data: { isDeleted: true, content: null }, + }); + + io.to(`chat:${message.chatId}`).emit('message_deleted', { + messageId: data.messageId, + chatId: message.chatId, + }); + } catch (error) { + console.error('Delete message error:', error); + } + }); + + // Массовое удаление сообщений (с опцией «только у меня» / «у всех») + socket.on('delete_messages', async (data: { messageIds: string[]; chatId: string; deleteForAll: boolean }) => { + try { + if (!checkRateLimit(userId)) return; + if (!data.chatId || !Array.isArray(data.messageIds) || data.messageIds.length === 0) return; + if (data.messageIds.length > 100) return; // лимит + + // Проверяем членство в чате + if (!(await isChatMember(data.chatId, userId))) return; + + if (data.deleteForAll) { + // Удалить у всех — любой участник чата может удалить любое сообщение + const messages = await prisma.message.findMany({ + where: { + id: { in: data.messageIds }, + chatId: data.chatId, + isDeleted: false, + }, + include: { media: true }, + }); + + const deletedIds: string[] = []; + + for (const message of messages) { + // Удаляем медиа-файлы с диска + if (message.media && message.media.length > 0) { + for (const m of message.media) { + if (m.url) deleteUploadedFile(m.url); + } + await prisma.media.deleteMany({ where: { messageId: message.id } }); + } + + await prisma.message.update({ + where: { id: message.id }, + data: { isDeleted: true, content: null }, + }); + + deletedIds.push(message.id); + } + + if (deletedIds.length > 0) { + io.to(`chat:${data.chatId}`).emit('messages_deleted', { + messageIds: deletedIds, + chatId: data.chatId, + }); + } + } else { + // Удалить только у меня — создаём записи HiddenMessage + const validMessages = await prisma.message.findMany({ + where: { + id: { in: data.messageIds }, + chatId: data.chatId, + isDeleted: false, + }, + select: { id: true }, + }); + + const validIds = validMessages.map(m => m.id); + if (validIds.length === 0) return; + + // Upsert hidden records (пропускаем дубли) + await prisma.$transaction( + validIds.map(msgId => + prisma.hiddenMessage.upsert({ + where: { messageId_userId: { messageId: msgId, userId } }, + create: { messageId: msgId, userId }, + update: {}, + }) + ) + ); + + // Отправляем только этому пользователю + socket.emit('messages_hidden', { + messageIds: validIds, + chatId: data.chatId, + }); + } + } catch (error) { + console.error('Bulk delete messages error:', error); + } + }); + + // Реакции + socket.on('add_reaction', async (data: { messageId: string; emoji: string; chatId: string }) => { + try { + if (!checkRateLimit(userId)) return; + if (!data.chatId || !data.messageId || !data.emoji) return; + if (typeof data.emoji !== 'string' || data.emoji.length > 10) return; + if (!(await isChatMember(data.chatId, userId))) return; + + await prisma.reaction.upsert({ + where: { + messageId_userId_emoji: { messageId: data.messageId, userId, emoji: data.emoji }, + }, + create: { messageId: data.messageId, userId, emoji: data.emoji }, + update: {}, + }); + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, displayName: true }, + }); + + io.to(`chat:${data.chatId}`).emit('reaction_added', { + messageId: data.messageId, + chatId: data.chatId, + userId, + username: user?.displayName || user?.username, + emoji: data.emoji, + }); + } catch (error) { + console.error('Add reaction error:', error); + } + }); + + socket.on('remove_reaction', async (data: { messageId: string; emoji: string; chatId: string }) => { + try { + if (!data.chatId || !data.messageId || !data.emoji) return; + if (!(await isChatMember(data.chatId, userId))) return; + + await prisma.reaction.deleteMany({ + where: { + messageId: data.messageId, + userId, + emoji: data.emoji, + }, + }); + + io.to(`chat:${data.chatId}`).emit('reaction_removed', { + messageId: data.messageId, + chatId: data.chatId, + userId, + emoji: data.emoji, + }); + } catch (error) { + console.error('Remove reaction error:', error); + } + }); + + // ======= Pin / Unpin Messages ======= + + socket.on('pin_message', async (data: { messageId: string; chatId: string }) => { + try { + // Verify user is member of the chat + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId: data.chatId, userId } }, + }); + if (!member) return; + + // Upsert pin + await prisma.pinnedMessage.upsert({ + where: { chatId_messageId: { chatId: data.chatId, messageId: data.messageId } }, + create: { chatId: data.chatId, messageId: data.messageId }, + update: { pinnedAt: new Date() }, + }); + + // Fetch the full message to broadcast + const message = await prisma.message.findUnique({ + where: { id: data.messageId }, + include: { + sender: { select: SENDER_SELECT }, + media: true, + }, + }); + + io.to(`chat:${data.chatId}`).emit('message_pinned', { + chatId: data.chatId, + message, + }); + } catch (error) { + console.error('Pin message error:', error); + } + }); + + socket.on('unpin_message', async (data: { messageId: string; chatId: string }) => { + try { + const member = await prisma.chatMember.findUnique({ + where: { chatId_userId: { chatId: data.chatId, userId } }, + }); + if (!member) return; + + await prisma.pinnedMessage.deleteMany({ + where: { chatId: data.chatId, messageId: data.messageId }, + }); + + // Find the new latest pinned message (if any) + const latestPin = await prisma.pinnedMessage.findFirst({ + where: { chatId: data.chatId }, + orderBy: { pinnedAt: 'desc' }, + include: { + message: { + include: { + sender: { select: SENDER_SELECT }, + media: true, + }, + }, + }, + }); + + io.to(`chat:${data.chatId}`).emit('message_unpinned', { + chatId: data.chatId, + messageId: data.messageId, + newPinnedMessage: latestPin?.message || null, + }); + } catch (error) { + console.error('Unpin message error:', error); + } + }); + + // ======= WebRTC Calls ======= + + // Initiate a call: relay offer to the target user + socket.on('call_offer', async (data: { targetUserId: string; offer: unknown; callType: 'voice' | 'video'; chatId?: string }) => { + if (!data.targetUserId) return; + + // Find a common personal chat between caller and target (server-side lookup for security) + let chatId = data.chatId; + if (!chatId) { + const commonChat = await prisma.chat.findFirst({ + where: { + type: 'personal', + AND: [ + { members: { some: { userId } } }, + { members: { some: { userId: data.targetUserId } } }, + ], + }, + select: { id: true }, + }); + if (!commonChat) { + socket.emit('call_unavailable', { targetUserId: data.targetUserId }); + return; + } + chatId = commonChat.id; + } else { + // If chatId provided, verify membership + if (!(await isChatMember(chatId, userId)) || !(await isChatMember(chatId, data.targetUserId))) { + socket.emit('error', { message: 'Нет общего чата для звонка' }); + return; + } + } + + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + // Look up caller info to send to callee + let callerInfo: { id: string; username: string; displayName: string; avatar: string | null } | null = null; + try { + const caller = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, displayName: true, avatar: true }, + }); + callerInfo = caller; + } catch (e) { + // Ignore lookup errors + } + for (const sid of targetSockets) { + io.to(sid).emit('call_incoming', { + from: userId, + offer: data.offer, + callType: data.callType, + chatId, + callerInfo, + }); + } + } else { + // Target is offline + socket.emit('call_unavailable', { targetUserId: data.targetUserId }); + } + }); + + // Relay answer back to caller + socket.on('call_answer', (data: { targetUserId: string; answer: unknown }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('call_answered', { + from: userId, + answer: data.answer, + }); + } + } + }); + + // ICE candidate exchange + socket.on('ice_candidate', (data: { targetUserId: string; candidate: unknown }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('ice_candidate', { + from: userId, + candidate: data.candidate, + }); + } + } + }); + + // End call + socket.on('call_end', (data: { targetUserId: string }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('call_ended', { from: userId }); + } + } + }); + + // Decline call + socket.on('call_decline', (data: { targetUserId: string }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('call_declined', { from: userId }); + } + } + }); + + // Renegotiate (when adding video/screen share to an existing call) + socket.on('renegotiate', (data: { targetUserId: string; offer: unknown }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('renegotiate', { from: userId, offer: data.offer }); + } + } + }); + + socket.on('renegotiate_answer', (data: { targetUserId: string; answer: unknown }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('renegotiate_answer', { from: userId, answer: data.answer }); + } + } + }); + + // ======= Group Conference Calls ======= + + // Query active group call status for a chat + socket.on('group_call_status', async (data: { chatId: string }) => { + if (!data.chatId || typeof data.chatId !== 'string') return; + if (!(await isChatMember(data.chatId, userId))) return; + const participants = activeGroupCalls.get(data.chatId); + socket.emit('group_call_active', { + chatId: data.chatId, + participants: participants ? Array.from(participants) : [], + callType: 'voice', + }); + }); + + // Start or join a group call + socket.on('group_call_join', async (data: { chatId: string; callType: 'voice' | 'video' }) => { + if (!data.chatId || typeof data.chatId !== 'string') return; + if (!(await isChatMember(data.chatId, userId))) { + socket.emit('error', { message: 'Нет доступа к этому чату' }); + return; + } + // Verify it's a group chat + const chat = await prisma.chat.findUnique({ where: { id: data.chatId }, select: { type: true } }); + if (!chat || chat.type !== 'group') return; + + if (!activeGroupCalls.has(data.chatId)) { + activeGroupCalls.set(data.chatId, new Set()); + } + const participants = activeGroupCalls.get(data.chatId)!; + const existingParticipants = Array.from(participants); + participants.add(userId); + + // Look up joiner info + const joinerInfo = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, displayName: true, avatar: true }, + }); + + // Notify existing participants that someone joined + for (const pid of existingParticipants) { + const pSockets = onlineUsers.get(pid); + if (pSockets) { + for (const sid of pSockets) { + io.to(sid).emit('group_call_user_joined', { + chatId: data.chatId, + userId, + userInfo: joinerInfo, + }); + } + } + } + + // Send current participant list to the joiner + const participantInfos = await prisma.user.findMany({ + where: { id: { in: existingParticipants } }, + select: { id: true, username: true, displayName: true, avatar: true }, + }); + + socket.emit('group_call_participants', { + chatId: data.chatId, + participants: participantInfos, + }); + + // Notify all group members about the active call (for "join" button) + io.to(`chat:${data.chatId}`).emit('group_call_active', { + chatId: data.chatId, + participants: Array.from(participants), + callType: data.callType, + }); + }); + + // Leave a group call + socket.on('group_call_leave', async (data: { chatId: string }) => { + if (!data.chatId) return; + const participants = activeGroupCalls.get(data.chatId); + if (!participants) return; + participants.delete(userId); + + // Notify remaining participants + for (const pid of participants) { + const pSockets = onlineUsers.get(pid); + if (pSockets) { + for (const sid of pSockets) { + io.to(sid).emit('group_call_user_left', { chatId: data.chatId, userId }); + } + } + } + + if (participants.size === 0) { + activeGroupCalls.delete(data.chatId); + } + + // Update active call status + io.to(`chat:${data.chatId}`).emit('group_call_active', { + chatId: data.chatId, + participants: participants.size > 0 ? Array.from(participants) : [], + callType: 'voice', + }); + }); + + // Relay group call signaling between specific participants + socket.on('group_call_offer', (data: { chatId: string; targetUserId: string; offer: unknown }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('group_call_offer', { chatId: data.chatId, from: userId, offer: data.offer }); + } + } + }); + + socket.on('group_call_answer', (data: { chatId: string; targetUserId: string; answer: unknown }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('group_call_answer', { chatId: data.chatId, from: userId, answer: data.answer }); + } + } + }); + + socket.on('group_ice_candidate', (data: { chatId: string; targetUserId: string; candidate: unknown }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('group_ice_candidate', { chatId: data.chatId, from: userId, candidate: data.candidate }); + } + } + }); + + socket.on('group_call_renegotiate', (data: { chatId: string; targetUserId: string; offer: unknown }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('group_call_renegotiate', { chatId: data.chatId, from: userId, offer: data.offer }); + } + } + }); + + socket.on('group_call_renegotiate_answer', (data: { chatId: string; targetUserId: string; answer: unknown }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('group_call_renegotiate_answer', { chatId: data.chatId, from: userId, answer: data.answer }); + } + } + }); + + // ======= Friend System Events ======= + + socket.on('friend_request', async (data: { friendId: string }) => { + if (!data.friendId || typeof data.friendId !== 'string') return; + // Verify a pending friendship actually exists + const friendship = await prisma.friendship.findFirst({ + where: { userId, friendId: data.friendId, status: 'pending' }, + }); + if (!friendship) return; + + const targetSockets = onlineUsers.get(data.friendId); + if (targetSockets) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, displayName: true, avatar: true }, + }); + for (const sid of targetSockets) { + io.to(sid).emit('friend_request_received', { from: user }); + } + } + }); + + socket.on('friend_accepted', async (data: { friendId: string }) => { + if (!data.friendId || typeof data.friendId !== 'string') return; + // Verify an accepted friendship actually exists + const friendship = await prisma.friendship.findFirst({ + where: { + status: 'accepted', + OR: [ + { userId, friendId: data.friendId }, + { userId: data.friendId, friendId: userId }, + ], + }, + }); + if (!friendship) return; + + const targetSockets = onlineUsers.get(data.friendId); + if (targetSockets) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, displayName: true, avatar: true }, + }); + for (const sid of targetSockets) { + io.to(sid).emit('friend_request_accepted', { from: user }); + } + } + }); + + socket.on('friend_removed', async (data: { friendId: string }) => { + if (!data.friendId || typeof data.friendId !== 'string') return; + // Verify friendship was actually deleted (no record exists) + const friendship = await prisma.friendship.findFirst({ + where: { + OR: [ + { userId, friendId: data.friendId }, + { userId: data.friendId, friendId: userId }, + ], + }, + }); + // If friendship still exists, don't emit removal + if (friendship) return; + + const targetSockets = onlineUsers.get(data.friendId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('friend_removed', { userId }); + } + } + }); + + // Отключение + socket.on('disconnect', async () => { + console.log(`Пользователь отключился: ${userId}`); + + // Remove from active group calls + for (const [chatId, participants] of activeGroupCalls) { + if (participants.has(userId)) { + participants.delete(userId); + for (const pid of participants) { + const pSockets = onlineUsers.get(pid); + if (pSockets) { + for (const sid of pSockets) { + io.to(sid).emit('group_call_user_left', { chatId, userId }); + } + } + } + if (participants.size === 0) { + activeGroupCalls.delete(chatId); + } + io.to(`chat:${chatId}`).emit('group_call_active', { + chatId, + participants: participants.size > 0 ? Array.from(participants) : [], + callType: 'voice', + }); + } + } + + const userSockets = onlineUsers.get(userId); + if (userSockets) { + userSockets.delete(socket.id); + if (userSockets.size === 0) { + onlineUsers.delete(userId); + + await prisma.user.update({ + where: { id: userId }, + data: { isOnline: false, lastSeen: new Date() }, + }); + + socket.broadcast.emit('user_offline', { + userId, + lastSeen: new Date().toISOString(), + }); + } + } + }); + }); +} + +async function rescheduleMessages(io: Server) { + try { + const scheduled = await prisma.message.findMany({ + where: { + scheduledAt: { not: null }, + }, + include: { + sender: { select: SENDER_SELECT }, + forwardedFrom: { select: SENDER_SELECT }, + replyTo: { + include: { sender: { select: { id: true, username: true, displayName: true } } }, + }, + media: true, + reactions: true, + readBy: true, + }, + }); + + for (let i = 0; i < scheduled.length; i++) { + const msg = scheduled[i]; + // Stagger overdue messages by 100ms each to avoid simultaneous DB spike + const rawDelay = new Date(msg.scheduledAt!).getTime() - Date.now(); + const delay = Math.min(Math.max(i * 100, rawDelay), MAX_TIMEOUT); + setTimeout(async () => { + try { + // Check if message was deleted while waiting + const current = await prisma.message.findUnique({ where: { id: msg.id } }); + if (!current || current.isDeleted) return; + + await prisma.message.update({ + where: { id: msg.id }, + data: { scheduledAt: null }, + }); + + // Create sender read receipt + await prisma.readReceipt.upsert({ + where: { messageId_userId: { messageId: msg.id, userId: msg.senderId } }, + create: { messageId: msg.id, userId: msg.senderId }, + update: {}, + }); + + const updated = await prisma.message.findUnique({ + where: { id: msg.id }, + include: { + sender: { select: SENDER_SELECT }, + forwardedFrom: { select: SENDER_SELECT }, + replyTo: { + include: { sender: { select: { id: true, username: true, displayName: true } } }, + }, + media: true, + reactions: true, + readBy: true, + }, + }); + + if (updated) { + io.to(`chat:${msg.chatId}`).emit('scheduled_delivered', { + ...updated, + readBy: updated.readBy.map(r => ({ userId: r.userId })), + }); + } + } catch (err) { + console.error('Scheduled delivery error:', err); + } + }, delay); + } + + if (scheduled.length > 0) { + console.log(` ✔ ${scheduled.length} scheduled message(s) re-armed`); + } + } catch (err) { + console.error('Error rescheduling messages:', err); + } +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..87cde83 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..49d72af --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + + + Vortex Messenger + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..c35d790 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "@vortex/web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", + "@tanstack/react-query": "^5.62.0", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "emoji-mart": "^5.6.0", + "framer-motion": "^11.15.0", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "socket.io-client": "^4.8.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.3", + "vite": "^6.0.0" + } +} diff --git a/apps/web/public/logo.jpg b/apps/web/public/logo.jpg new file mode 100644 index 0000000..49b5b97 Binary files /dev/null and b/apps/web/public/logo.jpg differ diff --git a/apps/web/public/logo.png b/apps/web/public/logo.png new file mode 100644 index 0000000..200427b Binary files /dev/null and b/apps/web/public/logo.png differ diff --git a/apps/web/public/sounds/abonent_nedostupen.mp3 b/apps/web/public/sounds/abonent_nedostupen.mp3 new file mode 100644 index 0000000..a20c328 Binary files /dev/null and b/apps/web/public/sounds/abonent_nedostupen.mp3 differ diff --git a/apps/web/public/sounds/call_sound.mp3 b/apps/web/public/sounds/call_sound.mp3 new file mode 100644 index 0000000..dabfc08 Binary files /dev/null and b/apps/web/public/sounds/call_sound.mp3 differ diff --git a/apps/web/public/vortex.svg b/apps/web/public/vortex.svg new file mode 100644 index 0000000..722eb23 --- /dev/null +++ b/apps/web/public/vortex.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..6acfac3 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { AnimatePresence } from 'framer-motion'; +import { useAuthStore } from './stores/authStore'; +import AuthPage from './pages/AuthPage'; +import ChatPage from './pages/ChatPage'; + +export default function App() { + const { token, user, checkAuth, isLoading } = useAuthStore(); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + if (isLoading) { + return ( +
+
+ +

Загрузка...

+
+
+ ); + } + + return ( + + {token && user ? ( + + ) : ( + + )} + + ); +} + +function VortexLoader() { + return ( +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/Avatar.tsx b/apps/web/src/components/Avatar.tsx new file mode 100644 index 0000000..2be858b --- /dev/null +++ b/apps/web/src/components/Avatar.tsx @@ -0,0 +1,60 @@ +import { memo } from 'react'; +import { getInitials, generateAvatarColor } from '../lib/utils'; + +interface AvatarProps { + src?: string | null; + name: string; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + className?: string; + online?: boolean; +} + +const sizeClasses = { + xs: 'w-6 h-6 text-[10px]', + sm: 'w-8 h-8 text-xs', + md: 'w-10 h-10 text-sm', + lg: 'w-12 h-12 text-base', + xl: 'w-20 h-20 text-xl', +} as const; + +const onlineDotSize = { + xs: 'w-1.5 h-1.5 border', + sm: 'w-2 h-2 border', + md: 'w-2.5 h-2.5 border-2', + lg: 'w-3 h-3 border-2', + xl: 'w-4 h-4 border-2', +} as const; + +function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarProps) { + const sizeClass = sizeClasses[size]; + const initials = getInitials(name || '?'); + const gradientClass = generateAvatarColor(name || ''); + + return ( +
+ {src ? ( + {name} + ) : ( +
+ {initials} +
+ )} + {online !== undefined && ( +
+ )} +
+ ); +} + +const Avatar = memo(AvatarInner); +export default Avatar; diff --git a/apps/web/src/components/CallModal.tsx b/apps/web/src/components/CallModal.tsx new file mode 100644 index 0000000..5b97de2 --- /dev/null +++ b/apps/web/src/components/CallModal.tsx @@ -0,0 +1,1766 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Maximize, Minimize, SwitchCamera, Minimize2, Maximize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react'; +import { getSocket } from '../lib/socket'; +import { api } from '../lib/api'; +import { useLang } from '../lib/i18n'; +import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../lib/sounds'; + +type CallState = 'idle' | 'calling' | 'incoming' | 'connected' | 'ended'; + +interface CallModalProps { + isOpen: boolean; + onClose: () => void; + targetUser: { id: string; displayName?: string; username: string; avatar?: string | null } | null; + callType: 'voice' | 'video'; + incoming?: { + from: string; + offer: RTCSessionDescriptionInit; + callType: 'voice' | 'video'; + callerInfo?: { displayName?: string; username?: string; avatar?: string | null } | null; + } | null; +} + +// ICE servers cache (fetched from server, includes TURN credentials when configured) +let cachedIceConfig: RTCConfiguration | null = null; +let iceCacheFetchedAt = 0; +const ICE_CACHE_TTL = 3600_000; // 1 hour + +const FALLBACK_ICE: RTCConfiguration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + ], +}; + +async function getIceServers(): Promise { + if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) { + return cachedIceConfig; + } + try { + const data = await api.getIceServers(); + if (data.iceServers && data.iceServers.length > 0) { + cachedIceConfig = { iceServers: data.iceServers }; + iceCacheFetchedAt = Date.now(); + return cachedIceConfig; + } + } catch (e) { + console.warn('Failed to fetch ICE servers, using fallback STUN:', e); + } + return FALLBACK_ICE; +} + +// Try to get video+audio, falling back to audio-only +// If preferDeviceId is given, try that camera first +async function getMediaWithCameraFallback( + wantVideo: boolean, + preferDeviceId?: string +): Promise<{ stream: MediaStream; hasVideo: boolean }> { + if (!wantVideo) { + const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); + return { stream, hasVideo: false }; + } + + // 1) If we have a preferred camera, try it first + if (preferDeviceId) { + try { + console.log('[getMedia] Trying preferred camera:', preferDeviceId.slice(0, 12)); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + video: { deviceId: { exact: preferDeviceId } }, + }); + console.log('[getMedia] Preferred camera success'); + return { stream, hasVideo: true }; + } catch (e) { + console.warn('[getMedia] Preferred camera failed:', e); + } + } + + // 2) Try default audio+video + try { + console.log('[getMedia] Requesting audio+video (default camera)...'); + const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: true }); + console.log('[getMedia] Success —', stream.getVideoTracks().map(t => `${t.label}:${t.readyState}`)); + return { stream, hasVideo: true }; + } catch (e) { + console.warn('[getMedia] audio+video failed:', e); + } + + // 3) Final fallback: audio only + console.warn('[getMedia] Camera unavailable, falling back to audio only'); + const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); + return { stream, hasVideo: false }; +} + +// Find a video sender on the peer connection, even if sender.track is null (recvonly transceiver) +function findVideoSender(pc: RTCPeerConnection): RTCRtpSender | undefined { + // First try sender with an active video track + const withTrack = pc.getSenders().find(s => s.track?.kind === 'video'); + if (withTrack) return withTrack; + // Fall back to sender from a video transceiver (may have null track) + const videoTransceiver = pc.getTransceivers().find( + t => t.receiver?.track?.kind === 'video' + ); + return videoTransceiver?.sender; +} + +export default function CallModal({ isOpen, onClose, targetUser, callType: initialCallType, incoming }: CallModalProps) { + const { t } = useLang(); + const [callState, setCallState] = useState('idle'); + const [callType, setCallType] = useState<'voice' | 'video'>(initialCallType); + const [isMuted, setIsMuted] = useState(false); + const [isVideoOff, setIsVideoOff] = useState(false); + const [isScreenSharing, setIsScreenSharing] = useState(false); + const [hasRemoteVideo, setHasRemoteVideo] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isMinimized, setIsMinimized] = useState(false); + const [duration, setDuration] = useState(0); + const [cameras, setCameras] = useState([]); + const [showCameraMenu, setShowCameraMenu] = useState(false); + const [activeCameraId, setActiveCameraId] = useState(''); + const [remoteVolume, setRemoteVolume] = useState(1); + const [showVolumeSlider, setShowVolumeSlider] = useState(false); + const [noiseSuppression, setNoiseSuppression] = useState(false); + const [microphones, setMicrophones] = useState([]); + const [activeMicId, setActiveMicId] = useState(''); + const [showMicMenu, setShowMicMenu] = useState(false); + + const peerRef = useRef(null); + const localStreamRef = useRef(null); + const screenStreamRef = useRef(null); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + const remoteAudioRef = useRef(null); + const timerRef = useRef>(undefined); + const callTimeoutRef = useRef>(undefined); + const closeTimeoutRef = useRef>(undefined); + const targetUserIdRef = useRef(''); + const iceCandidateBufferRef = useRef([]); + const callEndedRef = useRef(false); + const videoContainerRef = useRef(null); + const remoteStreamRef = useRef(null); + // Track whether video was active before screen share to decide restoration behavior + const hadVideoBeforeScreenShareRef = useRef(false); + // Noise gate refs + const noiseGateCtxRef = useRef(null); + const noiseGateGainRef = useRef(null); + const noiseGateRafRef = useRef(0); + const noiseGateTrackRef = useRef(null); + + const cleanup = useCallback(() => { + if (timerRef.current) clearInterval(timerRef.current); + if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current); + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + stopCallRingtone(); + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => track.stop()); + localStreamRef.current = null; + } + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach(track => track.stop()); + screenStreamRef.current = null; + } + if (peerRef.current) { + peerRef.current.onconnectionstatechange = null; + peerRef.current.onicecandidate = null; + peerRef.current.ontrack = null; + peerRef.current.close(); + peerRef.current = null; + } + iceCandidateBufferRef.current = []; + remoteStreamRef.current = null; + // Clean up noise gate + if (noiseGateRafRef.current) cancelAnimationFrame(noiseGateRafRef.current); + if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; } + if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; } + noiseGateGainRef.current = null; + setDuration(0); + setIsMuted(false); + setIsVideoOff(false); + setIsScreenSharing(false); + setHasRemoteVideo(false); + setIsFullscreen(false); + setIsMinimized(false); + setShowCameraMenu(false); + setShowVolumeSlider(false); + setShowMicMenu(false); + setNoiseSuppression(false); + }, []); + + // Setup common peer connection event handlers + const setupPeerHandlers = useCallback((pc: RTCPeerConnection) => { + pc.onicecandidate = (e) => { + if (e.candidate) { + const socket = getSocket(); + socket?.emit('ice_candidate', { + targetUserId: targetUserIdRef.current, + candidate: e.candidate, + }); + } + }; + + pc.ontrack = (e) => { + console.log('[ontrack] Received track:', e.track.kind, 'readyState:', e.track.readyState, + 'enabled:', e.track.enabled, 'streams:', e.streams.length, + 'muted:', e.track.muted); + // Always merge new tracks into the existing remote stream to avoid losing + // audio when a video-only screen share track arrives on a separate stream. + let stream: MediaStream; + if (remoteStreamRef.current) { + if (!remoteStreamRef.current.getTracks().includes(e.track)) { + remoteStreamRef.current.addTrack(e.track); + } + stream = remoteStreamRef.current; + } else if (e.streams[0]) { + stream = e.streams[0]; + } else { + stream = new MediaStream([e.track]); + } + + remoteStreamRef.current = stream; + + // Check if remote has video tracks + // Include !t.muted so we detect when the remote stops sending + // (replaceTrack(null) or direction change causes muted=true) + const checkVideo = () => { + const videoTracks = stream.getVideoTracks(); + const hasVideo = videoTracks.length > 0 && videoTracks.some( + t => t.readyState === 'live' && t.enabled && !t.muted + ); + console.log('[checkVideo] videoTracks:', videoTracks.map(t => + `${t.label} state=${t.readyState} enabled=${t.enabled} muted=${t.muted}` + ), '→ hasRemoteVideo:', hasVideo); + setHasRemoteVideo(hasVideo); + }; + + // Listen for unmute/mute on every video track (muted→unmuted when data starts flowing) + const attachTrackListeners = (track: MediaStreamTrack) => { + if (track.kind !== 'video') return; + track.onunmute = checkVideo; + track.onmute = checkVideo; + track.onended = checkVideo; + }; + + stream.getVideoTracks().forEach(attachTrackListeners); + checkVideo(); + + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = stream; + } + if (remoteAudioRef.current) { + remoteAudioRef.current.srcObject = stream; + remoteAudioRef.current.play().catch(() => { }); + } + + // Track future additions/removals + stream.onaddtrack = (ev) => { + attachTrackListeners(ev.track); + checkVideo(); + }; + stream.onremovetrack = checkVideo; + + // Also schedule a delayed check — some browsers delay unmute + setTimeout(checkVideo, 500); + setTimeout(checkVideo, 1500); + }; + + pc.onconnectionstatechange = () => { + if (callEndedRef.current) return; + if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + endCallSafe(); + } + }; + }, []); + + // Schedule a tracked close timeout + const scheduleClose = useCallback((delay = 1500) => { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = setTimeout(() => { + closeTimeoutRef.current = undefined; + onClose(); + }, delay); + }, [onClose]); + + // End call (safe to call multiple times) + const endCallSafe = useCallback(() => { + if (callEndedRef.current) return; + callEndedRef.current = true; + const socket = getSocket(); + socket?.emit('call_end', { targetUserId: targetUserIdRef.current }); + stopCallRingtone(); + setCallState('ended'); + cleanup(); + scheduleClose(); + }, [cleanup, scheduleClose]); + + // Start outgoing call + const startCall = useCallback(async () => { + if (!targetUser) return; + targetUserIdRef.current = targetUser.id; + callEndedRef.current = false; + setCallState('calling'); + + try { + // Get media (video with camera enumeration fallback) + console.log('[startCall] Getting media, wantVideo:', callType === 'video'); + const { stream, hasVideo } = await getMediaWithCameraFallback(callType === 'video'); + console.log('[startCall] Got media — hasVideo:', hasVideo, + 'audioTracks:', stream.getAudioTracks().length, + 'videoTracks:', stream.getVideoTracks().length); + let effectiveCallType = callType; + if (callType === 'video' && !hasVideo) { + effectiveCallType = 'voice'; + setCallType('voice'); + } + + if (callEndedRef.current) { + stream.getTracks().forEach(t => t.stop()); + return; + } + + localStreamRef.current = stream; + + // Track active camera & mic + const videoTrackSettings = stream.getVideoTracks()[0]?.getSettings(); + if (videoTrackSettings?.deviceId) setActiveCameraId(videoTrackSettings.deviceId); + const audioTrackSettings = stream.getAudioTracks()[0]?.getSettings(); + if (audioTrackSettings?.deviceId) setActiveMicId(audioTrackSettings.deviceId); + refreshCameras(); + refreshMicrophones(); + + const iceConfig = await getIceServers(); + const pc = new RTCPeerConnection(iceConfig); + peerRef.current = pc; + + // Add all tracks from the stream + stream.getTracks().forEach(track => pc.addTrack(track, stream)); + + // If this is a video call but we only have audio, add a recvonly video transceiver + // so we can receive video from the other side and potentially add video later + if (effectiveCallType === 'video' && stream.getVideoTracks().length === 0) { + pc.addTransceiver('video', { direction: 'recvonly' }); + } + + setupPeerHandlers(pc); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + const socket = getSocket(); + socket?.emit('call_offer', { + targetUserId: targetUser.id, + offer: pc.localDescription, + callType: effectiveCallType, + }); + + // 15 second no-answer timeout + callTimeoutRef.current = setTimeout(async () => { + if (callEndedRef.current) return; + callEndedRef.current = true; + stopCallRingtone(); + const s = getSocket(); + s?.emit('call_end', { targetUserId: targetUserIdRef.current }); + cleanup(); + setCallState('ended'); + await playUnavailableSound(); + onClose(); + }, 15000); + } catch (err: any) { + console.error('Error starting call:', err); + if (err?.name === 'NotAllowedError' || err?.name === 'NotFoundError') { + alert(callType === 'video' + ? 'Разрешите доступ к камере и микрофону в настройках браузера для совершения звонков' + : 'Разрешите доступ к микрофону в настройках браузера для совершения звонков'); + } + setCallState('ended'); + cleanup(); + } + }, [targetUser, callType, cleanup, onClose, setupPeerHandlers]); + + // Accept incoming call + const acceptCall = useCallback(async () => { + if (!incoming) return; + targetUserIdRef.current = incoming.from; + callEndedRef.current = false; + stopCallRingtone(); + + try { + console.log('[acceptCall] Getting media, wantVideo:', incoming.callType === 'video'); + const { stream, hasVideo } = await getMediaWithCameraFallback(incoming.callType === 'video'); + console.log('[acceptCall] Got media — hasVideo:', hasVideo, + 'audioTracks:', stream.getAudioTracks().length, + 'videoTracks:', stream.getVideoTracks().length, + 'videoEnabled:', stream.getVideoTracks().map(t => t.enabled)); + let effectiveCallType = incoming.callType; + if (incoming.callType === 'video' && !hasVideo) { + effectiveCallType = 'voice'; + } + + if (callEndedRef.current) { + stream.getTracks().forEach(t => t.stop()); + return; + } + + localStreamRef.current = stream; + + // Track active camera & mic + const videoTrackSettings = stream.getVideoTracks()[0]?.getSettings(); + if (videoTrackSettings?.deviceId) setActiveCameraId(videoTrackSettings.deviceId); + const audioTrackSettings = stream.getAudioTracks()[0]?.getSettings(); + if (audioTrackSettings?.deviceId) setActiveMicId(audioTrackSettings.deviceId); + refreshCameras(); + refreshMicrophones(); + + const iceConfig = await getIceServers(); + const pc = new RTCPeerConnection(iceConfig); + peerRef.current = pc; + + // Setup handlers first so ontrack is ready when setRemoteDescription fires + setupPeerHandlers(pc); + + // Standard answerer pattern: set remote description FIRST + // This creates transceivers from the offer's m-lines + await pc.setRemoteDescription(new RTCSessionDescription(incoming.offer)); + + // Now add local tracks — they reuse the transceivers created from the offer, + // changing direction from recvonly to sendrecv + stream.getTracks().forEach(track => { + pc.addTrack(track, stream); + }); + + // If this is a video call but we only have audio, we need a recvonly video transceiver. + // After setRemoteDescription, one already exists from the offer — but only if the offer + // contained a video m-line. If it did, the transceiver is already recvonly. + // If there's no video transceiver at all and we want to receive video, add one. + if (incoming.callType === 'video' && !hasVideo) { + const hasVideoTransceiver = pc.getTransceivers().some( + t => t.receiver?.track?.kind === 'video' + ); + if (!hasVideoTransceiver) { + pc.addTransceiver('video', { direction: 'recvonly' }); + } + } + + console.log('[acceptCall] Transceivers after setup:', + pc.getTransceivers().map(t => ({ + mid: t.mid, + direction: t.direction, + senderTrack: t.sender?.track?.kind ?? null, + receiverTrack: t.receiver?.track?.kind ?? null, + }))); + + if (callEndedRef.current) { + pc.onconnectionstatechange = null; + pc.close(); + peerRef.current = null; + stream.getTracks().forEach(t => t.stop()); + localStreamRef.current = null; + return; + } + + // Flush buffered ICE candidates + for (const candidate of iceCandidateBufferRef.current) { + pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(console.error); + } + iceCandidateBufferRef.current = []; + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + console.log('[acceptCall] Answer created, transceivers:', + pc.getTransceivers().map(t => ({ + mid: t.mid, + direction: t.direction, + currentDirection: t.currentDirection, + senderTrack: t.sender?.track?.kind ?? null, + }))); + + if (callEndedRef.current) { + pc.onconnectionstatechange = null; + pc.close(); + peerRef.current = null; + stream.getTracks().forEach(t => t.stop()); + localStreamRef.current = null; + return; + } + + const socket = getSocket(); + socket?.emit('call_answer', { + targetUserId: incoming.from, + answer: pc.localDescription, + }); + + setCallType(effectiveCallType); + setCallState('connected'); + console.log('[acceptCall] Call connected, effectiveCallType:', effectiveCallType); + timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000); + } catch (err: any) { + console.error('Error accepting call:', err); + if (err?.name === 'NotAllowedError' || err?.name === 'NotFoundError') { + alert('Разрешите доступ к микрофону в настройках браузера для совершения звонков'); + } + if (!callEndedRef.current) { + setCallState('ended'); + cleanup(); + } + } + }, [incoming, cleanup, setupPeerHandlers]); + + // Decline incoming call + const declineCall = useCallback(() => { + if (incoming) { + const socket = getSocket(); + socket?.emit('call_decline', { targetUserId: incoming.from }); + } + callEndedRef.current = true; + stopCallRingtone(); + setCallState('ended'); + cleanup(); + scheduleClose(); + }, [incoming, cleanup, scheduleClose]); + + // Toggle mic + const toggleMic = () => { + if (localStreamRef.current) { + localStreamRef.current.getAudioTracks().forEach((t) => { + t.enabled = !t.enabled; + }); + setIsMuted(!isMuted); + } + }; + + // Volume control for remote audio + const handleVolumeChange = useCallback((vol: number) => { + const v = Math.max(0, Math.min(1, vol)); + setRemoteVolume(v); + if (remoteAudioRef.current) remoteAudioRef.current.volume = v; + }, []); + + // Noise gate with look-ahead: analyse undelayed signal, gate delayed signal + // This prevents clipping word beginnings and rejects short transients (clicks) + const applyNoiseGate = useCallback(async () => { + const pc = peerRef.current; + if (!pc || !localStreamRef.current) return; + const rawTrack = localStreamRef.current.getAudioTracks()[0]; + if (!rawTrack) return; + + try { + const ctx = new AudioContext(); + const source = ctx.createMediaStreamSource(new MediaStream([rawTrack])); + + // Look-ahead delay: signal is delayed so gate can open before audio arrives + const delayNode = ctx.createDelay(0.2); + delayNode.delayTime.value = 0.05; // 50ms look-ahead + + // Analysis path: bandpass on voice fundamentals (undelayed, sees audio early) + const analysisHP = ctx.createBiquadFilter(); + analysisHP.type = 'highpass'; analysisHP.frequency.value = 100; analysisHP.Q.value = 0.7; + const analysisLP = ctx.createBiquadFilter(); + analysisLP.type = 'lowpass'; analysisLP.frequency.value = 4000; analysisLP.Q.value = 0.7; + + const analyser = ctx.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.3; + + const gainNode = ctx.createGain(); + gainNode.gain.value = 0; + const dest = ctx.createMediaStreamDestination(); + + // Signal path: source → delay → gain → output (full quality, just gated) + source.connect(delayNode); + delayNode.connect(gainNode); + gainNode.connect(dest); + + // Analysis path (no delay): source → bandpass → analyser + source.connect(analysisHP); + analysisHP.connect(analysisLP); + analysisLP.connect(analyser); + + const dataArray = new Float32Array(analyser.fftSize); + const OPEN_THRESHOLD = -38; // dB in voice band to trigger pending + const CLOSE_THRESHOLD = -48; // dB hysteresis / transient rejection + const CONFIRM_MS = 22; // ms energy must persist to confirm voice (rejects clicks) + const HOLD_TIME = 150; // ms keep gate open after speech stops + const ATTACK = 0.002; // fast open (gate already decided ahead of time) + const RELEASE = 0.02; // smooth close + + // State machine: 0=closed, 1=pending (waiting to confirm voice), 2=open + let state = 0; + let pendingSince = 0; + let holdUntil = 0; + + const check = () => { + analyser.getFloatTimeDomainData(dataArray); + let sum = 0; + for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i]; + const rms = Math.sqrt(sum / dataArray.length); + const db = rms > 0 ? 20 * Math.log10(rms) : -100; + const now = performance.now(); + + if (state === 0) { // closed + if (db > OPEN_THRESHOLD) { + state = 1; // pending + pendingSince = now; + } + } else if (state === 1) { // pending — wait to confirm sustained energy (voice) vs transient (click) + if (db <= CLOSE_THRESHOLD) { + state = 0; // energy dropped fast → was a click, stay closed + } else if (now - pendingSince >= CONFIRM_MS) { + state = 2; // sustained → voice confirmed, open gate + holdUntil = now + HOLD_TIME; + gainNode.gain.setTargetAtTime(1, ctx.currentTime, ATTACK); + } + } else { // open + if (db > CLOSE_THRESHOLD) { + holdUntil = now + HOLD_TIME; + } + if (now >= holdUntil) { + state = 0; + gainNode.gain.setTargetAtTime(0, ctx.currentTime, RELEASE); + } + } + noiseGateRafRef.current = requestAnimationFrame(check); + }; + check(); + + const gatedTrack = dest.stream.getAudioTracks()[0]; + gatedTrack.enabled = !isMuted; + + // Replace in PeerConnection + const sender = pc.getSenders().find(s => s.track?.kind === 'audio'); + if (sender) await sender.replaceTrack(gatedTrack); + + noiseGateCtxRef.current = ctx; + noiseGateGainRef.current = gainNode; + noiseGateTrackRef.current = gatedTrack; + setNoiseSuppression(true); + } catch (err) { + console.error('Failed to apply noise gate:', err); + } + }, [isMuted]); + + const removeNoiseGate = useCallback(async () => { + const pc = peerRef.current; + // Restore raw mic track directly to PeerConnection + if (pc && localStreamRef.current) { + const rawTrack = localStreamRef.current.getAudioTracks()[0]; + if (rawTrack) { + const sender = pc.getSenders().find(s => s.track?.kind === 'audio'); + if (sender) await sender.replaceTrack(rawTrack); + } + } + if (noiseGateRafRef.current) { cancelAnimationFrame(noiseGateRafRef.current); noiseGateRafRef.current = 0; } + if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; } + if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; } + noiseGateGainRef.current = null; + setNoiseSuppression(false); + }, []); + + const toggleNoiseSuppression = useCallback(async () => { + if (noiseSuppression) { + await removeNoiseGate(); + } else { + await applyNoiseGate(); + } + }, [noiseSuppression, applyNoiseGate, removeNoiseGate]); + + // Enumerate microphones + const refreshMicrophones = useCallback(async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = devices.filter(d => d.kind === 'audioinput'); + setMicrophones(audioInputs); + return audioInputs; + } catch { return []; } + }, []); + + // Switch microphone + const switchMicrophone = useCallback(async (deviceId: string) => { + const pc = peerRef.current; + if (!pc) return; + setShowMicMenu(false); + try { + const newStream = await navigator.mediaDevices.getUserMedia({ + audio: { deviceId: { exact: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + }); + const newTrack = newStream.getAudioTracks()[0]; + if (!newTrack) return; + newTrack.enabled = !isMuted; + + // Remove noise gate if active (will re-apply after switch if needed) + const wasGated = noiseSuppression; + if (wasGated) await removeNoiseGate(); + + // Replace in local stream + if (localStreamRef.current) { + localStreamRef.current.getAudioTracks().forEach(t => { + localStreamRef.current!.removeTrack(t); + t.stop(); + }); + localStreamRef.current.addTrack(newTrack); + } + // Replace in PeerConnection + const sender = pc.getSenders().find(s => s.track?.kind === 'audio'); + if (sender) await sender.replaceTrack(newTrack); + setActiveMicId(deviceId); + + // Re-apply noise gate if it was active + if (wasGated) await applyNoiseGate(); + } catch (err) { + console.error('Switch mic failed:', err); + } + }, [isMuted, noiseSuppression, removeNoiseGate, applyNoiseGate]); + + // Enumerate cameras + const refreshCameras = useCallback(async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoInputs = devices.filter(d => d.kind === 'videoinput'); + setCameras(videoInputs); + return videoInputs; + } catch (e) { + console.warn('Could not enumerate cameras:', e); + return []; + } + }, []); + + // Open camera menu (await enumeration, then show) + const openCameraMenu = useCallback(async () => { + if (showCameraMenu) { + setShowCameraMenu(false); + return; + } + const cams = await refreshCameras(); + if (cams.length > 0) { + setShowCameraMenu(true); + } + }, [showCameraMenu, refreshCameras]); + + // Switch to a specific camera by deviceId + const switchCamera = useCallback(async (deviceId: string) => { + const pc = peerRef.current; + if (!pc) return; + setShowCameraMenu(false); + const prevCameraId = activeCameraId; + + try { + // Strategy: try to acquire new camera FIRST without stopping the old one. + // If that fails (some devices can't have 2 cameras open), stop old then retry. + let newStream: MediaStream | null = null; + + try { + newStream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: deviceId } }, + }); + } catch (e1) { + console.warn('Could not open new camera while old is active, releasing old first:', e1); + // Stop old camera tracks, then retry + const currentSender = findVideoSender(pc); + if (currentSender?.track) currentSender.track.stop(); + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach(t => { + localStreamRef.current!.removeTrack(t); + t.stop(); + }); + } + newStream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: deviceId } }, + }); + } + + const newTrack = newStream.getVideoTracks()[0]; + if (!newTrack) { + console.error('No video track from selected camera'); + return; + } + + // Now stop old tracks (if they weren't stopped already in the fallback path above) + const currentSender = findVideoSender(pc); + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach(t => { + if (t !== newTrack) { + localStreamRef.current!.removeTrack(t); + t.stop(); + } + }); + } + if (currentSender?.track && currentSender.track !== newTrack) { + currentSender.track.stop(); + } + + // Replace track on sender or add new one + if (currentSender) { + await currentSender.replaceTrack(newTrack); + // If direction was recvonly, change to sendrecv + const transceiver = pc.getTransceivers().find(t => t.sender === currentSender); + if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) { + transceiver.direction = 'sendrecv'; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: pc.localDescription, + }); + } + } else { + pc.addTrack(newTrack, localStreamRef.current || newStream); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: pc.localDescription, + }); + } + + // Update local stream + if (localStreamRef.current) { + localStreamRef.current.addTrack(newTrack); + } else { + localStreamRef.current = new MediaStream([newTrack]); + } + + setActiveCameraId(deviceId); + setIsVideoOff(false); + setCallType('video'); + } catch (err) { + console.error('Switch camera failed:', err); + // Try to restore previous camera so video isn't permanently lost + try { + let restoreStream: MediaStream | undefined; + // Try the exact previous camera first + if (prevCameraId) { + try { + restoreStream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: prevCameraId } }, + }); + } catch { /* previous camera also unavailable */ } + } + // Fall back to any camera + if (!restoreStream) { + try { + restoreStream = await navigator.mediaDevices.getUserMedia({ video: true }); + } catch { /* no camera at all */ } + } + if (restoreStream) { + const restoreTrack = restoreStream.getVideoTracks()[0]; + if (restoreTrack) { + const sender = findVideoSender(pc); + if (sender) { + await sender.replaceTrack(restoreTrack); + // Ensure direction allows sending + renegotiate + const transceiver = pc.getTransceivers().find(t => t.sender === sender); + if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) { + transceiver.direction = 'sendrecv'; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: pc.localDescription, + }); + } + } + if (localStreamRef.current) { + localStreamRef.current.addTrack(restoreTrack); + } else { + localStreamRef.current = restoreStream; + } + setIsVideoOff(false); + } + } + } catch { /* nothing we can do */ } + } + }, [activeCameraId]); + + // Toggle video: enable/disable video track, or get camera if none + const toggleVideo = useCallback(async () => { + const pc = peerRef.current; + if (!pc) return; + + if (!isVideoOff) { + // Turn off: disable video track on sender and revert to voice + const sender = findVideoSender(pc); + if (sender?.track) { + sender.track.enabled = false; + } + setIsVideoOff(true); + setCallType('voice'); + } else { + // Turn on: re-enable existing track or get new camera + const sender = findVideoSender(pc); + if (sender?.track) { + sender.track.enabled = true; + setIsVideoOff(false); + } else { + try { + const { stream: camStream, hasVideo } = await getMediaWithCameraFallback(true); + if (!hasVideo) { + console.warn('No camera available'); + return; + } + const videoTrack = camStream.getVideoTracks()[0]; + + // Find a video sender (may have null track from transceiver) + const existingSender = findVideoSender(pc); + if (existingSender) { + await existingSender.replaceTrack(videoTrack); + // If direction was recvonly, change to sendrecv and renegotiate + const transceiver = pc.getTransceivers().find(t => t.sender === existingSender); + if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) { + transceiver.direction = 'sendrecv'; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: pc.localDescription, + }); + } + } else { + pc.addTrack(videoTrack, localStreamRef.current || camStream); + // Renegotiate since new track added + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: pc.localDescription, + }); + } + + if (localStreamRef.current) { + localStreamRef.current.addTrack(videoTrack); + } + + setIsVideoOff(false); + setCallType('video'); + } catch (err) { + console.error('Could not start camera:', err); + } + } + } + }, [isVideoOff]); + + // Screen sharing + const toggleScreenShare = useCallback(async () => { + const pc = peerRef.current; + if (!pc) return; + + if (isScreenSharing) { + // Stop screen share + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach(t => t.stop()); + screenStreamRef.current = null; + } + + const hadVideo = hadVideoBeforeScreenShareRef.current; + + if (hadVideo) { + // Restore camera — request video only (no audio) to avoid interfering with existing mic + let cameraRestored = false; + try { + const constraints: MediaStreamConstraints = activeCameraId + ? { video: { deviceId: { exact: activeCameraId } } } + : { video: true }; + const camStream = await navigator.mediaDevices.getUserMedia(constraints); + const cameraTrack = camStream.getVideoTracks()[0]; + if (cameraTrack) { + const sender = findVideoSender(pc); + if (sender) { + await sender.replaceTrack(cameraTrack); + } + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach(t => { + localStreamRef.current!.removeTrack(t); + t.stop(); + }); + localStreamRef.current.addTrack(cameraTrack); + } + cameraRestored = true; + } + } catch (err) { + console.error('Error restoring camera:', err); + } + if (!cameraRestored) { + // Null out sender and go recvonly + const sender = findVideoSender(pc); + if (sender) { + await sender.replaceTrack(null); + const transceiver = pc.getTransceivers().find(t => t.sender === sender); + if (transceiver && transceiver.direction !== 'recvonly') { + transceiver.direction = 'recvonly'; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: pc.localDescription, + }); + } catch (e) { console.error('Renegotiation after screen share stop failed:', e); } + } + } + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach(t => { + localStreamRef.current!.removeTrack(t); + t.stop(); + }); + } + setIsVideoOff(true); + } + } else { + // Was voice call — don't restore camera, just null out the video sender + const sender = findVideoSender(pc); + if (sender) { + await sender.replaceTrack(null); + const transceiver = pc.getTransceivers().find(t => t.sender === sender); + if (transceiver && transceiver.direction !== 'recvonly') { + transceiver.direction = 'recvonly'; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: pc.localDescription, + }); + } catch (e) { console.error('Renegotiation after screen share stop failed:', e); } + } + } + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach(t => { + localStreamRef.current!.removeTrack(t); + t.stop(); + }); + } + setCallType('voice'); + setIsVideoOff(false); + } + + setIsScreenSharing(false); + } else { + // Start screen share — remember current video state + hadVideoBeforeScreenShareRef.current = callType === 'video' && !isVideoOff; + try { + const screenStream = await navigator.mediaDevices.getDisplayMedia({ + video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } }, + audio: false, + }); + screenStreamRef.current = screenStream; + const screenTrack = screenStream.getVideoTracks()[0]; + + // Find video sender and replace track + const sender = findVideoSender(pc); + if (sender) { + await sender.replaceTrack(screenTrack); + const transceiver = pc.getTransceivers().find(t => t.sender === sender); + if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) { + transceiver.direction = 'sendrecv'; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: pc.localDescription, + }); + } + } else { + // No video sender — add track associated with localStream to keep audio on same stream + pc.addTrack(screenTrack, localStreamRef.current || screenStream); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: pc.localDescription, + }); + } + + screenTrack.onended = async () => { + setIsScreenSharing(false); + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach(t => t.stop()); + screenStreamRef.current = null; + } + + const hadVideo = hadVideoBeforeScreenShareRef.current; + const snd = peerRef.current ? findVideoSender(peerRef.current) : undefined; + + if (hadVideo) { + // Restore camera — video only, no audio + let cameraRestored = false; + try { + const constraints: MediaStreamConstraints = activeCameraId + ? { video: { deviceId: { exact: activeCameraId } } } + : { video: true }; + const camStream = await navigator.mediaDevices.getUserMedia(constraints); + const camTrack = camStream.getVideoTracks()[0]; + if (camTrack) { + if (snd) await snd.replaceTrack(camTrack); + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach(t => { + localStreamRef.current!.removeTrack(t); + t.stop(); + }); + localStreamRef.current.addTrack(camTrack); + } + cameraRestored = true; + } + } catch (e) { + console.error('Error restoring camera:', e); + } + if (!cameraRestored && snd) { + await snd.replaceTrack(null); + const currentPc = peerRef.current; + if (currentPc) { + const transceiver = currentPc.getTransceivers().find(t => t.sender === snd); + if (transceiver && transceiver.direction !== 'recvonly') { + transceiver.direction = 'recvonly'; + try { + const offer = await currentPc.createOffer(); + await currentPc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: currentPc.localDescription, + }); + } catch (e) { console.error('Renegotiation after screen share onended failed:', e); } + } + } + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach(t => { + localStreamRef.current!.removeTrack(t); + t.stop(); + }); + } + } + } else { + // Was voice call — just null out video sender + if (snd) { + await snd.replaceTrack(null); + const currentPc = peerRef.current; + if (currentPc) { + const transceiver = currentPc.getTransceivers().find(t => t.sender === snd); + if (transceiver && transceiver.direction !== 'recvonly') { + transceiver.direction = 'recvonly'; + try { + const offer = await currentPc.createOffer(); + await currentPc.setLocalDescription(offer); + const socket = getSocket(); + socket?.emit('renegotiate', { + targetUserId: targetUserIdRef.current, + offer: currentPc.localDescription, + }); + } catch (e) { console.error('Renegotiation after screen share onended failed:', e); } + } + } + } + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach(t => { + localStreamRef.current!.removeTrack(t); + t.stop(); + }); + } + setCallType('voice'); + } + }; + + setIsScreenSharing(true); + setCallType('video'); + } catch (err) { + console.error('Error starting screen share:', err); + } + } + }, [isScreenSharing, activeCameraId, callType, isVideoOff]); + + // Fullscreen toggle + const toggleFullscreen = useCallback(async () => { + if (!videoContainerRef.current) return; + try { + if (!document.fullscreenElement) { + await videoContainerRef.current.requestFullscreen(); + setIsFullscreen(true); + } else { + await document.exitFullscreen(); + setIsFullscreen(false); + } + } catch (e) { + console.error('Fullscreen error:', e); + } + }, []); + + useEffect(() => { + const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', onFsChange); + return () => document.removeEventListener('fullscreenchange', onFsChange); + }, []); + + // Socket event listeners + useEffect(() => { + const socket = getSocket(); + if (!socket) return; + + const onCallAnswered = async (data: { from: string; answer: RTCSessionDescriptionInit }) => { + if (!peerRef.current || data.from !== targetUserIdRef.current) return; + if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current); + stopCallRingtone(); + console.log('[onCallAnswered] Setting remote description (answer)'); + await peerRef.current.setRemoteDescription(new RTCSessionDescription(data.answer)); + console.log('[onCallAnswered] Transceivers after answer:', + peerRef.current.getTransceivers().map(t => ({ + mid: t.mid, + direction: t.direction, + currentDirection: t.currentDirection, + senderTrack: t.sender?.track?.kind ?? null, + receiverTrack: t.receiver?.track?.kind ?? null, + }))); + for (const candidate of iceCandidateBufferRef.current) { + peerRef.current?.addIceCandidate(new RTCIceCandidate(candidate)).catch(console.error); + } + iceCandidateBufferRef.current = []; + setCallState('connected'); + timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000); + }; + + const onIceCandidate = (data: { from: string; candidate: RTCIceCandidateInit }) => { + if (peerRef.current && peerRef.current.remoteDescription) { + peerRef.current.addIceCandidate(new RTCIceCandidate(data.candidate)).catch(console.error); + } else { + iceCandidateBufferRef.current.push(data.candidate); + } + }; + + const onCallEnded = (data: { from: string }) => { + if (callEndedRef.current) return; + if (data.from !== targetUserIdRef.current) return; + callEndedRef.current = true; + stopCallRingtone(); + setCallState('ended'); + cleanup(); + scheduleClose(); + }; + + const onCallDeclined = (data: { from: string }) => { + if (callEndedRef.current) return; + if (data.from !== targetUserIdRef.current) return; + callEndedRef.current = true; + stopCallRingtone(); + setCallState('ended'); + cleanup(); + scheduleClose(); + }; + + const onCallUnavailable = () => { + if (callEndedRef.current) return; + callEndedRef.current = true; + stopCallRingtone(); + setCallState('ended'); + cleanup(); + scheduleClose(); + }; + + // Renegotiation (e.g. when remote adds screen share to audio-only call) + const onRenegotiate = async (data: { from: string; offer: RTCSessionDescriptionInit }) => { + if (!peerRef.current || data.from !== targetUserIdRef.current) return; + try { + await peerRef.current.setRemoteDescription(new RTCSessionDescription(data.offer)); + const answer = await peerRef.current.createAnswer(); + await peerRef.current.setLocalDescription(answer); + socket.emit('renegotiate_answer', { + targetUserId: targetUserIdRef.current, + answer: peerRef.current.localDescription, + }); + } catch (err) { + console.error('Renegotiation error:', err); + } + }; + + const onRenegotiateAnswer = async (data: { from: string; answer: RTCSessionDescriptionInit }) => { + if (!peerRef.current || data.from !== targetUserIdRef.current) return; + try { + await peerRef.current.setRemoteDescription(new RTCSessionDescription(data.answer)); + } catch (err) { + console.error('Renegotiation answer error:', err); + } + }; + + socket.on('call_answered', onCallAnswered); + socket.on('ice_candidate', onIceCandidate); + socket.on('call_ended', onCallEnded); + socket.on('call_declined', onCallDeclined); + socket.on('call_unavailable', onCallUnavailable); + socket.on('renegotiate', onRenegotiate); + socket.on('renegotiate_answer', onRenegotiateAnswer); + + return () => { + socket.off('call_answered', onCallAnswered); + socket.off('ice_candidate', onIceCandidate); + socket.off('call_ended', onCallEnded); + socket.off('call_declined', onCallDeclined); + socket.off('call_unavailable', onCallUnavailable); + socket.off('renegotiate', onRenegotiate); + socket.off('renegotiate_answer', onRenegotiateAnswer); + }; + }, [cleanup, scheduleClose]); + + // Start call on mount (outgoing) + useEffect(() => { + if (isOpen && !incoming && targetUser && callState === 'idle') { + startCall(); + } + if (isOpen && incoming && callState === 'idle') { + targetUserIdRef.current = incoming.from; + setCallState('incoming'); + setCallType(incoming.callType); + playCallRingtone(); + } + }, [isOpen, incoming, targetUser, callState, startCall]); + + // Sync local video ref with stream (only when srcObject actually changes) + useEffect(() => { + if (!localVideoRef.current) return; + const desired = isScreenSharing && screenStreamRef.current + ? screenStreamRef.current + : localStreamRef.current; + if (desired && localVideoRef.current.srcObject !== desired) { + localVideoRef.current.srcObject = desired; + } + }); + + // Sync remote video ref with remote stream (only when srcObject actually changes) + useEffect(() => { + if (!remoteVideoRef.current) return; + if (remoteStreamRef.current && remoteVideoRef.current.srcObject !== remoteStreamRef.current) { + remoteVideoRef.current.srcObject = remoteStreamRef.current; + } + }); + + // Cleanup on unmount + useEffect(() => { + return () => { + stopCallRingtone(); + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + cleanup(); + }; + }, [cleanup]); + + const formatDuration = (sec: number) => { + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + }; + + const displayName = incoming + ? incoming.callerInfo?.displayName || incoming.callerInfo?.username || '...' + : targetUser?.displayName || targetUser?.username || '...'; + + const displayAvatar = incoming + ? incoming.callerInfo?.avatar + : targetUser?.avatar; + + const initials = displayName + .split(' ') + .map((w: string) => w[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + if (!isOpen) return null; + + const showVideoArea = callState === 'connected' && (hasRemoteVideo || (callType === 'video' && (!isVideoOff || isScreenSharing))); + const hasLocalVideo = !!( + localStreamRef.current?.getVideoTracks().some(t => t.enabled) || isScreenSharing + ); + + return ( + +