From c3999db9ebf02439b01c03f51bb8aa7c6b992e21 Mon Sep 17 00:00:00 2001 From: Artur Date: Mon, 22 Jun 2026 00:00:20 +0500 Subject: [PATCH] 22-06-2026+00-00 --- lib/domain/services/api_service.dart | 28 ++ lib/domain/services/crypto_service.dart | 60 ++++ lib/logic/auth_provider.dart | 139 +++++++-- lib/presentation/screens/chat_screen.dart | 44 ++- lib/presentation/screens/contacts_screen.dart | 76 ++--- lib/presentation/screens/login_screen.dart | 23 ++ lib/presentation/screens/qr_login_screen.dart | 283 ++++++++++++++++++ lib/presentation/screens/qr_scan_screen.dart | 263 ++++++++++++++++ lib/presentation/screens/sessions_screen.dart | 168 +++++++++-- lib/presentation/screens/settings_screen.dart | 38 ++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 24 ++ pubspec.yaml | 2 + srv/app/api/endpoints/auth.py | 95 +++++- srv/app/api/schemas.py | 9 +- srv/app/db/models.py | 14 + srv/app/websocket/connection_manager.py | 34 +++ 17 files changed, 1189 insertions(+), 113 deletions(-) create mode 100644 lib/presentation/screens/qr_login_screen.dart create mode 100644 lib/presentation/screens/qr_scan_screen.dart diff --git a/lib/domain/services/api_service.dart b/lib/domain/services/api_service.dart index e9ccc8c..4e24bb1 100644 --- a/lib/domain/services/api_service.dart +++ b/lib/domain/services/api_service.dart @@ -742,4 +742,32 @@ class ApiService extends ChangeNotifier { ); return response.statusCode == 200; } + + Future approveQrLogin({ + required String roomId, + required String phoneTempPub, + required String encPrivateKeyPayload, + required String deviceName, + }) async { + try { + final token = await getAccessToken(); + final response = await _client.post( + Uri.parse('${AppConstants.baseUrl}/auth/qr/approve'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'room_id': roomId, + 'phone_temp_pub': phoneTempPub, + 'enc_private_key_payload': encPrivateKeyPayload, + 'device_name': deviceName, + }), + ); + return response.statusCode == 200; + } catch (e) { + print('Ошибка при подтверждении QR входа: $e'); + return false; + } + } } diff --git a/lib/domain/services/crypto_service.dart b/lib/domain/services/crypto_service.dart index d0ba8ab..da9a694 100644 --- a/lib/domain/services/crypto_service.dart +++ b/lib/domain/services/crypto_service.dart @@ -581,4 +581,64 @@ class CryptoService { } return _currentSharedKey!; } + + Future generateTempKeyPair() async { + return await algorithm.newKeyPair(); + } + + Future encryptPrivateKeyWithEcdh({ + required SimpleKeyPair tempKeyPair, + required String tempRemotePublicKeyBase64, + required String privateKeyToEncrypt, + }) async { + final remotePublicKey = SimplePublicKey( + base64Decode(tempRemotePublicKeyBase64), + type: KeyPairType.x25519, + ); + + final sharedSecret = await algorithm.sharedSecretKey( + keyPair: tempKeyPair, + remotePublicKey: remotePublicKey, + ); + + final privateKeyBytes = base64Decode(privateKeyToEncrypt); + final nonce = aesGcm.newNonce(); + final encrypted = await aesGcm.encrypt( + privateKeyBytes, + secretKey: sharedSecret, + nonce: nonce, + ); + + final encryptedData = nonce + encrypted.mac.bytes + encrypted.cipherText; + return base64Encode(encryptedData); + } + + Future decryptPrivateKeyWithEcdh({ + required SimpleKeyPair tempKeyPair, + required String tempRemotePublicKeyBase64, + required String encryptedPayloadBase64, + }) async { + final remotePublicKey = SimplePublicKey( + base64Decode(tempRemotePublicKeyBase64), + type: KeyPairType.x25519, + ); + + final sharedSecret = await algorithm.sharedSecretKey( + keyPair: tempKeyPair, + remotePublicKey: remotePublicKey, + ); + + final encryptedData = base64Decode(encryptedPayloadBase64); + + final nonce = encryptedData.sublist(0, 12); + final macBytes = encryptedData.sublist(12, 28); + final cipherText = encryptedData.sublist(28); + + final decrypted = await aesGcm.decrypt( + SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)), + secretKey: sharedSecret, + ); + + return base64Encode(decrypted); + } } diff --git a/lib/logic/auth_provider.dart b/lib/logic/auth_provider.dart index 933758c..8f33ed1 100644 --- a/lib/logic/auth_provider.dart +++ b/lib/logic/auth_provider.dart @@ -15,6 +15,7 @@ import 'package:provider/provider.dart'; import 'package:path/path.dart' as p; import 'package:chepuhagram/data/models/session_model.dart'; import 'package:chepuhagram/presentation/screens/splash_screen.dart'; +import 'package:chepuhagram/presentation/screens/login_screen.dart'; import 'package:chepuhagram/main.dart'; class AuthProvider extends ChangeNotifier { @@ -115,18 +116,53 @@ class AuthProvider extends ChangeNotifier { } Future logout() async { + if (_isLoading) return; _isLoading = true; notifyListeners(); - await ApiService().logoutCurrentUser(); + // 1. Отзываем токен на сервере + try { + await ApiService().logoutCurrentUser(); + } catch (e) { + print("Сервер не ответил на logout: $e"); + } - // 1. Закрываем WebSocket connection - SocketService().disconnect(); + // 2. Отключаем WebSocket connection + try { + SocketService().disconnect(); + } catch (e) { + print("Ошибка при отключении сокета: $e"); + } + + String? mode; + String? color; + try { + mode = await _storage.read(key: 'theme_mode'); + color = await _storage.read(key: 'accent_color'); + } catch (e) { + print("Ошибка чтения настроек темы: $e"); + } + + try { + await _storage.deleteAll(); + } catch (e) { + print("Ошибка очистки SecureStorage: $e"); + // Пытаемся удалить хотя бы авторизационные токены + try { + await _storage.delete(key: 'access_token'); + await _storage.delete(key: 'refresh_token'); + await _storage.delete(key: 'private_key'); + } catch (ex) { + print("Ошибка удаления отдельных токенов: $ex"); + } + } + + try { + CryptoService.clearCache(); + } catch (e) { + print("Ошибка очистки кэша криптографии: $e"); + } - final mode = await _storage.read(key: 'theme_mode'); - final color = await _storage.read(key: 'accent_color'); - await _storage.deleteAll(); - CryptoService.clearCache(); final context = navigatorKey.currentContext; if (context != null) { try { @@ -135,9 +171,20 @@ class AuthProvider extends ChangeNotifier { print("Error clearing contact provider cache: $e"); } } - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - await LocalDbService().clearDatabase(); + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + } catch (e) { + print("Ошибка очистки SharedPreferences: $e"); + } + + try { + await LocalDbService().clearDatabase(); + } catch (e) { + print("Ошибка очистки локальной базы данных: $e"); + } + _currentUserId = null; _username = null; _firstName = null; @@ -147,16 +194,19 @@ class AuthProvider extends ChangeNotifier { _about = null; _avatarPath = null; _avatarUrl = null; - if (mode != null) { - await _storage.write(key: 'theme_mode', value: mode); - } - if (color != null) { - await _storage.write(key: 'accent_color', value: color); + + try { + if (mode != null) { + await _storage.write(key: 'theme_mode', value: mode); + } + if (color != null) { + await _storage.write(key: 'accent_color', value: color); + } + } catch (e) { + print("Ошибка записи настроек темы: $e"); } // 3. Уничтожаем локальную базу данных переписки (Drift) - // Поскольку у вас Drift открывает SqfliteQueryExecutor (local_db_service.dart), - // самый надежный способ — физически удалить файл базы данных чепухаграма. try { final dbFolder = await databaseFactory.getDatabasesPath(); final dbFile = File(p.join(dbFolder, 'chat_app.db')); @@ -177,18 +227,17 @@ class AuthProvider extends ChangeNotifier { await shmFile.delete(); } } catch (e) { - print("Ошибка удаления файла БД: $e"); + print("Ошибка удаления файлов БД: $e"); } - _currentUserId = null; _isLoading = false; - notifyListeners(); + // Перенаправляем напрямую на LoginScreen во избежание циклического автологина navigatorKey.currentState?.pushAndRemoveUntil( PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => - const SplashScreen(), + const LoginScreen(), transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition(opacity: animation, child: child); }, @@ -197,19 +246,26 @@ class AuthProvider extends ChangeNotifier { ); } - Future fetchSessions() async { - _isLoading = true; - _error = null; - notifyListeners(); + Future fetchSessions({bool silent = false}) async { + if (!silent) { + _isLoading = true; + _error = null; + notifyListeners(); + } try { final data = await _apiService.getActiveSessions(); print('Fetched sessions: $data'); _sessions = data.map((json) => Session.fromJson(json)).toList(); + _error = null; } catch (e) { - _error = 'Произошла ошибка при загрузке сессий: $e'; - print(_error); + if (!silent) { + _error = 'Произошла ошибка при загрузке сессий: $e'; + } + print('Ошибка при загрузке сессий: $e'); } finally { - _isLoading = false; + if (!silent) { + _isLoading = false; + } notifyListeners(); } } @@ -321,6 +377,33 @@ class AuthProvider extends ChangeNotifier { } } + Future loginWithQrData({ + required String accessToken, + required String refreshToken, + required String userId, + required String privateKey, + }) async { + _isLoading = true; + notifyListeners(); + + try { + await _storage.write(key: 'access_token', value: accessToken); + await _storage.write(key: 'refresh_token', value: refreshToken); + await _storage.write(key: 'user_id', value: userId); + await _storage.write(key: 'private_key', value: privateKey); + + _currentUserId = int.tryParse(userId); + + await _checkAccountStatus(); + } catch (e) { + print("Ошибка авторизации по QR: $e"); + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } + Future tryAutoLogin() async { String? token; try { diff --git a/lib/presentation/screens/chat_screen.dart b/lib/presentation/screens/chat_screen.dart index 8e31c0f..870b64b 100644 --- a/lib/presentation/screens/chat_screen.dart +++ b/lib/presentation/screens/chat_screen.dart @@ -830,9 +830,10 @@ class _ChatScreenState extends State with RouteAware { final double bottomPadding = MediaQuery.of( context, ).padding.bottom; + final double effectiveInputHeight = _currentContact.id == 0 ? 0.0 : inputBarHeight; return ScrollablePositionedList.builder( padding: EdgeInsets.only( - bottom: bottomPadding + inputBarHeight + 20.0, + bottom: bottomPadding + effectiveInputHeight + 20.0, left: 8, right: 8, top: 8, @@ -4275,10 +4276,18 @@ class _ChatScreenState extends State with RouteAware { final positions = _itemPositionsListener.itemPositions.value; if (positions.isEmpty) return; - // 1. Логика подгрузки истории через индексы - // В reverse: true списке: индекс 0 — это низ (новые), последний индекс — верх (старые) - final firstVisible = positions.first.index; - final lastVisible = positions.last.index; + // Так как позиции в itemPositions приходят не отсортированными, + // находим фактический первый и последний видимый элемент + int firstVisible = positions.first.index; + int lastVisible = positions.first.index; + for (final pos in positions) { + if (pos.index < firstVisible) { + firstVisible = pos.index; + } + if (pos.index > lastVisible) { + lastVisible = pos.index; + } + } // Если прокрутили вверх к старым сообщениям if (lastVisible >= messages.length - 5) { @@ -4290,8 +4299,29 @@ class _ChatScreenState extends State with RouteAware { _loadNewerMessages(); } - // 2. Логика кнопки скролла вниз - _showScrollButtonNotifier.value = firstVisible > 5; + // 2. Логика кнопки скролла вниз: + // Показываем кнопку сразу, как только первый элемент (индекс 0) полностью ушел с экрана, + // либо если он виден, но его нижний край опустился ниже границы видимости (leadingEdge < -0.001) + bool showScrollButton = false; + ItemPosition? pos0; + for (final p in positions) { + if (p.index == 0) { + pos0 = p; + break; + } + } + + if (pos0 == null) { + // Нижний элемент (индекс 0) больше не виден вообще + showScrollButton = true; + } else { + // Нижний элемент виден, но прокручен вниз за нижнюю границу (leadingEdge отрицательный) + if (pos0.itemLeadingEdge < -0.005) { + showScrollButton = true; + } + } + + _showScrollButtonNotifier.value = showScrollButton; } Future _scrollToBottom() async { diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 922b209..817f241 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -1107,46 +1107,50 @@ class _ContactsScreenState extends State with RouteAware { curve: Curves.fastOutSlowIn, width: railWidth, color: colorScheme.surfaceVariant.withOpacity(0.12), - child: Column( - children: [ - const SizedBox(height: 12), - IconButton( - icon: Icon( - _isLeftRailExpanded - ? Icons.menu_open_rounded - : Icons.menu_rounded, + child: SafeArea( + top: true, + bottom: true, + child: Column( + children: [ + const SizedBox(height: 12), + IconButton( + icon: Icon( + _isLeftRailExpanded + ? Icons.menu_open_rounded + : Icons.menu_rounded, + ), + onPressed: () => + setState(() => _isLeftRailExpanded = !_isLeftRailExpanded), ), - onPressed: () => - setState(() => _isLeftRailExpanded = !_isLeftRailExpanded), - ), - const Divider(height: 24, indent: 12, endIndent: 12), + const Divider(height: 24, indent: 12, endIndent: 12), - _buildRailItem( - Icons.chat_bubble_outline_rounded, - Icons.chat_bubble_rounded, - "Чаты", - 0, - onTap: () => setState(() => _currentIndex = 0), - ), + _buildRailItem( + Icons.chat_bubble_outline_rounded, + Icons.chat_bubble_rounded, + "Чаты", + 0, + onTap: () => setState(() => _currentIndex = 0), + ), - const SizedBox(height: 8), - const Divider(height: 12, indent: 12, endIndent: 12), + const SizedBox(height: 8), + const Divider(height: 12, indent: 12, endIndent: 12), - _buildRailItem( - Icons.settings_outlined, - Icons.settings_rounded, - "Настройки", - 1, - onTap: () => _showSettingsDialog(), - ), - _buildRailItem( - Icons.person_outline_rounded, - Icons.person_rounded, - "Профиль", - 2, - onTap: () => _showProfileDialog(), - ), - ], + _buildRailItem( + Icons.settings_outlined, + Icons.settings_rounded, + "Настройки", + 1, + onTap: () => _showSettingsDialog(), + ), + _buildRailItem( + Icons.person_outline_rounded, + Icons.person_rounded, + "Профиль", + 2, + onTap: () => _showProfileDialog(), + ), + ], + ), ), ); } diff --git a/lib/presentation/screens/login_screen.dart b/lib/presentation/screens/login_screen.dart index fdc44cc..9e7b0c8 100644 --- a/lib/presentation/screens/login_screen.dart +++ b/lib/presentation/screens/login_screen.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:chepuhagram/presentation/screens/contacts_screen.dart'; import 'package:chepuhagram/presentation/screens/account_setup_screen.dart'; import 'package:chepuhagram/presentation/screens/key_recovery_screen.dart'; +import 'package:chepuhagram/presentation/screens/qr_login_screen.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../logic/auth_provider.dart'; @@ -183,6 +184,28 @@ class _LoginScreenState extends State ), ), ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const QrLoginScreen(), + ), + ); + }, + icon: const Icon(Icons.qr_code_scanner), + label: const Text( + "Войти по QR-коду", + style: TextStyle(fontWeight: FontWeight.w600), + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), ], ), ), diff --git a/lib/presentation/screens/qr_login_screen.dart b/lib/presentation/screens/qr_login_screen.dart new file mode 100644 index 0000000..1c0bcd1 --- /dev/null +++ b/lib/presentation/screens/qr_login_screen.dart @@ -0,0 +1,283 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:cryptography/cryptography.dart'; + +import '../../core/constants.dart'; +import '../../domain/services/crypto_service.dart'; +import '../../domain/services/api_service.dart'; +import '../../logic/auth_provider.dart'; +import 'contacts_screen.dart'; + +class QrLoginScreen extends StatefulWidget { + const QrLoginScreen({super.key}); + + @override + State createState() => _QrLoginScreenState(); +} + +class _QrLoginScreenState extends State { + final _cryptoService = CryptoService(); + String? _roomId; + SimpleKeyPair? _tempKeyPair; + String? _publicKeyBase64; + String? _deviceName; + WebSocketChannel? _channel; + bool _isInitializing = true; + String? _errorMessage; + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + _initQrSession(); + } + + String _generateRandomRoomId() { + final rand = Random.secure(); + final bytes = List.generate(16, (i) => rand.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + Future _initQrSession() async { + try { + setState(() { + _isInitializing = true; + _errorMessage = null; + }); + + // 1. Генерируем ID комнаты сопряжения + final roomId = _generateRandomRoomId(); + + // 2. Генерируем временные ключи X25519 для шифрования канала + final tempKeyPair = await _cryptoService.generateTempKeyPair(); + final publicKey = await tempKeyPair.extractPublicKey(); + final publicKeyBase64 = base64Encode(publicKey.bytes); + + // Получаем имя текущего устройства + final apiService = ApiService(); + final deviceName = await apiService.getDeviceName(); + + setState(() { + _roomId = roomId; + _tempKeyPair = tempKeyPair; + _publicKeyBase64 = publicKeyBase64; + _deviceName = deviceName; + _isInitializing = false; + }); + + // 3. Подключаемся к WebSocket серверу сопряжения + _connectWebSocket(roomId); + } catch (e) { + setState(() { + _isInitializing = false; + _errorMessage = 'Ошибка инициализации: $e'; + }); + } + } + + void _connectWebSocket(String roomId) { + try { + final wsUri = Uri.parse('${AppConstants.wsUrl}/ws/qr?room_id=$roomId'); + _channel = WebSocketChannel.connect(wsUri); + + _subscription = _channel!.stream.listen( + (data) { + _handleWsMessage(data); + }, + onError: (error) { + setState(() { + _errorMessage = 'Сбой подключения: $error. Переподключение...'; + }); + // Попытка переподключения через 3 секунды + Future.delayed(const Duration(seconds: 3), () { + if (mounted && _roomId == roomId) { + _connectWebSocket(roomId); + } + }); + }, + onDone: () { + print('QR WS соединение закрыто'); + }, + ); + } catch (e) { + setState(() { + _errorMessage = 'Ошибка WebSocket: $e'; + }); + } + } + + Future _handleWsMessage(dynamic rawData) async { + try { + final data = jsonDecode(rawData) as Map; + if (data['type'] == 'qr_authorized') { + final accessToken = data['access_token'] as String; + final refreshToken = data['refresh_token'] as String; + final userId = data['user_id'].toString(); + final phoneTempPub = data['phone_temp_pub'] as String; + final encPrivateKeyPayload = data['enc_private_key_payload'] as String; + + if (_tempKeyPair == null) return; + + // Расшифровываем основной E2EE private_key + final decryptedPrivateKey = await _cryptoService.decryptPrivateKeyWithEcdh( + tempKeyPair: _tempKeyPair!, + tempRemotePublicKeyBase64: phoneTempPub, + encryptedPayloadBase64: encPrivateKeyPayload, + ); + + // Завершаем авторизацию и сохраняем все данные + final authProvider = context.read(); + await authProvider.loginWithQrData( + accessToken: accessToken, + refreshToken: refreshToken, + userId: userId, + privateKey: decryptedPrivateKey, + ); + + if (mounted) { + // Запускаем WebSocket клиент мессенджера + await authProvider.initRealtime(); + + // Перенаправляем на главный экран + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const ContactsScreen()), + (route) => false, + ); + } + } + } catch (e) { + setState(() { + _errorMessage = 'Не удалось завершить авторизацию: $e'; + }); + } + } + + @override + void dispose() { + _subscription?.cancel(); + _channel?.sink.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + // Содержимое QR-кода + final encodedPublicKey = Uri.encodeComponent(_publicKeyBase64 ?? ''); + final encodedDeviceName = Uri.encodeComponent(_deviceName ?? 'Новое устройство'); + final qrData = 'chepuhagram:qr_login?room_id=$_roomId&public_key=$encodedPublicKey&device_name=$encodedDeviceName'; + + return Scaffold( + appBar: AppBar( + title: Text('Вход по QR-коду', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), + iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_isInitializing) + const Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Генерация сессии авторизации...'), + ], + ) + else if (_errorMessage != null && _roomId == null) + Column( + children: [ + Icon(Icons.error_outline, size: 60, color: colorScheme.error), + const SizedBox(height: 16), + Text(_errorMessage!, textAlign: TextAlign.center), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _initQrSession, + child: const Text('Повторить попытку'), + ), + ], + ) + else + Column( + children: [ + Text( + 'Войти в Чепухаграм', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 12), + const Text( + 'Откройте Чепухаграм на вашем телефоне, зайдите в Настройки -> Устройства -> Подключить устройство и отсканируйте код.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, fontSize: 14), + ), + const SizedBox(height: 36), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: QrImageView( + data: qrData, + version: QrVersions.auto, + size: 260.0, + gapless: false, + ), + ), + const SizedBox(height: 36), + if (_errorMessage != null) + Text( + _errorMessage!, + style: TextStyle(color: colorScheme.error, fontSize: 13), + textAlign: TextAlign.center, + ) + else + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text( + 'Ожидание сканирования...', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/screens/qr_scan_screen.dart b/lib/presentation/screens/qr_scan_screen.dart new file mode 100644 index 0000000..62174e8 --- /dev/null +++ b/lib/presentation/screens/qr_scan_screen.dart @@ -0,0 +1,263 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:local_auth/local_auth.dart'; + +import '../../domain/services/crypto_service.dart'; +import '../../domain/services/api_service.dart'; + +class QrScanScreen extends StatefulWidget { + const QrScanScreen({super.key}); + + @override + State createState() => _QrScanScreenState(); +} + +class _QrScanScreenState extends State { + final _mobileScannerController = MobileScannerController(); + final _localAuth = LocalAuthentication(); + final _cryptoService = CryptoService(); + bool _isProcessing = false; + String? _statusMessage; + + @override + void dispose() { + _mobileScannerController.dispose(); + super.dispose(); + } + + Future _handleQrDetected(BarcodeCapture capture) async { + if (_isProcessing) return; + + final barcode = capture.barcodes.firstOrNull; + if (barcode == null || barcode.rawValue == null) return; + + final scannedValue = barcode.rawValue!; + print('Сканирован QR: $scannedValue'); + + try { + final uri = Uri.parse(scannedValue); + if (uri.scheme != 'chepuhagram' || !scannedValue.contains('qr_login')) { + setState(() { + _statusMessage = 'Неверный формат QR-кода Чепухаграм'; + }); + return; + } + + final roomId = uri.queryParameters['room_id']; + final tempPublicKeyBase64 = uri.queryParameters['public_key']?.replaceAll(' ', '+'); + final connectingDeviceName = uri.queryParameters['device_name']; + + if (roomId == null || tempPublicKeyBase64 == null) { + setState(() { + _statusMessage = 'Отсутствуют параметры сопряжения'; + }); + return; + } + + // Нашли валидный QR, останавливаем сканер и начинаем обработку + setState(() { + _isProcessing = true; + _statusMessage = 'Обработка запроса сопряжения...'; + }); + _mobileScannerController.stop(); + + // Запрашиваем подтверждение + final confirmed = await _showConfirmDialog(context); + if (!confirmed) { + _resetScanner(); + return; + } + + // Биометрическая аутентификация с ручным фолбеком при ошибке/отсутствии настроек + bool authenticated = false; + try { + authenticated = await _localAuth.authenticate( + localizedReason: 'Подтвердите личность для сопряжения нового устройства', + options: const AuthenticationOptions( + biometricOnly: false, + useErrorDialogs: true, + stickyAuth: false, + ), + ); + } catch (e) { + print('Исключение local_auth, пробуем ручное подтверждение: $e'); + authenticated = await _showManualConfirmationDialog(context); + } + + if (!authenticated) { + _resetScanner(); + return; + } + + // 1. Извлекаем локальный закрытый E2EE-ключ + final privateKey = await _cryptoService.getPrivateKey(); + if (privateKey == null) { + throw Exception('Локальный закрытый ключ шифрования не найден'); + } + + // 2. Генерируем свои временные ключи сопряжения + final phoneKeyPair = await _cryptoService.generateTempKeyPair(); + final phonePublicKey = await phoneKeyPair.extractPublicKey(); + final phonePublicKeyBase64 = base64Encode(phonePublicKey.bytes); + + // 3. Шифруем приватный ключ на временном общем секрете + final encPayload = await _cryptoService.encryptPrivateKeyWithEcdh( + tempKeyPair: phoneKeyPair, + tempRemotePublicKeyBase64: tempPublicKeyBase64, + privateKeyToEncrypt: privateKey, + ); + + // 4. Отправляем одобрение на сервер + final apiService = ApiService(); + + final success = await apiService.approveQrLogin( + roomId: roomId, + phoneTempPub: phonePublicKeyBase64, + encPrivateKeyPayload: encPayload, + deviceName: connectingDeviceName ?? 'Новое устройство', + ); + + if (success) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Новое устройство успешно авторизовано')), + ); + Navigator.pop(context); + } + } else { + throw Exception('Сервер отклонил авторизацию сопряжения'); + } + } catch (e) { + print(' Ошибка: $e'); + setState(() { + _statusMessage = 'Ошибка: $e'; + }); + _resetScanner(); + } + } + + void _resetScanner() { + setState(() { + _isProcessing = false; + }); + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + _mobileScannerController.start(); + } + }); + } + + Future _showConfirmDialog(BuildContext context) async { + return await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + title: const Text('Подтверждение входа'), + content: const Text( + 'Вы действительно хотите войти на новом устройстве? Это перенесет ваши зашифрованные ключи для доступа к чатам.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Войти'), + ), + ], + ); + }, + ) ?? + false; + } + + Future _showManualConfirmationDialog(BuildContext context) async { + return await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + title: const Text('Подтверждение сопряжения'), + content: const Text( + 'Биометрическая защита или блокировка экрана не настроены на этом телефоне.\n\nВы действительно подтверждаете вход на новом устройстве?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Подтвердить'), + ), + ], + ); + }, + ) ?? + false; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Сканировать QR-код'), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: Stack( + children: [ + // Сканер камеры + MobileScanner( + controller: _mobileScannerController, + onDetect: _handleQrDetected, + ), + + // Полупрозрачная рамка фокуса по центру + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: colorScheme.primary, width: 3), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + spreadRadius: 2000, + ), + ], + ), + ), + ), + + // Текстовая подсказка + Positioned( + bottom: 60, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + _statusMessage ?? 'Наведите камеру на QR-код на новом устройстве', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screens/sessions_screen.dart b/lib/presentation/screens/sessions_screen.dart index cb2877c..a0f9a6b 100644 --- a/lib/presentation/screens/sessions_screen.dart +++ b/lib/presentation/screens/sessions_screen.dart @@ -1,9 +1,11 @@ import 'dart:io'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:chepuhagram/logic/auth_provider.dart'; import 'package:chepuhagram/data/models/session_model.dart'; +import 'package:chepuhagram/presentation/screens/qr_scan_screen.dart'; import 'package:timeago/timeago.dart' as timeago; class SessionsScreen extends StatefulWidget { @@ -14,6 +16,8 @@ class SessionsScreen extends StatefulWidget { } class _SessionsScreenState extends State { + Timer? _refreshTimer; + @override void initState() { super.initState(); @@ -22,6 +26,18 @@ class _SessionsScreenState extends State { Provider.of(context, listen: false).fetchSessions(); } }); + // Периодическое обновление списка сессий каждые 5 секунд (фоновое) + _refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) { + if (mounted) { + Provider.of(context, listen: false).fetchSessions(silent: true); + } + }); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); } @override @@ -32,9 +48,14 @@ class _SessionsScreenState extends State { appBar: AppBar( title: Text( 'Активные сеансы', - style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + iconTheme: IconThemeData( + color: Theme.of(context).colorScheme.onSurface, ), - iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface), elevation: 0, backgroundColor: Colors.transparent, ), @@ -169,9 +190,83 @@ class _SessionsList extends StatelessWidget { .where((s) => s.id != currentSession?.id) .toList(); + final colorScheme = Theme.of(context).colorScheme; + return ListView( padding: EdgeInsets.zero, children: [ + if (Platform.isAndroid) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Container( + decoration: BoxDecoration( + color: colorScheme.surfaceVariant.withOpacity(0.2), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: colorScheme.outlineVariant.withOpacity(0.15), + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const QrScanScreen()), + ); + }, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.qr_code_scanner, + color: colorScheme.primary, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подключить устройство', + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + 'Сканируйте QR-код для входа на ПК или планшете', + style: TextStyle( + color: colorScheme.outline, + fontSize: 12, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: colorScheme.outline), + ], + ), + ), + ), + ), + ), + ), + ], + const SizedBox(height: 16), if (currentSession != null) ...[ _buildSectionHeader(context, 'Текущий сеанс'), Padding( @@ -264,22 +359,48 @@ class _SessionsList extends StatelessWidget { }) { final colorScheme = Theme.of(context).colorScheme; - DateTime now = DateTime.now(); + // Считаем устройство онлайн, если оно текущее или последняя активность была менее 90 секунд назад + final difference = DateTime.now().toUtc().difference(session.lastActive.toUtc()); + final isOnline = isCurrent || difference.inSeconds < 90; + + final statusText = isOnline + ? 'В сети' + : 'Активность: ${timeago.format(session.lastActive.toLocal(), locale: 'ru')}'; - Duration offset = now.timeZoneOffset; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(isCurrent ? 0.12 : 0.08), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - _getIconForDevice(session.deviceName), - color: colorScheme.primary, - size: 24, - ), + leading: Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(isCurrent ? 0.12 : 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _getIconForDevice(session.deviceName), + color: colorScheme.primary, + size: 24, + ), + ), + Positioned( + bottom: -2, + right: -2, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: isOnline ? Colors.green : Colors.grey, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).scaffoldBackgroundColor, + width: 2, + ), + ), + ), + ), + ], ), title: Text( session.deviceName, @@ -288,9 +409,20 @@ class _SessionsList extends StatelessWidget { fontSize: 16, ), ), - subtitle: Text( - '${session.ipAddress}\nАктивность: ${timeago.format(session.lastActive.toLocal(), locale: 'ru')}', - style: TextStyle(color: colorScheme.outline, fontSize: 13), + subtitle: Text.rich( + TextSpan( + children: [ + TextSpan(text: '${session.ipAddress}\n'), + TextSpan( + text: statusText, + style: TextStyle( + color: isOnline ? Colors.green : colorScheme.outline, + fontWeight: isOnline ? FontWeight.w500 : FontWeight.normal, + ), + ), + ], + ), + style: const TextStyle(fontSize: 13), ), trailing: isCurrent ? null diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index b2d8990..6cef4d7 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -368,28 +368,34 @@ class _SettingsScreenState extends State { horizontal: 20, vertical: 4, ), - leading: Icon(Icons.logout_rounded, color: colorScheme.error), + leading: authProv.isLoading + ? SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(colorScheme.error), + ), + ) + : Icon(Icons.logout_rounded, color: colorScheme.error), title: Text( - "Выйти из аккаунта", + authProv.isLoading ? "Выход из аккаунта..." : "Выйти из аккаунта", style: TextStyle( color: colorScheme.error, fontWeight: FontWeight.w600, ), ), - trailing: Icon( - Icons.chevron_right_rounded, - color: colorScheme.error, - ), - onTap: () async { - await authProv.logout(); - if (context.mounted) { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (_) => const LoginScreen()), - (r) => false, - ); - } - }, + trailing: authProv.isLoading + ? null + : Icon( + Icons.chevron_right_rounded, + color: colorScheme.error, + ), + onTap: authProv.isLoading + ? null + : () async { + await authProv.logout(); + }, ), ), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b65903f..c7352d6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -20,6 +20,7 @@ import flutter_secure_storage_darwin import flutter_webrtc import gal import local_auth_darwin +import mobile_scanner import package_info_plus import path_provider_foundation import photo_manager @@ -47,6 +48,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 1b91e6f..58e7175 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1168,6 +1168,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce + url: "https://pub.dev" + source: hosted + version: "7.2.0" nested: dependency: transitive description: @@ -1408,6 +1416,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" recase: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b3a9716..4ebc3eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,6 +85,8 @@ dependencies: timeago: ^3.6.1 cached_network_image: ^3.4.1 scrollable_positioned_list: ^0.3.8 + qr_flutter: ^4.1.0 + mobile_scanner: ^7.2.0 dev_dependencies: flutter_test: diff --git a/srv/app/api/endpoints/auth.py b/srv/app/api/endpoints/auth.py index 70eeffd..d805ef6 100644 --- a/srv/app/api/endpoints/auth.py +++ b/srv/app/api/endpoints/auth.py @@ -33,7 +33,8 @@ authRouter = APIRouter(prefix="/auth", tags=[]) async def _register_new_login_session(user_id: int, request: Request, db: AsyncSession, device_name: Optional[str] = None): if not device_name: - device_name = request.headers.get("X-Device-Name") or _parse_device_name(request) + device_name = request.headers.get( + "X-Device-Name") or _parse_device_name(request) ip_address = request.client.host if request.client else "0.0.0.0" new_session = models.Session( @@ -99,9 +100,10 @@ async def login(data: schemas.LoginRequest, request: Request, db: AsyncSession = raise HTTPException(status_code=400, detail="Неверный TOTP код") user_id = user.id - - explicit_device_name = getattr(data, "device_name", None) or request.headers.get("X-Device-Name") - + + explicit_device_name = getattr( + data, "device_name", None) or request.headers.get("X-Device-Name") + new_session = await _register_new_login_session(user_id, request, db, device_name=explicit_device_name) access_token = security.create_access_token( @@ -121,7 +123,7 @@ async def login(data: schemas.LoginRequest, request: Request, db: AsyncSession = f"Устройство: {device_name}\n" f"IP-адрес: {ip_address}\n" f"Время: {current_time}\n\n" - f"Если это не вы, немедленно перейдите в Настройки -> Активные сеансы и завершите подозрительную сессию." + f"Если это не вы, немедленно перейдите в Настройки -> Устройства и завершите подозрительную сессию." ) await send_system_notification( @@ -160,9 +162,9 @@ async def login_oauth(request: Request, form_data: OAuth2PasswordRequestForm = D raise HTTPException(status_code=400, detail="Неверный TOTP код") # Логируем сессию в базу данных - + oauth_device_name = request.headers.get("X-Device-Name") - + new_session = await _register_new_login_session(user.id, request, db, device_name=oauth_device_name) access_token = security.create_access_token( @@ -551,3 +553,82 @@ async def update_fcm(token: str, request: Request, current_user: models.User = D await db.refresh(session) return {"status": "ok"} + + +@authRouter.post("/qr/approve") +async def qr_approve( + data: schemas.QRApproveRequest, + request: Request, + current_user: models.User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + # 1. Проверяем, есть ли активное вебсокет-соединение для этой комнаты (room_id) + if data.room_id not in manager.qr_connections: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Сессия сопряжения не найдена или истекла" + ) + + # 2. Регистрируем новую сессию для сопряженного устройства + device_name = data.device_name or "Новое устройство (QR)" + new_session = await _register_new_login_session( + user_id=current_user.id, + request=request, + db=db, + device_name=device_name + ) + + # 3. Генерируем JWT-токены авторизации для новой сессии + access_token = security.create_access_token( + data={"sub": str(current_user.id)}, + session_id=new_session.id + ) + refresh_token = security.create_refresh_token( + data={"sub": str(current_user.id)}, + session_id=new_session.id + ) + + new_session.session_token = access_token + await db.commit() + + # 4. Отправляем все необходимые данные новому устройству через WebSocket + payload = { + "type": "qr_authorized", + "access_token": access_token, + "refresh_token": refresh_token, + "phone_temp_pub": data.phone_temp_pub, + "enc_private_key_payload": data.enc_private_key_payload, + "user_id": current_user.id + } + + # Отправляем сообщение + sent = await manager.send_qr_message(data.room_id, payload) + + # Закрываем сокет комнаты сопряжения + if sent: + ws = manager.qr_connections.get(data.room_id) + if ws: + try: + await ws.close() + except Exception: + pass + manager.disconnect_qr(data.room_id) + + # Отправляем системное оповещение на телефон о новом входе + current_time = datetime.now(timezone.utc).strftime("%H:%M:%S") + ip_address = request.client.host if request.client else "0.0.0.0" + security_alert_text = ( + f"Обнаружен новый вход в ваш аккаунт через QR-код.\n\n" + f"Устройство: {device_name}\n" + f"IP-адрес: {ip_address}\n" + f"Время: {current_time}\n\n" + f"Если это не вы, немедленно перейдите в Настройки -> Устройства и завершите подозрительную сессию." + ) + + await send_system_notification( + db=db, + receiver_id=current_user.id, + plain_text=security_alert_text + ) + + return {"status": "ok", "message": "Вход через QR-код успешно одобрен"} diff --git a/srv/app/api/schemas.py b/srv/app/api/schemas.py index d286225..7dbc47d 100644 --- a/srv/app/api/schemas.py +++ b/srv/app/api/schemas.py @@ -101,4 +101,11 @@ class AdminCreateUser(BaseModel): username: str password: str first_name: str - last_name: Optional[str] = None \ No newline at end of file + last_name: Optional[str] = None + + +class QRApproveRequest(BaseModel): + room_id: str + phone_temp_pub: str + enc_private_key_payload: str + device_name: Optional[str] = None \ No newline at end of file diff --git a/srv/app/db/models.py b/srv/app/db/models.py index 12434d0..8d1426f 100644 --- a/srv/app/db/models.py +++ b/srv/app/db/models.py @@ -17,6 +17,20 @@ elif SQLALCHEMY_DATABASE_URL.startswith("postgresql://"): connect_args = {"check_same_thread": False} if "sqlite" in SQLALCHEMY_DATABASE_URL else {} engine = create_async_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args) + +from sqlalchemy import event + +@event.listens_for(engine.sync_engine, "connect") +def register_sqlite_functions(dbapi_connection, connection_record): + real_conn = dbapi_connection + if hasattr(real_conn, "_conn"): + real_conn = real_conn._conn + if hasattr(real_conn, "create_function"): + try: + real_conn.create_function("LOWER", 1, lambda s: s.lower() if s is not None else None) + except Exception: + pass + AsyncSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession, expire_on_commit=False) Base = declarative_base() diff --git a/srv/app/websocket/connection_manager.py b/srv/app/websocket/connection_manager.py index 569cff4..ad1f21f 100644 --- a/srv/app/websocket/connection_manager.py +++ b/srv/app/websocket/connection_manager.py @@ -32,6 +32,23 @@ wsRouter = APIRouter( ) +@wsRouter.websocket("/qr") +async def qr_websocket_endpoint(websocket: WebSocket, room_id: str = Query(...)): + print(f"QR сопряжение: новое соединение для комнаты {room_id}") + await manager.connect_qr(websocket, room_id) + try: + while True: + data = await websocket.receive_text() + message_data = json.loads(data) + if message_data.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + except WebSocketDisconnect: + pass + finally: + manager.disconnect_qr(room_id) + print(f"QR сопряжение: отключено соединение для комнаты {room_id}") + + @wsRouter.websocket("") async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: AsyncSession = Depends(get_db)): if token is None: @@ -486,6 +503,23 @@ class ConnectionManager: def __init__(self): self.active_connections: Dict[str, Dict[int, WebSocket]] = {} self.online_users: Dict[str, datetime] = {} + self.qr_connections: Dict[str, WebSocket] = {} + + async def connect_qr(self, websocket: WebSocket, room_id: str): + await websocket.accept() + self.qr_connections[room_id] = websocket + + def disconnect_qr(self, room_id: str): + self.qr_connections.pop(room_id, None) + + async def send_qr_message(self, room_id: str, message: dict) -> bool: + if room_id in self.qr_connections: + try: + await self.qr_connections[room_id].send_json(message) + return True + except Exception: + self.disconnect_qr(room_id) + return False async def connect(self, websocket: WebSocket, user_id: int, session_id: int): await websocket.accept()