Initial commit: JustAMessenger v0.1.0
Серверная часть (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 — спецификация протокола
This commit is contained in:
@@ -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<String, String> get _headers => {
|
||||
'Content-Type': 'application/json',
|
||||
if (_token != null) 'Authorization': 'Bearer $_token',
|
||||
};
|
||||
|
||||
Future<bool> 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<List<Guild>> 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<dynamic> data = jsonDecode(resp.body);
|
||||
return data.map((g) => Guild.fromJson(g)).toList();
|
||||
}
|
||||
|
||||
Future<String> 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<Map<String, dynamic>> 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<Channel> 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<List<Message>> 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<dynamic> data = jsonDecode(resp.body);
|
||||
return data.map((m) => Message.fromJson(m)).toList();
|
||||
}
|
||||
|
||||
Future<String> 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<User> 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<Role> createRole({
|
||||
required String guildId,
|
||||
required String name,
|
||||
int color = 0,
|
||||
List<String>? 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));
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, dynamic>> _messageController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
Stream<Map<String, dynamic>> 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<void> 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<String, dynamic> 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<String, dynamic>;
|
||||
_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<String, dynamic>);
|
||||
break;
|
||||
case 13: // Reaction
|
||||
_messageController.add(packet['d'] as Map<String, dynamic>);
|
||||
break;
|
||||
case 40: // VoiceState
|
||||
_messageController.add(packet['d'] as Map<String, dynamic>);
|
||||
break;
|
||||
case 60: // Typing
|
||||
_messageController.add(packet['d'] as Map<String, dynamic>);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to handle message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void send(Map<String, dynamic> 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},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<int> a, List<int> 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user