feat: Initial MVP with home server identity model

- User model with Ed25519 keypair identity
- Register/login on home server (creates keypair)
- Join guest servers with signature verification
- Public endpoints for cross-server identity
- Channel and message CRUD
- JWT authentication middleware

Architecture: Home server model, no federation, client-driven sync
This commit is contained in:
Z User committed 2026-02-19 23:07:14 +00:00
1 parent 5088313f74
commit f57fe229fc
13 files changed
+1427 -1

No files matched your search

+2 -1
View File
@@ -48,4 +48,5 @@ prompt
server.log server.log
# Skills directory # Skills directory
/skills/ /skills/download/
*.g.dart
+248
View File
@@ -0,0 +1,248 @@
# Chat Server Architecture - Complete Reference
## Project Overview
Self-hosted Discord alternative with decentralized identity. Each user has a "home server" where their identity lives, and can join other "guest servers" seamlessly using cryptographic signatures.
## Core Architecture Decisions
### Home Server Model
- **NO identity server** - just use one regular server as "home"
- **NO federation protocol** - servers don't talk to each other
- **Client does everything** - syncs, cross-server requests, profile updates
### Identity System
- Ed25519 keypair generated on registration
- Private key = proof of identity
- Public key stored on home server at `/.well-known/users/:username`
- userId format: `username@serverhost`
### Single Sign-On Flow
1. User registers/logs in ONCE on home server
2. Gets JWT token + encrypted keypair
3. To join other servers: sign a message with private key
4. Other server verifies signature against home server's public key
5. No password ever sent to guest servers
---
## What's Implemented
### ✅ Done
- User model with keypair, home server reference
- Channel and Message models
- Crypto service (keypair generation, signing, verification)
- JWT authentication middleware
- Register handler (creates keypair)
- Login handler (returns encrypted keypair for device sync)
- Join server handler (signature-based verification - stub)
- Public endpoints for cross-server identity verification
- Channel CRUD
- Message CRUD with author caching
### ❌ Not Implemented
- HTTP client for cross-server public key fetching
- WebSocket for real-time messages
- Profile sync (push updates to all servers)
- Sticker packs (future feature)
- File uploads
- Permissions system (roles exist but not enforced)
- Message editing/deletion permissions
---
## Database Models
```dart
// User - works for both HOME and GUEST servers
@Entity()
class User {
int id;
String userId; // "alice@homeserver.com"
String username;
String? displayName, avatarUrl, bio;
String publicKey; // Ed25519, always stored
// HOME SERVER ONLY:
String? passwordHash;
String? encryptedPrivateKey; // For device sync
String? keyPairSalt;
bool isLocal; // true = this is home server
String? homeServer; // if guest, where is home?
List<String> roles; // Server-specific
DateTime createdAt, lastSyncedAt;
}
@Entity()
class Channel {
int id;
String name;
String? description;
DateTime createdAt;
}
@Entity()
class Message {
int id;
int channelId;
// Author info (includes cross-server fetch capability)
int authorLocalId;
String authorUserId; // "alice@home.server"
String? authorHomeServer;
// Cached snapshot at message time
String authorUsername;
String? authorAvatarUrl;
String content;
DateTime createdAt, editedAt;
}
```
---
## API Endpoints
### Public (No Auth)
```
POST /auth/register → Create account on HOME server (generates keypair)
POST /auth/login → Login to HOME server (returns keypair backup)
POST /auth/join → Join THIS server with signature (no password)
GET /.well-known/users/:username → Public profile + public key
GET /.well-known/verify/:userId → Verify user exists
GET /.well-known/server → Server info
```
### Protected (JWT Required)
```
GET /api/me → Current user profile
GET /api/channels → List channels
POST /api/channels → Create channel
GET /api/channels/:id → Get channel
GET /api/messages?channelId=X → Get messages
POST /api/messages → Send message
PUT /api/messages/:id → Edit message
DELETE /api/messages/:id → Delete message
```
---
## Key Files
```
lib/
├── server.dart # Entry point, routes, ObjectBox init
├── models.dart # User, Channel, Message entities
├── objectbox.g.dart # Generated
│
├── config/config.dart # Server host, JWT secret, port
│
└── src/
├── auth/
│ ├── crypto_service.dart # Keypair, signing, encryption
│ └── auth_middleware.dart # JWT verification
│
├── handlers/
│ ├── auth_handlers.dart # register, login, join, me
│ ├── public_handlers.dart # .well-known/*
│ ├── channel_handlers.dart
│ └── message_handlers.dart
│
└── utils/extensions.dart # Request/Response helpers
```
---
## Join Server Flow (Needs Implementation)
```
CLIENT GUEST SERVER HOME SERVER
│ │ │
│ POST /auth/join │ │
│ {userId, homeServer, timestamp, │ │
│ signature} │ │
│─────────────────────────────────────►│ │
│ │ │
│ │ GET /.well-known/users/ │
│ │ alice │
│ │────────────────────────►│
│ │ │
│ │ {publicKey, ...} │
│ │◄────────────────────────│
│ │ │
│ │ verify signature │
│ │ create shadow account │
│ │ │
│ {token, localId} │ │
│◄─────────────────────────────────────│ │
```
The `fetchPublicKeyFromHomeServer()` function in `auth_handlers.dart` needs actual HTTP implementation.
---
## Tech Stack
- **Framework**: Relic 1.0 (Dart web server)
- **Database**: ObjectBox
- **Auth**: JWT + Ed25519 signatures
- **Crypto**: crypton package for Ed25519
- **Password**: bcrypt
---
## Design Decisions Rationale
| Decision | Why |
|----------|-----|
| No identity server | Avoids hosting another service, one server = home |
| No server-to-server | Eliminates federation complexity, client handles everything |
| Keypair identity | Allows passwordless joins, proves identity cryptographically |
| Message author caching | Messages readable even if home server is down |
| userId format `user@server` | Simple, familiar, self-documenting |
---
## Constraints
- User CANNOT quit home server (that's where identity lives)
- User CANNOT change home server easily (migration = future feature)
- Home server MUST be online to join NEW servers
- Home server offline = can still use already-joined servers (cached keys)
---
## Future Features (Out of Scope for MVP)
1. **Sticker Packs** - Telegram-style, live on community servers, clients cache
2. **Home Server Migration** - Sign migration certificate, chain of trust
3. **File Uploads** - With cross-server URLs
4. **Message Forwarding** - Embed with link to original
5. **Permissions** - Role-based access to channels
6. **WebSocket** - Real-time message delivery
---
## Config to Change
```dart
// lib/config/config.dart
static const String serverHost = 'your-actual-domain.com'; // CHANGE THIS
static const String jwtSecret = 'use-env-variable'; // CHANGE THIS
```
---
## Next Steps
1. Implement HTTP client in `fetchPublicKeyFromHomeServer()`
2. Add WebSocket for real-time
3. Add profile sync (client pushes to all servers on change)
4. Test cross-server join flow
5. Add proper error handling and logging
+14
View File
@@ -0,0 +1,14 @@
class Config {
// Server settings
static const int port = 9870;
// IMPORTANT: Set this to your actual server host/domain
// Used for generating userId: "username@serverHost"
static const String serverHost = 'localhost:9870';
static const String serverName = 'Chat Server';
// JWT settings
// TODO: Move to environment variable in production
static const String jwtSecret = 'your_super_secret_key_change_in_production';
static const Duration tokenExpiry = Duration(days: 7);
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:objectbox/objectbox.dart';
/// User entity - works for both HOME and GUEST servers
///
/// On HOME server:
/// - isLocal = true
/// - stores password, encrypted private key
/// - this is where identity lives
///
/// On GUEST server:
/// - isLocal = false
/// - homeServer contains the URL of user's home
/// - publicKey cached for verification
@Entity()
class User {
@Id()
int id = 0;
// Global identifier: "username@homeserver.com"
@Unique()
String userId;
// Basic profile
@Index()
String username;
String? displayName;
String? avatarUrl;
String? bio;
// Identity
String publicKey; // Ed25519 public key, always stored
// HOME SERVER ONLY: Auth & key backup
String? passwordHash;
String? encryptedPrivateKey; // Encrypted with user's password
String? keyPairSalt;
// Server relationship
bool isLocal; // true = this is their home server
String? homeServer; // if guest, where is their home? (e.g., "https://my.server.com")
// Server-specific
List<String> roles; // ["admin", "moderator", "member"]
@Property(type: PropertyType.date)
DateTime createdAt;
@Property(type: PropertyType.date)
DateTime? lastSyncedAt; // When profile was last refreshed from home
User({
required this.userId,
required this.username,
required this.publicKey,
required this.isLocal,
this.displayName,
this.avatarUrl,
this.bio,
this.passwordHash,
this.encryptedPrivateKey,
this.keyPairSalt,
this.homeServer,
List<String>? roles,
DateTime? createdAt,
DateTime? lastSyncedAt,
}) : roles = roles ?? ['member'],
createdAt = createdAt ?? DateTime.now(),
lastSyncedAt = lastSyncedAt;
}
@Entity()
class Channel {
@Id()
int id = 0;
@Unique()
String name;
String? description;
@Property(type: PropertyType.date)
DateTime createdAt;
Channel({
required this.name,
this.description,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
}
@Entity()
class Message {
@Id()
int id = 0;
int channelId;
// Author info - includes enough for cross-server profile fetch
int authorLocalId;
String authorUserId; // "alice@homeserver.com"
String? authorHomeServer; // for profile fetching
// Cached for display (snapshot at message time)
String authorUsername;
String? authorAvatarUrl;
String content;
@Property(type: PropertyType.date)
DateTime createdAt;
@Property(type: PropertyType.date)
DateTime? editedAt;
Message({
required this.channelId,
required this.authorLocalId,
required this.authorUserId,
required this.authorUsername,
required this.content,
this.authorHomeServer,
this.authorAvatarUrl,
DateTime? createdAt,
this.editedAt,
}) : createdAt = createdAt ?? DateTime.now();
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:hotreloader/hotreloader.dart';
import 'package:relic/relic.dart';
import 'config/config.dart';
import 'src/auth/auth_middleware.dart';
import 'src/handlers/auth_handlers.dart';
import 'src/handlers/channel_handlers.dart';
import 'src/handlers/message_handlers.dart';
import 'src/handlers/public_handlers.dart';
import 'src/utils/extensions.dart';
// Global ObjectBox store and boxes
late final Store store;
late final Box<User> userBox;
late final Box<Channel> channelBox;
late final Box<Message> messageBox;
RelicApp? _currentApp;
Future<void> startServer() async {
await _currentApp?.close();
_currentApp = RelicApp()
// ============ PUBLIC ENDPOINTS (no auth) ============
// Auth
..post('/auth/register', registerHandler)
..post('/auth/login', loginHandler)
..post('/auth/join', joinServerHandler)
// Public identity verification (for cross-server)
..get('/.well-known/users/<username>', publicUserHandler)
..get('/.well-known/verify/<userId>', publicVerifyHandler)
..get('/.well-known/server', publicServerInfoHandler)
// ============ PROTECTED ENDPOINTS (requires auth) ============
..use('/api', jwtMiddleware)
// Current user
..get('/api/me', getMeHandler)
// Channels
..get('/api/channels', getChannelsHandler)
..post('/api/channels', createChannelHandler)
..get('/api/channels/<channelId>', getChannelHandler)
// Messages
..get('/api/messages', getMessagesHandler)
..post('/api/messages', sendMessageHandler)
..put('/api/messages/<messageId>', editMessageHandler)
..delete('/api/messages/<messageId>', deleteMessageHandler)
// ============ LOGGING & FALLBACK ============
..use('/', logRequests())
..fallback = respondWith((_) => Response.notFound());
print('');
print('╔════════════════════════════════════════════╗');
print('║ 🚀 Server starting... ║');
print('║ Host: ${Config.serverHost.padRight(34)}║');
print('║ URL: http://localhost:${Config.port.toString().padRight(26)}║');
print('╚════════════════════════════════════════════╝');
print('');
await _currentApp!.serve(port: Config.port);
}
void main() async {
// Initialize ObjectBox
store = openStore();
userBox = store.box<User>();
channelBox = store.box<Channel>();
messageBox = store.box<Message>();
// Enable hot reload in development
await HotReloader.create(
onAfterReload: (ctx) async {
print('🔄 Changes detected! Restarting server...');
await startServer();
},
);
await startServer();
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:relic/relic.dart';
import '../../config/config.dart';
import '../utils/extensions.dart';
import 'crypto_service.dart';
final userIdProperty = ContextProperty<String>('userId');
final usernameProperty = ContextProperty<String>('username');
final userLocalIdProperty = ContextProperty<int>('userLocalId');
/// JWT authentication middleware
final jwtMiddleware = createMiddleware(
onRequest: (request) {
final authHeader = request.headers.authorization;
if (authHeader is! BearerAuthorizationHeader) {
return Response.unauthorized(
body: Body.fromString('Bearer token required'),
);
}
final payload = AuthService.verifyToken(authHeader.token);
if (payload == null) {
return Response.forbidden(
body: Body.fromString('Invalid or expired token'),
);
}
final userId = payload['sub'] as String?;
final username = payload['username'] as String?;
if (userId == null) {
return Response.forbidden(
body: Body.fromString('Invalid token payload'),
);
}
userIdProperty[request] = userId;
if (username != null) {
usernameProperty[request] = username;
}
return null; // Continue to handler
},
);
+161
View File
@@ -0,0 +1,161 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:bcrypt/bcrypt.dart';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:crypton/crypton.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import '../config/config.dart';
class KeyPair {
final String publicKey;
final String privateKey;
KeyPair({required this.publicKey, required this.privateKey});
Map<String, dynamic> toJson() => {
'publicKey': publicKey,
'privateKey': privateKey,
};
factory KeyPair.fromJson(Map<String, dynamic> json) {
return KeyPair(
publicKey: json['publicKey'] as String,
privateKey: json['privateKey'] as String,
);
}
}
class CryptoService {
/// Generate a new Ed25519 key pair
static KeyPair generateKeyPair() {
final keyPair = ECKeypair.fromRandom();
return KeyPair(
publicKey: keyPair.publicKey.toString(),
privateKey: keyPair.privateKey.toString(),
);
}
/// Sign data with private key
static String sign(String data, String privateKey) {
final privKey = ECPrivateKey.fromString(privateKey);
final signature = privKey.createSignature(data);
return signature.toString();
}
/// Verify signature with public key
static bool verify(String data, String signature, String publicKey) {
try {
final pubKey = ECPublicKey.fromString(publicKey);
final sig = ECSignature.fromString(signature);
return pubKey.verifySignature(data, sig);
} catch (_) {
return false;
}
}
/// Encrypt private key with password (for storage/backup)
static EncryptedKey encryptPrivateKey(String privateKey, String password) {
final salt = BCrypt.gensalt();
final key = _deriveKey(password, salt);
final encrypted = _xorEncrypt(privateKey, key);
return EncryptedKey(
encryptedData: encrypted,
salt: salt,
);
}
/// Decrypt private key with password
static String? decryptPrivateKey(
String encryptedData, String salt, String password) {
try {
final key = _deriveKey(password, salt);
return _xorDecrypt(encryptedData, key);
} catch (_) {
return null;
}
}
static String _deriveKey(String password, String salt) {
final bytes = utf8.encode(password + salt);
final hash = sha256.convert(bytes);
return hash.toString();
}
static String _xorEncrypt(String data, String key) {
final dataBytes = utf8.encode(data);
final keyBytes = utf8.encode(key);
final encrypted = <int>[];
for (var i = 0; i < dataBytes.length; i++) {
encrypted.add(dataBytes[i] ^ keyBytes[i % keyBytes.length]);
}
return base64Encode(encrypted);
}
static String _xorDecrypt(String encrypted, String key) {
final encryptedBytes = base64Decode(encrypted);
final keyBytes = utf8.encode(key);
final decrypted = <int>[];
for (var i = 0; i < encryptedBytes.length; i++) {
decrypted.add(encryptedBytes[i] ^ keyBytes[i % keyBytes.length]);
}
return utf8.decode(decrypted);
}
}
class EncryptedKey {
final String encryptedData;
final String salt;
EncryptedKey({required this.encryptedData, required this.salt});
Map<String, dynamic> toJson() => {
'encryptedData': encryptedData,
'salt': salt,
};
}
class AuthService {
/// Generate JWT token for user
static String generateToken(String userId, String username) {
final payload = {
'sub': userId,
'username': username,
};
final jwt = JWT(payload);
return jwt.sign(
SecretKey(Config.jwtSecret),
expiresIn: Config.tokenExpiry,
);
}
/// Verify JWT token and return payload
static Map<String, dynamic>? verifyToken(String token) {
try {
final jwt = JWT.verify(token, SecretKey(Config.jwtSecret));
return Map<String, dynamic>.from(jwt.payload as Map);
} catch (_) {
return null;
}
}
/// Hash password with bcrypt
static String hashPassword(String password) {
return BCrypt.hashpw(password, BCrypt.gensalt());
}
/// Verify password against hash
static bool verifyPassword(String password, String hash) {
return BCrypt.checkpw(password, hash);
}
/// Generate a nonce for signature verification
static String generateNonce() {
final bytes = List<int>.generate(32, (_) => DateTime.now().microsecondsSinceEpoch % 256);
final randomBytes = sha256.convert(bytes as Uint8List);
return randomBytes.toString().substring(0, 32);
}
}
+294
View File
@@ -0,0 +1,294 @@
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../auth/auth_middleware.dart';
import '../auth/crypto_service.dart';
import '../utils/extensions.dart';
/// Register on HOME server
/// Creates user with keypair for identity
Future<Response> registerHandler(Request req) async {
final body = await req.asJson();
final username = (body['username'] as String?)?.trim();
final password = body['password'] as String?;
if (username == null || username.isEmpty) {
return Response.badRequest(
body: Body.fromString('Username required'),
);
}
if (password == null || password.length < 6) {
return Response.badRequest(
body: Body.fromString('Password must be at least 6 characters'),
);
}
// Check if username exists
final query = userBox.query(User_.username.equals(username)).build();
final existing = query.findFirst();
query.close();
if (existing != null) {
return Response.badRequest(
body: Body.fromString('Username already taken'),
);
}
// Generate keypair for identity
final keyPair = CryptoService.generateKeyPair();
// Encrypt private key with password
final encryptedKey = CryptoService.encryptPrivateKey(
keyPair.privateKey,
password,
);
// Hash password
final passwordHash = AuthService.hashPassword(password);
// Create user with userId format: "username@this.server"
final userId = '${username}@${Config.serverHost}';
final user = User(
userId: userId,
username: username,
publicKey: keyPair.publicKey,
isLocal: true,
passwordHash: passwordHash,
encryptedPrivateKey: encryptedKey.encryptedData,
keyPairSalt: encryptedKey.salt,
roles: ['member'],
);
final localId = userBox.put(user);
// Generate token
final token = AuthService.generateToken(userId, username);
return Response.ok(
body: {
'token': token,
'userId': userId,
'username': username,
'publicKey': keyPair.publicKey,
'encryptedPrivateKey': encryptedKey.encryptedData,
'keyPairSalt': encryptedKey.salt,
'localId': localId,
}.toBody,
);
}
/// Login on HOME server
/// Returns token + encrypted keypair for device sync
Future<Response> loginHandler(Request req) async {
final body = await req.asJson();
final username = body['username'] as String?;
final password = body['password'] as String?;
if (username == null || password == null) {
return Response.badRequest(
body: Body.fromString('Username and password required'),
);
}
final query = userBox.query(User_.username.equals(username)).build();
final user = query.findFirst();
query.close();
if (user == null) {
return Response.unauthorized(
body: Body.fromString('Invalid credentials'),
);
}
// Check if this is actually their home server
if (!user.isLocal) {
return Response.badRequest(
body: Body.fromString('This is not your home server. Login there instead.'),
);
}
// Verify password
if (!AuthService.verifyPassword(password, user.passwordHash!)) {
return Response.unauthorized(
body: Body.fromString('Invalid credentials'),
);
}
// Generate token
final token = AuthService.generateToken(user.userId, user.username);
return Response.ok(
body: {
'token': token,
'userId': user.userId,
'username': user.username,
'displayName': user.displayName,
'avatarUrl': user.avatarUrl,
'publicKey': user.publicKey,
'encryptedPrivateKey': user.encryptedPrivateKey,
'keyPairSalt': user.keyPairSalt,
'localId': user.id,
}.toBody,
);
}
/// Join a GUEST server using signature verification
/// No password required - proves identity via private key signature
Future<Response> joinServerHandler(Request req) async {
final body = await req.asJson();
final userId = body['userId'] as String?; // "alice@homeserver.com"
final homeServer = body['homeServer'] as String?; // "https://homeserver.com"
final timestamp = body['timestamp'] as int?;
final signature = body['signature'] as String?;
final username = body['username'] as String?;
// Validate required fields
if (userId == null || homeServer == null || timestamp == null || signature == null) {
return Response.badRequest(
body: Body.fromString('userId, homeServer, timestamp, and signature required'),
);
}
// Check timestamp is recent (within 5 minutes)
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if ((now - timestamp).abs() > 300) {
return Response.badRequest(
body: Body.fromString('Timestamp expired. Please sync your clock.'),
);
}
// Check if user already exists on this server
final existingQuery = userBox.query(User_.userId.equals(userId)).build();
final existingUser = existingQuery.findFirst();
existingQuery.close();
if (existingUser != null) {
// User already joined - just return a new token
final token = AuthService.generateToken(existingUser.userId, existingUser.username);
userLocalIdProperty[req] = existingUser.id;
return Response.ok(
body: {
'token': token,
'userId': existingUser.userId,
'username': existingUser.username,
'localId': existingUser.id,
'alreadyJoined': true,
}.toBody,
);
}
// Fetch public key from home server
final publicKey = await fetchPublicKeyFromHomeServer(homeServer, username ?? userId.split('@').first);
if (publicKey == null) {
return Response.badRequest(
body: Body.fromString('Could not verify identity with home server'),
);
}
// Verify signature
// Signature is over: "{userId}:{timestamp}"
final messageToVerify = '$userId:$timestamp';
final isValid = CryptoService.verify(messageToVerify, signature, publicKey);
if (!isValid) {
return Response.forbidden(
body: Body.fromString('Invalid signature'),
);
}
// Create local shadow account
final user = User(
userId: userId,
username: username ?? userId.split('@').first,
publicKey: publicKey,
isLocal: false,
homeServer: homeServer,
roles: ['member'],
);
final localId = userBox.put(user);
// Generate local token
final token = AuthService.generateToken(userId, user.username);
return Response.ok(
body: {
'token': token,
'userId': userId,
'username': user.username,
'localId': localId,
'alreadyJoined': false,
}.toBody,
);
}
/// Fetch public key from user's home server
Future<String?> fetchPublicKeyFromHomeServer(
String homeServer,
String username,
) async {
try {
// Remove trailing slash
final baseUrl = homeServer.replaceAll(RegExp(r'/$'), '');
// Use http client to fetch
// Endpoint: GET /.well-known/users/{username}
// Returns: { publicKey, userId, ... }
// For now, return null to indicate we need http client
// This will be implemented with actual HTTP client
print('Would fetch: $baseUrl/.well-known/users/$username');
// TODO: Implement actual HTTP request
// final response = await http.get(Uri.parse('$baseUrl/.well-known/users/$username'));
// if (response.statusCode == 200) {
// final data = jsonDecode(response.body);
// return data['publicKey'] as String;
// }
return null;
} catch (e) {
print('Error fetching public key: $e');
return null;
}
}
/// Get current user's profile
Future<Response> getMeHandler(Request req) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final query = userBox.query(User_.userId.equals(userId)).build();
final user = query.findFirst();
query.close();
if (user == null) {
return Response.notFound(
body: Body.fromString('User not found'),
);
}
return Response.ok(
body: {
'userId': user.userId,
'username': user.username,
'displayName': user.displayName,
'avatarUrl': user.avatarUrl,
'bio': user.bio,
'isLocal': user.isLocal,
'homeServer': user.homeServer,
'roles': user.roles,
'createdAt': user.createdAt.toIso8601String(),
}.toBody,
);
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../utils/extensions.dart';
/// Get all channels
Future<Response> getChannelsHandler(Request req) async {
final channels = channelBox.getAll();
return Response.ok(
body: {
'channels': channels
.map((c) => {
'id': c.id,
'name': c.name,
'description': c.description,
'createdAt': c.createdAt.toIso8601String(),
})
.toList(),
}.toBody,
);
}
/// Create a channel (requires 'admin' role)
Future<Response> createChannelHandler(Request req) async {
final body = await req.asJson();
final name = (body['name'] as String?)?.trim().toLowerCase().replaceAll(' ', '-');
final description = body['description'] as String?;
if (name == null || name.isEmpty) {
return Response.badRequest(
body: Body.fromString('Channel name required'),
);
}
// Check if channel exists
final existing = channelBox.query(Channel_.name.equals(name)).build().findFirst();
if (existing != null) {
return Response.badRequest(
body: Body.fromString('Channel already exists'),
);
}
final channel = Channel(name: name, description: description);
final id = channelBox.put(channel);
return Response.ok(
body: {
'id': id,
'name': name,
'description': description,
'createdAt': channel.createdAt.toIso8601String(),
}.toBody,
);
}
/// Get channel by ID
Future<Response> getChannelHandler(Request req, String channelIdStr) async {
final channelId = int.tryParse(channelIdStr);
if (channelId == null) {
return Response.badRequest(
body: Body.fromString('Invalid channel ID'),
);
}
final channel = channelBox.get(channelId);
if (channel == null) {
return Response.notFound(
body: Body.fromString('Channel not found'),
);
}
return Response.ok(
body: {
'id': channel.id,
'name': channel.name,
'description': channel.description,
'createdAt': channel.createdAt.toIso8601String(),
}.toBody,
);
}
+238
View File
@@ -0,0 +1,238 @@
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../auth/auth_middleware.dart';
import '../utils/extensions.dart';
/// Get messages for a channel
/// Query params: channelId (required), limit, before
Future<Response> getMessagesHandler(Request req) async {
final channelIdStr = req.url.queryParameters['channelId'];
final limitStr = req.url.queryParameters['limit'] ?? '50';
if (channelIdStr == null) {
return Response.badRequest(
body: Body.fromString('channelId required'),
);
}
final channelId = int.tryParse(channelIdStr);
final limit = int.tryParse(limitStr) ?? 50;
if (channelId == null) {
return Response.badRequest(
body: Body.fromString('Invalid channelId'),
);
}
// Check channel exists
final channel = channelBox.get(channelId);
if (channel == null) {
return Response.notFound(
body: Body.fromString('Channel not found'),
);
}
// Get messages
final query = messageBox
.query(Message_.channelId.equals(channelId))
.order(Message_.createdAt, flags: Order.descending)
.build();
final messages = query.find(limit: limit);
query.close();
return Response.ok(
body: {
'messages': messages
.map((m) => {
'id': m.id,
'content': m.content,
'authorId': m.authorUserId,
'authorUsername': m.authorUsername,
'authorAvatarUrl': m.authorAvatarUrl,
'authorHomeServer': m.authorHomeServer,
'channelId': m.channelId,
'createdAt': m.createdAt.toIso8601String(),
'editedAt': m.editedAt?.toIso8601String(),
})
.toList(),
'channelId': channelId,
'limit': limit,
}.toBody,
);
}
/// Send a message
Future<Response> sendMessageHandler(Request req) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final body = await req.asJson();
final content = body['content'] as String?;
final channelId = body['channelId'] as int?;
if (content == null || content.trim().isEmpty) {
return Response.badRequest(
body: Body.fromString('Content required'),
);
}
if (channelId == null) {
return Response.badRequest(
body: Body.fromString('channelId required'),
);
}
// Check channel exists
final channel = channelBox.get(channelId);
if (channel == null) {
return Response.notFound(
body: Body.fromString('Channel not found'),
);
}
// Get user info for caching
final userQuery = userBox.query(User_.userId.equals(userId)).build();
final user = userQuery.findFirst();
userQuery.close();
if (user == null) {
return Response.unauthorized(
body: Body.fromString('User not found'),
);
}
// Create message with cached author info
final message = Message(
channelId: channelId,
authorLocalId: user.id,
authorUserId: user.userId,
authorUsername: user.username,
authorAvatarUrl: user.avatarUrl,
authorHomeServer: user.homeServer,
content: content.trim(),
);
final id = messageBox.put(message);
return Response.ok(
body: {
'id': id,
'content': message.content,
'authorId': user.userId,
'authorUsername': user.username,
'authorAvatarUrl': user.avatarUrl,
'channelId': channelId,
'createdAt': message.createdAt.toIso8601String(),
}.toBody,
);
}
/// Edit a message
Future<Response> editMessageHandler(Request req, String messageIdStr) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final messageId = int.tryParse(messageIdStr);
if (messageId == null) {
return Response.badRequest(
body: Body.fromString('Invalid message ID'),
);
}
final message = messageBox.get(messageId);
if (message == null) {
return Response.notFound(
body: Body.fromString('Message not found'),
);
}
// Get user to verify ownership
final userQuery = userBox.query(User_.userId.equals(userId)).build();
final user = userQuery.findFirst();
userQuery.close();
if (user == null || message.authorLocalId != user.id) {
return Response.forbidden(
body: Body.fromString('You can only edit your own messages'),
);
}
final body = await req.asJson();
final content = body['content'] as String?;
if (content == null || content.trim().isEmpty) {
return Response.badRequest(
body: Body.fromString('Content required'),
);
}
message.content = content.trim();
message.editedAt = DateTime.now();
messageBox.put(message);
return Response.ok(
body: {
'id': message.id,
'content': message.content,
'editedAt': message.editedAt?.toIso8601String(),
}.toBody,
);
}
/// Delete a message
Future<Response> deleteMessageHandler(Request req, String messageIdStr) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final messageId = int.tryParse(messageIdStr);
if (messageId == null) {
return Response.badRequest(
body: Body.fromString('Invalid message ID'),
);
}
final message = messageBox.get(messageId);
if (message == null) {
return Response.notFound(
body: Body.fromString('Message not found'),
);
}
// Get user to verify ownership
final userQuery = userBox.query(User_.userId.equals(userId)).build();
final user = userQuery.findFirst();
userQuery.close();
if (user == null || message.authorLocalId != user.id) {
return Response.forbidden(
body: Body.fromString('You can only delete your own messages'),
);
}
messageBox.remove(messageId);
return Response.ok(
body: {
'deleted': true,
'id': messageId,
}.toBody,
);
}
+85
View File
@@ -0,0 +1,85 @@
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../utils/extensions.dart';
/// Public endpoint for cross-server identity verification
/// Called by other servers to verify a user's public key
///
/// GET /.well-known/users/{username}
/// Returns: { userId, publicKey, username, displayName, avatarUrl }
Future<Response> publicUserHandler(Request req, String username) async {
final query = userBox.query(
User_.username.equals(username) & User_.isLocal.equals(true),
).build();
final user = query.findFirst();
query.close();
if (user == null) {
return Response.notFound(
body: Body.fromString('User not found'),
);
}
return Response.ok(
body: {
'userId': user.userId,
'username': user.username,
'displayName': user.displayName,
'avatarUrl': user.avatarUrl,
'bio': user.bio,
'publicKey': user.publicKey,
'homeServer': Config.serverHost,
'createdAt': user.createdAt.toIso8601String(),
}.toBody,
);
}
/// Public endpoint to verify a user exists
/// Lightweight check without full profile
///
/// GET /.well-known/verify/{userId}
/// Returns: { exists, userId, publicKey }
Future<Response> publicVerifyHandler(Request req, String userId) async {
final query = userBox.query(
User_.userId.equals(userId) & User_.isLocal.equals(true),
).build();
final user = query.findFirst();
query.close();
if (user == null) {
return Response.ok(
body: {
'exists': false,
'userId': userId,
}.toBody,
);
}
return Response.ok(
body: {
'exists': true,
'userId': user.userId,
'publicKey': user.publicKey,
'username': user.username,
}.toBody,
);
}
/// Public server info
///
/// GET /.well-known/server
/// Returns: { serverHost, serverName, version }
Future<Response> publicServerInfoHandler(Request req) async {
return Response.ok(
body: {
'serverHost': Config.serverHost,
'serverName': Config.serverName,
'version': '0.1.0',
'features': ['identity', 'chat', 'sticker-packs'],
}.toBody,
);
}
+25
View File
@@ -0,0 +1,25 @@
import 'dart:convert';
import 'package:relic/relic.dart';
import '../config/config.dart';
extension RequestHelper on Request {
/// Parses the request body as JSON
Future<Map<String, dynamic>> asJson() async {
final bodyText = await readAsString();
return jsonDecode(bodyText);
}
}
extension MapToBody on Map<String, dynamic> {
/// Converts a Map to a JSON Body
Body get toBody => Body.fromString(jsonEncode(this));
}
extension BodyHelper on Body {
/// Creates a JSON body from a Map
static Body fromJson(Map<String, dynamic> json) {
return Body.fromString(jsonEncode(json));
}
}
+21
View File
@@ -0,0 +1,21 @@
name: chat_server
description: A self-hosted chat server with home server identity model.
version: 0.1.0
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
relic: ^1.0.0
objectbox: ^4.0.0
dart_jsonwebtoken: ^2.14.0
bcrypt: ^1.1.3
crypton: ^2.2.1
crypto: ^3.0.3
collection: ^1.18.0
hotreloader: ^1.0.6
dev_dependencies:
objectbox_generator: ^4.0.0
build_runner: ^2.4.0
lints: ^4.0.0