From 981d322e1d0b39eb7b4bfe9f261e1cdf4a1a430e Mon Sep 17 00:00:00 2001 From: Artur Date: Sun, 3 May 2026 17:36:04 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9E=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE=D0=B3=D1=80=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reports/problems/problems-report.html | 2 +- lib/data/datasources/local_db_service.dart | 73 ++- lib/data/models/contact_model.dart | 8 +- lib/data/models/message_model.dart | 22 +- lib/domain/services/api_service.dart | 35 +- lib/domain/services/crypto_service.dart | 76 ++- lib/logic/auth_provider.dart | 2 +- lib/logic/contact_provider.dart | 1 + .../screens/account_settings_screen.dart | 4 +- lib/presentation/screens/chat_screen.dart | 471 ++++++++++++++++-- lib/presentation/screens/contacts_screen.dart | 3 +- lib/presentation/screens/settings_screen.dart | 6 +- .../screens/user_profile_screen.dart | 3 +- lib/presentation/widgets/contact_tile.dart | 3 +- lib/presentation/widgets/message_bubble.dart | 180 +++++-- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 74 ++- pubspec.yaml | 4 + srv/app/api/endpoints/media.py | 409 ++++++++++++--- srv/app/api/endpoints/messages.py | 4 +- srv/app/api/endpoints/users.py | 2 +- srv/app/core/config.py | 9 + srv/app/db/models.py | 41 +- srv/app/websocket/connection_manager.py | 22 + srv/main.py | 4 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 27 files changed, 1274 insertions(+), 190 deletions(-) diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 8854391..0d3d26a 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/data/datasources/local_db_service.dart b/lib/data/datasources/local_db_service.dart index a36590a..7ed7330 100644 --- a/lib/data/datasources/local_db_service.dart +++ b/lib/data/datasources/local_db_service.dart @@ -1,6 +1,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:chepuhagram/data/models/message_model.dart'; +import 'dart:typed_data'; class LocalDbService { static final LocalDbService _instance = LocalDbService._internal(); @@ -19,7 +20,7 @@ class LocalDbService { String path = join(await getDatabasesPath(), 'chat_app.db'); return await openDatabase( path, - version: 4, + version: 7, onCreate: (db, version) async { await db.execute(''' CREATE TABLE messages( @@ -32,7 +33,10 @@ class LocalDbService { read_at TEXT, reply_to_id INTEGER, reply_to_text TEXT, - edited_at TEXT + edited_at TEXT, + message_type TEXT DEFAULT 'text', + file_id TEXT, + encrypted_key TEXT ) '''); }, @@ -52,6 +56,38 @@ class LocalDbService { if (oldVersion < 4) { await db.execute('ALTER TABLE messages ADD COLUMN edited_at TEXT'); } + if (oldVersion < 5) { + try { + await db.execute( + 'ALTER TABLE messages ADD COLUMN message_type TEXT', + ); + } catch (e) { + print('message_type column already exists: $e'); + } + try { + await db.execute('ALTER TABLE messages ADD COLUMN file_id TEXT'); + } catch (e) { + print('file_id column already exists: $e'); + } + } + if (oldVersion < 6) { + try { + await db.execute( + 'ALTER TABLE messages ADD COLUMN encrypted_key TEXT', + ); + } catch (e) { + print('encrypted_key column already exists: $e'); + } + } + if (oldVersion < 7) { + try { + await db.execute( + 'ALTER TABLE messages ADD COLUMN local_file_bytes BLOB', + ); + } catch (e) { + print('local_file_bytes column already exists: $e'); + } + } }, ); } @@ -61,23 +97,36 @@ class LocalDbService { await db.delete('messages'); } - // Сохранение списка сообщений (из истории) Future saveMessages(List messages) async { final db = await database; + final List incomingIds = messages.map((msg) { + return (msg is MessageModel) ? msg.id! : (msg['id'] as int); + }).toList(); + Batch batch = db.batch(); + + if (incomingIds.isNotEmpty) { + batch.delete('messages', where: 'id NOT IN (${incomingIds.join(',')})'); + } for (var msg in messages) { if (msg is MessageModel) { batch.insert('messages', { 'id': msg.id, 'sender_id': msg.senderId, 'receiver_id': msg.receiverId, - 'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст! + 'content': msg.text, 'timestamp': msg.createdAt.toIso8601String(), 'delivered_at': null, 'read_at': null, 'reply_to_id': msg.replyToId, 'reply_to_text': msg.replyToText, 'edited_at': msg.editedAt?.toIso8601String(), + 'message_type': msg.messageType == MessageType.image + ? 'image' + : 'text', + 'file_id': msg.fileId, + 'encrypted_key': msg.encryptedFileKey, + 'local_file_bytes': msg.localFileBytes, }, conflictAlgorithm: ConflictAlgorithm.replace); } else { // Если это Map из API @@ -93,6 +142,9 @@ class LocalDbService { 'reply_to_id': msg['reply_to_id'], 'reply_to_text': msg['reply_to_text'], 'edited_at': msg['edited_at'], + 'message_type': msg['message_type'] ?? 'text', + 'file_id': msg['file_id'], + 'encrypted_key': msg['encrypted_key'], }, conflictAlgorithm: ConflictAlgorithm.replace); } } @@ -159,6 +211,19 @@ class LocalDbService { ); } + Future updateMessageLocalFileBytes( + int messageId, + Uint8List localFileBytes, + ) async { + final db = await database; + await db.update( + 'messages', + {'local_file_bytes': localFileBytes}, + where: 'id = ?', + whereArgs: [messageId], + ); + } + Future updateMessageContent( int messageId, String content, diff --git a/lib/data/models/contact_model.dart b/lib/data/models/contact_model.dart index ee16ad5..7098b78 100644 --- a/lib/data/models/contact_model.dart +++ b/lib/data/models/contact_model.dart @@ -6,8 +6,8 @@ class Contact { String name; String surname; final String? lastMessage; - final String? avatarFileId; - final String? avatarUrl; + String? avatarFileId; + String? avatarUrl; final DateTime? lastMessageTime; final bool isOnline; final int unreadCount; @@ -51,8 +51,8 @@ class Contact { name: name ?? this.name, surname: surname ?? this.surname, lastMessage: lastMessage ?? this.lastMessage, - avatarFileId: avatarFileId ?? this.avatarFileId, - avatarUrl: avatarUrl ?? this.avatarUrl, + avatarFileId: avatarFileId, + avatarUrl: avatarUrl, lastMessageTime: lastMessageTime ?? this.lastMessageTime, isOnline: isOnline ?? this.isOnline, unreadCount: unreadCount ?? this.unreadCount, diff --git a/lib/data/models/message_model.dart b/lib/data/models/message_model.dart index 5a0e167..c4b3b11 100644 --- a/lib/data/models/message_model.dart +++ b/lib/data/models/message_model.dart @@ -2,6 +2,8 @@ import 'dart:typed_data'; enum MessageStatus { sending, sent, delivered, read, failed } +enum MessageType { text, image } + class MessageModel { final int? id; // server id (null пока не подтверждено сервером) final int? tempId; // client temp id (для сопоставления ack) @@ -15,6 +17,9 @@ class MessageModel { final String? replyToText; // текст сообщения, на которое отвечают (для отображения) final DateTime? editedAt; final Uint8List? localFileBytes; + final MessageType messageType; + final String? fileId; + final String? encryptedFileKey; MessageModel({ this.id, @@ -28,7 +33,10 @@ class MessageModel { this.replyToId, this.replyToText, this.editedAt, - this.localFileBytes + this.localFileBytes, + this.messageType = MessageType.text, + this.fileId, + this.encryptedFileKey, }); MessageModel copyWith({ @@ -44,6 +52,9 @@ class MessageModel { String? replyToText, DateTime? editedAt, Uint8List? localFileBytes, + MessageType? messageType, + String? fileId, + String? encryptedFileKey, }) { return MessageModel( id: id ?? this.id, @@ -58,6 +69,9 @@ class MessageModel { replyToText: replyToText ?? this.replyToText, editedAt: editedAt ?? this.editedAt, localFileBytes: localFileBytes ?? this.localFileBytes, + messageType: messageType ?? this.messageType, + fileId: fileId ?? this.fileId, + encryptedFileKey: encryptedFileKey ?? this.encryptedFileKey, ); } @@ -78,6 +92,9 @@ class MessageModel { replyToId: json['reply_to_id'] == null ? null : int.tryParse(json['reply_to_id'].toString()), replyToText: json['reply_to_text'] == null ? null : json['reply_to_text'].toString(), editedAt: json['edited_at'] == null ? null : DateTime.tryParse(json['edited_at'].toString()), + messageType: json['message_type'] == 'image' ? MessageType.image : MessageType.text, + fileId: json['file_id']?.toString(), + encryptedFileKey: json['encrypted_key']?.toString(), ); } @@ -93,6 +110,9 @@ class MessageModel { 'reply_to_id': replyToId, 'reply_to_text': replyToText, 'edited_at': editedAt?.toIso8601String(), + 'message_type': messageType == MessageType.image ? 'image' : 'text', + 'file_id': fileId, + 'encrypted_key': encryptedFileKey, }; } } diff --git a/lib/domain/services/api_service.dart b/lib/domain/services/api_service.dart index 4629f80..0a7c52d 100644 --- a/lib/domain/services/api_service.dart +++ b/lib/domain/services/api_service.dart @@ -1,21 +1,23 @@ import 'package:jwt_decoder/jwt_decoder.dart'; +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:chepuhagram/core/constants.dart'; import 'package:http/http.dart' as http; -import 'dart:convert'; class ApiService extends ChangeNotifier { final _client = http.Client(); final _storage = const FlutterSecureStorage(); bool _isRefreshing = false; - Future uploadMedia(List bytes) async { + Future uploadMedia(List bytes, {String purpose = 'media'}) async { try { - final token = getAccessToken(); + final token = await getAccessToken(); var request = http.MultipartRequest( 'POST', - Uri.parse('${AppConstants.baseUrl}/media/upload'), + Uri.parse('${AppConstants.baseUrl}/media/v2/upload'), ); request.headers.addAll({ 'Authorization': 'Bearer $token', @@ -28,9 +30,8 @@ class ApiService extends ChangeNotifier { filename: 'media.enc', // Имя файла для сервера ), ); - - // Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.) - // request.headers.addAll({'Authorization': 'Bearer $token'}); + // Добавляем purpose + request.fields['purpose'] = purpose; var streamedResponse = await request.send().timeout(Duration(seconds: 30)); var response = await http.Response.fromStream(streamedResponse); @@ -227,6 +228,26 @@ class ApiService extends ChangeNotifier { return jsonDecode(response.body) as List; } + Future downloadMedia(String fileId) async { + try { + final token = await getAccessToken(); + final response = await _client.get( + Uri.parse('${AppConstants.baseUrl}/media/$fileId'), + headers: { + 'Authorization': 'Bearer $token', + }, + ); + if (response.statusCode == 200) { + return response.bodyBytes; + } + print('Ошибка загрузки медиа: ${response.statusCode}'); + return null; + } catch (e) { + print('Ошибка downloadMedia: $e'); + return null; + } + } + Future> updateMe({ required String username, required String firstName, diff --git a/lib/domain/services/crypto_service.dart b/lib/domain/services/crypto_service.dart index 6e92b0c..48e297d 100644 --- a/lib/domain/services/crypto_service.dart +++ b/lib/domain/services/crypto_service.dart @@ -1,6 +1,7 @@ import 'package:cryptography/cryptography.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:chepuhagram/data/models/contact_model.dart'; class CryptoService { @@ -192,10 +193,19 @@ class CryptoService { contact.copyWith( lastMessage: utf8.decode(decrypted), isLastMsgDecrypted: true, + avatarFileId: contact.avatarFileId, + avatarUrl: contact.avatarUrl, ), ); } catch (e) { - result.add(contact.copyWith(lastMessage: '[не удалось расшифровать: $e]', isLastMsgDecrypted: true)); + result.add( + contact.copyWith( + lastMessage: '[не удалось расшифровать: $e]', + isLastMsgDecrypted: true, + avatarFileId: contact.avatarFileId, + avatarUrl: contact.avatarUrl, + ), + ); } } return result; @@ -264,6 +274,70 @@ class CryptoService { } } + Future decryptAesKey( + String encryptedKey, + SecretKey sharedKey, + ) async { + try { + final keyBytes = base64Decode(encryptedKey); + final nonce = keyBytes.sublist(0, 12); + final cipherText = keyBytes.sublist(12, keyBytes.length - 16); + final mac = keyBytes.sublist(keyBytes.length - 16); + + final decrypted = await aesGcm.decrypt( + SecretBox(cipherText, nonce: nonce, mac: Mac(mac)), + secretKey: sharedKey, + ); + return Uint8List.fromList(decrypted); + } catch (e) { + print('Ошибка дешифровки AES ключа: $e'); + return null; + } + } + + Future encryptAesKey(List keyBytes, SecretKey sharedKey) async { + try { + final encrypted = await aesGcm.encrypt(keyBytes, secretKey: sharedKey); + return base64Encode(encrypted.concatenation()); + } catch (e) { + print('Ошибка шифрования AES ключа: $e'); + return null; + } + } + + Future decryptImage( + List encryptedData, + String encryptedKey, + SecretKey sharedKey, + ) async { + try { + final keyBytes = base64Decode(encryptedKey); + final keyNonce = keyBytes.sublist(0, 12); + final keyCipher = keyBytes.sublist(12, keyBytes.length - 16); + final keyMac = keyBytes.sublist(keyBytes.length - 16); + + final decryptedFileKey = await aesGcm.decrypt( + SecretBox(keyCipher, nonce: keyNonce, mac: Mac(keyMac)), + secretKey: sharedKey, + ); + + final fileSecretKey = SecretKey(decryptedFileKey); + final nonce = encryptedData.sublist(0, 12); + final cipherText = encryptedData.sublist(12, encryptedData.length - 16); + final mac = encryptedData.sublist(encryptedData.length - 16); + + final decryptedBytes = await aesGcm.decrypt( + SecretBox(cipherText, nonce: nonce, mac: Mac(mac)), + secretKey: fileSecretKey, + ); + + return Uint8List.fromList(decryptedBytes); + } catch (e) { + print('Ошибка дешифровки медиа: $e'); + return null; + } + } + Future decryptMessage(String base64Data, SecretKey sharedKey) async { final data = base64Decode(base64Data); diff --git a/lib/logic/auth_provider.dart b/lib/logic/auth_provider.dart index 8234dd8..584d9ae 100644 --- a/lib/logic/auth_provider.dart +++ b/lib/logic/auth_provider.dart @@ -358,7 +358,7 @@ class AuthProvider extends ChangeNotifier { Future updateAvatar(String path) async { try { final bytes = await File(path).readAsBytes(); - final fileId = await _apiService.uploadMedia(bytes); + final fileId = await _apiService.uploadMedia(bytes, purpose: 'avatar'); if (fileId != null) { final success = await _apiService.updateAvatar(fileId); if (success) { diff --git a/lib/logic/contact_provider.dart b/lib/logic/contact_provider.dart index 067474e..150adae 100644 --- a/lib/logic/contact_provider.dart +++ b/lib/logic/contact_provider.dart @@ -139,6 +139,7 @@ class ContactProvider extends ChangeNotifier { isOnline: updatedContact.isOnline, publicKey: updatedContact.publicKey, ); + print("Контакт ${updatedContact.name} ${updatedContact.surname} ${updatedContact.id} ${updatedContact.avatarFileId} ${updatedContact.avatarUrl} обновлен"); notifyListeners(); } } catch (e) { diff --git a/lib/presentation/screens/account_settings_screen.dart b/lib/presentation/screens/account_settings_screen.dart index a1fb404..e35d2d3 100644 --- a/lib/presentation/screens/account_settings_screen.dart +++ b/lib/presentation/screens/account_settings_screen.dart @@ -155,8 +155,8 @@ class _AccountSettingsScreenState extends State { decoration: const InputDecoration( labelText: 'О себе', ), - minLines: 2, - maxLines: 5, + minLines: 1, + maxLines: 10, ), ], ), diff --git a/lib/presentation/screens/chat_screen.dart b/lib/presentation/screens/chat_screen.dart index 16a6331..48d4487 100644 --- a/lib/presentation/screens/chat_screen.dart +++ b/lib/presentation/screens/chat_screen.dart @@ -1,8 +1,12 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:cryptography/cryptography.dart'; import '/data/models/message_model.dart'; import '/data/models/contact_model.dart'; import 'package:chepuhagram/presentation/widgets/message_bubble.dart'; +import 'package:gal/gal.dart'; import 'package:chepuhagram/data/repositories/contact_repository.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart'; import 'package:chepuhagram/data/datasources/ws_client.dart'; @@ -18,7 +22,6 @@ import 'package:flutter/services.dart'; import 'user_profile_screen.dart'; import 'package:image_picker/image_picker.dart'; import '/core/theme_manager.dart'; -import 'dart:io'; class ChatScreen extends StatefulWidget { final Contact contact; @@ -43,6 +46,9 @@ class _ChatScreenState extends State with RouteAware { StreamSubscription? _socketSubscription; final Set _sentReadReceipts = {}; final LocalDbService _localDbService = LocalDbService(); + final ScrollController _scrollController = ScrollController(); + final Map _messageKeys = {}; + bool _showScrollToEnd = false; Uint8List? _pendingImageBytes; MessageModel? _replyTo; bool _isOnline = false; @@ -73,6 +79,8 @@ class _ChatScreenState extends State with RouteAware { startOnlineUpdates(); _controller.addListener(_sendTypingStatus); + _scrollController.addListener(_updateScrollButtonVisibility); + final socketService = Provider.of(context, listen: false); _socketSubscription = socketService.messages.listen(_handleIncomingMessage); } @@ -197,6 +205,8 @@ class _ChatScreenState extends State with RouteAware { void dispose() { currentActiveChatContactId = null; _socketSubscription?.cancel(); + _scrollController.removeListener(_updateScrollButtonVisibility); + _scrollController.dispose(); _controller.dispose(); routeObserver.unsubscribe(this); _inputFocusNode.dispose(); @@ -301,13 +311,27 @@ class _ChatScreenState extends State with RouteAware { ) : null, child: ListView.builder( + controller: _scrollController, reverse: true, // Сообщения растут снизу вверх itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[messages.length - 1 - index]; + final keyId = msg.id ?? msg.tempId ?? index; + final itemKey = _messageKeys.putIfAbsent( + keyId, + () => GlobalKey(), + ); return MessageBubble( + key: itemKey, message: msg, onTap: () => _showMessageActions(msg), + onReplyTap: msg.replyToId != null + ? () => _scrollToMessage(msg.replyToId) + : null, + onImageTap: msg.messageType == MessageType.image + ? () => _openFullScreenImage(msg) + : null, + onImageNeeded: _loadImageBytesForMessage, ); }, ), @@ -316,6 +340,13 @@ class _ChatScreenState extends State with RouteAware { _buildMessageInput(), ], ), + floatingActionButton: _showScrollToEnd + ? FloatingActionButton( + onPressed: _scrollToBottom, + child: const Icon(Icons.keyboard_arrow_down), + tooltip: 'Перейти к последнему сообщению', + ) + : null, ); } @@ -364,8 +395,11 @@ class _ChatScreenState extends State with RouteAware { title: const Text('Ответить'), onTap: () { Navigator.of(ctx).pop(); - setState(() => _replyTo = msg); - _inputFocusNode.requestFocus(); + String text = msg.text; + if (msg.text.isEmpty && msg.messageType == MessageType.image) { + text = "[Фото]"; + } + setState(() => _replyTo = msg.copyWith(text: text)); }, ), if (msg.isMe) @@ -401,6 +435,15 @@ class _ChatScreenState extends State with RouteAware { ); }, ), + if (msg.messageType == MessageType.image) + ListTile( + leading: const Icon(Icons.save_alt), + title: const Text('Сохранить в галерею'), + onTap: () { + Navigator.of(ctx).pop(); + _saveImageToGallery(msg); + }, + ), ListTile( leading: const Icon(Icons.forward), title: const Text('Переслать'), @@ -577,7 +620,8 @@ class _ChatScreenState extends State with RouteAware { Future _forwardMessage(MessageModel msg, Contact targetContact) async { final forwardText = msg.text.trim(); - if (forwardText.isEmpty) { + final isImage = msg.messageType == MessageType.image; + if (forwardText.isEmpty && !isImage) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -603,7 +647,7 @@ class _ChatScreenState extends State with RouteAware { ), behavior: SnackBarBehavior.floating, margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), - duration: Duration(seconds: 3), + duration: const Duration(seconds: 3), ), ); return; @@ -618,28 +662,90 @@ class _ChatScreenState extends State with RouteAware { myPrivKey, targetContact.publicKey!, ); + + String contentToEncrypt = forwardText; + if (contentToEncrypt.isEmpty && isImage) { + contentToEncrypt = ""; + } final encryptedContent = await _cryptoService.encryptMessage( - forwardText, + contentToEncrypt, sharedSecret, ); - final previewText = forwardText.length > 50 - ? forwardText.substring(0, 50) - : forwardText; + final String previewText = forwardText.isNotEmpty + ? (forwardText.length > 50 + ? forwardText.substring(0, 50) + : forwardText) + : (isImage ? "[Фото]" : ""); final encryptedContent50 = await _cryptoService.encryptMessage( previewText, sharedSecret, ); + String? fileIdToSend; + String? encryptedFileKeyToSend; + Uint8List? localImageBytes = msg.localFileBytes; + + if (isImage) { + if (msg.fileId != null && + msg.encryptedFileKey != null && + _currentContact.publicKey != null) { + final currentChatSharedSecret = await _cryptoService + .deriveSharedSecret(myPrivKey, _currentContact.publicKey!); + final originalFileKeyBytes = await _cryptoService.decryptAesKey( + msg.encryptedFileKey!, + currentChatSharedSecret, + ); + if (originalFileKeyBytes != null) { + final reencryptedKey = await _cryptoService.encryptAesKey( + originalFileKeyBytes, + sharedSecret, + ); + if (reencryptedKey != null) { + fileIdToSend = msg.fileId; + encryptedFileKeyToSend = reencryptedKey; + } + } + } + + if (fileIdToSend == null || encryptedFileKeyToSend == null) { + if (msg.localFileBytes != null) { + final imageEncryptResult = await _cryptoService.encryptImage( + msg.localFileBytes!, + sharedSecret, + ); + if (imageEncryptResult == null) { + throw Exception('Ошибка шифрования пересылаемой картинки'); + } + fileIdToSend = await apiService.uploadMedia(imageEncryptResult.$1); + if (fileIdToSend == null) { + throw Exception( + 'Не удалось загрузить пересылаемое изображение на сервер', + ); + } + encryptedFileKeyToSend = imageEncryptResult.$2; + } + } + + if (fileIdToSend == null || encryptedFileKeyToSend == null) { + throw Exception( + 'Невозможно переслать изображение: отсутствует шифрованный ключ или файл.', + ); + } + } + final tempId = DateTime.now().microsecondsSinceEpoch; final localMessage = MessageModel( tempId: tempId, - text: forwardText.isNotEmpty ? forwardText : "[Фото]", + text: forwardText.isNotEmpty ? forwardText : (isImage ? "[Фото]" : ""), isMe: true, senderId: myId, receiverId: targetContact.id, createdAt: DateTime.now(), status: MessageStatus.sending, - localFileBytes: _pendingImageBytes, + localFileBytes: isImage ? localImageBytes : null, + messageType: isImage ? MessageType.image : MessageType.text, + fileId: fileIdToSend, + encryptedFileKey: encryptedFileKeyToSend, ); if (_currentContact.id == targetContact.id) { setState(() { @@ -648,15 +754,23 @@ class _ChatScreenState extends State with RouteAware { }); } - final ok = Provider.of(context, listen: false) - .sendMessage({ - 'type': 'private_message', - 'receiver_id': targetContact.id, - 'message_type': 'text', - 'content': encryptedContent, - 'content50': encryptedContent50, - 'temp_id': tempId, - }); + final payload = { + 'type': 'private_message', + 'receiver_id': targetContact.id, + 'message_type': isImage ? 'image' : 'text', + 'content': encryptedContent, + 'content50': encryptedContent50, + 'temp_id': tempId, + if (isImage) ...{ + 'file_id': fileIdToSend, + 'encrypted_key': encryptedFileKeyToSend, + }, + }; + + final ok = Provider.of( + context, + listen: false, + ).sendMessage(payload); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -667,12 +781,12 @@ class _ChatScreenState extends State with RouteAware { : 'Не удалось переслать сообщение.', ), behavior: SnackBarBehavior.floating, // Обязательно для margin - margin: EdgeInsets.only( + margin: const EdgeInsets.only( bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) left: 10.0, right: 10.0, ), - duration: Duration(seconds: 3), + duration: const Duration(seconds: 3), ), ); @@ -697,8 +811,12 @@ class _ChatScreenState extends State with RouteAware { SnackBar( content: Text('Ошибка пересылки: $e'), behavior: SnackBarBehavior.floating, - margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), - duration: Duration(seconds: 5), + margin: const EdgeInsets.only( + bottom: 80.0 + 10.0, + left: 10.0, + right: 10.0, + ), + duration: const Duration(seconds: 5), ), ); } @@ -730,7 +848,11 @@ class _ChatScreenState extends State with RouteAware { const SizedBox(width: 8), Expanded( child: Text( - _replyTo!.text, + _replyTo!.text.isNotEmpty + ? _replyTo!.text + : (_replyTo!.messageType == MessageType.image + ? "[Фото]" + : ""), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -781,7 +903,6 @@ class _ChatScreenState extends State with RouteAware { Expanded( child: TextField( controller: _controller, - focusNode: _inputFocusNode, minLines: 1, maxLines: 5, textInputAction: TextInputAction.newline, @@ -845,6 +966,7 @@ class _ChatScreenState extends State with RouteAware { String? encryptedFileKey; String encryptedContent; String encryptedContent50; + String? encryptedReplyToText; // 2. Если есть изображение — сначала загружаем его if (hasImage) { @@ -883,17 +1005,27 @@ class _ChatScreenState extends State with RouteAware { sharedSecret, ); + if (_replyTo?.id != null && _replyTo!.text.trim().isNotEmpty) { + encryptedReplyToText = await _cryptoService.encryptMessage( + _replyTo!.text, + sharedSecret, + ); + } + // 4. Создаем локальную модель для мгновенного отображения final tempId = DateTime.now().microsecondsSinceEpoch; final localMessage = MessageModel( tempId: tempId, - text: rawText.isNotEmpty ? rawText : "[Фото]", + text: rawText, isMe: true, senderId: myId, receiverId: _currentContact.id, createdAt: DateTime.now(), status: MessageStatus.sending, localFileBytes: _pendingImageBytes, + messageType: hasImage ? MessageType.image : MessageType.text, + fileId: fileId, + encryptedFileKey: encryptedFileKey, replyToId: _replyTo?.id, replyToText: _replyTo?.text, ); @@ -917,7 +1049,8 @@ class _ChatScreenState extends State with RouteAware { }, if (_replyTo?.id != null) ...{ "reply_to_id": _replyTo!.id, - "reply_to_text": _replyTo!.text, + if (encryptedReplyToText != null) + "reply_to_text": encryptedReplyToText, }, }; @@ -1102,6 +1235,7 @@ class _ChatScreenState extends State with RouteAware { } if (data['type'] == 'private_message') { + print('DEBUG incoming private_message raw: $data'); setState(() { _typingTimer?.cancel(); _isTyping = false; @@ -1137,7 +1271,10 @@ class _ChatScreenState extends State with RouteAware { ); // 4. Добавляем в список и обновляем экран - await LocalDbService().saveMessages([data]); + String? encryptedFileKey = data['encrypted_key']?.toString(); + Uint8List? decryptedImageBytes; + // Lazy load images later + if (!mounted) return; final serverMessageId = int.tryParse(data['id']?.toString() ?? ''); @@ -1150,6 +1287,11 @@ class _ChatScreenState extends State with RouteAware { _sentReadReceipts.add(serverMessageId); } + final replyToText = await _decryptReplyText( + data['reply_to_text']?.toString(), + sharedSecret, + ); + setState(() { messages.add( MessageModel( @@ -1163,12 +1305,22 @@ class _ChatScreenState extends State with RouteAware { replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()), - replyToText: data['reply_to_text'] != null - ? data['reply_to_text'].toString() - : null, + replyToText: replyToText, + messageType: data['message_type'] == 'image' + ? MessageType.image + : MessageType.text, + fileId: data['file_id']?.toString(), + encryptedFileKey: encryptedFileKey, + localFileBytes: decryptedImageBytes, ), ); }); + // Save to local DB with cached image bytes + try { + await _localDbService.saveMessages([messages.last]); + } catch (e) { + print('Error saving incoming message to DB: $e'); + } } catch (e) { print("Ошибка расшифровки входящего сообщения: $e"); } @@ -1224,15 +1376,26 @@ class _ChatScreenState extends State with RouteAware { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_notificationLaunchKey); try { + print('[DEBUG] Начало загрузки истории'); final myPrivKey = await _cryptoService.getPrivateKey(); final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey!, widget.contact.publicKey!, ); + print('[DEBUG] Ключи получены'); final cached = await _localDbService.getChatHistory( widget.contact.id, myId, ); + print('[DEBUG] Локальная история загружена: ${cached.length} сообщений'); + + // Сохраняем кэшированные изображения перед обновлением + Map cachedImages = {}; + for (var msg in cached) { + if (msg['id'] != null && msg['local_file_bytes'] != null) { + cachedImages[msg['id'] as int] = msg['local_file_bytes'] as Uint8List; + } + } try { List loadedLocalMessages = []; @@ -1260,6 +1423,11 @@ class _ChatScreenState extends State with RouteAware { } } + Uint8List? decryptedImageBytes; + if (msg['message_type'] == 'image') { + decryptedImageBytes = msg['local_file_bytes'] as Uint8List?; + } + loadedLocalMessages.add( MessageModel( id: int.tryParse(msg['id']?.toString() ?? ''), @@ -1272,12 +1440,19 @@ class _ChatScreenState extends State with RouteAware { replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), - replyToText: msg['reply_to_text'] != null - ? msg['reply_to_text'].toString() - : null, + replyToText: await _decryptReplyText( + msg['reply_to_text']?.toString(), + sharedSecret, + ), editedAt: msg['edited_at'] != null ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) : null, + messageType: msg['message_type'] == 'image' + ? MessageType.image + : MessageType.text, + fileId: msg['file_id']?.toString(), + encryptedFileKey: msg['encrypted_key']?.toString(), + localFileBytes: decryptedImageBytes, ), ); } @@ -1293,6 +1468,7 @@ class _ChatScreenState extends State with RouteAware { } final history = await apiService.getChatHistory(widget.contact.id); + print('[DEBUG] История с сервера загружена: ${history.length} сообщений'); print(history); final alreadyReadIncomingMessageIds = {}; List loadedMessages = []; @@ -1327,6 +1503,9 @@ class _ChatScreenState extends State with RouteAware { } } + Uint8List? decryptedImageBytes; + // Lazy load images later to avoid downloading all at once + loadedMessages.insert( 0, MessageModel( @@ -1340,20 +1519,34 @@ class _ChatScreenState extends State with RouteAware { replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), - replyToText: msg['reply_to_text'] != null - ? msg['reply_to_text'].toString() - : null, + replyToText: await _decryptReplyText( + msg['reply_to_text']?.toString(), + sharedSecret, + ), editedAt: msg['edited_at'] != null ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) : null, + messageType: msg['message_type'] == 'image' + ? MessageType.image + : MessageType.text, + fileId: msg['file_id']?.toString(), + encryptedFileKey: msg['encrypted_key']?.toString(), + localFileBytes: cachedImages[int.tryParse(msg['id']?.toString() ?? '')] ?? decryptedImageBytes, ), ); } try { - await _localDbService.deleteChatHistory(widget.contact.id, myId); - await _localDbService.saveMessages(history); + print('[DEBUG] Начинаем очищение и сохранение истории в локальную БД'); + //await _localDbService.deleteChatHistory(widget.contact.id, myId); + await _localDbService.saveMessages(loadedMessages); + print('[DEBUG] История успешно сохранена в локальную БД'); + + // Восстанавливаем кэшированные изображения + for (var entry in cachedImages.entries) { + await _localDbService.updateMessageLocalFileBytes(entry.key, entry.value); + } } catch (e) { - print("Ошибка сохранения истории в локальную базу: $e"); + print("[ERROR] Ошибка сохранения истории в локальную базу: $e"); } if (!mounted) return; @@ -1378,6 +1571,202 @@ class _ChatScreenState extends State with RouteAware { setState(() => _isKeyLoading = false); } } + + Future _updateScrollButtonVisibility() async { + if (!mounted) return; + final shouldShow = + _scrollController.hasClients && _scrollController.offset > 100; + if (shouldShow != _showScrollToEnd) { + setState(() { + _showScrollToEnd = shouldShow; + }); + } + } + + Future _scrollToBottom() async { + if (!_scrollController.hasClients) return; + await _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + Future _scrollToMessage(int? messageId) async { + if (messageId == null) return; + final itemKey = _messageKeys[messageId]; + if (itemKey?.currentContext == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Сообщение не найдено для перехода.'), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), + ), + ); + return; + } + await Scrollable.ensureVisible( + itemKey!.currentContext!, + duration: const Duration(milliseconds: 300), + alignment: 0.1, + curve: Curves.easeInOut, + ); + } + + Future _loadImageBytesForMessage(MessageModel msg) async { + if (msg.localFileBytes != null) return msg.localFileBytes; + if (msg.fileId == null || msg.encryptedFileKey == null) return null; + + final myPrivKey = await _cryptoService.getPrivateKey(); + if (myPrivKey == null) return null; + final sharedSecret = await _cryptoService.deriveSharedSecret( + myPrivKey, + _currentContact.publicKey!, + ); + final bytes = await _downloadAndDecryptImage( + msg.fileId!, + msg.encryptedFileKey!, + sharedSecret, + ); + // Cache the downloaded bytes + if (bytes != null && msg.id != null) { + try { + await _localDbService.updateMessageLocalFileBytes(msg.id!, bytes); + // Update in-memory message + setState(() { + final idx = messages.indexWhere((m) => m.id == msg.id); + if (idx != -1) { + messages[idx] = messages[idx].copyWith(localFileBytes: bytes); + } + }); + } catch (e) { + print('Error caching image bytes: $e'); + } + } + return bytes; + } + + Future _openFullScreenImage(MessageModel msg) async { + final bytes = await _loadImageBytesForMessage(msg); + if (bytes == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Не удалось открыть изображение.'), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), + ), + ); + return; + } + if (!mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _FullScreenImageScreen(imageBytes: bytes), + ), + ); + } + + Future _saveImageToGallery(MessageModel msg) async { + final bytes = await _loadImageBytesForMessage(msg); + if (bytes == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Не удалось получить изображение для сохранения.'), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), + ), + ); + return; + } + + try { + await Gal.putImageBytes(bytes); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Изображение сохранено в галерею.'), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Не удалось сохранить изображение: $e'), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), + ), + ); + } + } + + Future _downloadAndDecryptImage( + String fileId, + String encryptedFileKey, + SecretKey sharedSecret, + ) async { + try { + print('DEBUG downloadMedia(fileId=$fileId)'); + final bytes = await apiService.downloadMedia(fileId); + if (bytes == null) { + print('DEBUG downloadMedia returned null for fileId=$fileId'); + return null; + } + print( + 'DEBUG downloadMedia bytes length=${bytes.length} for fileId=$fileId', + ); + final result = await _cryptoService.decryptImage( + bytes, + encryptedFileKey, + sharedSecret, + ); + print( + 'DEBUG decryptImage result length=${result?.length ?? 'null'} for fileId=$fileId', + ); + return result; + } catch (e) { + print('Ошибка загрузки и дешифровки медиа: $e'); + return null; + } + } + + Future _decryptReplyText( + String? encryptedReplyText, + SecretKey sharedSecret, + ) async { + if (encryptedReplyText == null) return null; + try { + return await _cryptoService.decryptMessage( + encryptedReplyText, + sharedSecret, + ); + } catch (_) { + return encryptedReplyText; + } + } +} + +class _FullScreenImageScreen extends StatelessWidget { + final Uint8List imageBytes; + + const _FullScreenImageScreen({required this.imageBytes}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: Center(child: InteractiveViewer(child: Image.memory(imageBytes))), + ); + } } class TypingIndicator extends StatefulWidget { diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 8a6679b..a0540d0 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -8,6 +8,7 @@ import '../screens/settings_screen.dart'; import '../screens/new_chat_screen.dart'; import '../screens/chat_screen.dart'; import '/logic/contact_provider.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '/logic/auth_provider.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -509,7 +510,7 @@ class _ContactsScreenState extends State with RouteAware { ? Theme.of(context).colorScheme.onSurface : null, backgroundImage: authProvider.avatarUrl != null - ? NetworkImage(authProvider.avatarUrl!) + ? CachedNetworkImageProvider(authProvider.avatarUrl!) : authProvider.avatarPath != null ? FileImage(File(authProvider.avatarPath!)) : null, diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 45ce634..3173f88 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -51,9 +51,7 @@ class _SettingsScreenState extends State { Widget build(BuildContext context) { final authProv = context.watch(); - final accountEmail = authProv.email?.isNotEmpty == true - ? authProv.email! - : authProv.username?.isNotEmpty == true + final accountUsername = authProv.username?.isNotEmpty == true ? '@${authProv.username!}' : 'Не указано'; @@ -78,7 +76,7 @@ class _SettingsScreenState extends State { style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), accountEmail: Text( - accountEmail, + accountUsername, style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), currentAccountPicture: GestureDetector( diff --git a/lib/presentation/screens/user_profile_screen.dart b/lib/presentation/screens/user_profile_screen.dart index 4408fd0..b2447fc 100644 --- a/lib/presentation/screens/user_profile_screen.dart +++ b/lib/presentation/screens/user_profile_screen.dart @@ -5,6 +5,7 @@ import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:chepuhagram/data/datasources/ws_client.dart'; import 'package:provider/provider.dart'; import '/core/constants.dart'; +import 'package:cached_network_image/cached_network_image.dart'; class UserProfileScreen extends StatefulWidget { final int userId; @@ -135,7 +136,7 @@ class _UserProfileScreenState extends State { backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), backgroundImage: (avatarUrl != null && _userData?['show_avatar'] == true) - ? NetworkImage(avatarUrl) + ? CachedNetworkImageProvider(avatarUrl) : null, child: (avatarUrl == null || _userData?['show_avatar'] != true) ? Text( diff --git a/lib/presentation/widgets/contact_tile.dart b/lib/presentation/widgets/contact_tile.dart index 993ab81..946129e 100644 --- a/lib/presentation/widgets/contact_tile.dart +++ b/lib/presentation/widgets/contact_tile.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '/data/models/contact_model.dart'; +import 'package:cached_network_image/cached_network_image.dart'; class ContactTile extends StatefulWidget { final Contact contact; @@ -78,7 +79,7 @@ class _ContactTileState extends State { radius: 28, backgroundColor: primary.withAlpha((0.1 * 255).round()), backgroundImage: widget.contact.effectiveAvatarUrl != null - ? NetworkImage(widget.contact.effectiveAvatarUrl!) + ? CachedNetworkImageProvider(widget.contact.effectiveAvatarUrl!) : null, child: widget.contact.effectiveAvatarUrl == null ? Text( diff --git a/lib/presentation/widgets/message_bubble.dart b/lib/presentation/widgets/message_bubble.dart index a224bf0..7fd1b66 100644 --- a/lib/presentation/widgets/message_bubble.dart +++ b/lib/presentation/widgets/message_bubble.dart @@ -3,21 +3,55 @@ import '/data/models/message_model.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:provider/provider.dart'; +import 'dart:typed_data'; import '/core/theme_manager.dart'; +import '/core/constants.dart'; -class MessageBubble extends StatelessWidget { +class MessageBubble extends StatefulWidget { final MessageModel message; final VoidCallback? onTap; + final VoidCallback? onReplyTap; + final VoidCallback? onImageTap; + final Future Function(MessageModel)? onImageNeeded; const MessageBubble({ super.key, required this.message, this.onTap, + this.onReplyTap, + this.onImageTap, + this.onImageNeeded, }); + @override + State createState() => _MessageBubbleState(); +} + +class _MessageBubbleState extends State { + Uint8List? _imageBytes; + + @override + void initState() { + super.initState(); + if (widget.message.localFileBytes == null && + widget.message.messageType == MessageType.image && + widget.onImageNeeded != null) { + _loadImage(); + } + } + + Future _loadImage() async { + final bytes = await widget.onImageNeeded!(widget.message); + if (mounted) { + setState(() { + _imageBytes = bytes; + }); + } + } + @override Widget build(BuildContext context) { - final isMe = message.isMe; + final isMe = widget.message.isMe; final themeProv = context.watch(); return Align( // Выравниваем вправо, если это мое сообщение, и влево — если чужое @@ -25,10 +59,10 @@ class MessageBubble extends StatelessWidget { child: Material( color: Colors.transparent, child: InkWell( - onTap: onTap, + onTap: widget.onTap, // На телефонах иногда удобнее/надежнее long-press (как в мессенджерах), // поэтому поддерживаем оба жеста. - onLongPress: onTap, + onLongPress: widget.onTap, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), @@ -57,68 +91,108 @@ class MessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (message.replyToText != null) ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - margin: const EdgeInsets.only(bottom: 4), - decoration: BoxDecoration( - color: (isMe ? Colors.white : Colors.black).withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - border: Border( - left: BorderSide( - color: isMe ? Colors.black54 : Colors.black38, - width: 2, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.reply, - size: 14, - color: isMe ? Colors.black54 : Colors.black54, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - message.replyToText!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: isMe ? const Color.fromARGB(221, 21, 21, 21) : const Color.fromARGB(221, 21, 21, 21), - fontSize: 12, - fontStyle: FontStyle.italic, - ), + if (widget.message.replyToText != null) ...[ + GestureDetector( + onTap: widget.onReplyTap, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + color: (isMe ? Colors.white : Colors.black).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border( + left: BorderSide( + color: isMe ? Colors.black54 : Colors.black38, + width: 2, ), ), - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.reply, + size: 14, + color: isMe ? Colors.black54 : Colors.black54, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + widget.message.replyToText!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isMe ? const Color.fromARGB(221, 21, 21, 21) : const Color.fromARGB(221, 21, 21, 21), + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), ), ), ], - Linkify( - onOpen: (link) async { - final Uri url = Uri.parse(link.url); - if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { - throw Exception('Could not launch $url'); - } - }, - text: message.text, - style: TextStyle(color: isMe ? (themeProv.isLight ? Colors.black : Colors.black) : Colors.black), - linkStyle: TextStyle(color: const Color.fromARGB(255, 10, 87, 123), fontWeight: FontWeight.bold), - ), + if (widget.message.messageType == MessageType.image) ...[ + GestureDetector( + onTap: widget.onImageTap, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: (_imageBytes ?? widget.message.localFileBytes) != null + ? Image.memory( + _imageBytes ?? widget.message.localFileBytes!, + width: 200, + height: 200, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 200, + height: 200, + color: Colors.grey[300], + child: const Icon(Icons.broken_image, size: 50), + ); + }, + ) + : Container( + width: 200, + height: 200, + color: Colors.grey[300], + child: const Center( + child: CircularProgressIndicator(), + ), + ), + ), + ), + if (widget.message.text.isNotEmpty) ...[ + const SizedBox(height: 8), + ], + ], + if (widget.message.messageType == MessageType.text || widget.message.text.isNotEmpty) ...[ + Linkify( + onOpen: (link) async { + final Uri url = Uri.parse(link.url); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + throw Exception('Could not launch $url'); + } + }, + text: widget.message.text, + style: TextStyle(color: isMe ? (themeProv.isLight ? Colors.black : Colors.black) : Colors.black), + linkStyle: TextStyle(color: const Color.fromARGB(255, 10, 87, 123), fontWeight: FontWeight.bold), + ), + ], const SizedBox(height: 4), Row( mainAxisSize: MainAxisSize.min, children: [ Text( - _formatTime(message.createdAt), + _formatTime(widget.message.createdAt), style: TextStyle( color: isMe ? Colors.black87 : Colors.black54, fontSize: 10, ), ), - if (message.editedAt != null) ...[ + if (widget.message.editedAt != null) ...[ const SizedBox(width: 6), Text( '(изменено)', @@ -132,9 +206,9 @@ class MessageBubble extends StatelessWidget { if (isMe) ...[ const SizedBox(width: 6), Icon( - _statusIcon(message.status), + _statusIcon(widget.message.status), size: 12, - color: _statusColor(message.status, isMe), + color: _statusColor(widget.message.status, isMe), ), ], ], diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c1d2f3b..66c0d33 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import firebase_messaging import flutter_image_compress_macos import flutter_local_notifications import flutter_secure_storage_darwin +import gal import local_auth_darwin import package_info_plus import path_provider_foundation @@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 6b345b0..4cad2c8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -249,11 +273,27 @@ packages: url: "https://pub.dev" source: hosted version: "3.5.18" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_image_compress: dependency: "direct main" description: @@ -408,6 +448,14 @@ packages: description: flutter source: sdk version: "0.0.0" + gal: + dependency: "direct main" + description: + name: gal + sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" + url: "https://pub.dev" + source: hosted + version: "2.3.2" http: dependency: "direct main" description: @@ -648,6 +696,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" open_filex: dependency: "direct main" description: @@ -689,7 +745,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -768,6 +824,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -1005,6 +1069,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 418284a..aa50479 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,11 +51,15 @@ dependencies: flutter_linkify: ^6.0.0 url_launcher: ^6.3.2 image_picker: ^1.0.4 + gal: ^2.3.2 flutter_image_compress: ^2.1.0 dio: ^5.9.2 package_info_plus: ^9.0.1 open_filex: ^4.3.2 convert: ^3.1.2 + cached_network_image: ^3.3.1 + flutter_cache_manager: ^3.0.2 + path_provider: ^2.1.3 dev_dependencies: flutter_test: diff --git a/srv/app/api/endpoints/media.py b/srv/app/api/endpoints/media.py index 595e478..7ce8a00 100644 --- a/srv/app/api/endpoints/media.py +++ b/srv/app/api/endpoints/media.py @@ -1,53 +1,43 @@ -from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, File, UploadFile, Request -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from fastapi import Depends, FastAPI, HTTPException, status, APIRouter, File, UploadFile, Request, Form +from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session -from app.core import security -from app.api import schemas -from app.db import models -from jose import JWTError, jwt +from sqlalchemy.sql import func from app.core.security import get_current_user -from fastapi.responses import FileResponse +from app.db import models +from app.core.config import config import os import re import uuid +import urllib.request +import urllib.error from io import BytesIO -# бд +import asyncio -def get_db(): - db = models.SessionLocal() - try: - yield db - finally: - db.close() +def _ensure_directory(path: str): + if not os.path.exists(path): + os.makedirs(path, exist_ok=True) -mediaRouter = APIRouter( - prefix="/media", - tags=[], -) - UPLOAD_FOLDER = 'uploads' -if not os.path.exists(UPLOAD_FOLDER): - os.makedirs(UPLOAD_FOLDER) def _parse_multipart_body(body: bytes): try: - if not body.startswith(b'--'): + if not body.startswith(b"--"): return None - boundary, rest = body.split(b'\r\n', 1) + boundary, _ = body.split(b"\r\n", 1) parts = body.split(boundary) for part in parts: - if not part or part in (b'--', b'--\r\n'): + if not part or part in (b"--", b"--\r\n"): continue - part = part.strip(b'\r\n') + part = part.strip(b"\r\n") if not part: continue - headers, _, content = part.partition(b'\r\n\r\n') + headers, _, content = part.partition(b"\r\n\r\n") if not headers or content is None: continue @@ -77,57 +67,350 @@ def _parse_multipart_body(body: bytes): return None +async def _get_upload_file(request: Request, uploaded_file: UploadFile | None): + if uploaded_file is not None: + return uploaded_file + + raw_body = await request.body() + parsed = _parse_multipart_body(raw_body) + if parsed is None: + return None + + filename, content, content_type = parsed + return UploadFile(filename=filename, file=BytesIO(content), content_type=content_type) + + +def _encode_multipart_formdata(fields, files): + boundary = uuid.uuid4().hex + body = BytesIO() + + for name, value in fields.items(): + body.write(f"--{boundary}\r\n".encode('utf-8')) + body.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode('utf-8')) + body.write(str(value).encode('utf-8')) + body.write(b"\r\n") + + for field_name, filename, content_type, file_bytes in files: + body.write(f"--{boundary}\r\n".encode('utf-8')) + body.write( + f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode('utf-8') + ) + body.write(f"Content-Type: {content_type}\r\n\r\n".encode('utf-8')) + body.write(file_bytes) + body.write(b"\r\n") + + body.write(f"--{boundary}--\r\n".encode('utf-8')) + return body.getvalue(), boundary + + +def _get_cloud_cache_size_bytes(db: Session) -> int: + total = db.query(func.sum(models.CloudMediaItem.size_bytes)).filter( + models.CloudMediaItem.status.in_(['pending', 'sending']), + models.CloudMediaItem.is_avatar == 0, + ).scalar() + return int(total or 0) + + +def _find_local_media_path(file_id: str) -> str | None: + candidates = [ + os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, f"{file_id}.enc"), + os.path.join('uploads', f"{file_id}.enc"), + os.path.join(config.HOME_MEDIA_FOLDER, f"{file_id}.enc"), + ] + for path in candidates: + if os.path.exists(path): + return path + return None + + +def _stream_response_from_remote(url: str): + try: + request = urllib.request.Request(url) + response = urllib.request.urlopen(request, timeout=45) + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise HTTPException(status_code=404, detail='File not found') + raise HTTPException(status_code=502, detail=f'Error fetching media from home server: {exc.code}') + except Exception as exc: + raise HTTPException(status_code=502, detail=f'Could not reach home server: {exc}') + + headers = {k.lower(): v for k, v in response.getheaders()} + content_type = headers.get('content-type', 'application/octet-stream') + return StreamingResponse( + iter(lambda: response.read(8192), b""), + media_type=content_type, + headers={ + 'Content-Disposition': headers.get('content-disposition', f'attachment; filename="{os.path.basename(url)}"') + }, + ) + + +def _post_file_to_home(item: models.CloudMediaItem) -> tuple[bool, str]: + file_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename) + if not os.path.exists(file_path): + return False, 'Local cache file not found' + + with open(file_path, 'rb') as f: + content = f.read() + + fields = { + 'owner_id': item.owner_id or '', + 'cloud_file_id': item.file_id, + 'original_filename': item.original_filename or item.local_filename, + } + files = [ + ('file', item.original_filename or item.local_filename, item.content_type or 'application/octet-stream', content), + ] + body, boundary = _encode_multipart_formdata(fields, files) + request = urllib.request.Request( + f"{config.HOME_SERVER_URL}/media/receive", + data=body, + headers={ + 'Content-Type': f'multipart/form-data; boundary={boundary}', + 'X-Media-Forwarding-Secret': config.MEDIA_FORWARDING_SECRET, + }, + ) + + try: + with urllib.request.urlopen(request, timeout=60) as response: + if response.status == 200: + return True, '' + return False, f'Home server returned {response.status}' + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors='ignore') + return False, f'Home server HTTP error {exc.code}: {body}' + except Exception as exc: + return False, str(exc) + + +def _cleanup_home_quota(db: Session, owner_id: int | None): + if owner_id is None: + return + + total = db.query(func.sum(models.HomeMediaFile.size_bytes)).filter( + models.HomeMediaFile.owner_id == owner_id + ).scalar() or 0 + total = int(total) + if total <= config.HOME_USER_QUOTA_BYTES: + return + + files = db.query(models.HomeMediaFile).filter( + models.HomeMediaFile.owner_id == owner_id + ).order_by(models.HomeMediaFile.created_at.asc()).all() + + for file_record in files: + if total <= config.HOME_USER_QUOTA_BYTES: + break + path = os.path.join(config.HOME_MEDIA_FOLDER, file_record.storage_filename) + if os.path.exists(path): + os.remove(path) + total -= file_record.size_bytes + db.delete(file_record) + db.commit() + + +def _cleanup_all_home_storage(): + db = models.SessionLocal() + try: + owner_ids = db.query(models.HomeMediaFile.owner_id).filter(models.HomeMediaFile.owner_id.isnot(None)).distinct().all() + for owner_id_tuple in owner_ids: + _cleanup_home_quota(db, owner_id_tuple[0]) + finally: + db.close() + + +async def forward_pending_media_loop(): + while True: + if config.SERVER_ROLE != 'cloud': + await asyncio.sleep(10) + continue + + db = models.SessionLocal() + try: + total_cache = _get_cloud_cache_size_bytes(db) + if total_cache >= config.CLOUD_CACHE_MAX_BYTES: + await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS) + continue + + pending_items = db.query(models.CloudMediaItem).filter( + models.CloudMediaItem.status == 'pending', + models.CloudMediaItem.is_avatar == 0, + ).order_by(models.CloudMediaItem.created_at.asc()).limit(5).all() + + for item in pending_items: + item.status = 'sending' + item.attempts += 1 + db.commit() + + success, error = _post_file_to_home(item) + if success: + item.status = 'sent' + item.sent_at = func.now() + item.error_message = None + db.commit() + cache_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename) + if os.path.exists(cache_path): + os.remove(cache_path) + else: + item.status = 'failed' + item.error_message = error + db.commit() + except Exception: + pass + finally: + db.close() + await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS) + + +async def home_storage_maintenance_loop(): + while True: + if config.SERVER_ROLE != 'home': + await asyncio.sleep(10) + continue + _cleanup_all_home_storage() + await asyncio.sleep(600) + + +mediaRouter = APIRouter( + prefix='/media', + tags=['media'], +) + + +_ensure_directory(UPLOAD_FOLDER) +_ensure_directory(config.CLOUD_MEDIA_CACHE_FOLDER) +_ensure_directory(config.HOME_MEDIA_FOLDER) + + @mediaRouter.post('/upload') -async def upload_file(request: Request, file: UploadFile = File(None)): - uploaded_file = file - if uploaded_file is None: - raw_body = await request.body() - parsed = _parse_multipart_body(raw_body) - if parsed is not None: - filename, content, content_type = parsed - uploaded_file = UploadFile( - filename=filename, - file=BytesIO(content), - content_type=content_type, - ) - +async def upload_file( + request: Request, + file: UploadFile = File(None), +): + uploaded_file = await _get_upload_file(request, file) if uploaded_file is None or not uploaded_file.filename: - raise HTTPException(status_code=400, detail="No selected file") + raise HTTPException(status_code=400, detail='No selected file') - # Валидация размера файла (макс 10MB) - MAX_FILE_SIZE = 10 * 1024 * 1024 content = await uploaded_file.read() - if len(content) > MAX_FILE_SIZE: - raise HTTPException(status_code=400, detail="File too large (max 10MB)") + if len(content) > config.MEDIA_UPLOAD_MAX_BYTES: + raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)') - # Валидация типа файла (для зашифрованных файлов пропускаем, так как content_type не image) - # ALLOWED_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'} - # if uploaded_file.content_type not in ALLOWED_TYPES: - # raise HTTPException(status_code=400, detail="Invalid file type") - - # Генерируем уникальное имя, чтобы файлы не перезаписывались - file_id = str(uuid.uuid4()) + file_id = uuid.uuid4().hex filename = f"{file_id}.enc" file_path = os.path.join(UPLOAD_FOLDER, filename) - - # Сохраняем - with open(file_path, "wb") as f: + with open(file_path, 'wb') as f: f.write(content) - print(f"Файл сохранен: {file_path}") - return { - "status": "ok", - "file_id": file_id + 'status': 'ok', + 'file_id': file_id, } +@mediaRouter.post('/v2/upload') +async def upload_file_v2( + request: Request, + file: UploadFile = File(None), + purpose: str = Form('media'), + current_user: models.User = Depends(get_current_user), +): + if config.SERVER_ROLE != 'cloud': + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Upload endpoint is available only on cloud server') + + uploaded_file = await _get_upload_file(request, file) + if uploaded_file is None or not uploaded_file.filename: + raise HTTPException(status_code=400, detail='No selected file') + + content = await uploaded_file.read() + if len(content) > config.MEDIA_UPLOAD_MAX_BYTES: + raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)') + + db = models.SessionLocal() + try: + cache_size = _get_cloud_cache_size_bytes(db) + is_avatar = purpose == 'avatar' + if cache_size >= config.CLOUD_CACHE_MAX_BYTES and not is_avatar: + raise HTTPException( + status_code=503, + detail='Cloud media cache is full; new uploads are temporarily paused until pending files are forwarded.', + ) + + file_id = uuid.uuid4().hex + local_filename = f"{file_id}.enc" + storage_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, local_filename) + with open(storage_path, 'wb') as f: + f.write(content) + + item = models.CloudMediaItem( + file_id=file_id, + owner_id=current_user.id, + original_filename=uploaded_file.filename, + content_type=uploaded_file.content_type or 'application/octet-stream', + local_filename=local_filename, + size_bytes=len(content), + status='avatar' if is_avatar else 'pending', + is_avatar=1 if is_avatar else 0, + ) + db.add(item) + db.commit() + finally: + db.close() + + return {'status': 'ok', 'file_id': file_id} + + +@mediaRouter.post('/receive') +async def receive_media( + request: Request, + file: UploadFile = File(None), + owner_id: int | None = Form(None), + cloud_file_id: str | None = Form(None), + original_filename: str | None = Form(None), +): + secret = request.headers.get('X-Media-Forwarding-Secret') + if secret != config.MEDIA_FORWARDING_SECRET: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid forwarding secret') + + uploaded_file = await _get_upload_file(request, file) + if uploaded_file is None or not uploaded_file.filename: + raise HTTPException(status_code=400, detail='No selected file') + + content = await uploaded_file.read() + if len(content) > config.MEDIA_UPLOAD_MAX_BYTES: + raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)') + + file_id = cloud_file_id or uuid.uuid4().hex + storage_filename = f"{file_id}.enc" + file_path = os.path.join(config.HOME_MEDIA_FOLDER, storage_filename) + with open(file_path, 'wb') as f: + f.write(content) + + db = models.SessionLocal() + try: + home_record = models.HomeMediaFile( + file_id=file_id, + owner_id=owner_id, + original_filename=original_filename or uploaded_file.filename, + content_type=uploaded_file.content_type or 'application/octet-stream', + storage_filename=storage_filename, + size_bytes=len(content), + ) + db.add(home_record) + db.commit() + _cleanup_home_quota(db, owner_id) + finally: + db.close() + + return {'status': 'ok', 'file_id': file_id} + + @mediaRouter.get('/{file_id}') async def get_file(file_id: str): - filename = f"{file_id}.enc" - file_path = os.path.join(UPLOAD_FOLDER, filename) + local_path = _find_local_media_path(file_id) + if local_path: + return FileResponse(local_path, media_type='application/octet-stream') - if not os.path.exists(file_path): - raise HTTPException(status_code=404, detail="File not found") + if config.SERVER_ROLE == 'cloud': + return _stream_response_from_remote(f"{config.HOME_SERVER_URL}/media/{file_id}") - return FileResponse(file_path, media_type="application/octet-stream") \ No newline at end of file + raise HTTPException(status_code=404, detail='File not found') diff --git a/srv/app/api/endpoints/messages.py b/srv/app/api/endpoints/messages.py index 4c5aeeb..f32d6b5 100644 --- a/srv/app/api/endpoints/messages.py +++ b/srv/app/api/endpoints/messages.py @@ -31,7 +31,9 @@ async def get_chat_history( (models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id) | (models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id) ).order_by(models.Message.timestamp.desc()).limit(limit).all() - + print( + f"DEBUG get_chat_history: user={current_user.id}, contact={contact_id}, count={len(messages)}, ids={[m.id for m in messages]}", + ) return jsonable_encoder(messages) diff --git a/srv/app/api/endpoints/users.py b/srv/app/api/endpoints/users.py index 75d0797..5e8f347 100644 --- a/srv/app/api/endpoints/users.py +++ b/srv/app/api/endpoints/users.py @@ -250,7 +250,7 @@ async def read_users_chats( "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key, - "avatar_file_id": user.avatar_file_id, + "avatar_file_id": user.avatar_file_id if user.show_avatar else None, "avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if user.show_avatar and user.avatar_file_id else None, "last_message": last_msg.content if last_msg else None, "last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None), diff --git a/srv/app/core/config.py b/srv/app/core/config.py index 62971ae..4d05b9f 100644 --- a/srv/app/core/config.py +++ b/srv/app/core/config.py @@ -18,6 +18,15 @@ class Config: # Server HOST: str = os.getenv("HOST", "0.0.0.0") PORT: int = int(os.getenv("PORT", "8000")) + SERVER_ROLE: str = os.getenv("SERVER_ROLE", "cloud").lower() + HOME_SERVER_URL: str = os.getenv("HOME_SERVER_URL", "http://home-server.local:8000") + MEDIA_FORWARDING_SECRET: str = os.getenv("MEDIA_FORWARDING_SECRET", "changeme") + CLOUD_MEDIA_CACHE_FOLDER: str = os.getenv("CLOUD_MEDIA_CACHE_FOLDER", "cloud_media_cache") + HOME_MEDIA_FOLDER: str = os.getenv("HOME_MEDIA_FOLDER", "home_media_store") + CLOUD_CACHE_MAX_BYTES: int = int(os.getenv("CLOUD_CACHE_MAX_BYTES", str(5 * 1024 * 1024 * 1024))) + HOME_USER_QUOTA_BYTES: int = int(os.getenv("HOME_USER_QUOTA_BYTES", str(10 * 1024 * 1024 * 1024))) + MEDIA_UPLOAD_MAX_BYTES: int = int(os.getenv("MEDIA_UPLOAD_MAX_BYTES", str(100 * 1024 * 1024))) + MEDIA_FORWARD_INTERVAL_SECONDS: int = int(os.getenv("MEDIA_FORWARD_INTERVAL_SECONDS", "12")) # CORS ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",") diff --git a/srv/app/db/models.py b/srv/app/db/models.py index 3e1d201..d00f849 100644 --- a/srv/app/db/models.py +++ b/srv/app/db/models.py @@ -10,7 +10,6 @@ SQLALCHEMY_DATABASE_URL = config.DATABASE_URL engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() -Base.metadata.create_all(bind=engine) class User(Base): __tablename__ = "users" @@ -43,13 +42,45 @@ class Message(Base): id = Column(Integer, primary_key=True, index=True) sender_id = Column(Integer, ForeignKey("users.id")) receiver_id = Column(Integer, ForeignKey("users.id")) - content = Column(Text) + content = Column(Text) timestamp = Column(DateTime(timezone=True), server_default=func.now()) delivered_at = Column(DateTime(timezone=True), nullable=True) read_at = Column(DateTime(timezone=True), nullable=True) reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True) reply_to_text = Column(Text, nullable=True) edited_at = Column(DateTime(timezone=True), nullable=True) + message_type = Column(String, nullable=False, server_default="text") + file_id = Column(String, nullable=True) + encrypted_key = Column(String, nullable=True) + +class CloudMediaItem(Base): + __tablename__ = "cloud_media_items" + id = Column(Integer, primary_key=True, index=True) + file_id = Column(String, unique=True, nullable=False, index=True) + owner_id = Column(Integer, ForeignKey("users.id"), nullable=True) + original_filename = Column(String, nullable=True) + content_type = Column(String, nullable=True) + local_filename = Column(String, nullable=False) + size_bytes = Column(Integer, nullable=False) + status = Column(String, nullable=False, server_default="pending") + is_avatar = Column(Integer, nullable=False, server_default="0") + attempts = Column(Integer, nullable=False, server_default="0") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + sent_at = Column(DateTime(timezone=True), nullable=True) + error_message = Column(Text, nullable=True) + +class HomeMediaFile(Base): + __tablename__ = "home_media_files" + id = Column(Integer, primary_key=True, index=True) + file_id = Column(String, unique=True, nullable=False, index=True) + owner_id = Column(Integer, ForeignKey("users.id"), nullable=True) + original_filename = Column(String, nullable=True) + content_type = Column(String, nullable=True) + storage_filename = Column(String, nullable=False) + size_bytes = Column(Integer, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) Base.metadata.create_all(bind=engine) @@ -71,6 +102,12 @@ def _ensure_sqlite_message_columns(): conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT")) if "edited_at" not in existing: conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME")) + if "message_type" not in existing: + conn.execute(text("ALTER TABLE messages ADD COLUMN message_type VARCHAR(32) DEFAULT 'text' NOT NULL")) + if "file_id" not in existing: + conn.execute(text("ALTER TABLE messages ADD COLUMN file_id VARCHAR(255)")) + if "encrypted_key" not in existing: + conn.execute(text("ALTER TABLE messages ADD COLUMN encrypted_key VARCHAR(1024)")) conn.commit() diff --git a/srv/app/websocket/connection_manager.py b/srv/app/websocket/connection_manager.py index d112a0e..348e19c 100644 --- a/srv/app/websocket/connection_manager.py +++ b/srv/app/websocket/connection_manager.py @@ -68,6 +68,13 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: temp_id = message_data.get("temp_id") content = message_data.get("content") content50 = message_data.get("content50") + message_type = message_data.get("message_type") or "text" + file_id = message_data.get("file_id") + encrypted_key = message_data.get("encrypted_key") + + print( + f"DEBUG private_message payload: temp_id={temp_id}, receiver_id={receiver_id}, message_type={message_type}, file_id={file_id}, encrypted_key_present={encrypted_key is not None}", + ) if receiver_id is None or content is None: await websocket.send_json({ @@ -89,6 +96,9 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: sender_id=user_id, receiver_id=receiver_id, content=content, + message_type=message_type, + file_id=file_id, + encrypted_key=encrypted_key, reply_to_id=message_data.get("reply_to_id"), reply_to_text=message_data.get("reply_to_text") ) @@ -96,6 +106,10 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: db.commit() db.refresh(new_msg) + print( + f"DEBUG saved message: id={new_msg.id}, sender={new_msg.sender_id}, receiver={new_msg.receiver_id}, message_type={new_msg.message_type}, file_id={new_msg.file_id}, encrypted_key_present={new_msg.encrypted_key is not None}", + ) + # ACK отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента). await manager.send_personal_message({ "type": "message_sent", @@ -124,14 +138,22 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: "sender_id": user_id, "receiver_id": receiver_id, "content": message_data.get("content"), + "message_type": message_type, + "file_id": file_id, + "encrypted_key": message_data.get("encrypted_key"), "timestamp": (new_msg.timestamp or datetime.now()).isoformat(), "reply_to_id": new_msg.reply_to_id, "reply_to_text": new_msg.reply_to_text, } + print( + f"DEBUG outgoing_message: id={outgoing_message['id']}, receiver_id={outgoing_message['receiver_id']}, file_id={outgoing_message['file_id']}, encrypted_key_present={outgoing_message['encrypted_key'] is not None}", + ) # Пересылаем получателю, если он в сети sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id)) + print(f"DEBUG send_personal_message returned: {sent_to_receiver}") + # Если сообщение реально ушло по сокету получателю — отмечаем delivered_at. if sent_to_receiver: try: diff --git a/srv/main.py b/srv/main.py index 03b2736..8aa21a7 100644 --- a/srv/main.py +++ b/srv/main.py @@ -57,6 +57,10 @@ async def head_image(): @app.on_event("startup") async def startup_event(): asyncio.create_task(cleanup_uploads()) + if config.SERVER_ROLE == 'cloud': + asyncio.create_task(media.forward_pending_media_loop()) + elif config.SERVER_ROLE == 'home': + asyncio.create_task(media.home_storage_maintenance_loop()) async def cleanup_uploads(): diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c1ddf6d..05102ec 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + GalPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GalPluginCApi")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 21c3cff..6b6e227 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_core flutter_secure_storage_windows + gal local_auth_windows url_launcher_windows )