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:
commit
6a8977c4c3
14 files changed
+1494
No files matched your search
+23
@@ -0,0 +1,23 @@
|
||||
# Dart
|
||||
.dart_tool/
|
||||
.packages
|
||||
|
||||
# ObjectBox generated
|
||||
*.g.dart
|
||||
objectbox-model.json
|
||||
|
||||
# Build
|
||||
build/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+248
@@ -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
|
||||
@@ -0,0 +1,46 @@
|
||||
# Chat Server
|
||||
|
||||
A self-hosted Discord alternative with decentralized identity using the Home Server model.
|
||||
|
||||
## Architecture
|
||||
|
||||
Each user has a **home server** where their identity lives (keypair), and can seamlessly join other **guest servers** using cryptographic signatures.
|
||||
|
||||
- **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
|
||||
|
||||
See [ARCHITECTURE.md](./ARCHITECTURE.md) for full details.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
dart pub get
|
||||
|
||||
# Generate ObjectBox code
|
||||
dart run build_runner build
|
||||
|
||||
# Run server
|
||||
dart run lib/server.dart
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── server.dart # Entry point, routes
|
||||
├── models.dart # User, Channel, Message entities
|
||||
├── objectbox.g.dart # Generated
|
||||
├── config/config.dart # Server config
|
||||
└── src/
|
||||
├── auth/ # Keypair, JWT, middleware
|
||||
├── handlers/ # API endpoints
|
||||
└── utils/ # Helpers
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Relic 1.0
|
||||
- **Database**: ObjectBox
|
||||
- **Auth**: JWT + Ed25519 signatures
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user