488 lines
12 KiB
Dart
488 lines
12 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:chat_server/models.dart';
|
|
import 'package:chat_server/objectbox.g.dart';
|
|
import 'package:relic/relic.dart';
|
|
|
|
import '../auth/auth_middleware.dart';
|
|
import '../gateway/gateway_handlers.dart';
|
|
import '../state/app_state.dart';
|
|
import '../utils/extensions.dart';
|
|
|
|
const _defaultMessageLimit = 50;
|
|
const _maxMessageLimit = 100;
|
|
|
|
/// Get messages for a channel
|
|
/// Query params: channelId (required), limit, before, after
|
|
Future<Response> getMessagesHandler(Request req) async {
|
|
final channelIdStr = req.url.queryParameters['channelId'];
|
|
final limitStr = req.url.queryParameters['limit'];
|
|
final beforeStr = req.url.queryParameters['before'];
|
|
final afterStr = req.url.queryParameters['after'];
|
|
|
|
if (channelIdStr == null) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('channelId required'),
|
|
);
|
|
}
|
|
|
|
if (beforeStr != null && afterStr != null) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Use either `before` or `after`, not both'),
|
|
);
|
|
}
|
|
|
|
final channelId = int.tryParse(channelIdStr);
|
|
final parsedLimit =
|
|
limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr);
|
|
int? before;
|
|
int? after;
|
|
|
|
if (channelId == null) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Invalid channelId'),
|
|
);
|
|
}
|
|
|
|
if (parsedLimit == null || parsedLimit <= 0) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Invalid limit'),
|
|
);
|
|
}
|
|
|
|
final limit = parsedLimit > _maxMessageLimit ? _maxMessageLimit : parsedLimit;
|
|
|
|
if (beforeStr != null) {
|
|
before = int.tryParse(beforeStr);
|
|
if (before == null || before <= 0) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Invalid `before` cursor'),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (afterStr != null) {
|
|
after = int.tryParse(afterStr);
|
|
if (after == null || after <= 0) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Invalid `after` cursor'),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check channel exists
|
|
final channel = channelBox.get(channelId);
|
|
if (channel == null) {
|
|
return Response.notFound(
|
|
body: Body.fromString('Channel not found'),
|
|
);
|
|
}
|
|
|
|
var condition = ChatMessage_.channelId.equals(channelId);
|
|
if (before != null) {
|
|
condition = condition & ChatMessage_.id.lessThan(before);
|
|
} else if (after != null) {
|
|
condition = condition & ChatMessage_.id.greaterThan(after);
|
|
}
|
|
|
|
final queryBuilder = messageBox.query(condition);
|
|
if (after != null) {
|
|
queryBuilder.order(ChatMessage_.id);
|
|
} else {
|
|
queryBuilder.order(ChatMessage_.id, flags: Order.descending);
|
|
}
|
|
|
|
final query = queryBuilder.build();
|
|
query.limit = limit + 1;
|
|
|
|
final fetchedMessages = query.find();
|
|
query.close();
|
|
|
|
final hasMore = fetchedMessages.length > limit;
|
|
final messages =
|
|
hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages;
|
|
|
|
int? nextBefore;
|
|
int? nextAfter;
|
|
if (messages.isNotEmpty) {
|
|
if (after != null) {
|
|
nextBefore = messages.first.id;
|
|
nextAfter = messages.last.id;
|
|
} else {
|
|
nextBefore = messages.last.id;
|
|
nextAfter = messages.first.id;
|
|
}
|
|
}
|
|
|
|
return Response.ok(
|
|
body: {
|
|
'messages': messages.map(_messageToMap).toList(),
|
|
'channelId': channelId,
|
|
'limit': limit,
|
|
'hasMore': hasMore,
|
|
'nextBefore': nextBefore,
|
|
'nextAfter': nextAfter,
|
|
}.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 channelId = body['channelId'] as int?;
|
|
final components = _parseComponents(body);
|
|
final contentOverride = (body['content'] as String?)?.trim();
|
|
|
|
if (components == null) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('content or components/componentsJson 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(ChatUser_.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 = ChatMessage(
|
|
channelId: channelId,
|
|
authorLocalId: user.id,
|
|
authorUserId: user.userId,
|
|
authorUsername: user.username,
|
|
authorAvatarUrl: user.avatarUrl,
|
|
authorHomeServer: user.homeServer,
|
|
content: _buildSearchContent(components, contentOverride),
|
|
componentsJson: jsonEncode(components),
|
|
);
|
|
|
|
final id = messageBox.put(message);
|
|
message.id = id;
|
|
_attachReferencedFiles(components);
|
|
|
|
broadcastMessageCreated(
|
|
channelId: channelId,
|
|
message: _messageToMap(message),
|
|
);
|
|
|
|
return Response.ok(
|
|
body: {
|
|
'id': id,
|
|
'content': message.content,
|
|
'componentsJson': message.componentsJson,
|
|
'authorId': user.userId,
|
|
'authorUsername': user.username,
|
|
'authorAvatarUrl': user.avatarUrl,
|
|
'channelId': channelId,
|
|
'createdAt': message.createdAt.toIso8601String(),
|
|
}.toBody,
|
|
);
|
|
}
|
|
|
|
/// Edit a message
|
|
Future<Response> editMessageHandler(Request req) async {
|
|
final userId = userIdProperty[req];
|
|
|
|
if (userId == null) {
|
|
return Response.unauthorized(
|
|
body: Body.fromString('Not authenticated'),
|
|
);
|
|
}
|
|
|
|
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(
|
|
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(ChatUser_.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 components = _parseComponents(body);
|
|
final contentOverride = (body['content'] as String?)?.trim();
|
|
|
|
if (components == null) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('content or components/componentsJson required'),
|
|
);
|
|
}
|
|
|
|
message.content = _buildSearchContent(components, contentOverride);
|
|
message.componentsJson = jsonEncode(components);
|
|
message.editedAt = DateTime.now();
|
|
messageBox.put(message);
|
|
_attachReferencedFiles(components);
|
|
|
|
broadcastMessageUpdated(
|
|
channelId: message.channelId,
|
|
message: _messageToMap(message),
|
|
);
|
|
|
|
return Response.ok(
|
|
body: {
|
|
'id': message.id,
|
|
'content': message.content,
|
|
'componentsJson': message.componentsJson,
|
|
'editedAt': message.editedAt?.toIso8601String(),
|
|
}.toBody,
|
|
);
|
|
}
|
|
|
|
/// Delete a message
|
|
Future<Response> deleteMessageHandler(Request req) async {
|
|
final userId = userIdProperty[req];
|
|
|
|
if (userId == null) {
|
|
return Response.unauthorized(
|
|
body: Body.fromString('Not authenticated'),
|
|
);
|
|
}
|
|
|
|
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(
|
|
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(ChatUser_.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'),
|
|
);
|
|
}
|
|
|
|
final channelId = message.channelId;
|
|
messageBox.remove(messageId);
|
|
|
|
broadcastMessageDeleted(
|
|
channelId: channelId,
|
|
messageId: messageId,
|
|
);
|
|
|
|
return Response.ok(
|
|
body: {
|
|
'deleted': true,
|
|
'id': messageId,
|
|
}.toBody,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> _messageToMap(ChatMessage message) {
|
|
return {
|
|
'id': message.id,
|
|
'content': message.content,
|
|
'componentsJson': message.componentsJson,
|
|
'authorId': message.authorUserId,
|
|
'authorUsername': message.authorUsername,
|
|
'authorAvatarUrl': message.authorAvatarUrl,
|
|
'authorHomeServer': message.authorHomeServer,
|
|
'channelId': message.channelId,
|
|
'createdAt': message.createdAt.toIso8601String(),
|
|
'editedAt': message.editedAt?.toIso8601String(),
|
|
};
|
|
}
|
|
|
|
List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
|
|
final rawComponents = body['components'];
|
|
if (rawComponents is List) {
|
|
final components = <Map<String, dynamic>>[];
|
|
for (final component in rawComponents) {
|
|
if (component is Map<String, dynamic>) {
|
|
components.add(component);
|
|
} else if (component is Map) {
|
|
components.add(component.map((k, v) => MapEntry(k.toString(), v)));
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return components;
|
|
}
|
|
|
|
final rawComponentsJson = body['componentsJson'];
|
|
if (rawComponentsJson is String && rawComponentsJson.trim().isNotEmpty) {
|
|
dynamic decoded;
|
|
try {
|
|
decoded = jsonDecode(rawComponentsJson);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
if (decoded is! List) {
|
|
return null;
|
|
}
|
|
final components = <Map<String, dynamic>>[];
|
|
for (final component in decoded) {
|
|
if (component is Map<String, dynamic>) {
|
|
components.add(component);
|
|
} else if (component is Map) {
|
|
components.add(component.map((k, v) => MapEntry(k.toString(), v)));
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return components;
|
|
}
|
|
|
|
final content = (body['content'] as String?)?.trim();
|
|
if (content == null || content.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
return <Map<String, dynamic>>[
|
|
{
|
|
'type': 'text',
|
|
'modifiers': <String>[],
|
|
'fallback': content,
|
|
'data': {
|
|
'text': content,
|
|
},
|
|
}
|
|
];
|
|
}
|
|
|
|
String _buildSearchContent(
|
|
List<Map<String, dynamic>> components,
|
|
String? contentOverride,
|
|
) {
|
|
if (contentOverride != null && contentOverride.isNotEmpty) {
|
|
return contentOverride;
|
|
}
|
|
|
|
final parts = <String>[];
|
|
for (final component in components) {
|
|
final fallback = component['fallback'];
|
|
if (fallback is String && fallback.trim().isNotEmpty) {
|
|
parts.add(fallback.trim());
|
|
}
|
|
|
|
final data = component['data'];
|
|
if (data is! Map) {
|
|
continue;
|
|
}
|
|
|
|
void addDataString(String key) {
|
|
final value = data[key];
|
|
if (value is String && value.trim().isNotEmpty) {
|
|
parts.add(value.trim());
|
|
}
|
|
}
|
|
|
|
addDataString('text');
|
|
addDataString('title');
|
|
addDataString('description');
|
|
addDataString('caption');
|
|
}
|
|
|
|
final content = parts.join(' ').trim();
|
|
return content.isEmpty ? '[component]' : content;
|
|
}
|
|
|
|
void _attachReferencedFiles(List<Map<String, dynamic>> components) {
|
|
final referencedFileIds = <int>{};
|
|
for (final component in components) {
|
|
final data = component['data'];
|
|
if (data is! Map) {
|
|
continue;
|
|
}
|
|
|
|
final items = data['items'];
|
|
if (items is! List) {
|
|
continue;
|
|
}
|
|
|
|
for (final item in items) {
|
|
if (item is! Map) {
|
|
continue;
|
|
}
|
|
final fileId = item['fileId'];
|
|
final parsedId = switch (fileId) {
|
|
int id when id > 0 => id,
|
|
String value => int.tryParse(value),
|
|
_ => null,
|
|
};
|
|
if (parsedId != null && parsedId > 0) {
|
|
referencedFileIds.add(parsedId);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (final fileId in referencedFileIds) {
|
|
final file = fileBox.get(fileId);
|
|
if (file == null || file.isAttached) {
|
|
continue;
|
|
}
|
|
file.isAttached = true;
|
|
fileBox.put(file);
|
|
}
|
|
}
|