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
|
- **Framework**: Relic 1.0
|
||||||
- **Database**: ObjectBox
|
- **Database**: ObjectBox
|
||||||
- **Auth**: JWT + Ed25519 signatures
|
- **Auth**: JWT + ECDSA signatures
|
||||||
+31
-1
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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>();
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user