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:
SashegDev
2026-06-06 22:39:14 +00:00
commit 096c4d0a2d
40 changed files with 5054 additions and 0 deletions
+31
View File
@@ -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
+63
View File
@@ -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<JAMAppShell> createState() => _JAMAppShellState();
}
class _JAMAppShellState extends State<JAMAppShell> {
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(),
},
),
);
}
}
+34
View File
@@ -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(),
);
}
}
+40
View File
@@ -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<String, dynamic> 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';
}
+62
View File
@@ -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<Channel> channels;
List<Role> roles;
List<Category> categories;
Guild({
required this.id,
required this.name,
required this.ownerId,
this.icon,
this.description,
DateTime? createdAt,
List<Channel>? channels,
List<Role>? roles,
List<Category>? categories,
}) : createdAt = createdAt ?? DateTime.now(),
channels = channels ?? [],
roles = roles ?? [],
categories = categories ?? [];
factory Guild.fromJson(Map<String, dynamic> 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<String, dynamic> json) {
return Category(
id: json['id'] as String,
name: json['name'] as String,
position: json['position'] as int? ?? 0,
);
}
}
+57
View File
@@ -0,0 +1,57 @@
class Message {
final String id;
final String channelId;
final String authorId;
String? authorUsername;
List<int>? content;
bool encrypted;
List<int>? nonce;
String messageType;
String? replyTo;
bool pinned;
DateTime? editedAt;
final DateTime createdAt;
bool sending;
bool failed;
List<String>? 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<String, dynamic> 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<dynamic>?)?.cast<int>(),
encrypted: json['encrypted'] as bool? ?? false,
nonce: (json['nonce'] as List<dynamic>?)?.cast<int>(),
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<dynamic>?)?.cast<String>(),
);
}
}
+35
View File
@@ -0,0 +1,35 @@
class Role {
final String id;
final String guildId;
final String name;
final int color;
final int position;
final List<String> permissions;
final bool isDefault;
Role({
required this.id,
required this.guildId,
required this.name,
this.color = 0,
this.position = 0,
List<String>? permissions,
this.isDefault = false,
}) : permissions = permissions ?? [];
factory Role.fromJson(Map<String, dynamic> 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<dynamic>?)
?.cast<String>() ??
[],
isDefault: json['is_default'] as bool? ?? false,
);
}
int get colorValue => color == 0 ? 0xFFB5BAC1 : color;
}
+39
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'id': id,
'username': username,
'avatar': avatar,
'bio': bio,
'public_key': publicKey,
'created_at': createdAt.toIso8601String(),
};
}
+237
View File
@@ -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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<Message> _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<ConnectionService>().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<void> _loadMessages() async {
try {
final conn = context.read<ConnectionService>();
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<String, dynamic> 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<ConnectionService>();
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,
),
),
],
),
);
}
}
+26
View File
@@ -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),
),
),
);
}
}
+425
View File
@@ -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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final ApiService _api = ApiService();
List<Guild> _guilds = [];
Guild? _selectedGuild;
bool _loading = true;
@override
void initState() {
super.initState();
_loadGuilds();
}
Future<void> _loadGuilds() async {
final conn = context.read<ConnectionService>();
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<ConnectionService>();
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<void> _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<ConnectionService>();
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),
),
],
),
);
}
}
+176
View File
@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
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<void> _connect() async {
setState(() {
_loading = true;
_error = null;
});
try {
final conn = context.read<ConnectionService>();
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)),
),
),
],
),
),
),
);
}
}
+178
View File
@@ -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<ConnectionService>();
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)),
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import '../services/theme_service.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeIn;
late Animation<double> _scale;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_fadeIn = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_scale = Tween<double>(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,
),
),
],
),
),
),
),
),
);
}
}
+163
View File
@@ -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));
}
}
+199
View File
@@ -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},
});
}
}
+86
View File
@@ -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();
}
}
+74
View File
@@ -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,
);
}
}
+162
View File
@@ -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<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
@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),
),
);
}
}
+79
View File
@@ -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,
),
);
}
}
+268
View File
@@ -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}';
}
}
}
+45
View File
@@ -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/
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
<title>JustAMessenger</title>
<link rel="manifest" href="manifest.json">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #313338; color: #F2F3F5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; overflow: hidden; }
#loading {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100vh; width: 100vw;
}
.jam-logo {
width: 80px; height: 80px; background: #5865F2; border-radius: 20px;
display: flex; align-items: center; justify-content: center;
font-size: 32px; font-weight: 800; color: white; letter-spacing: 2px;
box-shadow: 0 0 40px rgba(88, 101, 242, 0.4);
margin-bottom: 24px;
}
.spinner {
width: 32px; height: 32px; border: 3px solid #3F4147;
border-top-color: #5865F2; border-radius: 50%;
animation: spin 0.8s linear infinite; margin-top: 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.subtitle { color: #80848E; font-size: 14px; margin-top: 8px; }
.version { color: #5865F2; font-size: 12px; margin-top: 24px; }
flutter-view { height: 100vh; width: 100vw; }
</style>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/flutter_service_worker.js');
});
}
</script>
</head>
<body>
<div id="loading">
<div class="jam-logo">JAM</div>
<div class="spinner"></div>
<div class="subtitle">JustAMessenger</div>
<div class="version">v0.1.0</div>
</div>
<script src="flutter.js" defer></script>
<script>
window.addEventListener('load', function() {
const loading = document.getElementById('loading');
_flutter.loader.loadEntryPoint({
onEntryPointLoaded: function(engineInitializer) {
loading.style.display = 'none';
engineInitializer.initializeEngine().then(function(appRunner) {
appRunner.runApp();
});
}
});
});
</script>
</body>
</html>
+21
View File
@@ -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"
}
]
}
+92
View File
@@ -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": <int>,
"d": <object>,
"s": <int> // 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://<peer>/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=<id>`
`POST /api/guilds` body: `{name, owner_id, description}`
`GET /api/guilds/<id>` returns guild + channels + roles + categories
`DELETE /api/guilds/<id>`
### Channels
`POST /api/channels` body: `{guild_id, category_id, name, type, topic, position}`
`GET /api/channels/<id>`
`PUT /api/channels/<id>` body: `{name, topic, position}`
`DELETE /api/channels/<id>`
### Messages
`GET /api/messages?channel_id=<id>&limit=50&before=<id>`
### Users
`GET /api/users/<id>`
### Upload
`POST /api/upload` (multipart/form-data, field: file)
`GET /api/files/<id>`
### Roles
`POST /api/roles/` body: `{guild_id, name, color, permissions}`
+45
View File
@@ -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)
}
}
+10
View File
@@ -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"
}
+13
View File
@@ -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
+12
View File
@@ -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=
+454
View File
@@ -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
}
+135
View File
@@ -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
}
+195
View File
@@ -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
}
+46
View File
@@ -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
}
+92
View File
@@ -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)
}
+190
View File
@@ -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
}
+209
View File
@@ -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)
}
+108
View File
@@ -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"`
}
+128
View File
@@ -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
}
+180
View File
@@ -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
}
+473
View File
@@ -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
}