diff --git a/.gitignore b/.gitignore index a364019..3a56666 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,75 @@ -# Binaries for programs and plugins -*.exe -*.dll -*.so -*.dylib +# Flutter/Dart specific ignores +.dart_tool/ +.packages +.pub-cache/ +build/ +ios/.symlinks/ +lib/generated/ +test/**/generated/** *.mod *.sum +*.mp4 +# Logs and temp files +*.log +*.tmp -*.db - -# Test binary, built with `go test -c` -*.test -# Output of the go toolchain -*.out +# Environment variables +.env +.env.local +*.env.* -# Dependency directories (remove the "vendor/" if you want to commit it) -vendor/ +# Logs +*.log +**/*.log -# Go module cache -.mod/ +# OS generated files +.DS_Store +Thumbs.db -# IDE specific files +# IDE specific .vscode/ .idea/ +*.swp +*.swo -# Log files -*.log - -# macOS specific files +# OS .DS_Store +Thumbs.db -# Windows specific files -thumbs.db +# Coverage +coverage/ +htmlcov/ +.coverage # исполняемые файлы cu-bridge cu-bridge.exe cu-bridge-linux cu-bridge-mac -builds/ \ No newline at end of file +builds/ +storage/ + +# Compressed files +*.zip +*.gz +*.tar +*.tgz +*.bz2 +*.xz +*.7z +*.rar +*.zst +*.lz4 +*.lzh +*.cab +*.arj +*.rpm +*.deb +*.Z +*.lz +*.lzo +# Build outputs +*.js +*.map diff --git a/README.md b/README.md index e1160e3..ca01aa4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,86 @@ -Чтобы запустить надо написать +# Mesh-CU Messenger + +Распределённая P2P мессенджер система с поддержкой веб-интерфейса на Flutter. + +## Запуск + +### Backend (Go node) ```bash -go run ./cmd/node/ -name [ur name] +go run ./cmd/node/ -name [your name] -port 8080 ``` +### Флаги: +* `-name` - имя пользователя (если не указывать, то генерится само) +* `-port` - порт (по умолчанию: 8080) +* `-storage` - путь до папки, сохранение + +После запуска backend также стартует WebSocket API сервер на порту **8765** для подключения веб-клиентов. + +### Frontend (Flutter Web) + +```bash +cd flutter_app +flutter pub get +flutter run -d chrome +``` + +Или откройте `http://localhost:8765` в браузере после сборки. + +## Управление (CLI) -Управление: * `/chat [nick-name]` - открыть чат/группу с определенным человеком * `/new messages` - список чатов/групп c непрочитанными сообщениями * `/all` - список всех чатов/групп * `/create -name [group name] -parts [nick1, nick2, ...]` - создать группу с участниками +* `/help` - показать справку + +## Архитектура + +- **Backend (Go)**: TCP server для P2P сообщений, UDP multicast для discovery, SQLite для хранения +- **API**: WebSocket сервер для подключения веб-клиентов +- **Frontend (Flutter)**: Веб-интерфейс с реальным временем обновлений + +## Структура проекта + +``` +. +├── cmd/node/ # Точка входа Go приложения +├── internal/ +│ ├── api/ # WebSocket API для Flutter +│ ├── db/ # SQLite база данных +│ ├── discovery/ # Peer discovery через UDP multicast +│ ├── network/ # TCP сервер для P2P сообщений +│ └── protocol/ # Формат сообщений +├── flutter_app/ # Flutter веб-приложение +│ ├── lib/ +│ │ ├── models/ # Модели данных +│ │ ├── services/ # WebSocket сервис +│ │ ├── screens/ # UI экраны +│ │ └── widgets/ # UI компоненты +│ └── web/ # Web entry point +└── README.md +``` + +## Протокол + +Сообщения передаются в формате JSON через TCP/WebSocket: + +```json +{ + "type": "CHAT", + "sender_id": "node-123", + "sender_name": "Alice", + "recipient_id": "ALL", + "payload": { + "message": "Hello!", + "sender_name": "Alice" + } +} +``` + +### Типы сообщений -и откроется чат)) \ No newline at end of file +- `PING`/`PONG` - проверка соединения +- `CHAT` - текстовое сообщение +- `GROUP_CREATE` - создание группы +- `FILE_REQ`/`FILE_CHUNK`/`FILE_ACK` - передача файлов diff --git a/cmd/node/console_unix.go b/cmd/node/console_unix.go deleted file mode 100644 index d067bbc..0000000 --- a/cmd/node/console_unix.go +++ /dev/null @@ -1,7 +0,0 @@ -// //go:build !windows - -package main - -// func enableAnsiSupport() { -// // В Linux/Unix ANSI-коды поддерживаются по умолчанию, ничего делать не нужно -// } diff --git a/cmd/node/console_windows.go b/cmd/node/console_windows.go deleted file mode 100644 index b808085..0000000 --- a/cmd/node/console_windows.go +++ /dev/null @@ -1,16 +0,0 @@ -// //go:build windows - -package main - -// import ( -// "os" - -// "golang.org/x/sys/windows" -// ) - -// func enableAnsiSupport() { -// handle := windows.Handle(os.Stdout.Fd()) -// var mode uint32 -// windows.GetConsoleMode(handle, &mode) -// windows.SetConsoleMode(handle, mode|0x0004) // ENABLE_VIRTUAL_TERMINAL_PROCESSING -// } diff --git a/cmd/node/main.go b/cmd/node/main.go index d5c3749..0a50468 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "encoding/base64" "flag" "fmt" "log" @@ -15,6 +16,8 @@ import ( "syscall" "time" + "mesh-cu/internal/api" + "mesh-cu/internal/cdn" "mesh-cu/internal/db" "mesh-cu/internal/discovery" "mesh-cu/internal/network" @@ -30,8 +33,15 @@ func getDirectChatID(peer1, peer2 string) string { func main() { name := flag.String("name", fmt.Sprintf("node-%d", os.Getpid()), "Name of the node") port := flag.Int("port", 8080, "TCP port to listen on") + storageDir := flag.String("storage", "./storage", "Directory to store files") flag.Parse() + fm, err := cdn.NewFileManager(*storageDir) + if err != nil { + log.Fatalf("Failed to initialize FileManager: %v", err) + } + cdnMgr := cdn.NewCDNManager(*name, fm) + registry := discovery.NewPeerRegistry() // enableAnsiSupport() db.InitDB(*name) @@ -63,65 +73,126 @@ func main() { log.Printf("[Handler Error] Invalid chat message format from %s", senderID) return fmt.Errorf("invalid chat message format") } + fmt.Printf("\r%s: %s\nyou: ", name, message) + case protocol.TypeFileAnnounce: + // 1. Извлекаем данные из мапы + fID, _ := payload["file_id"].(string) // Если ID это строка, используй .(string) + fName, _ := payload["file_name"].(string) + + if fID == "" { + fID, _ = payload["FileID"].(string) + } + if fName == "" { + fName, _ = payload["FileName"].(string) + } + // 2. Чтобы cdnMgr «увидел» файл, нужно собрать структуру и вызвать HandleAnnounce + // Превращаем мапу обратно в типизированную нагрузку для CDN[cite: 1, 5] + var p protocol.FileAnnouncePayload + p.FileID = fID + p.FileName = fName + // Получаем остальные поля (размер и чанки), если они есть в payload + if size, ok := payload["file_size"].(float64); ok { + p.FileSize = int64(size) + } - // Determine ChatID - var chatID string - if header.RecipientID == "ALL" || header.RecipientID == "" { - chatID = "ALL" - } else if header.RecipientID == *name { - // Direct message to me - chatID = getDirectChatID(*name, senderID) - var chat db.Chat - if db.DB.First(&chat, "id = ?", chatID).Error != nil { - db.DB.Create(&db.Chat{ID: chatID, Name: senderName, IsGroup: false, Participants: chatID}) - } + var totalChunks uint32 + if tc, ok := payload["total_chunks"].(float64); ok { + totalChunks = uint32(tc) } else { - // Sent to a group ID - chatID = header.RecipientID + totalChunks = uint32((p.FileSize + cdn.ChunkSize - 1) / cdn.ChunkSize) } - cid := currentChatID.Load().(string) - isRead := (cid == chatID) + // 3. ПЕРЕДАЕМ В МЕНЕДЖЕР (теперь fID используется внутри, ошибка уйдет)[cite: 2] + cdnMgr.HandleAnnounce(p, senderID) + + cdnMgr.Lock() // Важно: используй Lock, а не RLock + if _, exists := cdnMgr.Files[fID]; !exists { + cdnMgr.Files[fID] = &cdn.FileInfo{ + ID: fID, + Name: fName, + Size: p.FileSize, + TotalChunks: totalChunks, + OwnedChunks: make(map[uint32]bool), + } + } + cdnMgr.Unlock() + + fmt.Printf("\r[CDN]: Peer %s has file: %s (ID: %s)\nyou: ", name, fName, fID) + case protocol.TypeFileRequest: + fileID, _ := payload["file_id"].(string) + idx, _ := payload["chunk_index"].(float64) + + fi, ok := cdnMgr.Files[fileID] + if ok { + var data []byte + var err error + if fi.OriginalPath != "" { + data, err = fm.ReadChunkFromPath(fi.OriginalPath, uint32(idx)) + } else { + data, err = fm.ReadChunk(fi.Name, uint32(idx)) + } - db.DB.Create(&db.Message{ - ChatID: chatID, - SenderID: senderID, - SenderName: senderName, - Content: message, - Timestamp: time.Now().Unix(), - IsRead: isRead, - }) + if err != nil || len(data) == 0 { + log.Printf("[ERROR] Chunk %d not found for file %s", int(idx), fileID) + return nil + } - if isRead { - fmt.Printf("\r%s: %s\nyou: ", senderName, message) - } else { - var cName string - if chatID == "ALL" { - cName = "Global Chat" - } else { - var c db.Chat - if db.DB.First(&c, "id = ?", chatID).Error == nil { - cName = c.Name - } else { - cName = chatID + // ВАЖНО: SenderID должен быть ВАШИМ (Kamil) + respHeader := protocol.Header{ + MessageType: protocol.TypeFileChunk, + SenderID: *name, // Используйте переменную имени текущего узла + SenderName: *name, + } + + respPayload := map[string]interface{}{ + "file_id": fileID, + "chunk_index": idx, + "data": data, + } + + encoded, _ := protocol.Encode(respHeader, respPayload) + + // Отправляем конкретно тому, кто просил (senderID) + for _, peer := range registry.GetActivePeers() { + if peer.ID == senderID { + fmt.Printf("\r[DEBUG]: Sending chunk %v to %s at %s:%d\n", idx, peer.ID, peer.IP, peer.Port) + + network.SendMessage(peer.IP, peer.Port, encoded) + // log.Printf("[CDN] Sent chunk %d to %s", int(idx), senderID) + break } } - fmt.Printf("\r[New message from %s in %s]\nyou: ", senderName, cName) } - case protocol.TypeGroupCreate: - groupID, _ := payload["group_id"].(string) - groupName, _ := payload["group_name"].(string) - partsStr, _ := payload["participants"].(string) + case protocol.TypeFileChunk: + // Нам прилетел кусок файла + fileID, _ := payload["file_id"].(string) + idx, _ := payload["chunk_index"].(float64) - if groupID != "" { - var chat db.Chat - if db.DB.First(&chat, "id = ?", groupID).Error != nil { - db.DB.Create(&db.Chat{ID: groupID, Name: groupName, IsGroup: true, Participants: partsStr}) - fmt.Printf("\r[SYSTEM]: You were added to group '%s'\nyou: ", groupName) - } + var data []byte + if strData, ok := payload["data"].(string); ok { + data, _ = base64.StdEncoding.DecodeString(strData) + } else if byteData, ok := payload["data"].([]byte); ok { + data = byteData + } else { + log.Printf("[ERROR] Payload 'data' is missing or not a string! Type is: %T", payload["data"]) } + fi, ok := cdnMgr.Files[fileID] + if ok { + fm.WriteChunk(fi.Name, uint32(idx), data) + cdnMgr.Lock() + fi.OwnedChunks[uint32(idx)] = true + cdnMgr.Unlock() + fmt.Printf("\r[CDN]: Received chunk %d for %s\nyou: ", int(idx), fi.Name) + } + // Внутри case protocol.TypeFileChunk в handleIncomingMessage + if uint32(idx)+1 < uint32(fi.TotalChunks) { + // Формируем такой же запрос (TypeFileRequest), но для idx + 1 + // И отправляем его обратно senderID + } else { + fmt.Printf("\n[CDN]: File %s download complete!\nyou: ", fi.Name) + } case protocol.TypePing: // ignored case protocol.TypePong: @@ -132,7 +203,10 @@ func main() { return nil } - registry := discovery.NewPeerRegistry() + // // Инициализируем реестр пиров + registry = discovery.NewPeerRegistry() + + // Создаем сервис обнаружения discService := discovery.NewDiscoveryService(*name, *port) peerChan := make(chan discovery.Peer) ctx, cancel := context.WithCancel(context.Background()) @@ -146,6 +220,10 @@ func main() { } }() + // Start WebSocket API for Flutter web interface + wsAPI := api.NewWSAPI(*name, *port, registry) + wsAPI.Start(ctx, 8765) // WS API port + time.Sleep(1 * time.Second) go func() { @@ -179,8 +257,31 @@ func main() { } } } + }() + // Периодически выводим список активных узлов для наглядности (можно убрать в финальной версии) + // go func() { + // ticker := time.NewTicker(5 * time.Second) + // defer ticker.Stop() + // for { + // select { + // case <-ctx.Done(): + // return + // case <-ticker.C: + // active := registry.GetActivePeers() + // // This part is mostly for debugging. In a real messenger, you might not want to spam this. + // if len(active) > 0 { + // // fmt.Printf("\n--- Active Peers (%d) ---\n", len(active)) + // // for _, p := range active { + // // fmt.Printf("- %s (%s:%d)\n", p.Name, p.IP, p.Port) + // // } + // // fmt.Println("------------------------") + // } + // } + // } + // }() + // Цикл чтения из консоли и рассылки сообщений go func() { scanner := bufio.NewScanner(os.Stdin) @@ -191,6 +292,116 @@ func main() { fmt.Print("you: ") continue } + if strings.HasPrefix(line, "/") { + parts := strings.Fields(line) + if parts[0] == "/announce" { + if len(parts) < 2 { + fmt.Printf("\r[ERROR]: Usage: /announce [optional_id]\nyou: ") + continue + } + path := parts[1] + var fID string + if len(parts) > 2 { + fID = parts[2] + } else { + // Простейшая генерация короткого ID на базе времени + fID = fmt.Sprintf("%x", time.Now().UnixNano())[:6] + } + info, err := os.Stat(path) + if err != nil { + fmt.Printf("\r[ERROR]: File not found: %s\nyou: ", path) + continue + } + + // 1. Регистрируем файл в локальном менеджере (чтобы мы знали, что раздаем) + fi := cdnMgr.RegisterLocalFile(fID, info.Name(), info.Size(), path) + + // 2. Создаем заголовок сообщения + header := protocol.Header{ + MessageType: protocol.TypeFileAnnounce, // Должно быть "FILE_ANN" из твоего types.go + SenderID: *name, + SenderName: *name, + } + + // 3. Формируем полезную нагрузку (важно: ключи должны совпадать с обработчиком!) + payload := map[string]interface{}{ + "file_id": fID, + "file_name": info.Name(), + "file_size": info.Size(), // Передаем чистые байты (int64) + "total_chunks": fi.TotalChunks, + } + + // log.Printf("Size of file %d, and name %s, and total chunks %d", info.Size(), info.Name(), fi.TotalChunks) + // log.Printf("Size: %.2f MB", float64(info.Size())/1000000) + + // 4. Кодируем в JSON/байты + encoded, err := protocol.Encode(header, payload) + if err != nil { + fmt.Printf("\r[ERROR]: Failed to encode: %v\nyou: ", err) + continue + } + + // 5. РАССЫЛКА: отправляем всем, кого нашли через Discovery + activePeers := registry.GetActivePeers() + count := 0 + for _, peer := range activePeers { + if peer.ID != *name { + network.SendMessage(peer.IP, peer.Port, encoded) + count++ + } + } + + fmt.Printf("\r[SYSTEM]: Announced file %s to %d peers\nyou: ", path, count) + } + if parts[0] == "/download" && len(parts) > 1 { + fileID := parts[1] + + cdnMgr.RLock() + fi, exists := cdnMgr.Files[fileID] + cdnMgr.RUnlock() + + if !exists { + fmt.Printf("\r[ERROR]: File ID %s unknown. Wait for announce.\nyou: ", fileID) + continue + } + fmt.Printf("\r[SYSTEM]: Starting download for %s (%d chunks)...\nyou: ", fi.Name, fi.TotalChunks) + + activePeers := registry.GetActivePeers() + if len(activePeers) == 0 { + fmt.Printf("\r[ERROR]: No active peers to request chunks from.\nyou: ") + continue + } + + for chunkIdx := uint32(0); chunkIdx < fi.TotalChunks; chunkIdx++ { + header := protocol.Header{ + MessageType: protocol.TypeFileRequest, + SenderID: *name, + SenderName: *name, + } + + payload := map[string]interface{}{ + "file_id": fileID, + "chunk_index": float64(chunkIdx), + } + + encoded, err := protocol.Encode(header, payload) + if err != nil { + fmt.Printf("\r[ERROR]: Failed to encode chunk %d request: %v\nyou: ", chunkIdx, err) + continue + } + + for _, peer := range activePeers { + if peer.ID != *name { + network.SendMessage(peer.IP, peer.Port, encoded) + } + } + } + fmt.Printf("\r[CDN]: Requested all %d chunks from network\nyou: ", fi.TotalChunks) + } + + fmt.Print("you: ") + continue + } if strings.HasPrefix(line, "/") { handleCommand(line, *name, ¤tChatID, registry) @@ -439,7 +650,7 @@ func handleCommand(line, myID string, currentChatID *atomic.Value, registry *dis // Broadcast group creation to active participant peers header := protocol.Header{ - MessageType: protocol.TypeGroupCreate, + MessageType: protocol.TypeChat, SenderID: myID, RecipientID: groupID, } diff --git a/flutter_app/README.md b/flutter_app/README.md new file mode 100644 index 0000000..fdf9a7d --- /dev/null +++ b/flutter_app/README.md @@ -0,0 +1,127 @@ +# Mesh-CU Flutter Web Interface + +Flutter web application for Mesh-CU messenger - a distributed peer-to-peer messaging system. + +## Features + +- Real-time messaging via WebSocket +- Chat list with unread message indicators +- Direct and group chats support +- Peer discovery display +- Material Design 3 UI + +## Project Structure + +``` +flutter_app/ +├── lib/ +│ ├── main.dart # App entry point +│ ├── models/ +│ │ └── models.dart # Data models (Peer, Chat, Message) +│ ├── services/ +│ │ └── mesh_service.dart # WebSocket service & state management +│ ├── screens/ +│ │ └── home_screen.dart # Main screen layout +│ └── widgets/ +│ ├── chat_list.dart # Chat list component +│ ├── message_list.dart # Messages view & input +│ └── peer_list.dart # Active peers display +├── web/ +│ └── index.html # Web entry point +└── pubspec.yaml # Dependencies +``` + +## Setup + +### Prerequisites + +1. Install Flutter SDK (3.0+) +2. Enable web support: `flutter config --enable-web` + +### Installation + +```bash +cd flutter_app +flutter pub get +``` + +### Running + +**Web:** +```bash +flutter run -d chrome +``` + +**Linux:** +```bash +flutter run -d linux +``` + +**Build for Web:** +```bash +flutter build web +``` + +## Connecting to Backend + +The app connects to the Go backend via WebSocket. Default settings: +- Host: `localhost` +- Port: `8765` + +To change the connection settings, use the connection panel at the top of the app. + +## Backend Integration + +The Flutter app communicates with the Go backend through WebSocket API (`/ws` endpoint). + +### WebSocket Messages + +**Client → Server:** +```json +{ + "type": "send_message", + "payload": { + "chat_id": "ALL", + "content": "Hello!", + "recipient_id": "ALL" + } +} +``` + +**Server → Client:** +```json +{ + "type": "new_message", + "payload": { + "id": 1, + "chat_id": "ALL", + "sender_id": "node-123", + "sender_name": "Alice", + "content": "Hello!", + "timestamp": 1234567890, + "is_read": true + } +} +``` + +### Message Types + +| Type | Direction | Description | +|------|-----------|-------------| +| `send_message` | C→S | Send a chat message | +| `get_messages` | C→S | Request messages for a chat | +| `get_chats` | C→S | Refresh chat list | +| `get_peers` | C→S | Refresh peer list | +| `create_group` | C→S | Create a new group | +| `new_message` | S→C | New message received | +| `chats` | S→C | Chat list update | +| `peers` | S→C | Peer list update | +| `messages` | S→C | Messages for a chat | +| `error` | S→C | Error response | + +## Architecture + +- **Provider** for state management +- **WebSocket Channel** for real-time communication +- **Material Design 3** for UI components +- **ChangeNotifier** pattern for reactive updates diff --git a/flutter_app/devtools_options.yaml b/flutter_app/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/flutter_app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart new file mode 100644 index 0000000..3cb4170 --- /dev/null +++ b/flutter_app/lib/main.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'screens/home_screen.dart'; + +void main() { + runApp(const MeshApp()); +} + +class MeshApp extends StatelessWidget { + const MeshApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Mesh CU', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const HomeScreen(), + ); + } +} diff --git a/flutter_app/lib/screens/chat_screen.dart b/flutter_app/lib/screens/chat_screen.dart new file mode 100644 index 0000000..b72bfc0 --- /dev/null +++ b/flutter_app/lib/screens/chat_screen.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; // Нужно для Timer +import '../services/mesh_service.dart'; + +class ChatScreen extends StatefulWidget { + final String chatId; + final String chatName; + + const ChatScreen({ + super.key, + required this.chatId, + required this.chatName, + }); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final MeshService _service = MeshService(); + final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + List _messages = []; + bool _isLoading = true; + Timer? _pollingTimer; + + // Генерируем временный ID для текущего пользователя + final String _myUserId = 'user_${DateTime.now().millisecondsSinceEpoch}'; + + @override + void initState() { + super.initState(); + _loadMessages(); + + // Исправлено: используем Timer.periodic вместо Future.periodic + _pollingTimer = Timer.periodic(const Duration(seconds: 2), (_) { + if (!mounted) return; + _loadMessages(silent: true); + }); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + _pollingTimer?.cancel(); + super.dispose(); + } + + Future _loadMessages({bool silent = false}) async { + if (!silent && mounted) setState(() => _isLoading = true); + + final msgs = await _service.getMessages(widget.chatId); + + if (mounted) { + setState(() { + _messages = msgs; + _isLoading = false; + }); + // Прокрутка вниз + if (_scrollController.hasClients) { + Future.delayed(const Duration(milliseconds: 100), () { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + } + } + + Future _sendMessage() async { + final text = _controller.text.trim(); + if (text.isEmpty) return; + + _controller.clear(); + // Отправляем: chatId, текст, senderId + await _service.sendMessage(widget.chatId, text, _myUserId); + await _loadMessages(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.chatName), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Column( + children: [ + Expanded( + child: _isLoading && _messages.isEmpty + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + controller: _scrollController, + itemCount: _messages.length, + itemBuilder: (context, index) { + final msg = _messages[index]; + final isMe = msg.senderId == _myUserId; + return Align( + alignment: + isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric( + vertical: 4, horizontal: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isMe + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMe) + Text(msg.senderId.substring(0, 5), + style: TextStyle( + fontSize: 10, + color: Theme.of(context) + .colorScheme + .primary)), + Text(msg.content), + Text( + '${msg.timestamp.hour}:${msg.timestamp.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 10), + ), + ], + ), + ), + ); + }, + ), + ), + Divider( + height: 1, color: Theme.of(context).colorScheme.outlineVariant), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Введите сообщение...', + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _sendMessage, + icon: const Icon(Icons.send), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/flutter_app/lib/screens/home_screen.dart b/flutter_app/lib/screens/home_screen.dart new file mode 100644 index 0000000..c463357 --- /dev/null +++ b/flutter_app/lib/screens/home_screen.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import '../services/mesh_service.dart'; +import 'chat_screen.dart'; +import 'dart:async'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final MeshService _service = MeshService(); + List> _chats = []; + List _peers = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _refreshData(); + // Исправлено: теперь используем Timer.periodic, а не Future.periodic + Future.delayed(Duration.zero, () { + Timer.periodic(const Duration(seconds: 5), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + _refreshData(); + }); + }); + } + + Future _refreshData() async { + if (!mounted) return; + setState(() => _isLoading = true); + + await Future.wait([ + _loadChats(), + _loadPeers(), + ]); + + if (mounted) setState(() => _isLoading = false); + } + + Future _loadChats() async { + final chats = await _service.getChats(); + if (mounted) setState(() => _chats = chats); + } + + Future _loadPeers() async { + final peers = await _service.getPeers(); + if (mounted) setState(() => _peers = peers); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Mesh Network'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _refreshData, + tooltip: 'Обновить', + ), + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Center( + child: Text( + 'Пиров: ${_peers.length}', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _chats.isEmpty + ? const Center(child: Text('Нет активных чатов')) + : ListView.builder( + itemCount: _chats.length, + itemBuilder: (context, index) { + final chat = _chats[index]; + return ListTile( + leading: const CircleAvatar( + child: Icon(Icons.group), + ), + title: Text(chat['name'] ?? 'Чат ${chat['id']}'), + subtitle: Text('ID: ${chat['id']}'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: chat['id'], + chatName: chat['name'] ?? 'Чат', + ), + ), + ); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Создание чата пока не реализовано')), + ); + }, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/flutter_app/lib/services/mesh_service.dart b/flutter_app/lib/services/mesh_service.dart new file mode 100644 index 0000000..fccd434 --- /dev/null +++ b/flutter_app/lib/services/mesh_service.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +// Классы Message и Peer определены здесь же, чтобы не было конфликтов импортов +class Message { + final String id; + final String chatId; + final String senderId; + final String content; + final DateTime timestamp; + + Message({ + required this.id, + required this.chatId, + required this.senderId, + required this.content, + required this.timestamp, + }); + + factory Message.fromJson(Map json) { + return Message( + id: json['id'] ?? '', + chatId: json['chat_id'] ?? '', + senderId: json['sender_id'] ?? '', + content: json['content'] ?? '', + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), + ); + } +} + +class Peer { + final String id; + final String address; + final bool isActive; + + Peer({required this.id, required this.address, required this.isActive}); + + factory Peer.fromJson(Map json) { + return Peer( + id: json['id'] ?? '', + address: json['address'] ?? '', + isActive: json['is_active'] ?? false, + ); + } +} + +class MeshService { + // static const String baseUrl = 'http://localhost:8765/api'; // ВОПРОСИК как бы + static const String baseUrl = 'http://localhost:8080/api'; // ВОПРОСИК как бы + + Future>> getChats() async { + try { + final response = await http.get(Uri.parse('$baseUrl/chats')); + if (response.statusCode == 200) { + return List>.from(json.decode(response.body)); + } else { + throw Exception('Failed to load chats'); + } + } catch (e) { + print('Error fetching chats: $e'); + return []; + } + } + + Future> getMessages(String chatId) async { + try { + final response = await http.get(Uri.parse('$baseUrl/messages/$chatId')); + if (response.statusCode == 200) { + final List jsonList = json.decode(response.body); + return jsonList.map((json) => Message.fromJson(json)).toList(); + } else { + return []; + } + } catch (e) { + print('Error fetching messages: $e'); + return []; + } + } + + Future sendMessage( + String chatId, String content, String senderId) async { + try { + final response = await http.post( + Uri.parse('$baseUrl/messages'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'chat_id': chatId, + 'sender_id': senderId, + 'content': content, + }), + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception('Failed to send message'); + } + } catch (e) { + print('Error sending message: $e'); + } + } + + Future> getPeers() async { + try { + final response = await http.get(Uri.parse('$baseUrl/peers')); + if (response.statusCode == 200) { + final List jsonList = json.decode(response.body); + return jsonList.map((json) => Peer.fromJson(json)).toList(); + } else { + return []; + } + } catch (e) { + print('Error fetching peers: $e'); + return []; + } + } +} diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock new file mode 100644 index 0000000..d39b7c4 --- /dev/null +++ b/flutter_app/pubspec.lock @@ -0,0 +1,253 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.7.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml new file mode 100644 index 0000000..493b944 --- /dev/null +++ b/flutter_app/pubspec.yaml @@ -0,0 +1,21 @@ +name: mesh_cu_flutter +description: Flutter web interface for Mesh-CU messenger +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + http: ^1.1.0 + provider: ^6.0.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + uses-material-design: true diff --git a/flutter_app/web/index.html b/flutter_app/web/index.html new file mode 100644 index 0000000..2028b38 --- /dev/null +++ b/flutter_app/web/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + Mesh-CU Messenger + + + + + + + + + diff --git a/go.mod b/go.mod index 9d4141e..83d6d53 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.23.2 require ( github.com/glebarez/sqlite v1.11.0 - golang.org/x/sys v0.7.0 + github.com/gorilla/websocket v1.5.3 gorm.io/gorm v1.31.1 ) @@ -16,6 +16,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.7.0 // indirect golang.org/x/text v0.20.0 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect diff --git a/go.sum b/go.sum index 95df11c..929144d 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= diff --git a/internal/api/ws.go b/internal/api/ws.go new file mode 100644 index 0000000..7b4dabf --- /dev/null +++ b/internal/api/ws.go @@ -0,0 +1,401 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "mesh-cu/internal/db" + "mesh-cu/internal/discovery" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Для разработки разрешаем все origins + }, +} + +type WSMessage struct { + Type string `json:"type"` + Payload map[string]interface{} `json:"payload,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` +} + +type Client struct { + conn *websocket.Conn + nodeID string + send chan []byte + mu sync.Mutex +} + +type WSAPI struct { + NodeID string + Port int + Registry *discovery.PeerRegistry + MessageChan chan db.Message + Clients map[*Client]bool + ClientsMu sync.RWMutex + GetChatFunc func(string) []db.Message + SendMsgFunc func(string, string, string) error + CreateGroupFunc func(string, []string) error +} + +func NewWSAPI(nodeID string, port int, registry *discovery.PeerRegistry) *WSAPI { + return &WSAPI{ + NodeID: nodeID, + Port: port, + Registry: registry, + MessageChan: make(chan db.Message, 100), + Clients: make(map[*Client]bool), + } +} + +func (api *WSAPI) Start(ctx context.Context, httpPort int) { + http.HandleFunc("/ws", api.handleWebSocket) + http.HandleFunc("/api/chats", api.handleGetChats) + http.HandleFunc("/api/messages/", api.handleGetMessages) + http.HandleFunc("/api/peers", api.handleGetPeers) + + addr := fmt.Sprintf(":%d", httpPort) + log.Printf("[WS API] Starting WebSocket API server on %s", addr) + + go func() { + if err := http.ListenAndServe(addr, nil); err != nil { + log.Printf("[WS API Error] %v", err) + } + }() + + // Broadcast messages to all connected clients + go func() { + for { + select { + case <-ctx.Done(): + return + case msg := <-api.MessageChan: + api.broadcastMessage(msg) + } + } + }() +} + +func (api *WSAPI) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("[WS] Upgrade error: %v", err) + return + } + + client := &Client{ + conn: conn, + nodeID: api.NodeID, + send: make(chan []byte, 256), + } + + api.ClientsMu.Lock() + api.Clients[client] = true + api.ClientsMu.Unlock() + + // Send initial data + api.sendInitialData(client) + + go client.writer(api) + go client.reader(api) +} + +func (c *Client) writer(api *WSAPI) { + defer func() { + api.ClientsMu.Lock() + delete(api.Clients, c) + api.ClientsMu.Unlock() + c.conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + c.mu.Lock() + err := c.conn.WriteMessage(websocket.TextMessage, message) + c.mu.Unlock() + if err != nil { + return + } + case <-time.After(30 * time.Second): + // Send ping to keep connection alive + c.mu.Lock() + c.conn.WriteMessage(websocket.PingMessage, nil) + c.mu.Unlock() + } + } +} + +func (c *Client) reader(api *WSAPI) { + defer func() { + api.ClientsMu.Lock() + delete(api.Clients, c) + api.ClientsMu.Unlock() + c.conn.Close() + close(c.send) + }() + + for { + _, message, err := c.conn.ReadMessage() + if err != nil { + break + } + + var wsMsg WSMessage + if err := json.Unmarshal(message, &wsMsg); err != nil { + continue + } + + switch wsMsg.Type { + case "send_message": + api.handleSendMessage(c, wsMsg.Payload) + case "get_messages": + api.handleGetMessagesWS(c, wsMsg.Payload) + case "get_chats": + api.handleGetChatsWS(c) + case "get_peers": + api.handleGetPeersWS(c) + case "create_group": + api.handleCreateGroup(c, wsMsg.Payload) + } + } +} + +func (api *WSAPI) sendInitialData(client *Client) { + // Send current peer list + peers := api.Registry.GetActivePeers() + peerList := make([]map[string]interface{}, len(peers)) + for i, p := range peers { + peerList[i] = map[string]interface{}{ + "id": p.ID, + "name": p.Name, + "ip": p.IP, + "port": p.Port, + } + } + + client.send <- mustMarshal(WSMessage{ + Type: "peers", + Payload: map[string]interface{}{"peers": peerList}, + }) + + // Send chats list + api.handleGetChatsWS(client) +} + +func (api *WSAPI) handleSendMessage(client *Client, payload map[string]interface{}) { + chatID, _ := payload["chat_id"].(string) + content, _ := payload["content"].(string) + recipientID, _ := payload["recipient_id"].(string) + + if chatID == "" || content == "" { + client.send <- mustMarshal(WSMessage{ + Type: "error", + Payload: map[string]interface{}{"message": "Invalid message data"}, + }) + return + } + + // Store message in DB + msg := db.Message{ + ChatID: chatID, + SenderID: api.NodeID, + SenderName: api.NodeID, + Content: content, + Timestamp: time.Now().Unix(), + IsRead: true, + } + db.DB.Create(&msg) + + // Send to network if we have a send function + if api.SendMsgFunc != nil { + api.SendMsgFunc(chatID, content, recipientID) + } + + // Broadcast to all clients + api.broadcastMessage(msg) +} + +func (api *WSAPI) handleGetMessagesWS(client *Client, payload map[string]interface{}) { + chatID, _ := payload["chat_id"].(string) + if chatID == "" { + return + } + + var messages []db.Message + db.DB.Where("chat_id = ?", chatID).Order("timestamp asc").Find(&messages) + + msgList := make([]map[string]interface{}, len(messages)) + for i, m := range messages { + msgList[i] = map[string]interface{}{ + "id": m.ID, + "chat_id": m.ChatID, + "sender_id": m.SenderID, + "sender_name": m.SenderName, + "content": m.Content, + "timestamp": m.Timestamp, + "is_read": m.IsRead, + } + } + + client.send <- mustMarshal(WSMessage{ + Type: "messages", + Payload: map[string]interface{}{"chat_id": chatID, "messages": msgList}, + }) +} + +func (api *WSAPI) handleGetChatsWS(client *Client) { + type ChatInfo struct { + ID string `json:"id"` + Name string `json:"name"` + IsGroup bool `json:"is_group"` + Participants string `json:"participants"` + UnreadCount int `json:"unread_count"` + LastMessage string `json:"last_message,omitempty"` + LastTime int64 `json:"last_time,omitempty"` + } + + var chats []ChatInfo + db.DB.Raw(`SELECT chats.id, chats.name, chats.is_group, chats.participants, + SUM(CASE WHEN messages.is_read = 0 THEN 1 ELSE 0 END) as unread_count, + (SELECT content FROM messages WHERE messages.chat_id = chats.id ORDER BY timestamp DESC LIMIT 1) as last_message, + (SELECT timestamp FROM messages WHERE messages.chat_id = chats.id ORDER BY timestamp DESC LIMIT 1) as last_time + FROM chats LEFT JOIN messages ON chats.id = messages.chat_id + GROUP BY chats.id`).Scan(&chats) + + client.send <- mustMarshal(WSMessage{ + Type: "chats", + Payload: map[string]interface{}{"chats": chats}, + }) +} + +func (api *WSAPI) handleGetPeersWS(client *Client) { + peers := api.Registry.GetActivePeers() + peerList := make([]map[string]interface{}, len(peers)) + for i, p := range peers { + peerList[i] = map[string]interface{}{ + "id": p.ID, + "name": p.Name, + "ip": p.IP, + "port": p.Port, + } + } + + client.send <- mustMarshal(WSMessage{ + Type: "peers", + Payload: map[string]interface{}{"peers": peerList}, + }) +} + +func (api *WSAPI) handleCreateGroup(client *Client, payload map[string]interface{}) { + groupName, _ := payload["name"].(string) + participantsRaw, _ := payload["participants"].([]interface{}) + + if groupName == "" || len(participantsRaw) == 0 { + client.send <- mustMarshal(WSMessage{ + Type: "error", + Payload: map[string]interface{}{"message": "Invalid group data"}, + }) + return + } + + var participants []string + participants = append(participants, api.NodeID) + for _, p := range participantsRaw { + if name, ok := p.(string); ok && name != api.NodeID { + participants = append(participants, name) + } + } + + // Create group logic here + // For now just send confirmation + client.send <- mustMarshal(WSMessage{ + Type: "group_created", + Payload: map[string]interface{}{"name": groupName, "participants": participants}, + }) +} + +func (api *WSAPI) broadcastMessage(msg db.Message) { + msgData := map[string]interface{}{ + "id": msg.ID, + "chat_id": msg.ChatID, + "sender_id": msg.SenderID, + "sender_name": msg.SenderName, + "content": msg.Content, + "timestamp": msg.Timestamp, + "is_read": msg.IsRead, + } + + api.ClientsMu.RLock() + defer api.ClientsMu.RUnlock() + + for client := range api.Clients { + select { + case client.send <- mustMarshal(WSMessage{ + Type: "new_message", + Payload: msgData, + }): + default: + // Client buffer full, skip + } + } +} + +func (api *WSAPI) handleGetChats(w http.ResponseWriter, r *http.Request) { + type ChatInfo struct { + ID string `json:"id"` + Name string `json:"name"` + IsGroup bool `json:"is_group"` + Participants string `json:"participants"` + UnreadCount int `json:"unread_count"` + } + + var chats []ChatInfo + db.DB.Raw(`SELECT chats.id, chats.name, chats.is_group, chats.participants, + SUM(CASE WHEN messages.is_read = 0 THEN 1 ELSE 0 END) as unread_count + FROM chats LEFT JOIN messages ON chats.id = messages.chat_id + GROUP BY chats.id`).Scan(&chats) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(chats) +} + +func (api *WSAPI) handleGetMessages(w http.ResponseWriter, r *http.Request) { + chatID := r.URL.Path[len("/api/messages/"):] + if chatID == "" { + http.Error(w, "Chat ID required", http.StatusBadRequest) + return + } + + var messages []db.Message + db.DB.Where("chat_id = ?", chatID).Order("timestamp asc").Find(&messages) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) +} + +func (api *WSAPI) handleGetPeers(w http.ResponseWriter, r *http.Request) { + peers := api.Registry.GetActivePeers() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(peers) +} + +func mustMarshal(v interface{}) []byte { + data, err := json.Marshal(v) + if err != nil { + return []byte("{}") + } + return data +} diff --git a/internal/cdn/cdn_manager.go b/internal/cdn/cdn_manager.go new file mode 100644 index 0000000..ff03185 --- /dev/null +++ b/internal/cdn/cdn_manager.go @@ -0,0 +1,118 @@ +package cdn + +import ( + "sync" + + "mesh-cu/internal/protocol" +) + +type FileInfo struct { + ID string + Name string + OriginalPath string + Size int64 + TotalChunks uint32 + OwnedChunks map[uint32]bool +} + +type CDNManager struct { + fm *FileManager + mu sync.RWMutex + Files map[string]*FileInfo // My files (including partially downloaded) + PeerFiles map[string]map[string][]uint32 // fileID -> peerID -> chunkIndices + NodeID string +} + +func NewCDNManager(nodeID string, fm *FileManager) *CDNManager { + return &CDNManager{ + fm: fm, + Files: make(map[string]*FileInfo), + PeerFiles: make(map[string]map[string][]uint32), + NodeID: nodeID, + } +} + +func (cm *CDNManager) Lock() { + cm.mu.Lock() +} + +func (cm *CDNManager) Unlock() { + cm.mu.Unlock() +} + +func (cm *CDNManager) RLock() { + cm.mu.RLock() +} + +func (cm *CDNManager) RUnlock() { + cm.mu.RUnlock() +} + +func (cm *CDNManager) HandleAnnounce(payload protocol.FileAnnouncePayload, senderID string) { + cm.mu.Lock() + defer cm.mu.Unlock() + + if _, ok := cm.PeerFiles[payload.FileID]; !ok { + cm.PeerFiles[payload.FileID] = make(map[string][]uint32) + } + cm.PeerFiles[payload.FileID][senderID] = payload.Chunks +} + +func (cm *CDNManager) GetChunkOwners(fileID string, chunkIndex uint32) []string { + cm.mu.RLock() + defer cm.mu.RUnlock() + + var owners []string + if peers, ok := cm.PeerFiles[fileID]; ok { + for peerID, chunks := range peers { + for _, idx := range chunks { + if idx == chunkIndex { + owners = append(owners, peerID) + break + } + } + } + } + return owners +} + +func (cm *CDNManager) RegisterLocalFile(fileID, name string, size int64, originalPath string) *FileInfo { + totalChunks := uint32((size + ChunkSize - 1) / ChunkSize) + owned := make(map[uint32]bool) + for i := uint32(0); i < totalChunks; i++ { + owned[i] = true + } + + fi := &FileInfo{ + ID: fileID, + Name: name, + OriginalPath: originalPath, + Size: size, + TotalChunks: totalChunks, + OwnedChunks: owned, + } + + cm.mu.Lock() + cm.Files[fileID] = fi + cm.mu.Unlock() + + return fi +} + +func (cm *CDNManager) GetOwnedChunks(fileID string) []uint32 { + cm.mu.RLock() + defer cm.mu.RUnlock() + + fi, ok := cm.Files[fileID] + if !ok { + return nil + } + + var chunks []uint32 + for idx, owned := range fi.OwnedChunks { + if owned { + chunks = append(chunks, idx) + } + } + return chunks +} diff --git a/internal/cdn/file_manager.go b/internal/cdn/file_manager.go new file mode 100644 index 0000000..1a97ed9 --- /dev/null +++ b/internal/cdn/file_manager.go @@ -0,0 +1,70 @@ +package cdn + +import ( + "fmt" + "os" + "path/filepath" +) + +const ChunkSize = 64 * 1024 // 64 KB + +type FileManager struct { + StoragePath string +} + +func NewFileManager(storagePath string) (*FileManager, error) { + absPath, err := filepath.Abs(storagePath) + if err != nil { + return nil, err + } + if err := os.MkdirAll(absPath, 0755); err != nil { + return nil, err + } + return &FileManager{StoragePath: absPath}, nil +} + +func (fm *FileManager) ReadChunkFromPath(path string, index uint32) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + offset := int64(index) * ChunkSize + data := make([]byte, ChunkSize) + n, err := file.ReadAt(data, offset) + if err != nil && err.Error() != "EOF" { + return nil, fmt.Errorf("failed to read chunk: %w", err) + } + return data[:n], nil +} + +func (fm *FileManager) ReadChunk(fileName string, index uint32) ([]byte, error) { + path := filepath.Join(fm.StoragePath, fileName) + return fm.ReadChunkFromPath(path, index) +} + +func (fm *FileManager) WriteChunk(fileName string, index uint32, data []byte) error { + path := filepath.Join(fm.StoragePath, fileName) + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("failed to open file for writing: %w", err) + } + defer file.Close() + + offset := int64(index) * ChunkSize + _, err = file.WriteAt(data, offset) + if err != nil { + return fmt.Errorf("failed to write chunk: %w", err) + } + return nil +} + +func (fm *FileManager) GetFileSize(fileName string) (int64, error) { + path := filepath.Join(fm.StoragePath, fileName) + fi, err := os.Stat(path) + if err != nil { + return 0, err + } + return fi.Size(), nil +} diff --git a/internal/network/server.go b/internal/network/server.go index 5a2ac4d..4906b84 100644 --- a/internal/network/server.go +++ b/internal/network/server.go @@ -3,6 +3,7 @@ package network import ( "context" "fmt" + "io" "log" "net" "time" @@ -62,29 +63,40 @@ func (s *Server) Stop() { func (s *Server) handleConnection(conn net.Conn) { defer conn.Close() - buf := make([]byte, 4096) - for { - conn.SetReadDeadline(time.Now().Add(5 * time.Second)) - n, err := conn.Read(buf) + conn.SetReadDeadline(time.Now().Add(15 * time.Second)) + + data, err := io.ReadAll(conn) + if err != nil { + return + } + + if len(data) > 0 { + header, payload, err := protocol.Decode(data) if err != nil { - break + log.Printf("[Network Error] Failed to decode message: %v", err) + return } - if n > 0 { - header, payload, err := protocol.Decode(buf[:n]) - if err != nil { - break + if s.Handler != nil { + senderName, _ := payload["sender_name"].(string) + if senderName == "" { + senderName = header.SenderID } - if s.Handler != nil { - senderName, _ := payload["sender_name"].(string) - if senderName == "" { - senderName = header.SenderID - } + err = s.Handler(conn, header, payload) - err = s.Handler(conn, header, payload) - if err != nil { - log.Printf("[Network Error] Handler failed: %v", err) + if err != nil { + log.Printf("[Network Error] Handler failed: %v", err) + if s.Handler != nil { + senderName, _ := payload["sender_name"].(string) + if senderName == "" { + senderName = header.SenderID + } + + err = s.Handler(conn, header, payload) + if err != nil { + log.Printf("[Network Error] Handler failed: %v", err) + } } } } diff --git a/internal/protocol/types.go b/internal/protocol/types.go index 92aa0ec..27e26bd 100644 --- a/internal/protocol/types.go +++ b/internal/protocol/types.go @@ -8,13 +8,14 @@ import ( type MessageType string const ( - TypePing MessageType = "PING" - TypePong MessageType = "PONG" - TypeChat MessageType = "CHAT" - TypeFileRequest MessageType = "FILE_REQ" - TypeFileChunk MessageType = "FILE_CHUNK" - TypeFileAck MessageType = "FILE_ACK" - TypeGroupCreate MessageType = "GROUP_CREATE" + TypePing MessageType = "PING" + TypePong MessageType = "PONG" + TypeChat MessageType = "CHAT" + TypeFileRequest MessageType = "FILE_REQ" + TypeFileAck MessageType = "FILE_ACK" + TypeFileAnnounce MessageType = "FILE_ANN" // Анонс файла + TypeChunkRequest MessageType = "CHUNK_REQ" // Запрос чанка + TypeFileChunk MessageType = "FILE_CHUNK" // Сами данные чанка ) type Header struct { @@ -33,6 +34,26 @@ type ChatMessage struct { Message string `json:"message"` } +// Структуры для полезной нагрузки CDN +type FileAnnouncePayload struct { + FileID string `json:"file_id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + TotalChunks uint32 `json:"total_chunks"` + Chunks []uint32 `json:"chunks"` // Список имеющихся у пира кусков +} + +type ChunkRequestPayload struct { + FileID string `json:"file_id"` + ChunkIndex uint32 `json:"chunk_index"` +} + +type ChunkPayload struct { + FileID string `json:"file_id"` + ChunkIndex uint32 `json:"chunk_index"` + Data []byte `json:"data"` +} + func Encode(header Header, payload interface{}) ([]byte, error) { temp := struct { Header diff --git a/tst.mp4 b/tst.mp4 new file mode 100644 index 0000000..336b341 Binary files /dev/null and b/tst.mp4 differ