Implement components + uploads flow and gateway-aligned payloads

This commit is contained in:
Foxyz committed 2026-02-20 17:41:08 +03:00
1 parent 458be6f991
commit a93d619b4c
7 files changed
+440 -11

No files matched your search

+1 -1
View File
@@ -44,4 +44,4 @@ lib/
- **Framework**: Relic 1.0 - **Framework**: Relic 1.0
- **Database**: ObjectBox - **Database**: ObjectBox
- **Auth**: JWT + Ed25519 signatures - **Auth**: JWT + ECDSA signatures
+31 -1
View File
@@ -28,7 +28,7 @@ class ChatUser {
String? bio; String? bio;
// Identity // Identity
String publicKey; // Ed25519 public key, always stored String publicKey; // ECDSA public key, always stored
// HOME SERVER ONLY: Auth & key backup // HOME SERVER ONLY: Auth & key backup
String? passwordHash; String? passwordHash;
@@ -106,6 +106,7 @@ class ChatMessage {
String? authorAvatarUrl; String? authorAvatarUrl;
String content; String content;
String componentsJson;
@Property(type: PropertyType.date) @Property(type: PropertyType.date)
DateTime createdAt; DateTime createdAt;
@@ -119,9 +120,38 @@ class ChatMessage {
required this.authorUserId, required this.authorUserId,
required this.authorUsername, required this.authorUsername,
required this.content, required this.content,
required this.componentsJson,
this.authorHomeServer, this.authorHomeServer,
this.authorAvatarUrl, this.authorAvatarUrl,
DateTime? createdAt, DateTime? createdAt,
this.editedAt, this.editedAt,
}) : createdAt = createdAt ?? DateTime.now(); }) : createdAt = createdAt ?? DateTime.now();
} }
@Entity()
class ChatFile {
@Id()
int id = 0;
int uploaderId;
String path;
String mimeType;
int width;
int height;
int sizeBytes;
bool isAttached;
@Property(type: PropertyType.date)
DateTime createdAt;
ChatFile({
required this.uploaderId,
required this.path,
required this.mimeType,
required this.width,
required this.height,
required this.sizeBytes,
this.isAttached = false,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
}
+3
View File
@@ -8,6 +8,7 @@ import 'src/handlers/auth_handlers.dart';
import 'src/handlers/channel_handlers.dart'; import 'src/handlers/channel_handlers.dart';
import 'src/handlers/message_handlers.dart'; import 'src/handlers/message_handlers.dart';
import 'src/handlers/public_handlers.dart'; import 'src/handlers/public_handlers.dart';
import 'src/handlers/upload_handlers.dart';
import 'src/state/app_state.dart'; import 'src/state/app_state.dart';
RelicApp? _currentApp; RelicApp? _currentApp;
@@ -27,6 +28,7 @@ Future<void> startServer() async {
..get('/.well-known/users/:username', publicUserHandler) ..get('/.well-known/users/:username', publicUserHandler)
..get('/.well-known/verify/:userId', publicVerifyHandler) ..get('/.well-known/verify/:userId', publicVerifyHandler)
..get('/.well-known/server', publicServerInfoHandler) ..get('/.well-known/server', publicServerInfoHandler)
..get('/uploads/:fileName', getUploadFileHandler)
..get('/gateway', gatewayHandler) ..get('/gateway', gatewayHandler)
// ============ PROTECTED ENDPOINTS (requires auth) ============ // ============ PROTECTED ENDPOINTS (requires auth) ============
@@ -42,6 +44,7 @@ Future<void> startServer() async {
// Messages // Messages
..get('/api/messages', getMessagesHandler) ..get('/api/messages', getMessagesHandler)
..post('/api/uploads', uploadFileHandler)
..post('/api/messages', sendMessageHandler) ..post('/api/messages', sendMessageHandler)
..put('/api/messages/:messageId', editMessageHandler) ..put('/api/messages/:messageId', editMessageHandler)
..delete('/api/messages/:messageId', deleteMessageHandler) ..delete('/api/messages/:messageId', deleteMessageHandler)
+1 -1
View File
@@ -29,7 +29,7 @@ class KeyPair {
} }
class CryptoService { class CryptoService {
/// Generate a new Ed25519 key pair /// Generate a new ECDSA key pair
static KeyPair generateKeyPair() { static KeyPair generateKeyPair() {
final keyPair = crypton.ECKeypair.fromRandom(); final keyPair = crypton.ECKeypair.fromRandom();
return KeyPair( return KeyPair(
+152 -8
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:chat_server/models.dart'; import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart'; import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
@@ -135,12 +137,13 @@ Future<Response> sendMessageHandler(Request req) async {
} }
final body = await req.asJson(); final body = await req.asJson();
final content = body['content'] as String?;
final channelId = body['channelId'] as int?; final channelId = body['channelId'] as int?;
final components = _parseComponents(body);
final contentOverride = (body['content'] as String?)?.trim();
if (content == null || content.trim().isEmpty) { if (components == null) {
return Response.badRequest( return Response.badRequest(
body: Body.fromString('Content required'), body: Body.fromString('content or components/componentsJson required'),
); );
} }
@@ -177,11 +180,13 @@ Future<Response> sendMessageHandler(Request req) async {
authorUsername: user.username, authorUsername: user.username,
authorAvatarUrl: user.avatarUrl, authorAvatarUrl: user.avatarUrl,
authorHomeServer: user.homeServer, authorHomeServer: user.homeServer,
content: content.trim(), content: _buildSearchContent(components, contentOverride),
componentsJson: jsonEncode(components),
); );
final id = messageBox.put(message); final id = messageBox.put(message);
message.id = id; message.id = id;
_attachReferencedFiles(components);
broadcastMessageCreated( broadcastMessageCreated(
channelId: channelId, channelId: channelId,
@@ -192,6 +197,7 @@ Future<Response> sendMessageHandler(Request req) async {
body: { body: {
'id': id, 'id': id,
'content': message.content, 'content': message.content,
'componentsJson': message.componentsJson,
'authorId': user.userId, 'authorId': user.userId,
'authorUsername': user.username, 'authorUsername': user.username,
'authorAvatarUrl': user.avatarUrl, 'authorAvatarUrl': user.avatarUrl,
@@ -244,17 +250,20 @@ Future<Response> editMessageHandler(Request req) async {
} }
final body = await req.asJson(); final body = await req.asJson();
final content = body['content'] as String?; final components = _parseComponents(body);
final contentOverride = (body['content'] as String?)?.trim();
if (content == null || content.trim().isEmpty) { if (components == null) {
return Response.badRequest( return Response.badRequest(
body: Body.fromString('Content required'), body: Body.fromString('content or components/componentsJson required'),
); );
} }
message.content = content.trim(); message.content = _buildSearchContent(components, contentOverride);
message.componentsJson = jsonEncode(components);
message.editedAt = DateTime.now(); message.editedAt = DateTime.now();
messageBox.put(message); messageBox.put(message);
_attachReferencedFiles(components);
broadcastMessageUpdated( broadcastMessageUpdated(
channelId: message.channelId, channelId: message.channelId,
@@ -265,6 +274,7 @@ Future<Response> editMessageHandler(Request req) async {
body: { body: {
'id': message.id, 'id': message.id,
'content': message.content, 'content': message.content,
'componentsJson': message.componentsJson,
'editedAt': message.editedAt?.toIso8601String(), 'editedAt': message.editedAt?.toIso8601String(),
}.toBody, }.toBody,
); );
@@ -332,6 +342,7 @@ Map<String, dynamic> _messageToMap(ChatMessage message) {
return { return {
'id': message.id, 'id': message.id,
'content': message.content, 'content': message.content,
'componentsJson': message.componentsJson,
'authorId': message.authorUserId, 'authorId': message.authorUserId,
'authorUsername': message.authorUsername, 'authorUsername': message.authorUsername,
'authorAvatarUrl': message.authorAvatarUrl, 'authorAvatarUrl': message.authorAvatarUrl,
@@ -341,3 +352,136 @@ Map<String, dynamic> _messageToMap(ChatMessage message) {
'editedAt': message.editedAt?.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);
}
}
+250
View File
@@ -0,0 +1,250 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:mime/mime.dart';
import 'package:relic/relic.dart';
import '../auth/auth_middleware.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart';
const _uploadsDir = 'uploads';
const _maxUploadBytes = 25 * 1024 * 1024;
Future<Response> uploadFileHandler(Request req) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
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'),
);
}
final contentTypeHeader = req.headers[Headers.contentTypeHeader]?.first;
final boundary = _extractBoundary(contentTypeHeader);
if (boundary == null) {
return Response.badRequest(
body: Body.fromString('Expected multipart/form-data with boundary'),
);
}
final multipartParts = await req
.read(maxLength: _maxUploadBytes)
.cast<List<int>>()
.transform(MimeMultipartTransformer(boundary))
.toList();
if (multipartParts.isEmpty) {
return Response.badRequest(
body: Body.fromString('No files in request'),
);
}
final uploadPart = multipartParts.firstWhere(
(part) => (part.headers['content-disposition'] ?? '').contains('filename='),
orElse: () => multipartParts.first,
);
final bytesBuilder = BytesBuilder(copy: false);
await for (final chunk in uploadPart) {
bytesBuilder.add(chunk);
}
final bytes = bytesBuilder.toBytes();
if (bytes.isEmpty) {
return Response.badRequest(
body: Body.fromString('Uploaded file is empty'),
);
}
final contentDisposition = uploadPart.headers['content-disposition'] ?? '';
final fileName = _extractFilename(contentDisposition);
final mimeType =
(uploadPart.headers['content-type'] ?? 'application/octet-stream').trim();
final ext = _resolveExtension(fileName, mimeType);
final dimensions = _extractImageDimensions(bytes, mimeType);
final entity = ChatFile(
uploaderId: user.id,
path: '/uploads/pending$ext',
mimeType: mimeType,
width: dimensions.$1,
height: dimensions.$2,
sizeBytes: bytes.length,
);
final id = fileBox.put(entity);
final directory = Directory(_uploadsDir);
if (!directory.existsSync()) {
directory.createSync(recursive: true);
}
final storedName = '$id$ext';
final diskPath = '${directory.path}${Platform.pathSeparator}$storedName';
File(diskPath).writeAsBytesSync(bytes, flush: true);
entity.id = id;
entity.path = '/uploads/$storedName';
fileBox.put(entity);
return Response.ok(
body: {
'id': id,
'url': entity.path,
'width': entity.width,
'height': entity.height,
'mimeType': entity.mimeType,
}.toBody,
);
}
Future<Response> getUploadFileHandler(Request req) async {
final fileName = req.rawPathParameters[#fileName];
if (fileName == null || fileName.isEmpty || fileName.contains('..')) {
return Response.badRequest(
body: Body.fromString('Invalid file name'),
);
}
final filePath = '$_uploadsDir${Platform.pathSeparator}$fileName';
final file = File(filePath);
if (!file.existsSync()) {
return Response.notFound(
body: Body.fromString('File not found'),
);
}
final data = file.readAsBytesSync();
return Response.ok(
body: Body.fromData(data),
);
}
String? _extractBoundary(String? contentTypeHeader) {
if (contentTypeHeader == null) {
return null;
}
if (!contentTypeHeader.toLowerCase().startsWith('multipart/form-data')) {
return null;
}
final match = RegExp(
r'boundary="?([^\";]+)"?',
caseSensitive: false,
).firstMatch(contentTypeHeader);
return match?.group(1);
}
String? _extractFilename(String contentDisposition) {
final match = RegExp(
r'filename="([^"]+)"',
caseSensitive: false,
).firstMatch(contentDisposition);
if (match == null) {
return null;
}
final decoded = Uri.decodeFull(match.group(1)!);
return decoded.split(RegExp(r'[\\/]')).last;
}
String _resolveExtension(String? fileName, String mimeType) {
final lowerName = fileName?.toLowerCase();
if (lowerName != null) {
final dotIndex = lowerName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < lowerName.length - 1) {
return lowerName.substring(dotIndex);
}
}
return switch (mimeType.toLowerCase()) {
'image/png' => '.png',
'image/jpeg' || 'image/jpg' => '.jpg',
'image/gif' => '.gif',
'image/webp' => '.webp',
'video/mp4' => '.mp4',
_ => '.bin',
};
}
(int, int) _extractImageDimensions(Uint8List bytes, String mimeType) {
final lowerMime = mimeType.toLowerCase();
if (lowerMime == 'image/png' && bytes.length >= 24) {
final width = _readUint32BigEndian(bytes, 16);
final height = _readUint32BigEndian(bytes, 20);
return (width, height);
}
if ((lowerMime == 'image/jpeg' || lowerMime == 'image/jpg') &&
bytes.length > 4) {
final jpeg = _extractJpegDimensions(bytes);
if (jpeg != null) {
return jpeg;
}
}
if (lowerMime == 'image/gif' && bytes.length >= 10) {
final width = bytes[6] | (bytes[7] << 8);
final height = bytes[8] | (bytes[9] << 8);
return (width, height);
}
return (0, 0);
}
int _readUint32BigEndian(Uint8List bytes, int offset) {
return (bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3];
}
(int, int)? _extractJpegDimensions(Uint8List bytes) {
if (bytes[0] != 0xFF || bytes[1] != 0xD8) {
return null;
}
var offset = 2;
while (offset + 9 < bytes.length) {
if (bytes[offset] != 0xFF) {
offset++;
continue;
}
final marker = bytes[offset + 1];
final hasSize = marker != 0xD8 && marker != 0xD9;
if (!hasSize) {
offset += 2;
continue;
}
if (offset + 4 >= bytes.length) {
return null;
}
final length = (bytes[offset + 2] << 8) | bytes[offset + 3];
if (length < 2 || offset + 2 + length > bytes.length) {
return null;
}
final isSofMarker = (marker >= 0xC0 && marker <= 0xC3) ||
(marker >= 0xC5 && marker <= 0xC7) ||
(marker >= 0xC9 && marker <= 0xCB) ||
(marker >= 0xCD && marker <= 0xCF);
if (isSofMarker && offset + 9 < bytes.length) {
final height = (bytes[offset + 5] << 8) | bytes[offset + 6];
final width = (bytes[offset + 7] << 8) | bytes[offset + 8];
return (width, height);
}
offset += 2 + length;
}
return null;
}
+2
View File
@@ -5,10 +5,12 @@ late final Store store;
late final Box<ChatUser> userBox; late final Box<ChatUser> userBox;
late final Box<ChatChannel> channelBox; late final Box<ChatChannel> channelBox;
late final Box<ChatMessage> messageBox; late final Box<ChatMessage> messageBox;
late final Box<ChatFile> fileBox;
void initializeStore() { void initializeStore() {
store = openStore(); store = openStore();
userBox = store.box<ChatUser>(); userBox = store.box<ChatUser>();
channelBox = store.box<ChatChannel>(); channelBox = store.box<ChatChannel>();
messageBox = store.box<ChatMessage>(); messageBox = store.box<ChatMessage>();
fileBox = store.box<ChatFile>();
} }