Implement chat server auth, models, handlers, and docs context
This commit is contained in:
1 parent
23602efd5f
commit
f6b27565cd
16 files changed
+1285
-152
No files matched your search
@@ -1,12 +1,12 @@
|
||||
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';
|
||||
|
||||
+13
-12
@@ -1,18 +1,18 @@
|
||||
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 {
|
||||
@Entity(uid: 3848231140411393897)
|
||||
class ChatUser {
|
||||
@Id()
|
||||
int id = 0;
|
||||
|
||||
@@ -37,7 +37,8 @@ class User {
|
||||
|
||||
// Server relationship
|
||||
bool isLocal; // true = this is their home server
|
||||
String? homeServer; // if guest, where is their home? (e.g., "https://my.server.com")
|
||||
String?
|
||||
homeServer; // if guest, where is their home? (e.g., "https://my.server.com")
|
||||
|
||||
// Server-specific
|
||||
List<String> roles; // ["admin", "moderator", "member"]
|
||||
@@ -48,7 +49,7 @@ class User {
|
||||
@Property(type: PropertyType.date)
|
||||
DateTime? lastSyncedAt; // When profile was last refreshed from home
|
||||
|
||||
User({
|
||||
ChatUser({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.publicKey,
|
||||
@@ -68,8 +69,8 @@ class User {
|
||||
lastSyncedAt = lastSyncedAt;
|
||||
}
|
||||
|
||||
@Entity()
|
||||
class Channel {
|
||||
@Entity(uid: 2663861829934850300)
|
||||
class ChatChannel {
|
||||
@Id()
|
||||
int id = 0;
|
||||
|
||||
@@ -81,15 +82,15 @@ class Channel {
|
||||
@Property(type: PropertyType.date)
|
||||
DateTime createdAt;
|
||||
|
||||
Channel({
|
||||
ChatChannel({
|
||||
required this.name,
|
||||
this.description,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
}
|
||||
|
||||
@Entity()
|
||||
class Message {
|
||||
@Entity(uid: 2697867424319168785)
|
||||
class ChatMessage {
|
||||
@Id()
|
||||
int id = 0;
|
||||
|
||||
@@ -112,7 +113,7 @@ class Message {
|
||||
@Property(type: PropertyType.date)
|
||||
DateTime? editedAt;
|
||||
|
||||
Message({
|
||||
ChatMessage({
|
||||
required this.channelId,
|
||||
required this.authorLocalId,
|
||||
required this.authorUserId,
|
||||
|
||||
+14
-26
@@ -1,5 +1,3 @@
|
||||
import 'package:chat_server/models.dart';
|
||||
import 'package:chat_server/objectbox.g.dart';
|
||||
import 'package:hotreloader/hotreloader.dart';
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
@@ -9,13 +7,7 @@ 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;
|
||||
import 'src/state/app_state.dart';
|
||||
|
||||
RelicApp? _currentApp;
|
||||
|
||||
@@ -24,34 +16,34 @@ Future<void> startServer() async {
|
||||
|
||||
_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/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)
|
||||
|
||||
..get('/api/channels/:channelId', getChannelHandler)
|
||||
|
||||
// Messages
|
||||
..get('/api/messages', getMessagesHandler)
|
||||
..post('/api/messages', sendMessageHandler)
|
||||
..put('/api/messages/<messageId>', editMessageHandler)
|
||||
..delete('/api/messages/<messageId>', deleteMessageHandler)
|
||||
|
||||
..put('/api/messages/:messageId', editMessageHandler)
|
||||
..delete('/api/messages/:messageId', deleteMessageHandler)
|
||||
|
||||
// ============ LOGGING & FALLBACK ============
|
||||
..use('/', logRequests())
|
||||
..fallback = respondWith((_) => Response.notFound());
|
||||
@@ -68,11 +60,7 @@ Future<void> startServer() async {
|
||||
}
|
||||
|
||||
void main() async {
|
||||
// Initialize ObjectBox
|
||||
store = openStore();
|
||||
userBox = store.box<User>();
|
||||
channelBox = store.box<Channel>();
|
||||
messageBox = store.box<Message>();
|
||||
initializeStore();
|
||||
|
||||
// Enable hot reload in development
|
||||
await HotReloader.create(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../utils/extensions.dart';
|
||||
import 'crypto_service.dart';
|
||||
|
||||
final userIdProperty = ContextProperty<String>('userId');
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
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 'package:crypton/crypton.dart' as crypton;
|
||||
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart' as jwt;
|
||||
|
||||
import '../config/config.dart';
|
||||
import '../../config/config.dart';
|
||||
|
||||
class KeyPair {
|
||||
final String publicKey;
|
||||
@@ -31,7 +31,7 @@ class KeyPair {
|
||||
class CryptoService {
|
||||
/// Generate a new Ed25519 key pair
|
||||
static KeyPair generateKeyPair() {
|
||||
final keyPair = ECKeypair.fromRandom();
|
||||
final keyPair = crypton.ECKeypair.fromRandom();
|
||||
return KeyPair(
|
||||
publicKey: keyPair.publicKey.toString(),
|
||||
privateKey: keyPair.privateKey.toString(),
|
||||
@@ -40,17 +40,19 @@ class CryptoService {
|
||||
|
||||
/// 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();
|
||||
final privKey = crypton.ECPrivateKey.fromString(privateKey);
|
||||
final message = Uint8List.fromList(utf8.encode(data));
|
||||
final signature = privKey.createSHA256Signature(message);
|
||||
return base64Encode(signature);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
final pubKey = crypton.ECPublicKey.fromString(publicKey);
|
||||
final message = Uint8List.fromList(utf8.encode(data));
|
||||
final signatureBytes = Uint8List.fromList(base64Decode(signature));
|
||||
return pubKey.verifySHA256Signature(message, signatureBytes);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
@@ -118,6 +120,8 @@ class EncryptedKey {
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
static final _nonceRandom = Random.secure();
|
||||
|
||||
/// Generate JWT token for user
|
||||
static String generateToken(String userId, String username) {
|
||||
final payload = {
|
||||
@@ -125,9 +129,9 @@ class AuthService {
|
||||
'username': username,
|
||||
};
|
||||
|
||||
final jwt = JWT(payload);
|
||||
return jwt.sign(
|
||||
SecretKey(Config.jwtSecret),
|
||||
final token = jwt.JWT(payload);
|
||||
return token.sign(
|
||||
jwt.SecretKey(Config.jwtSecret),
|
||||
expiresIn: Config.tokenExpiry,
|
||||
);
|
||||
}
|
||||
@@ -135,8 +139,8 @@ class AuthService {
|
||||
/// 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);
|
||||
final verified = jwt.JWT.verify(token, jwt.SecretKey(Config.jwtSecret));
|
||||
return Map<String, dynamic>.from(verified.payload as Map);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
@@ -154,8 +158,8 @@ class AuthService {
|
||||
|
||||
/// 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);
|
||||
final bytes = List<int>.generate(32, (_) => _nonceRandom.nextInt(256));
|
||||
final hash = sha256.convert(bytes);
|
||||
return hash.toString().substring(0, 32);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,44 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chat_server/models.dart';
|
||||
import 'package:chat_server/objectbox.g.dart';
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../auth/auth_middleware.dart';
|
||||
import '../auth/crypto_service.dart';
|
||||
import '../state/app_state.dart';
|
||||
import '../utils/extensions.dart';
|
||||
|
||||
class RemoteIdentity {
|
||||
final String userId;
|
||||
final String username;
|
||||
final String publicKey;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
final String? bio;
|
||||
|
||||
RemoteIdentity({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.publicKey,
|
||||
this.displayName,
|
||||
this.avatarUrl,
|
||||
this.bio,
|
||||
});
|
||||
}
|
||||
|
||||
class ParsedUserId {
|
||||
final String username;
|
||||
final String host;
|
||||
|
||||
ParsedUserId({
|
||||
required this.username,
|
||||
required this.host,
|
||||
});
|
||||
}
|
||||
|
||||
/// Register on HOME server
|
||||
/// Creates user with keypair for identity
|
||||
Future<Response> registerHandler(Request req) async {
|
||||
@@ -25,8 +58,12 @@ Future<Response> registerHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
final query = userBox.query(User_.username.equals(username)).build();
|
||||
// Check if username exists on this home server
|
||||
final query = userBox
|
||||
.query(
|
||||
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
|
||||
)
|
||||
.build();
|
||||
final existing = query.findFirst();
|
||||
query.close();
|
||||
|
||||
@@ -51,7 +88,7 @@ Future<Response> registerHandler(Request req) async {
|
||||
// Create user with userId format: "username@this.server"
|
||||
final userId = '${username}@${Config.serverHost}';
|
||||
|
||||
final user = User(
|
||||
final user = ChatUser(
|
||||
userId: userId,
|
||||
username: username,
|
||||
publicKey: keyPair.publicKey,
|
||||
@@ -93,7 +130,11 @@ Future<Response> loginHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
final query = userBox.query(User_.username.equals(username)).build();
|
||||
final query = userBox
|
||||
.query(
|
||||
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
|
||||
)
|
||||
.build();
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
|
||||
@@ -106,7 +147,8 @@ Future<Response> loginHandler(Request req) async {
|
||||
// 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.'),
|
||||
body:
|
||||
Body.fromString('This is not your home server. Login there instead.'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,12 +185,33 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
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) {
|
||||
if (userId == null || timestamp == null || signature == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('userId, homeServer, timestamp, and signature required'),
|
||||
body: Body.fromString(
|
||||
'userId, timestamp, and signature required (homeServer optional)'),
|
||||
);
|
||||
}
|
||||
|
||||
final parsedUser = parseUserId(userId);
|
||||
if (parsedUser == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid userId format. Expected username@host'),
|
||||
);
|
||||
}
|
||||
|
||||
final resolvedHomeServer = normalizeHomeServer(homeServer ?? parsedUser.host);
|
||||
|
||||
if (resolvedHomeServer == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid homeServer URL'),
|
||||
);
|
||||
}
|
||||
|
||||
if (!homeServerMatchesUserIdHost(resolvedHomeServer, parsedUser.host)) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('homeServer does not match userId host'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,14 +223,54 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch current identity from home server
|
||||
final identity = await fetchIdentityFromHomeServer(
|
||||
resolvedHomeServer,
|
||||
parsedUser.username,
|
||||
);
|
||||
|
||||
if (identity == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Could not verify identity with home server'),
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure fetched identity matches what caller claims
|
||||
if (identity.userId != userId || identity.username != parsedUser.username) {
|
||||
return Response.forbidden(
|
||||
body: Body.fromString('Identity mismatch from home server'),
|
||||
);
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
// Signature is over: "{userId}:{timestamp}"
|
||||
final messageToVerify = '$userId:$timestamp';
|
||||
final isValid =
|
||||
CryptoService.verify(messageToVerify, signature, identity.publicKey);
|
||||
|
||||
if (!isValid) {
|
||||
return Response.forbidden(
|
||||
body: Body.fromString('Invalid signature'),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already exists on this server
|
||||
final existingQuery = userBox.query(User_.userId.equals(userId)).build();
|
||||
final existingQuery = userBox.query(ChatUser_.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);
|
||||
existingUser.username = identity.username;
|
||||
existingUser.displayName = identity.displayName;
|
||||
existingUser.avatarUrl = identity.avatarUrl;
|
||||
existingUser.bio = identity.bio;
|
||||
existingUser.publicKey = identity.publicKey;
|
||||
existingUser.homeServer = resolvedHomeServer;
|
||||
existingUser.lastSyncedAt = DateTime.now();
|
||||
userBox.put(existingUser);
|
||||
|
||||
final token =
|
||||
AuthService.generateToken(existingUser.userId, existingUser.username);
|
||||
userLocalIdProperty[req] = existingUser.id;
|
||||
|
||||
return Response.ok(
|
||||
@@ -181,34 +284,18 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
final user = ChatUser(
|
||||
userId: userId,
|
||||
username: username ?? userId.split('@').first,
|
||||
publicKey: publicKey,
|
||||
username: identity.username,
|
||||
displayName: identity.displayName,
|
||||
avatarUrl: identity.avatarUrl,
|
||||
bio: identity.bio,
|
||||
publicKey: identity.publicKey,
|
||||
isLocal: false,
|
||||
homeServer: homeServer,
|
||||
homeServer: resolvedHomeServer,
|
||||
roles: ['member'],
|
||||
lastSyncedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final localId = userBox.put(user);
|
||||
@@ -227,37 +314,130 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch public key from user's home server
|
||||
Future<String?> fetchPublicKeyFromHomeServer(
|
||||
/// Fetch public identity from user's home server
|
||||
Future<RemoteIdentity?> fetchIdentityFromHomeServer(
|
||||
String homeServer,
|
||||
String username,
|
||||
) async {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 5);
|
||||
|
||||
try {
|
||||
// Remove trailing slash
|
||||
final baseUrl = homeServer.replaceAll(RegExp(r'/$'), '');
|
||||
final endpoint = Uri.parse(
|
||||
'$baseUrl/.well-known/users/${Uri.encodeComponent(username)}');
|
||||
|
||||
// Use http client to fetch
|
||||
// Endpoint: GET /.well-known/users/{username}
|
||||
// Returns: { publicKey, userId, ... }
|
||||
final request = await client.getUrl(endpoint);
|
||||
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
|
||||
final response = await request.close();
|
||||
|
||||
// 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');
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// }
|
||||
final responseBody = await utf8.decodeStream(response);
|
||||
final dynamic decoded = jsonDecode(responseBody);
|
||||
if (decoded is! Map) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
final data = Map<String, dynamic>.from(decoded);
|
||||
final userId = data['userId'] as String?;
|
||||
final fetchedUsername = data['username'] as String?;
|
||||
final publicKey = data['publicKey'] as String?;
|
||||
|
||||
if (userId == null || fetchedUsername == null || publicKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RemoteIdentity(
|
||||
userId: userId,
|
||||
username: fetchedUsername,
|
||||
publicKey: publicKey,
|
||||
displayName: data['displayName'] as String?,
|
||||
avatarUrl: data['avatarUrl'] as String?,
|
||||
bio: data['bio'] as String?,
|
||||
);
|
||||
} catch (e) {
|
||||
print('Error fetching public key: $e');
|
||||
return null;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
ParsedUserId? parseUserId(String userId) {
|
||||
final parts = userId.split('@');
|
||||
if (parts.length != 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final username = parts[0].trim();
|
||||
final host = parts[1].trim();
|
||||
|
||||
if (username.isEmpty || host.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParsedUserId(username: username, host: host);
|
||||
}
|
||||
|
||||
String? normalizeHomeServer(String homeServerOrHost) {
|
||||
final trimmed = homeServerOrHost.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final hasScheme = trimmed.contains('://');
|
||||
final candidate =
|
||||
hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed';
|
||||
final uri = Uri.tryParse(candidate);
|
||||
|
||||
if (uri == null || uri.host.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri
|
||||
.replace(path: '', query: null, fragment: null)
|
||||
.toString()
|
||||
.replaceAll(RegExp(r'/$'), '');
|
||||
}
|
||||
|
||||
String defaultSchemeForHost(String host) {
|
||||
final lowerHost = host.toLowerCase();
|
||||
if (lowerHost.startsWith('localhost') ||
|
||||
lowerHost.startsWith('127.') ||
|
||||
lowerHost.startsWith('[::1]')) {
|
||||
return 'http';
|
||||
}
|
||||
|
||||
return 'https';
|
||||
}
|
||||
|
||||
bool homeServerMatchesUserIdHost(String homeServerUrl, String userIdHost) {
|
||||
final uri = Uri.tryParse(homeServerUrl);
|
||||
if (uri == null || uri.host.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final normalizedUserIdHost = userIdHost.trim().toLowerCase();
|
||||
final urlHost = uri.host.toLowerCase();
|
||||
final urlHostWithPort = uri.hasPort ? '$urlHost:${uri.port}' : urlHost;
|
||||
|
||||
if (normalizedUserIdHost == urlHostWithPort) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!uri.hasPort) {
|
||||
final defaultPort = uri.scheme == 'http' ? 80 : 443;
|
||||
if (normalizedUserIdHost == '$urlHost:$defaultPort') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get current user's profile
|
||||
Future<Response> getMeHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
@@ -268,7 +448,7 @@ Future<Response> getMeHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
final query = userBox.query(User_.userId.equals(userId)).build();
|
||||
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:chat_server/models.dart';
|
||||
import 'package:chat_server/objectbox.g.dart';
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
import '../state/app_state.dart';
|
||||
import '../utils/extensions.dart';
|
||||
|
||||
/// Get all channels
|
||||
@@ -25,7 +26,8 @@ Future<Response> getChannelsHandler(Request req) async {
|
||||
/// 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 name =
|
||||
(body['name'] as String?)?.trim().toLowerCase().replaceAll(' ', '-');
|
||||
final description = body['description'] as String?;
|
||||
|
||||
if (name == null || name.isEmpty) {
|
||||
@@ -35,14 +37,15 @@ Future<Response> createChannelHandler(Request req) async {
|
||||
}
|
||||
|
||||
// Check if channel exists
|
||||
final existing = channelBox.query(Channel_.name.equals(name)).build().findFirst();
|
||||
final existing =
|
||||
channelBox.query(ChatChannel_.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 channel = ChatChannel(name: name, description: description);
|
||||
final id = channelBox.put(channel);
|
||||
|
||||
return Response.ok(
|
||||
@@ -56,7 +59,14 @@ Future<Response> createChannelHandler(Request req) async {
|
||||
}
|
||||
|
||||
/// Get channel by ID
|
||||
Future<Response> getChannelHandler(Request req, String channelIdStr) async {
|
||||
Future<Response> getChannelHandler(Request req) async {
|
||||
final channelIdStr = req.rawPathParameters[#channelId];
|
||||
if (channelIdStr == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid channel ID'),
|
||||
);
|
||||
}
|
||||
|
||||
final channelId = int.tryParse(channelIdStr);
|
||||
if (channelId == null) {
|
||||
return Response.badRequest(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:chat_server/objectbox.g.dart';
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
import '../auth/auth_middleware.dart';
|
||||
import '../state/app_state.dart';
|
||||
import '../utils/extensions.dart';
|
||||
|
||||
/// Get messages for a channel
|
||||
@@ -36,11 +37,12 @@ Future<Response> getMessagesHandler(Request req) async {
|
||||
|
||||
// Get messages
|
||||
final query = messageBox
|
||||
.query(Message_.channelId.equals(channelId))
|
||||
.order(Message_.createdAt, flags: Order.descending)
|
||||
.query(ChatMessage_.channelId.equals(channelId))
|
||||
.order(ChatMessage_.createdAt, flags: Order.descending)
|
||||
.build();
|
||||
|
||||
final messages = query.find(limit: limit);
|
||||
query.limit = limit;
|
||||
final messages = query.find();
|
||||
query.close();
|
||||
|
||||
return Response.ok(
|
||||
@@ -99,7 +101,7 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
}
|
||||
|
||||
// Get user info for caching
|
||||
final userQuery = userBox.query(User_.userId.equals(userId)).build();
|
||||
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = userQuery.findFirst();
|
||||
userQuery.close();
|
||||
|
||||
@@ -110,7 +112,7 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
}
|
||||
|
||||
// Create message with cached author info
|
||||
final message = Message(
|
||||
final message = ChatMessage(
|
||||
channelId: channelId,
|
||||
authorLocalId: user.id,
|
||||
authorUserId: user.userId,
|
||||
@@ -136,7 +138,7 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
}
|
||||
|
||||
/// Edit a message
|
||||
Future<Response> editMessageHandler(Request req, String messageIdStr) async {
|
||||
Future<Response> editMessageHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
|
||||
if (userId == null) {
|
||||
@@ -145,6 +147,13 @@ Future<Response> editMessageHandler(Request req, String messageIdStr) async {
|
||||
);
|
||||
}
|
||||
|
||||
final messageIdStr = req.rawPathParameters[#messageId];
|
||||
if (messageIdStr == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid message ID'),
|
||||
);
|
||||
}
|
||||
|
||||
final messageId = int.tryParse(messageIdStr);
|
||||
if (messageId == null) {
|
||||
return Response.badRequest(
|
||||
@@ -160,7 +169,7 @@ Future<Response> editMessageHandler(Request req, String messageIdStr) async {
|
||||
}
|
||||
|
||||
// Get user to verify ownership
|
||||
final userQuery = userBox.query(User_.userId.equals(userId)).build();
|
||||
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = userQuery.findFirst();
|
||||
userQuery.close();
|
||||
|
||||
@@ -193,7 +202,7 @@ Future<Response> editMessageHandler(Request req, String messageIdStr) async {
|
||||
}
|
||||
|
||||
/// Delete a message
|
||||
Future<Response> deleteMessageHandler(Request req, String messageIdStr) async {
|
||||
Future<Response> deleteMessageHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
|
||||
if (userId == null) {
|
||||
@@ -202,6 +211,13 @@ Future<Response> deleteMessageHandler(Request req, String messageIdStr) async {
|
||||
);
|
||||
}
|
||||
|
||||
final messageIdStr = req.rawPathParameters[#messageId];
|
||||
if (messageIdStr == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid message ID'),
|
||||
);
|
||||
}
|
||||
|
||||
final messageId = int.tryParse(messageIdStr);
|
||||
if (messageId == null) {
|
||||
return Response.badRequest(
|
||||
@@ -217,7 +233,7 @@ Future<Response> deleteMessageHandler(Request req, String messageIdStr) async {
|
||||
}
|
||||
|
||||
// Get user to verify ownership
|
||||
final userQuery = userBox.query(User_.userId.equals(userId)).build();
|
||||
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = userQuery.findFirst();
|
||||
userQuery.close();
|
||||
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import 'package:chat_server/models.dart';
|
||||
import 'package:chat_server/objectbox.g.dart';
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../state/app_state.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();
|
||||
Future<Response> publicUserHandler(Request req) async {
|
||||
final username = req.rawPathParameters[#username];
|
||||
if (username == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Username required'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = userBox
|
||||
.query(
|
||||
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
|
||||
)
|
||||
.build();
|
||||
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
@@ -39,13 +49,22 @@ Future<Response> publicUserHandler(Request req, String username) async {
|
||||
|
||||
/// 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();
|
||||
Future<Response> publicVerifyHandler(Request req) async {
|
||||
final userId = req.rawPathParameters[#userId];
|
||||
if (userId == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('userId required'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = userBox
|
||||
.query(
|
||||
ChatUser_.userId.equals(userId) & ChatUser_.isLocal.equals(true),
|
||||
)
|
||||
.build();
|
||||
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
@@ -70,7 +89,7 @@ Future<Response> publicVerifyHandler(Request req, String userId) async {
|
||||
}
|
||||
|
||||
/// Public server info
|
||||
///
|
||||
///
|
||||
/// GET /.well-known/server
|
||||
/// Returns: { serverHost, serverName, version }
|
||||
Future<Response> publicServerInfoHandler(Request req) async {
|
||||
|
||||
@@ -2,8 +2,6 @@ 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 {
|
||||
|
||||
Reference in new issue
Block a user