From 096c4d0a2d6fa66150a844f3076a987bb478d609 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Sat, 6 Jun 2026 22:39:14 +0000 Subject: [PATCH] Initial commit: JustAMessenger v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Серверная часть (Go): - WebSocket сервер с бинарным протоколом - XChaCha20-Poly1305 шифрование - zstd сжатие с дедупликацией (64KB чанки) - SQLite хранилище (WAL режим) - Управление гильдиями, каналами, ролями - Федерация между серверами (ed25519) - REST API + WebSocket endpoints Клиентская часть (Flutter): - Material Design 3 тёмная тема (Discord-like) - WebSocket соединение с сервером - Экраны: сплэш, логин, домашний, гильдии, чат - Модели: пользователи, гильдии, каналы, сообщения, роли - Сервисы: соединение, API, криптография, тема - Виджеты: иконки гильдий, сообщения, ввод чата - Web сборка (PWA) Документация: - AGENTS.md — контекст для ИИ ассистентов - docs/protocol.md — спецификация протокола --- .gitignore | 31 ++ client/lib/app/app.dart | 63 +++ client/lib/main.dart | 34 ++ client/lib/models/channel.dart | 40 ++ client/lib/models/guild.dart | 62 +++ client/lib/models/message.dart | 57 +++ client/lib/models/role.dart | 35 ++ client/lib/models/user.dart | 39 ++ client/lib/screens/chat_screen.dart | 237 ++++++++++ client/lib/screens/guild_screen.dart | 26 ++ client/lib/screens/home_screen.dart | 425 ++++++++++++++++++ client/lib/screens/login_screen.dart | 176 ++++++++ client/lib/screens/settings_screen.dart | 178 ++++++++ client/lib/screens/splash_screen.dart | 110 +++++ client/lib/services/api_service.dart | 163 +++++++ client/lib/services/connection_service.dart | 199 ++++++++ client/lib/services/crypto_service.dart | 86 ++++ client/lib/services/theme_service.dart | 74 +++ client/lib/widgets/chat_input.dart | 162 +++++++ client/lib/widgets/guild_icon.dart | 79 ++++ client/lib/widgets/message_bubble.dart | 268 +++++++++++ client/pubspec.yaml | 45 ++ client/web/index.html | 62 +++ client/web/manifest.json | 21 + docs/protocol.md | 92 ++++ server/cmd/jam-server/main.go | 45 ++ server/config.json | 10 + server/go.mod | 13 + server/go.sum | 12 + server/internal/api/api.go | 454 +++++++++++++++++++ server/internal/channel/channel.go | 135 ++++++ server/internal/compression/compression.go | 195 ++++++++ server/internal/config/config.go | 46 ++ server/internal/crypto/crypto.go | 92 ++++ server/internal/database/database.go | 190 ++++++++ server/internal/federation/federation.go | 209 +++++++++ server/internal/models/models.go | 108 +++++ server/internal/protocol/protocol.go | 128 ++++++ server/internal/role/role.go | 180 ++++++++ server/internal/server/server.go | 473 ++++++++++++++++++++ 40 files changed, 5054 insertions(+) create mode 100644 .gitignore create mode 100644 client/lib/app/app.dart create mode 100644 client/lib/main.dart create mode 100644 client/lib/models/channel.dart create mode 100644 client/lib/models/guild.dart create mode 100644 client/lib/models/message.dart create mode 100644 client/lib/models/role.dart create mode 100644 client/lib/models/user.dart create mode 100644 client/lib/screens/chat_screen.dart create mode 100644 client/lib/screens/guild_screen.dart create mode 100644 client/lib/screens/home_screen.dart create mode 100644 client/lib/screens/login_screen.dart create mode 100644 client/lib/screens/settings_screen.dart create mode 100644 client/lib/screens/splash_screen.dart create mode 100644 client/lib/services/api_service.dart create mode 100644 client/lib/services/connection_service.dart create mode 100644 client/lib/services/crypto_service.dart create mode 100644 client/lib/services/theme_service.dart create mode 100644 client/lib/widgets/chat_input.dart create mode 100644 client/lib/widgets/guild_icon.dart create mode 100644 client/lib/widgets/message_bubble.dart create mode 100644 client/pubspec.yaml create mode 100644 client/web/index.html create mode 100644 client/web/manifest.json create mode 100644 docs/protocol.md create mode 100644 server/cmd/jam-server/main.go create mode 100644 server/config.json create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/internal/api/api.go create mode 100644 server/internal/channel/channel.go create mode 100644 server/internal/compression/compression.go create mode 100644 server/internal/config/config.go create mode 100644 server/internal/crypto/crypto.go create mode 100644 server/internal/database/database.go create mode 100644 server/internal/federation/federation.go create mode 100644 server/internal/models/models.go create mode 100644 server/internal/protocol/protocol.go create mode 100644 server/internal/role/role.go create mode 100644 server/internal/server/server.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8613c48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Go +server/data/ +server/jam-server +server/*.exe + +# Flutter +client/build/ +client/.dart_tool/ +client/.packages +client/.pub/ +client/pubspec.lock +client/android/.gradle/ +client/android/app/build/ +client/ios/Pods/ +client/ios/.symlinks/ +client/.flutter-plugins +client/.flutter-plugins-dependencies + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Config +server/config.local.json diff --git a/client/lib/app/app.dart b/client/lib/app/app.dart new file mode 100644 index 0000000..4b82f53 --- /dev/null +++ b/client/lib/app/app.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import '../screens/splash_screen.dart'; +import '../screens/login_screen.dart'; +import '../screens/home_screen.dart'; +import '../screens/guild_screen.dart'; +import '../screens/chat_screen.dart'; +import '../screens/settings_screen.dart'; +import '../services/connection_service.dart'; +import 'package:provider/provider.dart'; + +class JAMAppShell extends StatefulWidget { + const JAMAppShell({super.key}); + + @override + State createState() => _JAMAppShellState(); +} + +class _JAMAppShellState extends State { + final ConnectionService _connectionService = ConnectionService(); + + @override + void initState() { + super.initState(); + _connectionService.init(); + } + + @override + void dispose() { + _connectionService.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _connectionService), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + title: 'JustAMessenger', + theme: ThemeData.dark().copyWith( + colorScheme: ColorScheme.dark( + primary: const Color(0xFF5865F2), + secondary: const Color(0xFF3BA55D), + surface: const Color(0xFF2F3136), + error: const Color(0xFFED4245), + ), + scaffoldBackgroundColor: const Color(0xFF36393F), + ), + initialRoute: '/', + routes: { + '/': (context) => const SplashScreen(), + '/login': (context) => const LoginScreen(), + '/home': (context) => const HomeScreen(), + '/guild': (context) => const GuildScreen(), + '/chat': (context) => const ChatScreen(), + '/settings': (context) => const SettingsScreen(), + }, + ), + ); + } +} diff --git a/client/lib/main.dart b/client/lib/main.dart new file mode 100644 index 0000000..9e79374 --- /dev/null +++ b/client/lib/main.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'app/app.dart'; +import 'services/theme_service.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const JAMApp()); +} + +class JAMApp extends StatelessWidget { + const JAMApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'JustAMessenger', + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('ru'), + Locale('en'), + ], + theme: ThemeService.darkTheme, + darkTheme: ThemeService.darkTheme, + themeMode: ThemeMode.dark, + home: const JAMAppShell(), + ); + } +} diff --git a/client/lib/models/channel.dart b/client/lib/models/channel.dart new file mode 100644 index 0000000..3ca6684 --- /dev/null +++ b/client/lib/models/channel.dart @@ -0,0 +1,40 @@ +class Channel { + final String id; + final String guildId; + final String categoryId; + final String name; + final String type; + final String? topic; + final int position; + final DateTime createdAt; + + Channel({ + required this.id, + required this.guildId, + this.categoryId = '', + required this.name, + this.type = 'text', + this.topic, + this.position = 0, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); + + factory Channel.fromJson(Map json) { + return Channel( + id: json['id'] as String, + guildId: json['guild_id'] as String, + categoryId: json['category_id'] as String? ?? '', + name: json['name'] as String, + type: json['type'] as String? ?? 'text', + topic: json['topic'] as String?, + position: json['position'] as int? ?? 0, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : DateTime.now(), + ); + } + + bool get isText => type == 'text'; + bool get isVoice => type == 'voice'; + bool get isStream => type == 'stream'; +} diff --git a/client/lib/models/guild.dart b/client/lib/models/guild.dart new file mode 100644 index 0000000..a4c0a4c --- /dev/null +++ b/client/lib/models/guild.dart @@ -0,0 +1,62 @@ +import 'channel.dart'; +import 'role.dart'; + +class Guild { + final String id; + final String name; + final String ownerId; + final String? icon; + final String? description; + final DateTime createdAt; + List channels; + List roles; + List categories; + + Guild({ + required this.id, + required this.name, + required this.ownerId, + this.icon, + this.description, + DateTime? createdAt, + List? channels, + List? roles, + List? categories, + }) : createdAt = createdAt ?? DateTime.now(), + channels = channels ?? [], + roles = roles ?? [], + categories = categories ?? []; + + factory Guild.fromJson(Map json) { + return Guild( + id: json['id'] as String, + name: json['name'] as String, + ownerId: json['owner_id'] as String, + icon: json['icon'] as String?, + description: json['description'] as String?, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : DateTime.now(), + ); + } +} + +class Category { + final String id; + final String name; + final int position; + + Category({ + required this.id, + required this.name, + required this.position, + }); + + factory Category.fromJson(Map json) { + return Category( + id: json['id'] as String, + name: json['name'] as String, + position: json['position'] as int? ?? 0, + ); + } +} diff --git a/client/lib/models/message.dart b/client/lib/models/message.dart new file mode 100644 index 0000000..5dbfc3b --- /dev/null +++ b/client/lib/models/message.dart @@ -0,0 +1,57 @@ +class Message { + final String id; + final String channelId; + final String authorId; + String? authorUsername; + List? content; + bool encrypted; + List? nonce; + String messageType; + String? replyTo; + bool pinned; + DateTime? editedAt; + final DateTime createdAt; + bool sending; + bool failed; + List? attachments; + + Message({ + required this.id, + required this.channelId, + required this.authorId, + this.authorUsername, + this.content, + this.encrypted = false, + this.nonce, + this.messageType = 'text', + this.replyTo, + this.pinned = false, + this.editedAt, + DateTime? createdAt, + this.sending = false, + this.failed = false, + this.attachments, + }) : createdAt = createdAt ?? DateTime.now(); + + factory Message.fromJson(Map json) { + return Message( + id: json['id'] as String, + channelId: json['channel_id'] as String, + authorId: json['author_id'] as String, + authorUsername: json['username'] as String?, + content: (json['content'] as List?)?.cast(), + encrypted: json['encrypted'] as bool? ?? false, + nonce: (json['nonce'] as List?)?.cast(), + messageType: json['message_type'] as String? ?? 'text', + replyTo: json['reply_to'] as String?, + pinned: json['pinned'] as bool? ?? false, + editedAt: json['edited_at'] != null + ? DateTime.parse(json['edited_at'] as String) + : null, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : DateTime.now(), + attachments: (json['attachments'] as List?)?.cast(), + ); + } +} diff --git a/client/lib/models/role.dart b/client/lib/models/role.dart new file mode 100644 index 0000000..424cd27 --- /dev/null +++ b/client/lib/models/role.dart @@ -0,0 +1,35 @@ +class Role { + final String id; + final String guildId; + final String name; + final int color; + final int position; + final List permissions; + final bool isDefault; + + Role({ + required this.id, + required this.guildId, + required this.name, + this.color = 0, + this.position = 0, + List? permissions, + this.isDefault = false, + }) : permissions = permissions ?? []; + + factory Role.fromJson(Map json) { + return Role( + id: json['id'] as String, + guildId: json['guild_id'] as String, + name: json['name'] as String, + color: json['color'] as int? ?? 0, + position: json['position'] as int? ?? 0, + permissions: (json['permissions'] as List?) + ?.cast() ?? + [], + isDefault: json['is_default'] as bool? ?? false, + ); + } + + int get colorValue => color == 0 ? 0xFFB5BAC1 : color; +} diff --git a/client/lib/models/user.dart b/client/lib/models/user.dart new file mode 100644 index 0000000..22aa0ba --- /dev/null +++ b/client/lib/models/user.dart @@ -0,0 +1,39 @@ +class User { + final String id; + final String username; + final String? avatar; + final String? bio; + final String? publicKey; + final DateTime createdAt; + + User({ + required this.id, + required this.username, + this.avatar, + this.bio, + this.publicKey, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String, + username: json['username'] as String, + avatar: json['avatar'] as String?, + bio: json['bio'] as String?, + publicKey: json['public_key'] as String?, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : DateTime.now(), + ); + } + + Map toJson() => { + 'id': id, + 'username': username, + 'avatar': avatar, + 'bio': bio, + 'public_key': publicKey, + 'created_at': createdAt.toIso8601String(), + }; +} diff --git a/client/lib/screens/chat_screen.dart b/client/lib/screens/chat_screen.dart new file mode 100644 index 0000000..fdee05d --- /dev/null +++ b/client/lib/screens/chat_screen.dart @@ -0,0 +1,237 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/connection_service.dart'; +import '../services/api_service.dart'; +import '../services/theme_service.dart'; +import '../models/message.dart'; +import '../models/channel.dart'; +import '../models/guild.dart'; +import '../widgets/message_bubble.dart'; +import '../widgets/chat_input.dart'; + +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final List _messages = []; + StreamSubscription? _sub; + bool _loading = true; + bool _atBottom = true; + + late Channel _channel; + late Guild _guild; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final args = ModalRoute.of(context)?.settings.arguments as Map?; + if (args != null) { + _channel = args['channel'] as Channel; + _guild = args['guild'] as Guild; + } + } + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadMessages()); + _sub = context.read().messageStream.listen(_onMessage); + } + + @override + void dispose() { + _sub?.cancel(); + _messageController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.hasClients) { + final atBottom = _scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 50; + if (atBottom != _atBottom) { + setState(() => _atBottom = atBottom); + } + } + } + + Future _loadMessages() async { + try { + final conn = context.read(); + final api = ApiService(); + api.updateFromWsUrl(conn.serverUrl); + final messages = await api.getMessages(_channel.id); + if (mounted) { + setState(() { + _messages.addAll(messages.reversed); + _loading = false; + }); + _scrollToBottom(); + } + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + void _onMessage(Map data) { + final msgChannelId = data['channel_id'] as String?; + if (msgChannelId != _channel.id) return; + + final msg = Message.fromJson(data); + setState(() => _messages.add(msg)); + if (_atBottom) _scrollToBottom(); + } + + void _sendMessage(String text) { + if (text.trim().isEmpty) return; + final conn = context.read(); + final content = Uint8List.fromList(utf8.encode(text)); + conn.sendMessage(channelId: _channel.id, content: content); + _messageController.clear(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ThemeService.backgroundPrimary, + body: Column( + children: [ + _buildHeader(), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator(strokeWidth: 2)) + : _messages.isEmpty + ? _buildEmptyState() + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, index) { + final msg = _messages[index]; + final showAuthor = index == 0 || + _messages[index - 1].authorId != msg.authorId; + return MessageBubble( + message: msg, + showAuthor: showAuthor, + onReply: (m) => _messageController.text = '@${m.authorUsername ?? "unknown"} ', + ); + }, + ), + ), + ChatInput( + controller: _messageController, + onSendMessage: _sendMessage, + channelId: _channel.id, + ), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: const BoxDecoration( + color: ThemeService.surfaceSecondary, + border: Border(bottom: BorderSide(color: Color(0xFF3F4147))), + ), + child: Row( + children: [ + Icon( + _channel.isText ? Icons.tag : Icons.volume_up, + size: 20, + color: ThemeService.textMuted, + ), + const SizedBox(width: 8), + Text( + _channel.name, + style: const TextStyle( + color: ThemeService.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (_channel.isVoice) + IconButton( + icon: const Icon(Icons.call, size: 20), + color: ThemeService.success, + onPressed: () {}, + tooltip: 'Start call', + ), + IconButton( + icon: const Icon(Icons.search, size: 20), + color: ThemeService.textSecondary, + onPressed: () {}, + tooltip: 'Search', + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _channel.isText ? Icons.tag : Icons.volume_up, + size: 48, + color: ThemeService.textMuted.withValues(alpha: 0.5), + ), + const SizedBox(height: 12), + Text( + '#${_channel.name}', + style: const TextStyle( + color: ThemeService.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + if (_channel.topic != null && _channel.topic!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + _channel.topic!, + style: const TextStyle( + color: ThemeService.textSecondary, + fontSize: 13, + ), + ), + ], + const SizedBox(height: 16), + Text( + 'No messages yet. Start the conversation!', + style: TextStyle( + color: ThemeService.textMuted.withValues(alpha: 0.8), + fontSize: 14, + ), + ), + ], + ), + ); + } +} diff --git a/client/lib/screens/guild_screen.dart b/client/lib/screens/guild_screen.dart new file mode 100644 index 0000000..aff8ca6 --- /dev/null +++ b/client/lib/screens/guild_screen.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import '../services/theme_service.dart'; +import '../models/guild.dart'; + +class GuildScreen extends StatelessWidget { + const GuildScreen({super.key}); + + @override + Widget build(BuildContext context) { + final args = ModalRoute.of(context)?.settings.arguments as Map?; + final guild = args?['guild'] as Guild?; + + return Scaffold( + backgroundColor: ThemeService.backgroundPrimary, + appBar: AppBar( + title: Text(guild?.name ?? 'Guild'), + ), + body: Center( + child: Text( + 'Guild: ${guild?.name ?? "Unknown"}', + style: const TextStyle(color: ThemeService.textPrimary), + ), + ), + ); + } +} diff --git a/client/lib/screens/home_screen.dart b/client/lib/screens/home_screen.dart new file mode 100644 index 0000000..11a70e1 --- /dev/null +++ b/client/lib/screens/home_screen.dart @@ -0,0 +1,425 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/connection_service.dart'; +import '../services/api_service.dart'; +import '../services/theme_service.dart'; +import '../models/guild.dart'; +import '../models/channel.dart'; +import '../models/role.dart'; +import '../widgets/guild_icon.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final ApiService _api = ApiService(); + List _guilds = []; + Guild? _selectedGuild; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadGuilds(); + } + + Future _loadGuilds() async { + final conn = context.read(); + if (conn.userId == null) return; + + _api.updateFromWsUrl(conn.serverUrl); + + try { + final guilds = await _api.getGuilds(conn.userId!); + if (mounted) { + setState(() { + _guilds = guilds; + _loading = false; + }); + } + } catch (e) { + if (mounted) setState(() => _loading = false); + } + } + + void _showCreateGuildDialog() { + final nameController = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: ThemeService.surfaceSecondary, + title: const Text('Create Guild', + style: TextStyle(color: ThemeService.textPrimary)), + content: TextField( + controller: nameController, + decoration: const InputDecoration( + hintText: 'Guild name', + hintStyle: TextStyle(color: ThemeService.textMuted), + ), + style: const TextStyle(color: ThemeService.textPrimary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final conn = context.read(); + if (nameController.text.isNotEmpty && conn.userId != null) { + await _api.createGuild(nameController.text, conn.userId!); + if (ctx.mounted) Navigator.pop(ctx); + _loadGuilds(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: ThemeService.primary, + ), + child: const Text('Create'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ThemeService.backgroundTertiary, + body: Row( + children: [ + Container( + width: 72, + color: ThemeService.backgroundTertiary, + child: Column( + children: [ + const SizedBox(height: 12), + _buildHomeButton(), + const SizedBox(height: 8), + const Divider( + color: Color(0xFF3F4147), + height: 2, + indent: 20, + endIndent: 20, + ), + const SizedBox(height: 8), + Expanded( + child: _loading + ? const Center( + child: CircularProgressIndicator(strokeWidth: 2)) + : ListView.builder( + itemCount: _guilds.length + 1, + itemBuilder: (context, index) { + if (index == 0) return _buildAddGuildButton(); + final guild = _guilds[index - 1]; + return _buildGuildItem(guild); + }, + ), + ), + _buildUserMenu(), + ], + ), + ), + if (_selectedGuild != null) + Expanded( + child: _buildChannelList(), + ) + else + const Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chat_bubble_outline, + color: ThemeService.textMuted, size: 64), + SizedBox(height: 16), + Text( + 'Select a guild', + style: TextStyle( + color: ThemeService.textMuted, + fontSize: 18, + ), + ), + SizedBox(height: 8), + Text( + 'Choose a guild from the left or create a new one', + style: TextStyle( + color: ThemeService.textSecondary, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildHomeButton() { + return GestureDetector( + onTap: () => setState(() => _selectedGuild = null), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _selectedGuild == null + ? ThemeService.primary + : ThemeService.surfacePrimary, + borderRadius: BorderRadius.circular(_selectedGuild == null ? 14 : 24), + ), + child: const Icon(Icons.home, color: Colors.white, size: 24), + ), + ); + } + + Widget _buildGuildItem(Guild guild) { + final selected = _selectedGuild?.id == guild.id; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: GestureDetector( + onTap: () => _loadGuildDetail(guild), + child: GuildIcon( + name: guild.name, + icon: guild.icon, + selected: selected, + size: 48, + ), + ), + ); + } + + Widget _buildAddGuildButton() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: GestureDetector( + onTap: _showCreateGuildDialog, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: ThemeService.surfacePrimary, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: ThemeService.success.withValues(alpha: 0.5), + width: 2, + ), + ), + child: const Icon(Icons.add, color: ThemeService.success, size: 28), + ), + ), + ); + } + + Future _loadGuildDetail(Guild guild) async { + try { + final data = await _api.getGuild(guild.id); + if (mounted) { + setState(() { + guild.channels = (data['channels'] as List) + .map((c) => Channel.fromJson(c)) + .toList(); + guild.roles = (data['roles'] as List) + .map((r) => Role.fromJson(r)) + .toList(); + guild.categories = (data['categories'] as List) + .map((c) => Category.fromJson(c)) + .toList(); + _selectedGuild = guild; + }); + } + } catch (_) {} + } + + Widget _buildChannelList() { + if (_selectedGuild == null) return const SizedBox(); + + final guild = _selectedGuild!; + final textChannels = + guild.channels.where((c) => c.isText).toList(); + final voiceChannels = + guild.channels.where((c) => c.isVoice).toList(); + + return Container( + color: ThemeService.surfaceSecondary, + width: 240, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFF3F4147)), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + guild.name, + style: const TextStyle( + color: ThemeService.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.expand_more, + color: ThemeService.textSecondary, size: 20), + ], + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + if (textChannels.isNotEmpty) ...[ + _buildChannelSection('TEXT CHANNELS'), + ...textChannels.map((c) => _buildChannelItem(c)), + ], + if (voiceChannels.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildChannelSection('VOICE CHANNELS'), + ...voiceChannels.map((c) => _buildChannelItem(c)), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildChannelSection(String title) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + const Icon(Icons.keyboard_arrow_down, + size: 12, color: ThemeService.textSecondary), + const SizedBox(width: 4), + Text( + title, + style: const TextStyle( + color: ThemeService.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], + ), + ); + } + + Widget _buildChannelItem(Channel channel) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () { + Navigator.pushNamed(context, '/chat', arguments: { + 'channel': channel, + 'guild': _selectedGuild, + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Icon( + channel.isText ? Icons.tag : Icons.volume_up, + size: 18, + color: ThemeService.textMuted, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + channel.name, + style: const TextStyle( + color: ThemeService.textSecondary, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildUserMenu() { + final conn = context.watch(); + return Container( + padding: const EdgeInsets.all(8), + color: ThemeService.backgroundSecondary, + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ThemeService.primary, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + (conn.username ?? '?')[0].toUpperCase(), + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.w700), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + conn.username ?? 'Unknown', + style: const TextStyle( + color: ThemeService.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + Text( + conn.isConnected ? 'Connected' : 'Disconnected', + style: TextStyle( + color: conn.isConnected + ? ThemeService.success + : ThemeService.danger, + fontSize: 11, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.settings, size: 18), + color: ThemeService.textSecondary, + onPressed: () => Navigator.pushNamed(context, '/settings'), + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(4), + ), + ], + ), + ); + } +} + + diff --git a/client/lib/screens/login_screen.dart b/client/lib/screens/login_screen.dart new file mode 100644 index 0000000..d126452 --- /dev/null +++ b/client/lib/screens/login_screen.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/connection_service.dart'; +import '../services/theme_service.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _usernameController = TextEditingController(); + final _serverUrlController = TextEditingController(text: 'ws://localhost:8443/ws'); + bool _loading = false; + String? _error; + + @override + void dispose() { + _usernameController.dispose(); + _serverUrlController.dispose(); + super.dispose(); + } + + Future _connect() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + final conn = context.read(); + conn.serverUrl = _serverUrlController.text.trim(); + + await conn.connect(); + + final username = _usernameController.text.trim(); + if (username.isNotEmpty) { + conn.authenticate(username: username); + } + + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + Navigator.pushReplacementNamed(context, '/home'); + } + } catch (e) { + setState(() => _error = 'Connection failed: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ThemeService.backgroundPrimary, + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: ThemeService.primary, + borderRadius: BorderRadius.circular(18), + ), + child: const Center( + child: Text('JAM', + style: TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.w800)), + ), + ), + const SizedBox(height: 32), + const Text( + 'Welcome to JustAMessenger', + style: TextStyle( + color: ThemeService.textPrimary, + fontSize: 24, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + const Text( + 'Connect to a server to get started', + style: TextStyle( + color: ThemeService.textSecondary, + fontSize: 14, + ), + ), + const SizedBox(height: 32), + TextField( + controller: _serverUrlController, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'ws://localhost:8443/ws', + prefixIcon: Icon(Icons.dns_outlined), + ), + style: const TextStyle(color: ThemeService.textPrimary), + ), + const SizedBox(height: 16), + TextField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + hintText: 'Enter your username', + prefixIcon: Icon(Icons.person_outline), + ), + style: const TextStyle(color: ThemeService.textPrimary), + textCapitalization: TextCapitalization.none, + ), + if (_error != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: ThemeService.danger.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: ThemeService.danger.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, + color: ThemeService.danger, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text(_error!, + style: const TextStyle( + color: ThemeService.danger, fontSize: 13)), + ), + ], + ), + ), + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: _loading ? null : _connect, + style: ElevatedButton.styleFrom( + backgroundColor: ThemeService.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: _loading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Connect', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client/lib/screens/settings_screen.dart b/client/lib/screens/settings_screen.dart new file mode 100644 index 0000000..7826d23 --- /dev/null +++ b/client/lib/screens/settings_screen.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/connection_service.dart'; +import '../services/theme_service.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final conn = context.watch(); + + return Scaffold( + backgroundColor: ThemeService.backgroundPrimary, + appBar: AppBar( + title: const Text('Settings'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSection('Account'), + _buildTile( + icon: Icons.person, + title: 'Username', + subtitle: conn.username ?? 'Not set', + onTap: () {}, + ), + _buildTile( + icon: Icons.link, + title: 'Connected to', + subtitle: conn.serverUrl, + onTap: () {}, + ), + _buildTile( + icon: conn.isConnected ? Icons.cloud_done : Icons.cloud_off, + title: 'Status', + subtitle: conn.isConnected ? 'Connected' : 'Disconnected', + trailing: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: conn.isConnected + ? ThemeService.success + : ThemeService.danger, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(height: 24), + _buildSection('Appearance'), + _buildTile( + icon: Icons.palette, + title: 'Theme', + subtitle: 'Dark (JAM Default)', + onTap: () {}, + ), + _buildTile( + icon: Icons.font_download, + title: 'Font Size', + subtitle: 'Default', + onTap: () {}, + ), + const SizedBox(height: 24), + _buildSection('Privacy & Security'), + _buildTile( + icon: Icons.lock, + title: 'Encryption', + subtitle: 'XChaCha20-Poly1305', + onTap: () {}, + ), + _buildTile( + icon: Icons.timer, + title: 'Self-destructing messages', + subtitle: 'Off', + onTap: () {}, + ), + _buildTile( + icon: Icons.visibility_off, + title: 'Secret Chats', + subtitle: 'End-to-end encrypted', + onTap: () {}, + ), + const SizedBox(height: 24), + _buildSection('Storage & Data'), + _buildTile( + icon: Icons.storage, + title: 'Cache', + subtitle: 'Clear cached data', + onTap: () {}, + ), + _buildTile( + icon: Icons.download, + title: 'Auto-download', + subtitle: 'Wi-Fi only', + onTap: () {}, + ), + const SizedBox(height: 24), + _buildSection('About'), + _buildTile( + icon: Icons.info, + title: 'Version', + subtitle: '0.1.0', + onTap: () {}, + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + conn.disconnect(); + Navigator.pushReplacementNamed(context, '/login'); + }, + icon: const Icon(Icons.logout, color: ThemeService.danger), + label: const Text( + 'Disconnect', + style: TextStyle(color: ThemeService.danger), + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: ThemeService.danger), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(height: 32), + ], + ), + ); + } + + Widget _buildSection(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + title.toUpperCase(), + style: const TextStyle( + color: ThemeService.textMuted, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ); + } + + Widget _buildTile({ + required IconData icon, + required String title, + required String subtitle, + Widget? trailing, + VoidCallback? onTap, + }) { + return ListTile( + leading: Icon(icon, color: ThemeService.textSecondary, size: 22), + title: Text( + title, + style: const TextStyle( + color: ThemeService.textPrimary, + fontSize: 15, + ), + ), + subtitle: Text( + subtitle, + style: const TextStyle( + color: ThemeService.textSecondary, + fontSize: 13, + ), + ), + trailing: trailing ?? const Icon(Icons.chevron_right, + color: ThemeService.textMuted, size: 20), + onTap: onTap, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ); + } +} diff --git a/client/lib/screens/splash_screen.dart b/client/lib/screens/splash_screen.dart new file mode 100644 index 0000000..394b707 --- /dev/null +++ b/client/lib/screens/splash_screen.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import '../services/theme_service.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeIn; + late Animation _scale; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + ); + _fadeIn = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + _scale = Tween(begin: 0.8, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.elasticOut), + ); + _controller.forward(); + + Future.delayed(const Duration(seconds: 2), () { + if (mounted) Navigator.pushReplacementNamed(context, '/login'); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ThemeService.backgroundPrimary, + body: Center( + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) => Opacity( + opacity: _fadeIn.value, + child: Transform.scale( + scale: _scale.value, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: ThemeService.primary, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: ThemeService.primary.withValues(alpha: 0.4), + blurRadius: 30, + spreadRadius: 5, + ), + ], + ), + child: const Center( + child: Text( + 'JAM', + style: TextStyle( + color: Colors.white, + fontSize: 36, + fontWeight: FontWeight.w800, + letterSpacing: 2, + ), + ), + ), + ), + const SizedBox(height: 24), + const Text( + 'JustAMessenger', + style: TextStyle( + color: ThemeService.textPrimary, + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + const SizedBox(height: 8), + Text( + 'Lightweight · Decentralized · Secure', + style: TextStyle( + color: ThemeService.textSecondary.withValues(alpha: 0.7), + fontSize: 14, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/client/lib/services/api_service.dart b/client/lib/services/api_service.dart new file mode 100644 index 0000000..c0251bf --- /dev/null +++ b/client/lib/services/api_service.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http_parser/http_parser.dart'; +import 'package:http/http.dart' as http; +import 'package:mime/mime.dart'; +import '../models/guild.dart'; +import '../models/channel.dart'; +import '../models/message.dart'; +import '../models/user.dart'; +import '../models/role.dart'; + +class ApiService { + String _baseUrl = 'http://localhost:8443'; + String? _token; + + ApiService({String? baseUrl}) { + if (baseUrl != null) _baseUrl = baseUrl; + } + + void setBaseUrl(String url) { + _baseUrl = url; + } + + void updateFromWsUrl(String wsUrl) { + _baseUrl = wsUrl.replaceFirst('ws://', 'http://').replaceFirst('wss://', 'https://'); + if (_baseUrl.endsWith('/ws')) { + _baseUrl = _baseUrl.substring(0, _baseUrl.length - 3); + } + } + + Map get _headers => { + 'Content-Type': 'application/json', + if (_token != null) 'Authorization': 'Bearer $_token', + }; + + Future healthCheck() async { + try { + final resp = await http.get( + Uri.parse('$_baseUrl/api/health'), + ).timeout(const Duration(seconds: 5)); + return resp.statusCode == 200; + } catch (_) { + return false; + } + } + + Future> getGuilds(String userId) async { + final resp = await http.get( + Uri.parse('$_baseUrl/api/guilds?user_id=$userId'), + headers: _headers, + ); + if (resp.statusCode != 200) throw Exception('Failed to load guilds'); + final List data = jsonDecode(resp.body); + return data.map((g) => Guild.fromJson(g)).toList(); + } + + Future createGuild(String name, String ownerId, {String? description}) async { + final resp = await http.post( + Uri.parse('$_baseUrl/api/guilds'), + headers: _headers, + body: jsonEncode({ + 'name': name, + 'owner_id': ownerId, + 'description': description ?? '', + }), + ); + if (resp.statusCode != 201) throw Exception('Failed to create guild'); + final data = jsonDecode(resp.body); + return data['id'] as String; + } + + Future> getGuild(String guildId) async { + final resp = await http.get( + Uri.parse('$_baseUrl/api/guilds/$guildId'), + headers: _headers, + ); + if (resp.statusCode != 200) throw Exception('Failed to load guild'); + return jsonDecode(resp.body); + } + + Future createChannel({ + required String guildId, + required String name, + String type = 'text', + String? categoryId, + String? topic, + }) async { + final resp = await http.post( + Uri.parse('$_baseUrl/api/channels'), + headers: _headers, + body: jsonEncode({ + 'guild_id': guildId, + 'category_id': categoryId ?? '', + 'name': name, + 'type': type, + 'topic': topic ?? '', + 'position': 0, + }), + ); + if (resp.statusCode != 201) throw Exception('Failed to create channel'); + return Channel.fromJson(jsonDecode(resp.body)); + } + + Future> getMessages(String channelId, {int limit = 50, String? before}) async { + final params = 'channel_id=$channelId&limit=$limit${before != null ? '&before=$before' : ''}'; + final resp = await http.get( + Uri.parse('$_baseUrl/api/messages?$params'), + headers: _headers, + ); + if (resp.statusCode != 200) throw Exception('Failed to load messages'); + final List data = jsonDecode(resp.body); + return data.map((m) => Message.fromJson(m)).toList(); + } + + Future uploadFile(File file) async { + final uri = Uri.parse('$_baseUrl/api/upload'); + final request = http.MultipartRequest('POST', uri); + final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream'; + final parts = mimeType.split('/'); + + request.files.add(await http.MultipartFile.fromPath( + 'file', + file.path, + contentType: MediaType(parts[0], parts[1]), + )); + if (_token != null) request.headers['Authorization'] = 'Bearer $_token'; + + final resp = await request.send(); + if (resp.statusCode != 200) throw Exception('Upload failed'); + final data = jsonDecode(await resp.stream.bytesToString()); + return data['url'] as String; + } + + Future getUser(String userId) async { + final resp = await http.get( + Uri.parse('$_baseUrl/api/users/$userId'), + headers: _headers, + ); + if (resp.statusCode != 200) throw Exception('Failed to load user'); + return User.fromJson(jsonDecode(resp.body)); + } + + Future createRole({ + required String guildId, + required String name, + int color = 0, + List? permissions, + }) async { + final resp = await http.post( + Uri.parse('$_baseUrl/api/roles/'), + headers: _headers, + body: jsonEncode({ + 'guild_id': guildId, + 'name': name, + 'color': color, + 'position': 0, + 'permissions': permissions ?? [], + }), + ); + if (resp.statusCode != 201) throw Exception('Failed to create role'); + return Role.fromJson(jsonDecode(resp.body)); + } +} diff --git a/client/lib/services/connection_service.dart b/client/lib/services/connection_service.dart new file mode 100644 index 0000000..fdb023f --- /dev/null +++ b/client/lib/services/connection_service.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../models/message.dart'; + +enum ConnectionState { disconnected, connecting, connected } + +class ConnectionService extends ChangeNotifier { + WebSocketChannel? _channel; + ConnectionState _state = ConnectionState.disconnected; + String _serverUrl = 'ws://localhost:8443/ws'; + String? _userId; + String? _username; + String? _token; + Timer? _heartbeatTimer; + int _seq = 0; + + final StreamController> _messageController = + StreamController>.broadcast(); + + Stream> get messageStream => _messageController.stream; + + ConnectionState get state => _state; + String? get userId => _userId; + String? get username => _username; + String? get token => _token; + bool get isConnected => _state == ConnectionState.connected; + + String get serverUrl => _serverUrl; + set serverUrl(String url) { + _serverUrl = url; + notifyListeners(); + } + + @override + void dispose() { + disconnect(); + _messageController.close(); + super.dispose(); + } + + void init() { + notifyListeners(); + } + + Future connect() async { + _state = ConnectionState.connecting; + notifyListeners(); + + try { + _channel = WebSocketChannel.connect(Uri.parse(_serverUrl)); + await _channel!.ready; + + _channel!.stream.listen( + (data) { + _handleMessage(data); + }, + onError: (error) { + _state = ConnectionState.disconnected; + notifyListeners(); + }, + onDone: () { + _state = ConnectionState.disconnected; + _heartbeatTimer?.cancel(); + notifyListeners(); + }, + ); + + _state = ConnectionState.connected; + _startHeartbeat(); + notifyListeners(); + } catch (e) { + _state = ConnectionState.disconnected; + notifyListeners(); + rethrow; + } + } + + void disconnect() { + _heartbeatTimer?.cancel(); + _channel?.sink.close(); + _channel = null; + _state = ConnectionState.disconnected; + notifyListeners(); + } + + void _startHeartbeat() { + _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) { + send({'op': 0}); + }); + } + + void _handleMessage(dynamic data) { + try { + final Map packet = jsonDecode(data as String); + final int op = packet['op'] as int; + + switch (op) { + case 1: // Hello + break; + case 3: // Authenticated + final d = packet['d'] as Map; + _userId = d['user_id'] as String?; + _username = d['username'] as String?; + _token = d['token'] as String?; + notifyListeners(); + break; + case 10: // MessageCreate + _messageController.add(packet['d'] as Map); + break; + case 13: // Reaction + _messageController.add(packet['d'] as Map); + break; + case 40: // VoiceState + _messageController.add(packet['d'] as Map); + break; + case 60: // Typing + _messageController.add(packet['d'] as Map); + break; + } + } catch (e) { + debugPrint('Failed to handle message: $e'); + } + } + + void send(Map packet) { + if (_channel == null) return; + packet['s'] = ++_seq; + _channel!.sink.add(jsonEncode(packet)); + } + + void authenticate({String? token, String? username}) { + send({ + 'op': 2, + 'd': { + if (token != null) 'token': token, + if (username != null) 'username': username, + }, + }); + } + + void sendMessage({ + required String channelId, + required Uint8List content, + bool encrypted = false, + Uint8List? nonce, + String messageType = 'text', + String? replyTo, + }) { + send({ + 'op': 10, + 'd': { + 'channel_id': channelId, + 'content': content.toList(), + 'encrypted': encrypted, + if (nonce != null) 'nonce': nonce.toList(), + 'message_type': messageType, + if (replyTo != null) 'reply_to': replyTo, + }, + }); + } + + void sendReaction(String messageId, String emoji, {bool add = true}) { + send({ + 'op': 13, + 'd': { + 'message_id': messageId, + 'emoji': emoji, + 'add': add, + }, + }); + } + + void updateVoiceState({ + required String guildId, + String? channelId, + bool muted = false, + bool deafened = false, + }) { + send({ + 'op': 40, + 'd': { + 'guild_id': guildId, + 'channel_id': channelId ?? '', + 'muted': muted, + 'deafened': deafened, + }, + }); + } + + void sendTyping(String channelId) { + send({ + 'op': 60, + 'd': {'channel_id': channelId}, + }); + } +} diff --git a/client/lib/services/crypto_service.dart b/client/lib/services/crypto_service.dart new file mode 100644 index 0000000..294e8be --- /dev/null +++ b/client/lib/services/crypto_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart' as pc; + +class CryptoService { + static const int keySize = 32; + static const int nonceSize = 24; + static const int saltSize = 16; + + Uint8List generateKey() { + final random = Random.secure(); + final key = Uint8List(keySize); + for (int i = 0; i < keySize; i++) { + key[i] = random.nextInt(256); + } + return key; + } + + Uint8List generateNonce() { + final random = Random.secure(); + final nonce = Uint8List(nonceSize); + for (int i = 0; i < nonceSize; i++) { + nonce[i] = random.nextInt(256); + } + return nonce; + } + + Uint8List deriveKey(String password, Uint8List salt) { + final key = pbkdf2( + hash: sha256, + password: utf8.encode(password), + salt: salt, + iterations: 100000, + keyLength: keySize, + ); + return Uint8List.fromList(key); + } + + Uint8List encrypt(Uint8List plaintext, Uint8List key) { + final nonce = generateNonce(); + final hmac = Hmac(sha256, key); + final mac = hmac.convert(plaintext).bytes; + + final result = Uint8List(nonce.length + mac.length + plaintext.length); + result.setAll(0, nonce); + result.setAll(nonce.length, mac); + result.setAll(nonce.length + mac.length, plaintext); + + return result; + } + + Uint8List? decrypt(Uint8List ciphertext, Uint8List key) { + try { + if (ciphertext.length < nonceSize + 32) return null; + final nonce = ciphertext.sublist(0, nonceSize); + final mac = ciphertext.sublist(nonceSize, nonceSize + 32); + final plaintext = ciphertext.sublist(nonceSize + 32); + + final hmac = Hmac(sha256, key); + final expectedMac = hmac.convert(plaintext).bytes; + if (!listEquals(mac, expectedMac)) return null; + + return plaintext; + } catch (_) { + return null; + } + } + + String hashFile(Uint8List data) { + return sha256.convert(data).toString(); + } + + bool listEquals(List a, List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + Uint8List generateKeyPairSeed() { + return generateKey(); + } +} diff --git a/client/lib/services/theme_service.dart b/client/lib/services/theme_service.dart new file mode 100644 index 0000000..78018e4 --- /dev/null +++ b/client/lib/services/theme_service.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +class ThemeService { + static const Color primary = Color(0xFF5865F2); + static const Color success = Color(0xFF3BA55D); + static const Color danger = Color(0xFFED4245); + static const Color warning = Color(0xFFFAA81A); + static const Color surfacePrimary = Color(0xFF313338); + static const Color surfaceSecondary = Color(0xFF2B2D31); + static const Color surfaceTertiary = Color(0xFF1E1F22); + static const Color textPrimary = Color(0xFFF2F3F5); + static const Color textSecondary = Color(0xFFB5BAC1); + static const Color textMuted = Color(0xFF80848E); + static const Color backgroundPrimary = Color(0xFF313338); + static const Color backgroundSecondary = Color(0xFF2B2D31); + static const Color backgroundTertiary = Color(0xFF1E1F22); + + static ThemeData get darkTheme { + return ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: const Color(0xFF313338), + primaryColor: primary, + colorScheme: const ColorScheme.dark( + primary: primary, + secondary: success, + surface: surfacePrimary, + error: danger, + ), + appBarTheme: const AppBarTheme( + backgroundColor: surfaceSecondary, + elevation: 0, + centerTitle: true, + titleTextStyle: TextStyle( + color: textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + iconTheme: IconThemeData(color: textPrimary), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: surfaceSecondary, + selectedItemColor: primary, + unselectedItemColor: textMuted, + ), + cardTheme: CardTheme( + color: surfaceSecondary, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surfaceTertiary, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + hintStyle: const TextStyle(color: textMuted), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + dividerTheme: const DividerThemeData( + color: Color(0xFF3F4147), + thickness: 1, + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: surfaceTertiary, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + useMaterial3: true, + ); + } +} diff --git a/client/lib/widgets/chat_input.dart b/client/lib/widgets/chat_input.dart new file mode 100644 index 0000000..63ea4c8 --- /dev/null +++ b/client/lib/widgets/chat_input.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import '../services/theme_service.dart'; + +class ChatInput extends StatefulWidget { + final TextEditingController controller; + final VoidCallback? onSend; + final String channelId; + final void Function(String)? onSendMessage; + + const ChatInput({ + super.key, + required this.controller, + this.onSend, + this.onSendMessage, + required this.channelId, + }); + + @override + State createState() => _ChatInputState(); +} + +class _ChatInputState extends State { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: const BoxDecoration( + color: ThemeService.surfaceSecondary, + border: Border(top: BorderSide(color: Color(0xFF3F4147))), + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.add_circle_outline, + color: ThemeService.textSecondary, size: 24), + onPressed: _showAttachmentMenu, + tooltip: 'Attach', + ), + const SizedBox(width: 4), + Expanded( + child: TextField( + controller: widget.controller, + maxLines: 5, + minLines: 1, + textCapitalization: TextCapitalization.sentences, + style: const TextStyle( + color: ThemeService.textPrimary, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Message #${widget.channelId.split('-').first}', + hintStyle: const TextStyle( + color: ThemeService.textMuted, + fontSize: 15, + ), + filled: true, + fillColor: ThemeService.surfacePrimary, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + isDense: true, + ), + onSubmitted: (text) => widget.onSendMessage?.call(text), + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.mic_none, + color: ThemeService.textSecondary, size: 24), + onPressed: _startVoiceRecording, + tooltip: 'Voice message', + ), + IconButton( + icon: const Icon(Icons.send_rounded, + color: ThemeService.primary, size: 24), + onPressed: () => widget.onSendMessage + ?.call(widget.controller.text), + tooltip: 'Send', + ), + ], + ), + ); + } + + void _showAttachmentMenu() { + showModalBottomSheet( + context: context, + backgroundColor: ThemeService.surfaceSecondary, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Attach', + style: TextStyle( + color: ThemeService.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _attachmentButton(Icons.photo, 'Image', () => Navigator.pop(ctx)), + _attachmentButton(Icons.videocam, 'Video', () => Navigator.pop(ctx)), + _attachmentButton(Icons.description, 'File', () => Navigator.pop(ctx)), + _attachmentButton(Icons.mic, 'Voice', () => Navigator.pop(ctx)), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _attachmentButton(IconData icon, String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: ThemeService.surfacePrimary, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: ThemeService.textSecondary, size: 28), + ), + const SizedBox(height: 6), + Text( + label, + style: const TextStyle( + color: ThemeService.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ); + } + + void _startVoiceRecording() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Voice recording coming soon...'), + duration: Duration(seconds: 1), + ), + ); + } +} diff --git a/client/lib/widgets/guild_icon.dart b/client/lib/widgets/guild_icon.dart new file mode 100644 index 0000000..796761e --- /dev/null +++ b/client/lib/widgets/guild_icon.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import '../services/theme_service.dart'; + +class GuildIcon extends StatelessWidget { + final String name; + final String? icon; + final bool selected; + final double size; + + const GuildIcon({ + super.key, + required this.name, + this.icon, + this.selected = false, + this.size = 48, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = selected ? 14.0 : 24.0; + + return Stack( + children: [ + if (selected) + Positioned( + left: -8, + top: 0, + bottom: 0, + child: Container( + width: 4, + height: size * 0.4, + decoration: BoxDecoration( + color: ThemeService.textPrimary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: size * 0.25), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: selected + ? ThemeService.primary + : ThemeService.surfacePrimary, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Center( + child: icon != null + ? ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: Image.network( + icon!, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildInitial(), + ), + ) + : _buildInitial(), + ), + ), + ), + ], + ); + } + + Widget _buildInitial() { + return Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: selected ? Colors.white : ThemeService.textSecondary, + fontSize: size * 0.4, + fontWeight: FontWeight.w700, + ), + ); + } +} diff --git a/client/lib/widgets/message_bubble.dart b/client/lib/widgets/message_bubble.dart new file mode 100644 index 0000000..25e1a92 --- /dev/null +++ b/client/lib/widgets/message_bubble.dart @@ -0,0 +1,268 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import '../services/theme_service.dart'; +import '../models/message.dart'; + +class MessageBubble extends StatelessWidget { + final Message message; + final bool showAuthor; + final void Function(Message)? onReply; + + const MessageBubble({ + super.key, + required this.message, + this.showAuthor = true, + this.onReply, + }); + + @override + Widget build(BuildContext context) { + final text = message.content != null + ? utf8.decode(message.content!) + : ''; + + return Padding( + padding: const EdgeInsets.only(top: 2, bottom: 2), + child: InkWell( + onLongPress: () => _showContextMenu(context), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: EdgeInsets.only(left: showAuthor ? 0 : 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) ...[ + const SizedBox(height: 12), + Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ThemeService.primary.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + (message.authorUsername ?? '?')[0].toUpperCase(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + ), + ), + const SizedBox(width: 8), + Text( + message.authorUsername ?? 'Unknown', + style: const TextStyle( + color: ThemeService.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Text( + _formatTime(message.createdAt), + style: const TextStyle( + color: ThemeService.textMuted, + fontSize: 11, + ), + ), + ], + ), + const SizedBox(height: 4), + ], + Padding( + padding: const EdgeInsets.only(left: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (message.replyTo != null) + Container( + padding: const EdgeInsets.all(6), + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + color: ThemeService.surfaceTertiary, + borderRadius: BorderRadius.circular(4), + border: Border( + left: BorderSide( + color: ThemeService.primary.withValues(alpha: 0.5), + width: 3, + ), + ), + ), + child: Text( + 'Replying to a message', + style: const TextStyle( + color: ThemeService.textMuted, + fontSize: 12, + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: message.messageType == 'voice' + ? _buildVoiceMessage() + : message.messageType == 'image' + ? _buildImageMessage(text) + : Text( + text, + style: const TextStyle( + color: ThemeService.textPrimary, + fontSize: 15, + height: 1.4, + ), + ), + ), + ], + ), + if (message.editedAt != null) + Text( + '(edited)', + style: const TextStyle( + color: ThemeService.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildVoiceMessage() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: ThemeService.surfaceTertiary, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.play_arrow, color: ThemeService.primary, size: 24), + const SizedBox(width: 8), + Container( + width: 120, + height: 4, + decoration: BoxDecoration( + color: ThemeService.textMuted.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + const Text( + '0:12', + style: TextStyle( + color: ThemeService.textMuted, + fontSize: 12, + ), + ), + ], + ), + ); + } + + Widget _buildImageMessage(String url) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + url, + width: 300, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + height: 100, + color: ThemeService.surfaceTertiary, + child: const Center( + child: Icon(Icons.broken_image, color: ThemeService.textMuted), + ), + ), + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return Container( + width: 300, + height: 200, + color: ThemeService.surfaceTertiary, + child: Center( + child: CircularProgressIndicator( + value: progress.expectedTotalBytes != null + ? progress.cumulativeBytesLoaded / + progress.expectedTotalBytes! + : null, + strokeWidth: 2, + ), + ), + ); + }, + ), + ); + } + + void _showContextMenu(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: ThemeService.surfaceSecondary, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.reply, color: ThemeService.textSecondary), + title: const Text('Reply', + style: TextStyle(color: ThemeService.textPrimary)), + onTap: () { + Navigator.pop(ctx); + onReply?.call(message); + }, + ), + ListTile( + leading: const Icon(Icons.copy, color: ThemeService.textSecondary), + title: const Text('Copy', + style: TextStyle(color: ThemeService.textPrimary)), + onTap: () { + Navigator.pop(ctx); + }, + ), + ListTile( + leading: const Icon(Icons.push_pin, color: ThemeService.textSecondary), + title: const Text('Pin', + style: TextStyle(color: ThemeService.textPrimary)), + onTap: () => Navigator.pop(ctx), + ), + ListTile( + leading: + const Icon(Icons.delete, color: ThemeService.danger), + title: const Text('Delete', + style: TextStyle(color: ThemeService.danger)), + onTap: () => Navigator.pop(ctx), + ), + ], + ), + ), + ); + } + + String _formatTime(DateTime dt) { + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inDays == 0) { + return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } else if (diff.inDays == 1) { + return 'Yesterday'; + } else { + return '${dt.day}/${dt.month}/${dt.year}'; + } + } +} diff --git a/client/pubspec.yaml b/client/pubspec.yaml new file mode 100644 index 0000000..6d0749c --- /dev/null +++ b/client/pubspec.yaml @@ -0,0 +1,45 @@ +name: jam_client +description: JustAMessenger - Lightweight Decentralized Messenger +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: '>=3.2.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + cupertino_icons: ^1.0.8 + web_socket_channel: ^3.0.1 + provider: ^6.1.2 + shared_preferences: ^2.3.3 + file_picker: ^8.1.6 + image_picker: ^1.1.2 + permission_handler: ^11.3.1 + crypto: ^3.0.6 + path_provider: ^2.1.4 + sqflite: ^2.4.0 + intl: ^0.19.0 + audioplayers: ^6.1.0 + record: ^5.2.0 + video_player: ^2.9.2 + flutter_svg: ^2.0.17 + cached_network_image: ^3.4.1 + photo_view: ^0.15.0 + emoji_picker_flutter: ^4.3.2 + flutter_webrtc: ^0.11.7 + mime: ^2.0.0 + http_parser: ^4.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true + assets: + - assets/icons/ + - assets/stickers/ diff --git a/client/web/index.html b/client/web/index.html new file mode 100644 index 0000000..064f628 --- /dev/null +++ b/client/web/index.html @@ -0,0 +1,62 @@ + + + + + + JustAMessenger + + + + + +
+ +
+
JustAMessenger
+
v0.1.0
+
+ + + + diff --git a/client/web/manifest.json b/client/web/manifest.json new file mode 100644 index 0000000..3702f0d --- /dev/null +++ b/client/web/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "JustAMessenger", + "short_name": "JAM", + "start_url": ".", + "display": "standalone", + "background_color": "#313338", + "theme_color": "#5865F2", + "description": "Lightweight Decentralized Messenger", + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..d9de71d --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,92 @@ +# JAM Protocol v0.1 + +## Transport +- WebSocket (ws:// or wss://) at `/ws` +- REST API at `/api/*` +- Federation at `/federation/receive` + +## WebSocket Packet Format +```json +{ + "op": , + "d": , + "s": // sequence number +} +``` + +## Opcodes +| Op | Name | Direction | Description | +|----|------|-----------|-------------| +| 0 | Heartbeat | C↔S | Keepalive | +| 1 | Hello | S→C | Server greeting | +| 2 | Authenticate | C→S | Login/register | +| 3 | Authenticated | S→C | Auth success | +| 4 | Error | S→C | Error response | +| 10 | MessageCreate | C↔S | Send message | +| 11 | MessageUpdate | C↔S | Edit message | +| 12 | MessageDelete | C↔S | Delete message | +| 13 | MessageReaction | C↔S | Add/remove reaction | +| 20 | ChannelCreate | S→C | New channel | +| 21 | ChannelUpdate | S→C | Channel updated | +| 22 | ChannelDelete | S→C | Channel deleted | +| 30 | GuildCreate | S→C | New guild | +| 31 | GuildUpdate | S→C | Guild updated | +| 40 | VoiceStateUpdate | C↔S | Voice channel join/leave | +| 50 | StreamStart | C↔S | Start stream | +| 51 | StreamEnd | C↔S | End stream | +| 60 | TypingStart | C↔S | User typing | +| 70 | UserPresence | S→C | Presence update | +| 80 | FederationPacket | S↔S | Server-to-server data | +| 90 | CallOffer | C↔S | WebRTC offer | +| 91 | CallAnswer | C↔S | WebRTC answer | +| 92 | CallICE | C↔S | ICE candidate | +| 93 | CallEnd | C↔S | End call | + +## Encryption +- Algorithm: XChaCha20-Poly1305 +- Key exchange: X25519 +- Key derivation: Argon2id +- Nonce: 24 bytes random +- Messages with `encrypted: true` have content encrypted with the channel's session key + +## Compression +- Algorithm: zstd (level: best compression) +- Window size: 16MB +- Deduplication: 64KB chunks, SHA256 hash +- Only files > 1KB are compressed +- Ratio target: 10MB → 512KB-2MB + +## Federation +- HTTP POST to `https:///federation/receive` +- Headers: `X-JAM-Domain`, `X-JAM-Signature` (ed25519) +- Body: `FederationData` JSON +- Peers discovered via manual configuration + +## REST API +### Health +`GET /api/health` + +### Guilds +`GET /api/guilds?user_id=` +`POST /api/guilds` body: `{name, owner_id, description}` +`GET /api/guilds/` returns guild + channels + roles + categories +`DELETE /api/guilds/` + +### Channels +`POST /api/channels` body: `{guild_id, category_id, name, type, topic, position}` +`GET /api/channels/` +`PUT /api/channels/` body: `{name, topic, position}` +`DELETE /api/channels/` + +### Messages +`GET /api/messages?channel_id=&limit=50&before=` + +### Users +`GET /api/users/` + +### Upload +`POST /api/upload` (multipart/form-data, field: file) +`GET /api/files/` + +### Roles +`POST /api/roles/` body: `{guild_id, name, color, permissions}` diff --git a/server/cmd/jam-server/main.go b/server/cmd/jam-server/main.go new file mode 100644 index 0000000..50a883b --- /dev/null +++ b/server/cmd/jam-server/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "log" + "os" + "os/signal" + "syscall" + + "github.com/justamessenger/server/internal/api" + "github.com/justamessenger/server/internal/config" + "github.com/justamessenger/server/internal/server" +) + +func main() { + configPath := flag.String("config", "config.json", "path to config file") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + srv, err := server.New(cfg) + if err != nil { + log.Fatalf("Failed to create server: %v", err) + } + + srv.Start() + + apiServer := api.New(srv, cfg) + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + log.Println("Shutting down...") + srv.Stop() + os.Exit(0) + }() + + if err := apiServer.Start(cfg.ListenAddr); err != nil { + log.Fatalf("API server failed: %v", err) + } +} diff --git a/server/config.json b/server/config.json new file mode 100644 index 0000000..a928dcd --- /dev/null +++ b/server/config.json @@ -0,0 +1,10 @@ +{ + "server_name": "JustAMessenger", + "listen_addr": ":8443", + "domain": "localhost", + "data_dir": "./data", + "max_file_size": 2147483648, + "federation": false, + "federated_with": [], + "log_level": "info" +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..243b7b9 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,13 @@ +module github.com/justamessenger/server + +go 1.22 + +require ( + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/klauspost/compress v1.17.9 + github.com/mattn/go-sqlite3 v1.14.22 + golang.org/x/crypto v0.26.0 +) + +require golang.org/x/sys v0.23.0 // indirect diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..8cb1e25 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,12 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.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/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/server/internal/api/api.go b/server/internal/api/api.go new file mode 100644 index 0000000..74dd6ff --- /dev/null +++ b/server/internal/api/api.go @@ -0,0 +1,454 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + "github.com/justamessenger/server/internal/channel" + "github.com/justamessenger/server/internal/config" + "github.com/justamessenger/server/internal/database" + "github.com/justamessenger/server/internal/federation" + "github.com/justamessenger/server/internal/models" + "github.com/justamessenger/server/internal/role" + "github.com/justamessenger/server/internal/server" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type HTTPServer struct { + srv *server.Server + cfg *config.Config + db *database.DB + chanM *channel.Manager + roleM *role.Manager + fedM *federation.Manager + mux *http.ServeMux + httpS *http.Server +} + +func New(srv *server.Server, cfg *config.Config) *HTTPServer { + h := &HTTPServer{ + srv: srv, + cfg: cfg, + db: srv.GetDB(), + chanM: srv.GetChannelManager(), + roleM: srv.GetRoleManager(), + fedM: srv.GetFederationManager(), + mux: http.NewServeMux(), + } + h.registerRoutes() + return h +} + +func (h *HTTPServer) registerRoutes() { + h.mux.HandleFunc("/ws", h.handleWebSocket) + h.mux.HandleFunc("/api/health", h.handleHealth) + h.mux.HandleFunc("/api/guilds", h.handleGuilds) + h.mux.HandleFunc("/api/guilds/", h.handleGuild) + h.mux.HandleFunc("/api/channels", h.handleChannels) + h.mux.HandleFunc("/api/channels/", h.handleChannel) + h.mux.HandleFunc("/api/roles/", h.handleRoles) + h.mux.HandleFunc("/api/messages/", h.handleMessages) + h.mux.HandleFunc("/api/users/", h.handleUsers) + h.mux.HandleFunc("/api/upload", h.handleUpload) + h.mux.HandleFunc("/api/files/", h.handleFile) + h.mux.HandleFunc("/federation/receive", h.handleFederationReceive) + + fs := http.FileServer(http.Dir(filepath.Join(h.cfg.DataDir, "files"))) + h.mux.Handle("/files/", http.StripPrefix("/files/", fs)) +} + +func (h *HTTPServer) Start(addr string) error { + h.httpS = &http.Server{ + Addr: addr, + Handler: h.mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + log.Printf("API server listening on %s", addr) + return h.httpS.ListenAndServe() +} + +func (h *HTTPServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade failed: %v", err) + return + } + h.srv.HandleConnection(conn) +} + +func (h *HTTPServer) handleHealth(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "ok", + "name": h.cfg.ServerName, + "version": "0.1.0", + "uptime": time.Now().Unix(), + }) +} + +func (h *HTTPServer) handleGuilds(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + userID := r.URL.Query().Get("user_id") + if userID == "" { + http.Error(w, "user_id required", http.StatusBadRequest) + return + } + rows, err := h.db.Query( + `SELECT g.id, g.name, g.owner_id, g.icon, g.description, g.created_at + FROM guilds g + INNER JOIN guild_members gm ON g.id = gm.guild_id + WHERE gm.user_id = ?`, userID, + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var guilds []*models.Guild + for rows.Next() { + g := &models.Guild{} + rows.Scan(&g.ID, &g.Name, &g.OwnerID, &g.Icon, &g.Description, &g.CreatedAt) + guilds = append(guilds, g) + } + json.NewEncoder(w).Encode(guilds) + + case http.MethodPost: + var input struct { + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + + id := uuid.New().String() + _, err := h.db.Exec( + `INSERT INTO guilds (id, name, owner_id, description) VALUES (?, ?, ?, ?)`, + id, input.Name, input.OwnerID, input.Description, + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + h.db.Exec( + `INSERT INTO guild_members (guild_id, user_id) VALUES (?, ?)`, + id, input.OwnerID, + ) + h.roleM.CreateDefaultRoles(id) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"id": id}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *HTTPServer) handleGuild(w http.ResponseWriter, r *http.Request) { + id := extractID(r.URL.Path, "/api/guilds/") + if id == "" { + http.Error(w, "guild id required", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + var g models.Guild + err := h.db.QueryRow( + `SELECT id, name, owner_id, icon, description, created_at FROM guilds WHERE id = ?`, id, + ).Scan(&g.ID, &g.Name, &g.OwnerID, &g.Icon, &g.Description, &g.CreatedAt) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + channels, _ := h.chanM.ListByGuild(id) + roles, _ := h.roleM.ListByGuild(id) + categories, _ := h.chanM.ListCategories(id) + + json.NewEncoder(w).Encode(map[string]interface{}{ + "guild": g, + "channels": channels, + "roles": roles, + "categories": categories, + }) + + case http.MethodDelete: + h.db.Exec(`DELETE FROM guilds WHERE id = ?`, id) + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *HTTPServer) handleChannels(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var input struct { + GuildID string `json:"guild_id"` + CategoryID string `json:"category_id"` + Name string `json:"name"` + Type string `json:"type"` + Topic string `json:"topic"` + Position int `json:"position"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + + ch, err := h.chanM.Create(input.GuildID, input.CategoryID, input.Name, input.Type, input.Topic, input.Position) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(ch) +} + +func (h *HTTPServer) handleChannel(w http.ResponseWriter, r *http.Request) { + id := extractID(r.URL.Path, "/api/channels/") + if id == "" { + http.Error(w, "channel id required", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + ch, err := h.chanM.Get(id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(ch) + + case http.MethodPut: + var input struct { + Name string `json:"name"` + Topic string `json:"topic"` + Position int `json:"position"` + } + json.NewDecoder(r.Body).Decode(&input) + if err := h.chanM.Update(id, input.Name, input.Topic, input.Position); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + + case http.MethodDelete: + if err := h.chanM.Delete(id); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *HTTPServer) handleRoles(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + var input struct { + GuildID string `json:"guild_id"` + Name string `json:"name"` + Color int `json:"color"` + Position int `json:"position"` + Permissions []string `json:"permissions"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "invalid", http.StatusBadRequest) + return + } + rl, err := h.roleM.Create(input.GuildID, input.Name, input.Color, input.Position, input.Permissions) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(rl) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *HTTPServer) handleMessages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + channelID := r.URL.Query().Get("channel_id") + if channelID == "" { + http.Error(w, "channel_id required", http.StatusBadRequest) + return + } + + limitStr := r.URL.Query().Get("limit") + limit := 50 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 200 { + limit = l + } + + before := r.URL.Query().Get("before") + + var query string + var args []interface{} + + if before != "" { + query = `SELECT id, channel_id, author_id, content, encrypted, nonce, message_type, reply_to, pinned, edited_at, created_at + FROM messages WHERE channel_id = ? AND id < ? ORDER BY created_at DESC LIMIT ?` + args = []interface{}{channelID, before, limit} + } else { + query = `SELECT id, channel_id, author_id, content, encrypted, nonce, message_type, reply_to, pinned, edited_at, created_at + FROM messages WHERE channel_id = ? ORDER BY created_at DESC LIMIT ?` + args = []interface{}{channelID, limit} + } + + rows, err := h.db.Query(query, args...) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var messages []*models.Message + for rows.Next() { + m := &models.Message{} + rows.Scan(&m.ID, &m.ChannelID, &m.AuthorID, &m.Content, &m.Encrypted, &m.Nonce, + &m.MessageType, &m.ReplyTo, &m.Pinned, &m.EditedAt, &m.CreatedAt) + messages = append(messages, m) + } + + json.NewEncoder(w).Encode(messages) +} + +func (h *HTTPServer) handleUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := extractID(r.URL.Path, "/api/users/") + if id == "" { + http.Error(w, "user id required", http.StatusBadRequest) + return + } + + var u models.User + err := h.db.QueryRow( + `SELECT id, username, avatar, bio, public_key, created_at FROM users WHERE id = ?`, id, + ).Scan(&u.ID, &u.Username, &u.Avatar, &u.Bio, &u.PublicKey, &u.CreatedAt) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + json.NewEncoder(w).Encode(u) +} + +func (h *HTTPServer) handleUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + r.ParseMultipartForm(2 << 30) + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "no file", http.StatusBadRequest) + return + } + defer file.Close() + + uploadDir := filepath.Join(h.cfg.DataDir, "files") + os.MkdirAll(uploadDir, 0755) + + id := uuid.New().String() + ext := filepath.Ext(header.Filename) + filename := id + ext + dst, err := os.Create(filepath.Join(uploadDir, filename)) + if err != nil { + http.Error(w, "failed to save", http.StatusInternalServerError) + return + } + defer dst.Close() + + size, _ := io.Copy(dst, file) + + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": id, + "filename": header.Filename, + "size": size, + "url": "/files/" + filename, + }) +} + +func (h *HTTPServer) handleFile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := extractID(r.URL.Path, "/api/files/") + if id == "" { + http.Error(w, "file id required", http.StatusBadRequest) + return + } + + http.ServeFile(w, r, filepath.Join(h.cfg.DataDir, "files", id)) +} + +func (h *HTTPServer) handleFederationReceive(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + h.fedM.HandleReceive(w, r) +} + +func extractID(path, prefix string) string { + if len(path) <= len(prefix) { + return "" + } + id := path[len(prefix):] + if idx := stringsIndex(id, "/"); idx >= 0 { + id = id[:idx] + } + return id +} + +func stringsIndex(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/server/internal/channel/channel.go b/server/internal/channel/channel.go new file mode 100644 index 0000000..678786b --- /dev/null +++ b/server/internal/channel/channel.go @@ -0,0 +1,135 @@ +package channel + +import ( + "errors" + + "github.com/google/uuid" + "github.com/justamessenger/server/internal/database" + "github.com/justamessenger/server/internal/models" +) + +type Manager struct { + db *database.DB +} + +func NewManager(db *database.DB) *Manager { + return &Manager{db: db} +} + +func (m *Manager) Create(guildID, categoryID, name, channelType, topic string, position int) (*models.Channel, error) { + id := uuid.New().String() + _, err := m.db.Exec( + `INSERT INTO channels (id, guild_id, category_id, name, type, topic, position) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, guildID, categoryID, name, channelType, topic, position, + ) + if err != nil { + return nil, err + } + return m.Get(id) +} + +func (m *Manager) Get(id string) (*models.Channel, error) { + row := m.db.QueryRow( + `SELECT id, guild_id, category_id, name, type, topic, position, created_at + FROM channels WHERE id = ?`, id, + ) + c := &models.Channel{} + err := row.Scan(&c.ID, &c.GuildID, &c.Category, &c.Name, &c.Type, &c.Topic, &c.Position, &c.CreatedAt) + if err != nil { + return nil, err + } + return c, nil +} + +func (m *Manager) ListByGuild(guildID string) ([]*models.Channel, error) { + rows, err := m.db.Query( + `SELECT id, guild_id, category_id, name, type, topic, position, created_at + FROM channels WHERE guild_id = ? ORDER BY position`, guildID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var channels []*models.Channel + for rows.Next() { + c := &models.Channel{} + if err := rows.Scan(&c.ID, &c.GuildID, &c.Category, &c.Name, &c.Type, &c.Topic, &c.Position, &c.CreatedAt); err != nil { + return nil, err + } + channels = append(channels, c) + } + return channels, nil +} + +func (m *Manager) Update(id, name, topic string, position int) error { + result, err := m.db.Exec( + `UPDATE channels SET name = ?, topic = ?, position = ? WHERE id = ?`, + name, topic, position, id, + ) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return errors.New("channel not found") + } + return nil +} + +func (m *Manager) Delete(id string) error { + result, err := m.db.Exec(`DELETE FROM channels WHERE id = ?`, id) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return errors.New("channel not found") + } + return nil +} + +func (m *Manager) CreateCategory(guildID, name string, position int) (string, error) { + id := uuid.New().String() + _, err := m.db.Exec( + `INSERT INTO categories (id, guild_id, name, position) VALUES (?, ?, ?, ?)`, + id, guildID, name, position, + ) + if err != nil { + return "", err + } + return id, nil +} + +func (m *Manager) ListCategories(guildID string) ([]*struct { + ID string + Name string + Position int +}, error) { + rows, err := m.db.Query( + `SELECT id, name, position FROM categories WHERE guild_id = ? ORDER BY position`, guildID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var cats []*struct { + ID string + Name string + Position int + } + for rows.Next() { + var c struct { + ID string + Name string + Position int + } + if err := rows.Scan(&c.ID, &c.Name, &c.Position); err != nil { + return nil, err + } + cats = append(cats, &c) + } + return cats, nil +} diff --git a/server/internal/compression/compression.go b/server/internal/compression/compression.go new file mode 100644 index 0000000..3841f4d --- /dev/null +++ b/server/internal/compression/compression.go @@ -0,0 +1,195 @@ +package compression + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "io" + "sync" + + "github.com/klauspost/compress/zstd" +) + +var ( + encOnce sync.Once + decOnce sync.Once + encoder *zstd.Encoder + decoder *zstd.Decoder +) + +func getEncoder() *zstd.Encoder { + encOnce.Do(func() { + opts := []zstd.EOption{ + zstd.WithEncoderLevel(zstd.SpeedBestCompression), + zstd.WithWindowSize(1 << 24), + } + enc, _ := zstd.NewWriter(nil, opts...) + encoder = enc + }) + return encoder +} + +func getDecoder() *zstd.Decoder { + decOnce.Do(func() { + dec, _ := zstd.NewReader(nil) + decoder = dec + }) + return decoder +} + +type Chunk struct { + Hash [32]byte + Data []byte + Offset int64 + Size int +} + +type DedupStore struct { + mu sync.RWMutex + chunks map[[32]byte][]byte +} + +func NewDedupStore() *DedupStore { + return &DedupStore{chunks: make(map[[32]byte][]byte)} +} + +func chunkData(data []byte, chunkSize int) []Chunk { + var chunks []Chunk + for offset := 0; offset < len(data); offset += chunkSize { + end := offset + chunkSize + if end > len(data) { + end = len(data) + } + chunk := data[offset:end] + hash := sha256.Sum256(chunk) + chunks = append(chunks, Chunk{ + Hash: hash, + Data: chunk, + Offset: int64(offset), + Size: len(chunk), + }) + } + return chunks +} + +func (ds *DedupStore) Deduplicate(data []byte, chunkSize int) ([]Chunk, int, error) { + chunks := chunkData(data, chunkSize) + uniqueSize := 0 + ds.mu.Lock() + defer ds.mu.Unlock() + + for i, c := range chunks { + if existing, ok := ds.chunks[c.Hash]; ok { + chunks[i].Data = existing + chunks[i].Size = len(existing) + } else { + ds.chunks[c.Hash] = c.Data + uniqueSize += len(c.Data) + } + } + return chunks, uniqueSize, nil +} + +func Compress(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, nil + } + var buf bytes.Buffer + enc := getEncoder() + enc.Reset(&buf) + if _, err := enc.Write(data); err != nil { + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func Decompress(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, nil + } + dec := getDecoder() + return dec.DecodeAll(data, nil) +} + +type Compressor struct { + ChunkSize int + DedupStore *DedupStore +} + +func New(chunkSize int) *Compressor { + if chunkSize <= 0 { + chunkSize = 65536 + } + return &Compressor{ + ChunkSize: chunkSize, + DedupStore: NewDedupStore(), + } +} + +type CompressResult struct { + Data []byte + OriginalSize int64 + CompressedSize int64 + Chunks int + UniqueChunks int +} + +func (c *Compressor) CompressFile(data []byte) (*CompressResult, error) { + originalSize := int64(len(data)) + + chunks, uniqueSize, err := c.DedupStore.Deduplicate(data, c.ChunkSize) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + buf.Write(binary.AppendVarint(nil, int64(len(chunks)))) + + for _, chunk := range chunks { + buf.Write(chunk.Hash[:]) + buf.Write(binary.AppendVarint(nil, int64(chunk.Offset))) + buf.Write(binary.AppendVarint(nil, int64(chunk.Size))) + } + + compressed, err := Compress(data) + if err != nil { + return nil, err + } + + return &CompressResult{ + Data: compressed, + OriginalSize: originalSize, + CompressedSize: int64(len(compressed)), + Chunks: len(chunks), + UniqueChunks: uniqueSize / c.ChunkSize, + }, nil +} + +func (c *Compressor) DecompressFile(compressed []byte) ([]byte, error) { + return Decompress(compressed) +} + +func CompressStream(r io.Reader, w io.Writer) error { + enc := getEncoder() + defer enc.Close() + + enc.Reset(w) + if _, err := io.Copy(enc, r); err != nil { + return err + } + return enc.Close() +} + +func DecompressStream(r io.Reader, w io.Writer) error { + dec := getDecoder() + defer dec.Close() + + dec.Reset(r) + if _, err := io.Copy(w, dec); err != nil { + return err + } + return nil +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..ef1d6a5 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,46 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type Config struct { + ServerName string `json:"server_name"` + ListenAddr string `json:"listen_addr"` + Domain string `json:"domain"` + DataDir string `json:"data_dir"` + MaxFileSize int64 `json:"max_file_size"` + Federation bool `json:"federation"` + FederatedWith []string `json:"federated_with"` + LogLevel string `json:"log_level"` +} + +func Default() *Config { + return &Config{ + ServerName: "JustAMessenger", + ListenAddr: ":8443", + Domain: "localhost", + DataDir: "./data", + MaxFileSize: 2 << 30, + Federation: false, + LogLevel: "info", + } +} + +func Load(path string) (*Config, error) { + cfg := Default() + abs, err := filepath.Abs(path) + if err != nil { + return cfg, nil + } + data, err := os.ReadFile(abs) + if err != nil { + return cfg, nil + } + if err := json.Unmarshal(data, cfg); err != nil { + return nil, err + } + return cfg, nil +} diff --git a/server/internal/crypto/crypto.go b/server/internal/crypto/crypto.go new file mode 100644 index 0000000..36eda7e --- /dev/null +++ b/server/internal/crypto/crypto.go @@ -0,0 +1,92 @@ +package crypto + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "io" + + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/argon2" +) + +const ( + KeySize = 32 + NonceSize = chacha20poly1305.NonceSizeX +) + +func GenerateKey() ([]byte, error) { + key := make([]byte, KeySize) + if _, err := rand.Read(key); err != nil { + return nil, err + } + return key, nil +} + +func GenerateNonce() ([]byte, error) { + nonce := make([]byte, NonceSize) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return nonce, nil +} + +func DeriveKey(password string, salt []byte) []byte { + return argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, KeySize) +} + +func Encrypt(plaintext []byte, key []byte) ([]byte, []byte, error) { + aead, err := chacha20poly1305.NewX(key) + if err != nil { + return nil, nil, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, err + } + ciphertext := aead.Seal(nil, nonce, plaintext, nil) + return ciphertext, nonce, nil +} + +func Decrypt(ciphertext []byte, key []byte, nonce []byte) ([]byte, error) { + aead, err := chacha20poly1305.NewX(key) + if err != nil { + return nil, err + } + if len(nonce) != aead.NonceSize() { + return nil, errors.New("invalid nonce size") + } + plaintext, err := aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + return plaintext, nil +} + +func GenerateKeyPair() ([]byte, []byte, error) { + priv := make([]byte, 32) + if _, err := rand.Read(priv); err != nil { + return nil, nil, err + } + priv[0] &= 248 + priv[31] &= 127 + priv[31] |= 64 + pub, err := curve25519.X25519(priv, curve25519.Basepoint) + if err != nil { + return nil, nil, err + } + return priv, pub, nil +} + +func ComputeSharedSecret(privateKey, publicKey []byte) ([]byte, error) { + return curve25519.X25519(privateKey, publicKey) +} + +func KeyToString(key []byte) string { + return hex.EncodeToString(key) +} + +func StringToKey(s string) ([]byte, error) { + return hex.DecodeString(s) +} diff --git a/server/internal/database/database.go b/server/internal/database/database.go new file mode 100644 index 0000000..99e8992 --- /dev/null +++ b/server/internal/database/database.go @@ -0,0 +1,190 @@ +package database + +import ( + "database/sql" + "os" + "path/filepath" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type DB struct { + *sql.DB +} + +func Open(dataDir string) (*DB, error) { + if err := os.MkdirAll(dataDir, 0755); err != nil { + return nil, err + } + dbPath := filepath.Join(dataDir, "jam.db") + + db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(time.Hour) + + d := &DB{db} + if err := d.migrate(); err != nil { + return nil, err + } + return d, nil +} + +func (db *DB) migrate() error { + schema := ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + avatar TEXT DEFAULT '', + bio TEXT DEFAULT '', + public_key BLOB, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS guilds ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_id TEXT NOT NULL, + icon TEXT DEFAULT '', + description TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS guild_members ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + nickname TEXT DEFAULT '', + avatar TEXT DEFAULT '', + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, user_id), + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + name TEXT NOT NULL, + position INTEGER DEFAULT 0, + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + category_id TEXT DEFAULT '', + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + topic TEXT DEFAULT '', + position INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + name TEXT NOT NULL, + color INTEGER DEFAULT 0, + position INTEGER DEFAULT 0, + permissions TEXT DEFAULT '[]', + is_default INTEGER DEFAULT 0, + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS role_members ( + role_id TEXT NOT NULL, + user_id TEXT NOT NULL, + PRIMARY KEY (role_id, user_id), + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + author_id TEXT NOT NULL, + content BLOB, + encrypted INTEGER DEFAULT 0, + nonce BLOB, + message_type TEXT DEFAULT 'text', + reply_to TEXT, + pinned INTEGER DEFAULT 0, + self_destruct DATETIME, + edited_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + FOREIGN KEY (author_id) REFERENCES users(id) + ); + + CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); + + CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + filename TEXT NOT NULL, + file_type TEXT DEFAULT '', + size INTEGER DEFAULT 0, + compressed INTEGER DEFAULT 0, + original_size INTEGER DEFAULT 0, + url TEXT NOT NULL, + hash TEXT DEFAULT '', + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS reactions ( + message_id TEXT NOT NULL, + user_id TEXT NOT NULL, + emoji TEXT NOT NULL, + PRIMARY KEY (message_id, user_id, emoji), + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS stickers ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + name TEXT NOT NULL, + data BLOB, + format TEXT DEFAULT 'png', + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS voice_states ( + user_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + muted INTEGER DEFAULT 0, + deafened INTEGER DEFAULT 0, + PRIMARY KEY (user_id, channel_id) + ); + + CREATE TABLE IF NOT EXISTS streams ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + user_id TEXT NOT NULL, + title TEXT DEFAULT '', + started_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS federation_peers ( + domain TEXT PRIMARY KEY, + name TEXT NOT NULL, + public_key BLOB, + last_seen DATETIME, + is_active INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS server_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + ` + _, err := db.Exec(schema) + return err +} diff --git a/server/internal/federation/federation.go b/server/internal/federation/federation.go new file mode 100644 index 0000000..34ef6b6 --- /dev/null +++ b/server/internal/federation/federation.go @@ -0,0 +1,209 @@ +package federation + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" + "time" + + jcrypto "github.com/justamessenger/server/internal/crypto" + "github.com/justamessenger/server/internal/database" + "github.com/justamessenger/server/internal/models" + "github.com/justamessenger/server/internal/protocol" +) + +type Manager struct { + db *database.DB + domain string + privateKey ed25519.PrivateKey + publicKey ed25519.PublicKey + peers map[string]*models.FederationPeer + mu sync.RWMutex + httpClient *http.Client +} + +func NewManager(db *database.DB, domain string) (*Manager, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate federation keypair: %w", err) + } + + m := &Manager{ + db: db, + domain: domain, + privateKey: priv, + publicKey: pub, + peers: make(map[string]*models.FederationPeer), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 20, + IdleConnTimeout: 90 * time.Second, + }, + }, + } + + if err := m.loadPeers(); err != nil { + log.Printf("Warning: failed to load federation peers: %v", err) + } + + return m, nil +} + +func (m *Manager) PublicKey() []byte { + return m.publicKey +} + +func (m *Manager) loadPeers() error { + rows, err := m.db.Query( + `SELECT domain, name, public_key, last_seen, is_active + FROM federation_peers WHERE is_active = 1`, + ) + if err != nil { + return err + } + defer rows.Close() + + m.mu.Lock() + defer m.mu.Unlock() + + for rows.Next() { + p := &models.FederationPeer{} + if err := rows.Scan(&p.Domain, &p.Name, &p.PublicKey, &p.LastSeen, &p.IsActive); err != nil { + return err + } + m.peers[p.Domain] = p + } + return nil +} + +func (m *Manager) AddPeer(domain, name string, publicKey []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + + peer := &models.FederationPeer{ + Domain: domain, + Name: name, + PublicKey: publicKey, + LastSeen: time.Now(), + IsActive: true, + } + m.peers[domain] = peer + + _, err := m.db.Exec( + `INSERT OR REPLACE INTO federation_peers (domain, name, public_key, last_seen, is_active) + VALUES (?, ?, ?, ?, 1)`, + domain, name, publicKey, time.Now(), + ) + return err +} + +func (m *Manager) Sign(data []byte) []byte { + return ed25519.Sign(m.privateKey, data) +} + +func (m *Manager) Verify(publicKey, data, signature []byte) bool { + return ed25519.Verify(publicKey, data, signature) +} + +func (m *Manager) SendToPeer(peerDomain string, pkt *protocol.FederationData) error { + m.mu.RLock() + peer, ok := m.peers[peerDomain] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("unknown peer: %s", peerDomain) + } + + payload, err := json.Marshal(pkt) + if err != nil { + return err + } + + url := fmt.Sprintf("https://%s/federation/receive", peer.Domain) + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-JAM-Domain", m.domain) + req.Header.Set("X-JAM-Signature", string(m.Sign(payload))) + + resp, err := m.httpClient.Do(req) + if err != nil { + return fmt.Errorf("federation request to %s failed: %w", peer.Domain, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("peer %s returned %d: %s", peer.Domain, resp.StatusCode, string(body)) + } + + m.mu.Lock() + peer.LastSeen = time.Now() + m.mu.Unlock() + + return nil +} + +func (m *Manager) BroadcastToPeers(pkt *protocol.FederationData) { + m.mu.RLock() + domains := make([]string, 0, len(m.peers)) + for domain := range m.peers { + domains = append(domains, domain) + } + m.mu.RUnlock() + + for _, domain := range domains { + if domain == m.domain { + continue + } + if err := m.SendToPeer(domain, pkt); err != nil { + log.Printf("Federation broadcast to %s failed: %v", domain, err) + } + } +} + +func (m *Manager) HandleReceive(w http.ResponseWriter, r *http.Request) { + signature := r.Header.Get("X-JAM-Signature") + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + var pkt protocol.FederationData + if err := json.Unmarshal(body, &pkt); err != nil { + http.Error(w, "invalid packet", http.StatusBadRequest) + return + } + + m.mu.RLock() + peer, ok := m.peers[pkt.FromDomain] + m.mu.RUnlock() + + if ok && !m.Verify(peer.PublicKey, body, []byte(signature)) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + + log.Printf("Federation packet from %s: type=%s", pkt.FromDomain, pkt.PacketType) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (m *Manager) EncryptFederationPayload(payload []byte) ([]byte, []byte, error) { + key, err := jcrypto.GenerateKey() + if err != nil { + return nil, nil, err + } + return jcrypto.Encrypt(payload, key) +} diff --git a/server/internal/models/models.go b/server/internal/models/models.go new file mode 100644 index 0000000..c97e40c --- /dev/null +++ b/server/internal/models/models.go @@ -0,0 +1,108 @@ +package models + +import "time" + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar,omitempty"` + Bio string `json:"bio,omitempty"` + PublicKey []byte `json:"public_key"` + CreatedAt time.Time `json:"created_at"` +} + +type Guild struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Icon string `json:"icon,omitempty"` + Description string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Channel struct { + ID string `json:"id"` + GuildID string `json:"guild_id"` + Name string `json:"name"` + Type string `json:"type"` + Topic string `json:"topic,omitempty"` + Position int `json:"position"` + Category string `json:"category,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Role struct { + ID string `json:"id"` + GuildID string `json:"guild_id"` + Name string `json:"name"` + Color int `json:"color"` + Position int `json:"position"` + Permissions []string `json:"permissions"` + IsDefault bool `json:"is_default"` +} + +type Message struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + AuthorID string `json:"author_id"` + Content []byte `json:"content,omitempty"` + Encrypted bool `json:"encrypted"` + Nonce []byte `json:"nonce,omitempty"` + MessageType string `json:"message_type"` + ReplyTo string `json:"reply_to,omitempty"` + Attachments []string `json:"attachments,omitempty"` + EditedAt *time.Time `json:"edited_at,omitempty"` + Pinned bool `json:"pinned"` + SelfDestruct *time.Time `json:"self_destruct,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type VoiceState struct { + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + GuildID string `json:"guild_id"` + Muted bool `json:"muted"` + Deafened bool `json:"deafened"` +} + +type Stream struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + UserID string `json:"user_id"` + Title string `json:"title"` + StartedAt time.Time `json:"started_at"` +} + +type Reaction struct { + MessageID string `json:"message_id"` + UserID string `json:"user_id"` + Emoji string `json:"emoji"` +} + +type Attachment struct { + ID string `json:"id"` + MessageID string `json:"message_id"` + Filename string `json:"filename"` + FileType string `json:"file_type"` + Size int64 `json:"size"` + Compressed bool `json:"compressed"` + OriginalSize int64 `json:"original_size,omitempty"` + URL string `json:"url"` + Hash string `json:"hash"` +} + +type Sticker struct { + ID string `json:"id"` + GuildID string `json:"guild_id"` + Name string `json:"name"` + Data []byte `json:"data"` + Format string `json:"format"` +} + +type FederationPeer struct { + Domain string `json:"domain"` + Name string `json:"name"` + PublicKey []byte `json:"public_key"` + LastSeen time.Time `json:"last_seen"` + IsActive bool `json:"is_active"` +} diff --git a/server/internal/protocol/protocol.go b/server/internal/protocol/protocol.go new file mode 100644 index 0000000..ac438be --- /dev/null +++ b/server/internal/protocol/protocol.go @@ -0,0 +1,128 @@ +package protocol + +import "encoding/json" + +type OpCode int + +const ( + OpHeartbeat OpCode = 0 + OpHello OpCode = 1 + OpAuthenticate OpCode = 2 + OpAuthenticated OpCode = 3 + OpError OpCode = 4 + + OpMessageCreate OpCode = 10 + OpMessageUpdate OpCode = 11 + OpMessageDelete OpCode = 12 + OpMessageReaction OpCode = 13 + + OpChannelCreate OpCode = 20 + OpChannelUpdate OpCode = 21 + OpChannelDelete OpCode = 22 + + OpGuildCreate OpCode = 30 + OpGuildUpdate OpCode = 31 + OpGuildDelete OpCode = 32 + OpGuildMemberAdd OpCode = 33 + OpGuildMemberRemove OpCode = 34 + + OpVoiceStateUpdate OpCode = 40 + OpStreamStart OpCode = 50 + OpStreamEnd OpCode = 51 + + OpTypingStart OpCode = 60 + OpTypingStop OpCode = 61 + + OpUserPresence OpCode = 70 + OpUserUpdate OpCode = 71 + + OpFederationPacket OpCode = 80 + + OpCallOffer OpCode = 90 + OpCallAnswer OpCode = 91 + OpCallICE OpCode = 92 + OpCallEnd OpCode = 93 +) + +type Packet struct { + Op OpCode `json:"op"` + Data json.RawMessage `json:"d,omitempty"` + Seq int64 `json:"s,omitempty"` +} + +type HelloData struct { + HeartbeatInterval int `json:"heartbeat_interval"` + ServerName string `json:"server_name"` + ServerVersion string `json:"server_version"` +} + +type AuthData struct { + Token string `json:"token"` + Username string `json:"username,omitempty"` +} + +type MessageData struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Content []byte `json:"content,omitempty"` + Encrypted bool `json:"encrypted"` + Nonce []byte `json:"nonce,omitempty"` + MessageType string `json:"message_type,omitempty"` + ReplyTo string `json:"reply_to,omitempty"` +} + +type ReactionData struct { + MessageID string `json:"message_id"` + Emoji string `json:"emoji"` + Add bool `json:"add"` +} + +type VoiceStateData struct { + GuildID string `json:"guild_id"` + ChannelID string `json:"channel_id"` + Muted bool `json:"muted"` + Deafened bool `json:"deafened"` +} + +type StreamData struct { + ChannelID string `json:"channel_id"` + Title string `json:"title"` + Action string `json:"action"` +} + +type FederationData struct { + FromDomain string `json:"from_domain"` + TargetID string `json:"target_id"` + PacketType string `json:"packet_type"` + Payload json.RawMessage `json:"payload"` + Signature []byte `json:"signature"` +} + +type CallData struct { + ChannelID string `json:"channel_id"` + Type string `json:"type"` + PeerID string `json:"peer_id,omitempty"` + SDP json.RawMessage `json:"sdp,omitempty"` + ICE json.RawMessage `json:"ice,omitempty"` +} + +type ErrorData struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func NewPacket(op OpCode, data interface{}) (*Packet, error) { + raw, err := json.Marshal(data) + if err != nil { + return nil, err + } + return &Packet{Op: op, Data: raw}, nil +} + +func MustPacket(op OpCode, data interface{}) *Packet { + p, err := NewPacket(op, data) + if err != nil { + panic(err) + } + return p +} diff --git a/server/internal/role/role.go b/server/internal/role/role.go new file mode 100644 index 0000000..cb72338 --- /dev/null +++ b/server/internal/role/role.go @@ -0,0 +1,180 @@ +package role + +import ( + "encoding/json" + "errors" + + "github.com/google/uuid" + "github.com/justamessenger/server/internal/database" + "github.com/justamessenger/server/internal/models" +) + +type Manager struct { + db *database.DB +} + +func NewManager(db *database.DB) *Manager { + return &Manager{db: db} +} + +var DefaultPermissions = []string{ + "view_channels", + "send_messages", + "add_reactions", + "read_message_history", + "connect_voice", + "speak", +} + +var AdminPermissions = []string{ + "administrator", + "manage_guild", + "manage_channels", + "manage_roles", + "manage_messages", + "kick_members", + "ban_members", + "view_channels", + "send_messages", + "add_reactions", + "read_message_history", + "connect_voice", + "speak", + "mute_members", + "deafen_members", + "move_members", + "stream", +} + +func (m *Manager) CreateDefaultRoles(guildID string) error { + _, err := m.db.Exec( + `INSERT OR IGNORE INTO roles (id, guild_id, name, color, position, permissions, is_default) + VALUES (?, ?, 'everyone', 0, 0, ?, 1)`, + uuid.New().String(), guildID, mustJSON(DefaultPermissions), + ) + return err +} + +func mustJSON(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) +} + +func (m *Manager) Create(guildID, name string, color, position int, permissions []string) (*models.Role, error) { + id := uuid.New().String() + permJSON, _ := json.Marshal(permissions) + _, err := m.db.Exec( + `INSERT INTO roles (id, guild_id, name, color, position, permissions) + VALUES (?, ?, ?, ?, ?, ?)`, + id, guildID, name, color, position, string(permJSON), + ) + if err != nil { + return nil, err + } + return m.Get(id) +} + +func (m *Manager) Get(id string) (*models.Role, error) { + row := m.db.QueryRow( + `SELECT id, guild_id, name, color, position, permissions, is_default + FROM roles WHERE id = ?`, id, + ) + r := &models.Role{} + var permJSON string + err := row.Scan(&r.ID, &r.GuildID, &r.Name, &r.Color, &r.Position, &permJSON, &r.IsDefault) + if err != nil { + return nil, err + } + json.Unmarshal([]byte(permJSON), &r.Permissions) + return r, nil +} + +func (m *Manager) ListByGuild(guildID string) ([]*models.Role, error) { + rows, err := m.db.Query( + `SELECT id, guild_id, name, color, position, permissions, is_default + FROM roles WHERE guild_id = ? ORDER BY position DESC`, guildID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []*models.Role + for rows.Next() { + r := &models.Role{} + var permJSON string + if err := rows.Scan(&r.ID, &r.GuildID, &r.Name, &r.Color, &r.Position, &permJSON, &r.IsDefault); err != nil { + return nil, err + } + json.Unmarshal([]byte(permJSON), &r.Permissions) + roles = append(roles, r) + } + return roles, nil +} + +func (m *Manager) AssignRole(roleID, userID string) error { + _, err := m.db.Exec( + `INSERT OR IGNORE INTO role_members (role_id, user_id) VALUES (?, ?)`, + roleID, userID, + ) + return err +} + +func (m *Manager) RemoveRole(roleID, userID string) error { + result, err := m.db.Exec( + `DELETE FROM role_members WHERE role_id = ? AND user_id = ?`, + roleID, userID, + ) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return errors.New("role assignment not found") + } + return nil +} + +func (m *Manager) GetUserRoles(guildID, userID string) ([]*models.Role, error) { + rows, err := m.db.Query( + `SELECT r.id, r.guild_id, r.name, r.color, r.position, r.permissions, r.is_default + FROM roles r + INNER JOIN role_members rm ON r.id = rm.role_id + WHERE r.guild_id = ? AND rm.user_id = ? + UNION + SELECT id, guild_id, name, color, position, permissions, is_default + FROM roles WHERE guild_id = ? AND is_default = 1 + ORDER BY position DESC`, guildID, userID, guildID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []*models.Role + for rows.Next() { + r := &models.Role{} + var permJSON string + if err := rows.Scan(&r.ID, &r.GuildID, &r.Name, &r.Color, &r.Position, &permJSON, &r.IsDefault); err != nil { + return nil, err + } + json.Unmarshal([]byte(permJSON), &r.Permissions) + roles = append(roles, r) + } + return roles, nil +} + +func (m *Manager) HasPermission(guildID, userID, permission string) (bool, error) { + roles, err := m.GetUserRoles(guildID, userID) + if err != nil { + return false, err + } + for _, r := range roles { + for _, p := range r.Permissions { + if p == "administrator" || p == permission { + return true, nil + } + } + } + return false, nil +} diff --git a/server/internal/server/server.go b/server/internal/server/server.go new file mode 100644 index 0000000..26b6a09 --- /dev/null +++ b/server/internal/server/server.go @@ -0,0 +1,473 @@ +package server + +import ( + "encoding/json" + "fmt" + "log" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + "github.com/justamessenger/server/internal/channel" + "github.com/justamessenger/server/internal/compression" + "github.com/justamessenger/server/internal/config" + jcrypto "github.com/justamessenger/server/internal/crypto" + "github.com/justamessenger/server/internal/database" + "github.com/justamessenger/server/internal/federation" + "github.com/justamessenger/server/internal/models" + "github.com/justamessenger/server/internal/protocol" + "github.com/justamessenger/server/internal/role" +) + +type Client struct { + ID string + UserID string + Username string + Conn *websocket.Conn + Send chan []byte + Guilds map[string]bool + mu sync.RWMutex +} + +type Server struct { + config *config.Config + db *database.DB + clients map[string]*Client + mu sync.RWMutex + register chan *Client + unregister chan *Client + channelMgr *channel.Manager + roleMgr *role.Manager + fedMgr *federation.Manager + compressor *compression.Compressor + tokens map[string]string + tokenMu sync.RWMutex + stopCh chan struct{} +} + +func New(cfg *config.Config) (*Server, error) { + db, err := database.Open(cfg.DataDir) + if err != nil { + return nil, fmt.Errorf("database: %w", err) + } + + fedMgr, err := federation.NewManager(db, cfg.Domain) + if err != nil { + return nil, fmt.Errorf("federation: %w", err) + } + + s := &Server{ + config: cfg, + db: db, + clients: make(map[string]*Client), + register: make(chan *Client, 256), + unregister: make(chan *Client, 256), + channelMgr: channel.NewManager(db), + roleMgr: role.NewManager(db), + fedMgr: fedMgr, + compressor: compression.New(65536), + tokens: make(map[string]string), + stopCh: make(chan struct{}), + } + + return s, nil +} + +func (s *Server) Start() { + log.Printf("JAM Server %s starting on %s", s.config.ServerName, s.config.ListenAddr) + if s.config.Federation { + log.Printf("Federation enabled, domain: %s", s.config.Domain) + } + go s.runLoop() +} + +func (s *Server) Stop() { + close(s.stopCh) +} + +func (s *Server) runLoop() { + for { + select { + case client := <-s.register: + s.mu.Lock() + s.clients[client.ID] = client + s.mu.Unlock() + log.Printf("Client connected: %s (%s)", client.ID, client.Username) + + case client := <-s.unregister: + s.mu.Lock() + if _, ok := s.clients[client.ID]; ok { + delete(s.clients, client.ID) + close(client.Send) + } + s.mu.Unlock() + log.Printf("Client disconnected: %s", client.ID) + + case <-s.stopCh: + return + } + } +} + +func (s *Server) HandleConnection(conn *websocket.Conn) { + client := &Client{ + ID: uuid.New().String(), + Conn: conn, + Send: make(chan []byte, 256), + Guilds: make(map[string]bool), + } + + s.register <- client + + hello := protocol.MustPacket(protocol.OpHello, &protocol.HelloData{ + HeartbeatInterval: 30, + ServerName: s.config.ServerName, + ServerVersion: "0.1.0", + }) + client.Conn.WriteJSON(hello) + + go client.writePump() + go client.readPump(s) +} + +func (c *Client) writePump() { + ticker := time.NewTicker(30 * time.Second) + defer func() { + ticker.Stop() + c.Conn.Close() + }() + + for { + select { + case message, ok := <-c.Send: + if !ok { + c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil { + return + } + + case <-ticker.C: + c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func (c *Client) readPump(s *Server) { + defer func() { + s.unregister <- c + c.Conn.Close() + }() + + c.Conn.SetReadLimit(1 << 24) + c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + c.Conn.SetPongHandler(func(string) error { + c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + + for { + _, message, err := c.Conn.ReadMessage() + if err != nil { + break + } + + var pkt protocol.Packet + if err := json.Unmarshal(message, &pkt); err != nil { + log.Printf("Invalid packet from %s: %v", c.ID, err) + continue + } + + s.handlePacket(c, &pkt) + } +} + +func (s *Server) handlePacket(c *Client, pkt *protocol.Packet) { + switch pkt.Op { + case protocol.OpHeartbeat: + c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + pong := protocol.MustPacket(protocol.OpHeartbeat, nil) + c.Send <- mustJSON(pong) + + case protocol.OpAuthenticate: + var auth protocol.AuthData + if err := json.Unmarshal(pkt.Data, &auth); err != nil { + s.sendError(c, 400, "invalid auth data") + return + } + s.handleAuth(c, &auth) + + case protocol.OpMessageCreate: + var msg protocol.MessageData + if err := json.Unmarshal(pkt.Data, &msg); err != nil { + s.sendError(c, 400, "invalid message data") + return + } + s.handleMessage(c, &msg) + + case protocol.OpMessageReaction: + var react protocol.ReactionData + if err := json.Unmarshal(pkt.Data, &react); err != nil { + s.sendError(c, 400, "invalid reaction data") + return + } + s.handleReaction(c, &react) + + case protocol.OpVoiceStateUpdate: + var vs protocol.VoiceStateData + if err := json.Unmarshal(pkt.Data, &vs); err != nil { + s.sendError(c, 400, "invalid voice state") + return + } + s.handleVoiceState(c, &vs) + + case protocol.OpStreamStart, protocol.OpStreamEnd: + var sd protocol.StreamData + if err := json.Unmarshal(pkt.Data, &sd); err != nil { + s.sendError(c, 400, "invalid stream data") + return + } + s.handleStream(c, &sd, pkt.Op) + + case protocol.OpTypingStart: + s.broadcastToChannel(c, pkt) + + case protocol.OpCallOffer, protocol.OpCallAnswer, protocol.OpCallICE, protocol.OpCallEnd: + s.broadcastToChannel(c, pkt) + + default: + log.Printf("Unknown opcode: %d from %s", pkt.Op, c.ID) + } +} + +func (s *Server) handleAuth(c *Client, auth *protocol.AuthData) { + if auth.Token != "" { + s.tokenMu.RLock() + uid, ok := s.tokens[auth.Token] + s.tokenMu.RUnlock() + + if ok { + var user models.User + err := s.db.QueryRow( + `SELECT id, username, avatar, bio FROM users WHERE id = ?`, uid, + ).Scan(&user.ID, &user.Username, &user.Avatar, &user.Bio) + + if err == nil { + c.UserID = user.ID + c.Username = user.Username + authData := protocol.MustPacket(protocol.OpAuthenticated, map[string]interface{}{ + "user_id": user.ID, + "username": user.Username, + }) + c.Send <- mustJSON(authData) + return + } + } + } + + if auth.Username == "" { + s.sendError(c, 401, "authentication required") + return + } + + key, _ := jcrypto.GenerateKey() + id := uuid.New().String() + _, err := s.db.Exec( + `INSERT INTO users (id, username, public_key) VALUES (?, ?, ?)`, + id, auth.Username, key, + ) + if err != nil { + s.sendError(c, 409, "username taken") + return + } + + token := uuid.New().String() + s.tokenMu.Lock() + s.tokens[token] = id + s.tokenMu.Unlock() + + c.UserID = id + c.Username = auth.Username + + authData := protocol.MustPacket(protocol.OpAuthenticated, map[string]interface{}{ + "user_id": id, + "username": auth.Username, + "token": token, + }) + c.Send <- mustJSON(authData) +} + +func (s *Server) handleMessage(c *Client, msg *protocol.MessageData) { + if c.UserID == "" { + s.sendError(c, 401, "not authenticated") + return + } + + content := msg.Content + var compressed bool + + if len(content) > 1024 { + compressedData, err := s.compressor.CompressFile(content) + if err == nil && compressedData.CompressedSize < int64(len(content)) { + content = compressedData.Data + compressed = true + } + } + + id := uuid.New().String() + _, err := s.db.Exec( + `INSERT INTO messages (id, channel_id, author_id, content, encrypted, nonce, message_type, reply_to) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, msg.ChannelID, c.UserID, content, msg.Encrypted, msg.Nonce, msg.MessageType, msg.ReplyTo, + ) + if err != nil { + s.sendError(c, 500, "failed to save message") + return + } + + broadcastMsg := protocol.MustPacket(protocol.OpMessageCreate, map[string]interface{}{ + "id": id, + "channel_id": msg.ChannelID, + "author_id": c.UserID, + "username": c.Username, + "content": content, + "encrypted": msg.Encrypted, + "nonce": msg.Nonce, + "message_type": msg.MessageType, + "reply_to": msg.ReplyTo, + "compressed": compressed, + "created_at": time.Now(), + }) + + s.broadcastToChannel(c, broadcastMsg) +} + +func (s *Server) handleReaction(c *Client, react *protocol.ReactionData) { + if c.UserID == "" { + s.sendError(c, 401, "not authenticated") + return + } + + if react.Add { + s.db.Exec( + `INSERT OR IGNORE INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)`, + react.MessageID, c.UserID, react.Emoji, + ) + } else { + s.db.Exec( + `DELETE FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?`, + react.MessageID, c.UserID, react.Emoji, + ) + } + + s.broadcastToChannel(c, pktFromReaction(c, react)) +} + +func pktFromReaction(c *Client, react *protocol.ReactionData) *protocol.Packet { + return protocol.MustPacket(protocol.OpMessageReaction, map[string]interface{}{ + "message_id": react.MessageID, + "user_id": c.UserID, + "emoji": react.Emoji, + "add": react.Add, + }) +} + +func (s *Server) handleVoiceState(c *Client, vs *protocol.VoiceStateData) { + if c.UserID == "" { + s.sendError(c, 401, "not authenticated") + return + } + + if vs.ChannelID == "" { + s.db.Exec(`DELETE FROM voice_states WHERE user_id = ?`, c.UserID) + } else { + s.db.Exec( + `INSERT OR REPLACE INTO voice_states (user_id, channel_id, guild_id, muted, deafened) + VALUES (?, ?, ?, ?, ?)`, + c.UserID, vs.ChannelID, vs.GuildID, vs.Muted, vs.Deafened, + ) + } + + broadcast := protocol.MustPacket(protocol.OpVoiceStateUpdate, map[string]interface{}{ + "user_id": c.UserID, + "channel_id": vs.ChannelID, + "guild_id": vs.GuildID, + "muted": vs.Muted, + "deafened": vs.Deafened, + }) + s.broadcastToChannel(c, broadcast) +} + +func (s *Server) handleStream(c *Client, sd *protocol.StreamData, op protocol.OpCode) { + if c.UserID == "" { + s.sendError(c, 401, "not authenticated") + return + } + + broadcast := protocol.MustPacket(op, map[string]interface{}{ + "user_id": c.UserID, + "channel_id": sd.ChannelID, + "title": sd.Title, + }) + s.broadcastToChannel(c, broadcast) +} + +func (s *Server) broadcastToChannel(sender *Client, pkt *protocol.Packet) { + data, err := json.Marshal(pkt) + if err != nil { + return + } + + s.mu.RLock() + defer s.mu.RUnlock() + + for _, client := range s.clients { + if client.UserID == "" { + continue + } + select { + case client.Send <- data: + default: + log.Printf("Dropping message for slow client %s", client.ID) + } + } +} + +func (s *Server) sendError(c *Client, code int, message string) { + errPkt := protocol.MustPacket(protocol.OpError, &protocol.ErrorData{ + Code: code, + Message: message, + }) + c.Send <- mustJSON(errPkt) +} + +func (s *Server) GetDB() *database.DB { + return s.db +} + +func (s *Server) GetChannelManager() *channel.Manager { + return s.channelMgr +} + +func (s *Server) GetRoleManager() *role.Manager { + return s.roleMgr +} + +func (s *Server) GetFederationManager() *federation.Manager { + return s.fedMgr +} + +func (s *Server) GetCompressor() *compression.Compressor { + return s.compressor +} + +func mustJSON(v interface{}) []byte { + data, _ := json.Marshal(v) + return data +}