Implement components + uploads flow and gateway-aligned payloads
This commit is contained in:
1 parent
458be6f991
commit
a93d619b4c
7 files changed
+440
-11
No files matched your search
@@ -44,4 +44,4 @@ lib/
|
||||
|
||||
- **Framework**: Relic 1.0
|
||||
- **Database**: ObjectBox
|
||||
- **Auth**: JWT + Ed25519 signatures
|
||||
- **Auth**: JWT + ECDSA signatures
|
||||
+31
-1
@@ -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();
|
||||
}
|
||||
@@ -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<void> 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<void> 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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Response> 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<Response> 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<Response> 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<Response> 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<Response> editMessageHandler(Request req) async {
|
||||
body: {
|
||||
'id': message.id,
|
||||
'content': message.content,
|
||||
'componentsJson': message.componentsJson,
|
||||
'editedAt': message.editedAt?.toIso8601String(),
|
||||
}.toBody,
|
||||
);
|
||||
@@ -332,6 +342,7 @@ 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,
|
||||
@@ -341,3 +352,136 @@ Map<String, dynamic> _messageToMap(ChatMessage message) {
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,10 +5,12 @@ late final Store store;
|
||||
late final Box<ChatUser> userBox;
|
||||
late final Box<ChatChannel> channelBox;
|
||||
late final Box<ChatMessage> messageBox;
|
||||
late final Box<ChatFile> fileBox;
|
||||
|
||||
void initializeStore() {
|
||||
store = openStore();
|
||||
userBox = store.box<ChatUser>();
|
||||
channelBox = store.box<ChatChannel>();
|
||||
messageBox = store.box<ChatMessage>();
|
||||
fileBox = store.box<ChatFile>();
|
||||
}
|
||||
Reference in new issue
Block a user