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
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user