From a93d619b4cd0baa213b08567a4bfd8c55c6b69f7 Mon Sep 17 00:00:00 2001 From: Foxyz Date: Fri, 20 Feb 2026 17:41:08 +0300 Subject: [PATCH] Implement components + uploads flow and gateway-aligned payloads --- README.md | 2 +- lib/models.dart | 32 +++- lib/server.dart | 3 + lib/src/auth/crypto_service.dart | 2 +- lib/src/handlers/message_handlers.dart | 160 +++++++++++++++- lib/src/handlers/upload_handlers.dart | 250 +++++++++++++++++++++++++ lib/src/state/app_state.dart | 2 + 7 files changed, 440 insertions(+), 11 deletions(-) create mode 100644 lib/src/handlers/upload_handlers.dart diff --git a/README.md b/README.md index 6f72bc7..4ef35d0 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,4 @@ lib/ - **Framework**: Relic 1.0 - **Database**: ObjectBox -- **Auth**: JWT + Ed25519 signatures +- **Auth**: JWT + ECDSA signatures diff --git a/lib/models.dart b/lib/models.dart index 5a6a701..de61940 100644 --- a/lib/models.dart +++ b/lib/models.dart @@ -28,7 +28,7 @@ class ChatUser { String? bio; // Identity - String publicKey; // Ed25519 public key, always stored + String publicKey; // ECDSA public key, always stored // HOME SERVER ONLY: Auth & key backup String? passwordHash; @@ -106,6 +106,7 @@ class ChatMessage { String? authorAvatarUrl; String content; + String componentsJson; @Property(type: PropertyType.date) DateTime createdAt; @@ -119,9 +120,38 @@ class ChatMessage { required this.authorUserId, required this.authorUsername, required this.content, + required this.componentsJson, this.authorHomeServer, this.authorAvatarUrl, DateTime? createdAt, this.editedAt, }) : 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(); +} diff --git a/lib/server.dart b/lib/server.dart index 3b417f4..44742ec 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -8,6 +8,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/handlers/upload_handlers.dart'; import 'src/state/app_state.dart'; RelicApp? _currentApp; @@ -27,6 +28,7 @@ Future startServer() async { ..get('/.well-known/users/:username', publicUserHandler) ..get('/.well-known/verify/:userId', publicVerifyHandler) ..get('/.well-known/server', publicServerInfoHandler) + ..get('/uploads/:fileName', getUploadFileHandler) ..get('/gateway', gatewayHandler) // ============ PROTECTED ENDPOINTS (requires auth) ============ @@ -42,6 +44,7 @@ Future startServer() async { // Messages ..get('/api/messages', getMessagesHandler) + ..post('/api/uploads', uploadFileHandler) ..post('/api/messages', sendMessageHandler) ..put('/api/messages/:messageId', editMessageHandler) ..delete('/api/messages/:messageId', deleteMessageHandler) diff --git a/lib/src/auth/crypto_service.dart b/lib/src/auth/crypto_service.dart index bb369a5..5d2623f 100644 --- a/lib/src/auth/crypto_service.dart +++ b/lib/src/auth/crypto_service.dart @@ -29,7 +29,7 @@ class KeyPair { } class CryptoService { - /// Generate a new Ed25519 key pair + /// Generate a new ECDSA key pair static KeyPair generateKeyPair() { final keyPair = crypton.ECKeypair.fromRandom(); return KeyPair( diff --git a/lib/src/handlers/message_handlers.dart b/lib/src/handlers/message_handlers.dart index a1a9cb3..327a10a 100644 --- a/lib/src/handlers/message_handlers.dart +++ b/lib/src/handlers/message_handlers.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:chat_server/models.dart'; import 'package:chat_server/objectbox.g.dart'; import 'package:relic/relic.dart'; @@ -135,12 +137,13 @@ Future sendMessageHandler(Request req) async { } final body = await req.asJson(); - final content = body['content'] as String?; 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( - body: Body.fromString('Content required'), + body: Body.fromString('content or components/componentsJson required'), ); } @@ -177,11 +180,13 @@ Future sendMessageHandler(Request req) async { authorUsername: user.username, authorAvatarUrl: user.avatarUrl, authorHomeServer: user.homeServer, - content: content.trim(), + content: _buildSearchContent(components, contentOverride), + componentsJson: jsonEncode(components), ); final id = messageBox.put(message); message.id = id; + _attachReferencedFiles(components); broadcastMessageCreated( channelId: channelId, @@ -192,6 +197,7 @@ Future sendMessageHandler(Request req) async { body: { 'id': id, 'content': message.content, + 'componentsJson': message.componentsJson, 'authorId': user.userId, 'authorUsername': user.username, 'authorAvatarUrl': user.avatarUrl, @@ -244,17 +250,20 @@ Future editMessageHandler(Request req) async { } 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( - 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(); messageBox.put(message); + _attachReferencedFiles(components); broadcastMessageUpdated( channelId: message.channelId, @@ -265,6 +274,7 @@ Future editMessageHandler(Request req) async { body: { 'id': message.id, 'content': message.content, + 'componentsJson': message.componentsJson, 'editedAt': message.editedAt?.toIso8601String(), }.toBody, ); @@ -332,6 +342,7 @@ Map _messageToMap(ChatMessage message) { return { 'id': message.id, 'content': message.content, + 'componentsJson': message.componentsJson, 'authorId': message.authorUserId, 'authorUsername': message.authorUsername, 'authorAvatarUrl': message.authorAvatarUrl, @@ -341,3 +352,136 @@ Map _messageToMap(ChatMessage message) { 'editedAt': message.editedAt?.toIso8601String(), }; } + +List>? _parseComponents(Map body) { + final rawComponents = body['components']; + if (rawComponents is List) { + final components = >[]; + for (final component in rawComponents) { + if (component is Map) { + 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 = >[]; + for (final component in decoded) { + if (component is Map) { + 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 >[ + { + 'type': 'text', + 'modifiers': [], + 'fallback': content, + 'data': { + 'text': content, + }, + } + ]; +} + +String _buildSearchContent( + List> components, + String? contentOverride, +) { + if (contentOverride != null && contentOverride.isNotEmpty) { + return contentOverride; + } + + final parts = []; + 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> components) { + final referencedFileIds = {}; + 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); + } +} diff --git a/lib/src/handlers/upload_handlers.dart b/lib/src/handlers/upload_handlers.dart new file mode 100644 index 0000000..dbc8e9c --- /dev/null +++ b/lib/src/handlers/upload_handlers.dart @@ -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 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>() + .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 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; +} diff --git a/lib/src/state/app_state.dart b/lib/src/state/app_state.dart index 2eddcac..0bd15f2 100644 --- a/lib/src/state/app_state.dart +++ b/lib/src/state/app_state.dart @@ -5,10 +5,12 @@ late final Store store; late final Box userBox; late final Box channelBox; late final Box messageBox; +late final Box fileBox; void initializeStore() { store = openStore(); userBox = store.box(); channelBox = store.box(); messageBox = store.box(); + fileBox = store.box(); }