Major architecture update: stable identity, component validation, media indexing, and protocol docs
This commit is contained in:
1 parent
ce7c97d13a
commit
01c110bbe1
16 files changed
+2718
-468
No files matched your search
+158
-10
@@ -1,14 +1,162 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
class Config {
|
||||
// Server settings
|
||||
static const int port = 9870;
|
||||
static late String bindHost;
|
||||
static late int bindPort;
|
||||
static late String publicBaseUrl;
|
||||
static late String serverName;
|
||||
static late String jwtSecret;
|
||||
static late bool configured;
|
||||
static late bool setupMode;
|
||||
static late int bootstrapAdminLimit;
|
||||
static late bool devDbTools;
|
||||
|
||||
// IMPORTANT: Set this to your actual server host/domain
|
||||
// Used for generating userId: "username@serverHost"
|
||||
static const String serverHost = 'localhost:9870';
|
||||
static const String serverName = 'Chat Server';
|
||||
|
||||
// JWT settings
|
||||
// TODO: Move to environment variable in production
|
||||
static const String jwtSecret = 'your_super_secret_key_change_in_production';
|
||||
static const Duration tokenExpiry = Duration(days: 7);
|
||||
|
||||
static void initialize() {
|
||||
final configFile = File('config.yaml');
|
||||
|
||||
if (!configFile.existsSync()) {
|
||||
final generatedSecret = _generateJwtSecret();
|
||||
configFile.writeAsStringSync(_defaultConfig(generatedSecret));
|
||||
print('✅ Generated config.yaml (first run).');
|
||||
print('⚠️ Review server.public_base_url and security.jwt_secret before production use.');
|
||||
}
|
||||
|
||||
final yamlString = configFile.readAsStringSync();
|
||||
final yamlMap = loadYaml(yamlString) as YamlMap;
|
||||
|
||||
final serverMap = yamlMap['server'] as YamlMap?;
|
||||
final securityMap = yamlMap['security'] as YamlMap?;
|
||||
|
||||
serverName = (serverMap?['name'] as String?)?.trim() ?? '';
|
||||
bindHost = (serverMap?['bind_host'] as String?)?.trim() ?? (serverMap?['host'] as String?)?.trim() ?? '127.0.0.1';
|
||||
bindPort = (serverMap?['bind_port'] as int?) ?? (serverMap?['port'] as int?) ?? 9870;
|
||||
final rawPublicBaseUrl =
|
||||
(serverMap?['public_base_url'] as String?)?.trim() ?? _legacyPublicBaseUrlFromHost((serverMap?['host'] as String?)?.trim());
|
||||
publicBaseUrl = _normalizePublicBaseUrl(rawPublicBaseUrl) ?? '';
|
||||
configured = (serverMap?['configured'] as bool?) ?? false;
|
||||
setupMode = (serverMap?['first_setup'] as bool?) ?? false;
|
||||
bootstrapAdminLimit = (serverMap?['bootstrap_admin_limit'] as int?) ?? 1;
|
||||
devDbTools = (serverMap?['dev_db_tools'] as bool?) ?? false;
|
||||
|
||||
jwtSecret = (securityMap?['jwt_secret'] as String?)?.trim() ?? '';
|
||||
|
||||
if (serverName.isEmpty) {
|
||||
throw StateError('config.yaml: server.name is required.');
|
||||
}
|
||||
if (bindHost.isEmpty) {
|
||||
throw StateError('config.yaml: server.bind_host is required.');
|
||||
}
|
||||
if (bindPort <= 0 || bindPort > 65535) {
|
||||
throw StateError('config.yaml: server.bind_port must be between 1 and 65535.');
|
||||
}
|
||||
if (publicBaseUrl.isEmpty) {
|
||||
throw StateError(
|
||||
'config.yaml: server.public_base_url is required (example: https://chat.example.com).',
|
||||
);
|
||||
}
|
||||
if (jwtSecret.isEmpty) {
|
||||
throw StateError('config.yaml: security.jwt_secret is required.');
|
||||
}
|
||||
if (!configured) {
|
||||
throw StateError(
|
||||
'Server setup is not complete.\n'
|
||||
'Edit config.yaml and set:\n'
|
||||
' server.configured: true\n'
|
||||
'After reviewing server.public_base_url and security.jwt_secret.',
|
||||
);
|
||||
}
|
||||
if (bootstrapAdminLimit < 0) {
|
||||
throw StateError('config.yaml: server.bootstrap_admin_limit must be >= 0.');
|
||||
}
|
||||
|
||||
print(
|
||||
'🚀 Server Config Loaded: $serverName (bind=$bindHost:$bindPort, public=$publicBaseUrl, configured=$configured, setupMode=$setupMode, bootstrapAdminLimit=$bootstrapAdminLimit, devDbTools=$devDbTools)',
|
||||
);
|
||||
}
|
||||
|
||||
static String _generateJwtSecret() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
static String _defaultConfig(String generatedSecret) => '''
|
||||
# Reliquary Server Configuration
|
||||
server:
|
||||
# Display name shown in clients
|
||||
name: "Reliquary Server"
|
||||
|
||||
# Local bind address for the HTTP server
|
||||
bind_host: "127.0.0.1"
|
||||
|
||||
# Local bind port for the HTTP server
|
||||
bind_port: 9870
|
||||
|
||||
# Public base URL used for identity, handles, and cross-server references
|
||||
# Dev example: http://localhost:9870
|
||||
# Prod example: https://chat.example.com
|
||||
public_base_url: "http://localhost:9870"
|
||||
|
||||
# Set to true after reviewing this file; server refuses to start while false
|
||||
configured: false
|
||||
|
||||
# First local registration becomes admin
|
||||
first_setup: true
|
||||
|
||||
# Number of local registrations auto-promoted to admin while first_setup=true
|
||||
bootstrap_admin_limit: 1
|
||||
|
||||
# Testing-only DB admin/debug endpoints
|
||||
dev_db_tools: true
|
||||
|
||||
security:
|
||||
# Auto-generated on first run; replace for production
|
||||
jwt_secret: "$generatedSecret"
|
||||
''';
|
||||
|
||||
static String get publicHost {
|
||||
final uri = Uri.tryParse(publicBaseUrl);
|
||||
if (uri == null || uri.host.isEmpty) {
|
||||
return publicBaseUrl;
|
||||
}
|
||||
return uri.hasPort ? '${uri.host}:${uri.port}' : uri.host;
|
||||
}
|
||||
|
||||
static String? _normalizePublicBaseUrl(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final uri = Uri.tryParse(value.trim());
|
||||
if (uri == null || uri.host.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
return null;
|
||||
}
|
||||
return Uri(
|
||||
scheme: uri.scheme,
|
||||
host: uri.host,
|
||||
port: uri.hasPort ? uri.port : null,
|
||||
).toString().replaceAll(RegExp(r'/$'), '');
|
||||
}
|
||||
|
||||
static String? _legacyPublicBaseUrlFromHost(String? hostValue) {
|
||||
if (hostValue == null || hostValue.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final host = hostValue.trim();
|
||||
if (host.contains('://')) {
|
||||
return host;
|
||||
}
|
||||
final lower = host.toLowerCase();
|
||||
final isLocal = lower.startsWith('localhost') || lower.startsWith('127.') || lower.startsWith('[::1]');
|
||||
final scheme = isLocal ? 'http' : 'https';
|
||||
return '$scheme://$host';
|
||||
}
|
||||
}
|
||||
+91
-4
@@ -16,7 +16,7 @@ class ChatUser {
|
||||
@Id()
|
||||
int id = 0;
|
||||
|
||||
// Global identifier: "username@homeserver.com"
|
||||
// Global immutable identifier (opaque, host-independent).
|
||||
@Unique()
|
||||
String userId;
|
||||
|
||||
@@ -37,8 +37,7 @@ class ChatUser {
|
||||
|
||||
// Server relationship
|
||||
bool isLocal; // true = this is their home server
|
||||
String?
|
||||
homeServer; // if guest, where is their home? (e.g., "https://my.server.com")
|
||||
String? homeServer; // if guest, where is their home? (e.g., "https://my.server.com")
|
||||
|
||||
// Server-specific
|
||||
List<String> roles; // ["admin", "moderator", "member"]
|
||||
@@ -98,7 +97,7 @@ class ChatMessage {
|
||||
|
||||
// Author info - includes enough for cross-server profile fetch
|
||||
int authorLocalId;
|
||||
String authorUserId; // "alice@homeserver.com"
|
||||
String authorUserId; // Immutable userId
|
||||
String? authorHomeServer; // for profile fetching
|
||||
|
||||
// Cached for display (snapshot at message time)
|
||||
@@ -133,25 +132,113 @@ class ChatFile {
|
||||
@Id()
|
||||
int id = 0;
|
||||
|
||||
@Unique()
|
||||
String attachmentId;
|
||||
|
||||
int uploaderId;
|
||||
String path;
|
||||
String originalFileName;
|
||||
String storedFileName;
|
||||
String sha256;
|
||||
String mimeType;
|
||||
int width;
|
||||
int height;
|
||||
int sizeBytes;
|
||||
bool isAttached;
|
||||
String usageType; // message | avatar | server_icon | server_banner
|
||||
String status; // pending | ready | failed
|
||||
String? errorCode;
|
||||
|
||||
@Property(type: PropertyType.date)
|
||||
DateTime createdAt;
|
||||
|
||||
ChatFile({
|
||||
required this.attachmentId,
|
||||
required this.uploaderId,
|
||||
required this.path,
|
||||
required this.originalFileName,
|
||||
required this.storedFileName,
|
||||
required this.sha256,
|
||||
required this.mimeType,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.sizeBytes,
|
||||
this.usageType = 'message',
|
||||
this.status = 'pending',
|
||||
this.isAttached = false,
|
||||
this.errorCode,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
}
|
||||
|
||||
@Entity()
|
||||
class ServerSettings {
|
||||
@Id()
|
||||
int id = 0;
|
||||
|
||||
@Unique()
|
||||
String singletonKey;
|
||||
|
||||
String serverName;
|
||||
String? description;
|
||||
String? iconAttachmentId;
|
||||
String? bannerAttachmentId;
|
||||
String? updatedByUserId;
|
||||
|
||||
@Property(type: PropertyType.date)
|
||||
DateTime updatedAt;
|
||||
|
||||
ServerSettings({
|
||||
this.singletonKey = 'default',
|
||||
required this.serverName,
|
||||
this.description,
|
||||
this.iconAttachmentId,
|
||||
this.bannerAttachmentId,
|
||||
this.updatedByUserId,
|
||||
DateTime? updatedAt,
|
||||
}) : updatedAt = updatedAt ?? DateTime.now();
|
||||
}
|
||||
|
||||
@Entity()
|
||||
class ChatMessageMedia {
|
||||
@Id()
|
||||
int id = 0;
|
||||
|
||||
int messageId;
|
||||
int channelId;
|
||||
String authorUserId;
|
||||
|
||||
// local_upload
|
||||
String? attachmentId;
|
||||
|
||||
// external_url
|
||||
String? externalUrl;
|
||||
|
||||
// image | video | file
|
||||
String kind;
|
||||
String? mimeType;
|
||||
String? originalFileName;
|
||||
int? sizeBytes;
|
||||
int? width;
|
||||
int? height;
|
||||
String? thumbHash;
|
||||
|
||||
@Property(type: PropertyType.date)
|
||||
DateTime createdAt;
|
||||
|
||||
ChatMessageMedia({
|
||||
required this.messageId,
|
||||
required this.channelId,
|
||||
required this.authorUserId,
|
||||
required this.kind,
|
||||
this.attachmentId,
|
||||
this.externalUrl,
|
||||
this.mimeType,
|
||||
this.originalFileName,
|
||||
this.sizeBytes,
|
||||
this.width,
|
||||
this.height,
|
||||
this.thumbHash,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
}
|
||||
+41
-15
@@ -1,4 +1,5 @@
|
||||
import 'package:hotreloader/hotreloader.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
import 'config/config.dart';
|
||||
@@ -6,8 +7,10 @@ import 'src/auth/auth_middleware.dart';
|
||||
import 'src/gateway/gateway_handlers.dart';
|
||||
import 'src/handlers/auth_handlers.dart';
|
||||
import 'src/handlers/channel_handlers.dart';
|
||||
import 'src/handlers/dev_db_handlers.dart';
|
||||
import 'src/handlers/message_handlers.dart';
|
||||
import 'src/handlers/public_handlers.dart';
|
||||
import 'src/handlers/settings_handlers.dart';
|
||||
import 'src/handlers/upload_handlers.dart';
|
||||
import 'src/state/app_state.dart';
|
||||
|
||||
@@ -29,6 +32,10 @@ Future<void> startServer() async {
|
||||
..get('/.well-known/verify/:userId', publicVerifyHandler)
|
||||
..get('/.well-known/server', publicServerInfoHandler)
|
||||
..get('/uploads/:fileName', getUploadFileHandler)
|
||||
..get('/attachments/:attachmentId', getAttachmentHandler)
|
||||
..get('/avatars/:userId', getAvatarAliasHandler)
|
||||
..get('/icons/:serverId', getServerIconAliasHandler)
|
||||
..get('/banners/:serverId', getServerBannerAliasHandler)
|
||||
..get('/gateway', gatewayHandler)
|
||||
|
||||
// ============ PROTECTED ENDPOINTS (requires auth) ============
|
||||
@@ -36,6 +43,20 @@ Future<void> startServer() async {
|
||||
|
||||
// Current user
|
||||
..get('/api/me', getMeHandler)
|
||||
..patch('/api/me', updateProfileHandler)
|
||||
..put('/api/me/avatar', setAvatarHandler)
|
||||
|
||||
// Server settings
|
||||
..get('/api/server/settings', getServerSettingsHandler)
|
||||
..patch('/api/server/settings', patchServerSettingsHandler)
|
||||
..put('/api/server/icon', setServerIconHandler)
|
||||
..put('/api/server/banner', setServerBannerHandler)
|
||||
|
||||
// Dev DB tools (testing only, admin only, requires config flag)
|
||||
..get('/api/dev/db/snapshot', getDevDbSnapshotHandler)
|
||||
..post('/api/dev/db/reset', resetDevDbHandler)
|
||||
..post('/api/dev/users/promote-admin', promoteUserToAdminHandler)
|
||||
..post('/api/dev/users/demote-admin', demoteUserFromAdminHandler)
|
||||
|
||||
// Channels
|
||||
..get('/api/channels', getChannelsHandler)
|
||||
@@ -44,6 +65,9 @@ Future<void> startServer() async {
|
||||
|
||||
// Messages
|
||||
..get('/api/messages', getMessagesHandler)
|
||||
..get('/api/channels/:channelId/media', getChannelMediaHandler)
|
||||
..post('/api/uploads/init', initUploadHandler)
|
||||
..put('/api/uploads/:uploadId', completeUploadHandler)
|
||||
..post('/api/uploads', uploadFileHandler)
|
||||
..post('/api/messages', sendMessageHandler)
|
||||
..put('/api/messages/:messageId', editMessageHandler)
|
||||
@@ -54,26 +78,28 @@ Future<void> startServer() async {
|
||||
..fallback = respondWith((_) => Response.notFound());
|
||||
|
||||
print('');
|
||||
print('╔════════════════════════════════════════════╗');
|
||||
print('║ 🚀 Server starting... ║');
|
||||
print('║ Host: ${Config.serverHost.padRight(34)}║');
|
||||
print('║ URL: http://localhost:${Config.port.toString().padRight(26)}║');
|
||||
print('╚════════════════════════════════════════════╝');
|
||||
print('🚀 Server starting...');
|
||||
print('Bind: ${'${Config.bindHost}:${Config.bindPort}'.padRight(34)}');
|
||||
print('Public URL: ${Config.publicBaseUrl.padRight(26)}');
|
||||
print('');
|
||||
|
||||
await _currentApp!.serve(port: Config.port);
|
||||
final bindAddress = InternetAddress.tryParse(Config.bindHost) ?? (await InternetAddress.lookup(Config.bindHost)).first;
|
||||
|
||||
await _currentApp!.serve(
|
||||
address: bindAddress,
|
||||
port: Config.bindPort,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
await _currentApp?.close();
|
||||
_currentApp = null;
|
||||
}
|
||||
|
||||
void main() async {
|
||||
initializeStore();
|
||||
Config.initialize();
|
||||
|
||||
// Enable hot reload in development
|
||||
await HotReloader.create(
|
||||
onAfterReload: (ctx) async {
|
||||
print('🔄 Changes detected! Restarting server...');
|
||||
await startServer();
|
||||
},
|
||||
);
|
||||
initializeStore();
|
||||
|
||||
await startServer();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:chat_server/models.dart';
|
||||
import 'package:chat_server/objectbox.g.dart';
|
||||
@@ -29,16 +30,6 @@ class RemoteIdentity {
|
||||
});
|
||||
}
|
||||
|
||||
class ParsedUserId {
|
||||
final String username;
|
||||
final String host;
|
||||
|
||||
ParsedUserId({
|
||||
required this.username,
|
||||
required this.host,
|
||||
});
|
||||
}
|
||||
|
||||
/// Register on HOME server
|
||||
/// Creates user with keypair for identity
|
||||
Future<Response> registerHandler(Request req) async {
|
||||
@@ -85,22 +76,30 @@ Future<Response> registerHandler(Request req) async {
|
||||
// Hash password
|
||||
final passwordHash = AuthService.hashPassword(password);
|
||||
|
||||
// Create user with userId format: "username@this.server"
|
||||
final userId = '${username}@${Config.serverHost}';
|
||||
// Create immutable opaque identity ID.
|
||||
final userId = generateUserId();
|
||||
|
||||
final isBootstrapAdmin = Config.setupMode && _adminCount() < Config.bootstrapAdminLimit;
|
||||
final roles = isBootstrapAdmin ? <String>['admin'] : <String>['member'];
|
||||
|
||||
final user = ChatUser(
|
||||
userId: userId,
|
||||
username: username,
|
||||
publicKey: keyPair.publicKey,
|
||||
isLocal: true,
|
||||
homeServer: Config.publicBaseUrl,
|
||||
passwordHash: passwordHash,
|
||||
encryptedPrivateKey: encryptedKey.encryptedData,
|
||||
keyPairSalt: encryptedKey.salt,
|
||||
roles: ['member'],
|
||||
roles: roles,
|
||||
);
|
||||
|
||||
final localId = userBox.put(user);
|
||||
|
||||
if (isBootstrapAdmin) {
|
||||
print('🔐 Setup mode: granted initial admin to $userId');
|
||||
}
|
||||
|
||||
// Generate token
|
||||
final token = AuthService.generateToken(userId, username);
|
||||
|
||||
@@ -108,11 +107,14 @@ Future<Response> registerHandler(Request req) async {
|
||||
body: {
|
||||
'token': token,
|
||||
'userId': userId,
|
||||
'username': username,
|
||||
'username': user.username,
|
||||
'handle': toUserHandle(user.username, Config.publicBaseUrl),
|
||||
'publicKey': keyPair.publicKey,
|
||||
'encryptedPrivateKey': encryptedKey.encryptedData,
|
||||
'keyPairSalt': encryptedKey.salt,
|
||||
'localId': localId,
|
||||
'roles': roles,
|
||||
'isBootstrapAdmin': isBootstrapAdmin,
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
@@ -147,8 +149,7 @@ Future<Response> loginHandler(Request req) async {
|
||||
// Check if this is actually their home server
|
||||
if (!user.isLocal) {
|
||||
return Response.badRequest(
|
||||
body:
|
||||
Body.fromString('This is not your home server. Login there instead.'),
|
||||
body: Body.fromString('This is not your home server. Login there instead.'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +168,7 @@ Future<Response> loginHandler(Request req) async {
|
||||
'token': token,
|
||||
'userId': user.userId,
|
||||
'username': user.username,
|
||||
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
|
||||
'displayName': user.displayName,
|
||||
'avatarUrl': user.avatarUrl,
|
||||
'publicKey': user.publicKey,
|
||||
@@ -181,27 +183,27 @@ Future<Response> loginHandler(Request req) async {
|
||||
/// No password required - proves identity via private key signature
|
||||
Future<Response> joinServerHandler(Request req) async {
|
||||
final body = await req.asJson();
|
||||
final userId = body['userId'] as String?; // "alice@homeserver.com"
|
||||
final homeServer = body['homeServer'] as String?; // "https://homeserver.com"
|
||||
final userId = body['userId'] as String?;
|
||||
final username = (body['username'] as String?)?.trim();
|
||||
final homeServer = body['homeServer'] as String?;
|
||||
final timestamp = body['timestamp'] as int?;
|
||||
final signature = body['signature'] as String?;
|
||||
|
||||
// Validate required fields
|
||||
if (userId == null || timestamp == null || signature == null) {
|
||||
if (userId == null ||
|
||||
userId.isEmpty ||
|
||||
username == null ||
|
||||
username.isEmpty ||
|
||||
homeServer == null ||
|
||||
homeServer.trim().isEmpty ||
|
||||
timestamp == null ||
|
||||
signature == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString(
|
||||
'userId, timestamp, and signature required (homeServer optional)'),
|
||||
body: Body.fromString('userId, username, homeServer, timestamp, and signature required'),
|
||||
);
|
||||
}
|
||||
|
||||
final parsedUser = parseUserId(userId);
|
||||
if (parsedUser == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid userId format. Expected username@host'),
|
||||
);
|
||||
}
|
||||
|
||||
final resolvedHomeServer = normalizeHomeServer(homeServer ?? parsedUser.host);
|
||||
final resolvedHomeServer = normalizeHomeServer(homeServer);
|
||||
|
||||
if (resolvedHomeServer == null) {
|
||||
return Response.badRequest(
|
||||
@@ -209,12 +211,6 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
if (!homeServerMatchesUserIdHost(resolvedHomeServer, parsedUser.host)) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('homeServer does not match userId host'),
|
||||
);
|
||||
}
|
||||
|
||||
// Check timestamp is recent (within 5 minutes)
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
if ((now - timestamp).abs() > 300) {
|
||||
@@ -226,7 +222,7 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
// Fetch current identity from home server
|
||||
final identity = await fetchIdentityFromHomeServer(
|
||||
resolvedHomeServer,
|
||||
parsedUser.username,
|
||||
username,
|
||||
);
|
||||
|
||||
if (identity == null) {
|
||||
@@ -236,7 +232,7 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
}
|
||||
|
||||
// Ensure fetched identity matches what caller claims
|
||||
if (identity.userId != userId || identity.username != parsedUser.username) {
|
||||
if (identity.userId != userId || identity.username != username) {
|
||||
return Response.forbidden(
|
||||
body: Body.fromString('Identity mismatch from home server'),
|
||||
);
|
||||
@@ -245,8 +241,7 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
// Verify signature
|
||||
// Signature is over: "{userId}:{timestamp}"
|
||||
final messageToVerify = '$userId:$timestamp';
|
||||
final isValid =
|
||||
CryptoService.verify(messageToVerify, signature, identity.publicKey);
|
||||
final isValid = CryptoService.verify(messageToVerify, signature, identity.publicKey);
|
||||
|
||||
if (!isValid) {
|
||||
return Response.forbidden(
|
||||
@@ -269,8 +264,7 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
existingUser.lastSyncedAt = DateTime.now();
|
||||
userBox.put(existingUser);
|
||||
|
||||
final token =
|
||||
AuthService.generateToken(existingUser.userId, existingUser.username);
|
||||
final token = AuthService.generateToken(existingUser.userId, existingUser.username);
|
||||
userLocalIdProperty[req] = existingUser.id;
|
||||
|
||||
return Response.ok(
|
||||
@@ -278,6 +272,7 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
'token': token,
|
||||
'userId': existingUser.userId,
|
||||
'username': existingUser.username,
|
||||
'handle': toUserHandle(existingUser.username, existingUser.homeServer ?? Config.publicBaseUrl),
|
||||
'localId': existingUser.id,
|
||||
'alreadyJoined': true,
|
||||
}.toBody,
|
||||
@@ -308,6 +303,7 @@ Future<Response> joinServerHandler(Request req) async {
|
||||
'token': token,
|
||||
'userId': userId,
|
||||
'username': user.username,
|
||||
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
|
||||
'localId': localId,
|
||||
'alreadyJoined': false,
|
||||
}.toBody,
|
||||
@@ -324,8 +320,7 @@ Future<RemoteIdentity?> fetchIdentityFromHomeServer(
|
||||
|
||||
try {
|
||||
final baseUrl = homeServer.replaceAll(RegExp(r'/$'), '');
|
||||
final endpoint = Uri.parse(
|
||||
'$baseUrl/.well-known/users/${Uri.encodeComponent(username)}');
|
||||
final endpoint = Uri.parse('$baseUrl/.well-known/users/${Uri.encodeComponent(username)}');
|
||||
|
||||
final request = await client.getUrl(endpoint);
|
||||
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
|
||||
@@ -366,22 +361,6 @@ Future<RemoteIdentity?> fetchIdentityFromHomeServer(
|
||||
}
|
||||
}
|
||||
|
||||
ParsedUserId? parseUserId(String userId) {
|
||||
final parts = userId.split('@');
|
||||
if (parts.length != 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final username = parts[0].trim();
|
||||
final host = parts[1].trim();
|
||||
|
||||
if (username.isEmpty || host.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParsedUserId(username: username, host: host);
|
||||
}
|
||||
|
||||
String? normalizeHomeServer(String homeServerOrHost) {
|
||||
final trimmed = homeServerOrHost.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
@@ -389,8 +368,7 @@ String? normalizeHomeServer(String homeServerOrHost) {
|
||||
}
|
||||
|
||||
final hasScheme = trimmed.contains('://');
|
||||
final candidate =
|
||||
hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed';
|
||||
final candidate = hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed';
|
||||
final uri = Uri.tryParse(candidate);
|
||||
|
||||
if (uri == null || uri.host.isEmpty) {
|
||||
@@ -409,37 +387,22 @@ String? normalizeHomeServer(String homeServerOrHost) {
|
||||
|
||||
String defaultSchemeForHost(String host) {
|
||||
final lowerHost = host.toLowerCase();
|
||||
if (lowerHost.startsWith('localhost') ||
|
||||
lowerHost.startsWith('127.') ||
|
||||
lowerHost.startsWith('[::1]')) {
|
||||
if (lowerHost.startsWith('localhost') || lowerHost.startsWith('127.') || lowerHost.startsWith('[::1]')) {
|
||||
return 'http';
|
||||
}
|
||||
|
||||
return 'https';
|
||||
}
|
||||
|
||||
bool homeServerMatchesUserIdHost(String homeServerUrl, String userIdHost) {
|
||||
final uri = Uri.tryParse(homeServerUrl);
|
||||
if (uri == null || uri.host.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final normalizedUserIdHost = userIdHost.trim().toLowerCase();
|
||||
final urlHost = uri.host.toLowerCase();
|
||||
final urlHostWithPort = uri.hasPort ? '$urlHost:${uri.port}' : urlHost;
|
||||
|
||||
if (normalizedUserIdHost == urlHostWithPort) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!uri.hasPort) {
|
||||
final defaultPort = uri.scheme == 'http' ? 80 : 443;
|
||||
if (normalizedUserIdHost == '$urlHost:$defaultPort') {
|
||||
return true;
|
||||
int _adminCount() {
|
||||
var count = 0;
|
||||
final users = userBox.getAll();
|
||||
for (final user in users) {
|
||||
if (user.roles.contains('admin')) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Get current user's profile
|
||||
@@ -466,6 +429,7 @@ Future<Response> getMeHandler(Request req) async {
|
||||
body: {
|
||||
'userId': user.userId,
|
||||
'username': user.username,
|
||||
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
|
||||
'displayName': user.displayName,
|
||||
'avatarUrl': user.avatarUrl,
|
||||
'bio': user.bio,
|
||||
@@ -476,3 +440,27 @@ Future<Response> getMeHandler(Request req) async {
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
String generateUserId() {
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
final random = Random.secure();
|
||||
while (true) {
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
final id = bytes.map((b) => alphabet[b % alphabet.length]).join();
|
||||
final query = userBox.query(ChatUser_.userId.equals(id)).build();
|
||||
final exists = query.findFirst() != null;
|
||||
query.close();
|
||||
if (!exists) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String toUserHandle(String username, String homeServer) {
|
||||
final uri = Uri.tryParse(homeServer);
|
||||
if (uri != null && uri.host.isNotEmpty) {
|
||||
final host = uri.hasPort ? '${uri.host}:${uri.port}' : uri.host;
|
||||
return '@$username@$host';
|
||||
}
|
||||
return '@$username@${homeServer.trim()}';
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chat_server/models.dart';
|
||||
import 'package:chat_server/objectbox.g.dart';
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../auth/auth_middleware.dart';
|
||||
import '../state/app_state.dart';
|
||||
import '../utils/extensions.dart';
|
||||
|
||||
const _maxSnapshotLimit = 500;
|
||||
|
||||
Future<Response> getDevDbSnapshotHandler(Request req) async {
|
||||
final guard = _guardDevDbTools(req);
|
||||
if (guard != null) {
|
||||
return guard;
|
||||
}
|
||||
|
||||
final rawLimit = req.url.queryParameters['limit'];
|
||||
final parsedLimit = rawLimit == null ? 100 : int.tryParse(rawLimit);
|
||||
if (parsedLimit == null || parsedLimit <= 0) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid limit'),
|
||||
);
|
||||
}
|
||||
final limit = parsedLimit > _maxSnapshotLimit ? _maxSnapshotLimit : parsedLimit;
|
||||
|
||||
final users = userBox.getAll().take(limit).map(_userToMap).toList();
|
||||
final channels = channelBox.getAll().take(limit).map(_channelToMap).toList();
|
||||
final messages = messageBox.getAll().take(limit).map(_messageToMap).toList();
|
||||
final files = fileBox.getAll().take(limit).map(_fileToMap).toList();
|
||||
final messageMedia = messageMediaBox.getAll().take(limit).map(_messageMediaToMap).toList();
|
||||
final settings = serverSettingsBox.getAll().take(limit).map(_settingsToMap).toList();
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'limit': limit,
|
||||
'counts': {
|
||||
'users': userBox.count(),
|
||||
'channels': channelBox.count(),
|
||||
'messages': messageBox.count(),
|
||||
'files': fileBox.count(),
|
||||
'messageMedia': messageMediaBox.count(),
|
||||
'serverSettings': serverSettingsBox.count(),
|
||||
},
|
||||
'users': users,
|
||||
'channels': channels,
|
||||
'messages': messages,
|
||||
'files': files,
|
||||
'messageMedia': messageMedia,
|
||||
'serverSettings': settings,
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> resetDevDbHandler(Request req) async {
|
||||
final guard = _guardDevDbTools(req);
|
||||
if (guard != null) {
|
||||
return guard;
|
||||
}
|
||||
|
||||
final body = await req.asJson();
|
||||
if ((body['confirm'] as String?) != 'RESET') {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Body must include {"confirm":"RESET"}'),
|
||||
);
|
||||
}
|
||||
|
||||
messageBox.removeAll();
|
||||
channelBox.removeAll();
|
||||
fileBox.removeAll();
|
||||
messageMediaBox.removeAll();
|
||||
serverSettingsBox.removeAll();
|
||||
userBox.removeAll();
|
||||
|
||||
final uploadsDir = Directory('uploads');
|
||||
if (uploadsDir.existsSync()) {
|
||||
for (final entity in uploadsDir.listSync()) {
|
||||
if (entity is File) {
|
||||
entity.deleteSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'reset': true,
|
||||
'counts': {
|
||||
'users': userBox.count(),
|
||||
'channels': channelBox.count(),
|
||||
'messages': messageBox.count(),
|
||||
'files': fileBox.count(),
|
||||
'messageMedia': messageMediaBox.count(),
|
||||
'serverSettings': serverSettingsBox.count(),
|
||||
},
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> promoteUserToAdminHandler(Request req) async {
|
||||
final guard = _guardDevDbTools(req);
|
||||
if (guard != null) {
|
||||
return guard;
|
||||
}
|
||||
|
||||
final body = await req.asJson();
|
||||
final userId = (body['userId'] as String?)?.trim();
|
||||
if (userId == null || userId.isEmpty) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('userId is required'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
if (user == null) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('User not found'),
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.roles.contains('admin')) {
|
||||
user.roles = [...user.roles, 'admin'];
|
||||
userBox.put(user);
|
||||
}
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'updated': true,
|
||||
'userId': user.userId,
|
||||
'roles': user.roles,
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> demoteUserFromAdminHandler(Request req) async {
|
||||
final guard = _guardDevDbTools(req);
|
||||
if (guard != null) {
|
||||
return guard;
|
||||
}
|
||||
|
||||
final body = await req.asJson();
|
||||
final userId = (body['userId'] as String?)?.trim();
|
||||
if (userId == null || userId.isEmpty) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('userId is required'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
if (user == null) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('User not found'),
|
||||
);
|
||||
}
|
||||
|
||||
user.roles = user.roles.where((role) => role != 'admin').toList();
|
||||
userBox.put(user);
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'updated': true,
|
||||
'userId': user.userId,
|
||||
'roles': user.roles,
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Response? _guardDevDbTools(Request req) {
|
||||
if (!Config.devDbTools) {
|
||||
return Response.forbidden(
|
||||
body: Body.fromString('Dev DB tools are disabled in config'),
|
||||
);
|
||||
}
|
||||
|
||||
final authUserId = userIdProperty[req];
|
||||
if (authUserId == null) {
|
||||
return Response.unauthorized(
|
||||
body: Body.fromString('Not authenticated'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = userBox.query(ChatUser_.userId.equals(authUserId)).build();
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
if (user == null) {
|
||||
return Response.unauthorized(
|
||||
body: Body.fromString('User not found'),
|
||||
);
|
||||
}
|
||||
if (!user.roles.contains('admin')) {
|
||||
return Response.forbidden(
|
||||
body: Body.fromString('Admin role required'),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _userToMap(ChatUser user) => {
|
||||
'id': user.id,
|
||||
'userId': user.userId,
|
||||
'username': user.username,
|
||||
'displayName': user.displayName,
|
||||
'avatarUrl': user.avatarUrl,
|
||||
'bio': user.bio,
|
||||
'isLocal': user.isLocal,
|
||||
'homeServer': user.homeServer,
|
||||
'roles': user.roles,
|
||||
'createdAt': user.createdAt.toIso8601String(),
|
||||
'lastSyncedAt': user.lastSyncedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
Map<String, dynamic> _channelToMap(ChatChannel channel) => {
|
||||
'id': channel.id,
|
||||
'name': channel.name,
|
||||
'description': channel.description,
|
||||
'createdAt': channel.createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
Map<String, dynamic> _messageToMap(ChatMessage message) => {
|
||||
'id': message.id,
|
||||
'channelId': message.channelId,
|
||||
'authorLocalId': message.authorLocalId,
|
||||
'authorUserId': message.authorUserId,
|
||||
'authorUsername': message.authorUsername,
|
||||
'content': message.content,
|
||||
'componentsJson': message.componentsJson,
|
||||
'createdAt': message.createdAt.toIso8601String(),
|
||||
'editedAt': message.editedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
Map<String, dynamic> _fileToMap(ChatFile file) => {
|
||||
'id': file.id,
|
||||
'attachmentId': file.attachmentId,
|
||||
'uploaderId': file.uploaderId,
|
||||
'path': file.path,
|
||||
'originalFileName': file.originalFileName,
|
||||
'storedFileName': file.storedFileName,
|
||||
'mimeType': file.mimeType,
|
||||
'sizeBytes': file.sizeBytes,
|
||||
'width': file.width,
|
||||
'height': file.height,
|
||||
'usageType': file.usageType,
|
||||
'status': file.status,
|
||||
'isAttached': file.isAttached,
|
||||
'createdAt': file.createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
Map<String, dynamic> _settingsToMap(ServerSettings settings) => {
|
||||
'id': settings.id,
|
||||
'singletonKey': settings.singletonKey,
|
||||
'serverName': settings.serverName,
|
||||
'description': settings.description,
|
||||
'iconAttachmentId': settings.iconAttachmentId,
|
||||
'bannerAttachmentId': settings.bannerAttachmentId,
|
||||
'updatedByUserId': settings.updatedByUserId,
|
||||
'updatedAt': settings.updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
Map<String, dynamic> _messageMediaToMap(ChatMessageMedia media) => {
|
||||
'id': media.id,
|
||||
'messageId': media.messageId,
|
||||
'channelId': media.channelId,
|
||||
'authorUserId': media.authorUserId,
|
||||
'attachmentId': media.attachmentId,
|
||||
'externalUrl': media.externalUrl,
|
||||
'kind': media.kind,
|
||||
'mimeType': media.mimeType,
|
||||
'originalFileName': media.originalFileName,
|
||||
'sizeBytes': media.sizeBytes,
|
||||
'width': media.width,
|
||||
'height': media.height,
|
||||
'thumbHash': media.thumbHash,
|
||||
'createdAt': media.createdAt.toIso8601String(),
|
||||
};
|
||||
@@ -12,6 +12,14 @@ import '../utils/extensions.dart';
|
||||
const _defaultMessageLimit = 50;
|
||||
const _maxMessageLimit = 100;
|
||||
|
||||
const _defaultMediaLimit = 50;
|
||||
const _maxMediaLimit = 100;
|
||||
|
||||
const _maxComponentDepth = 6;
|
||||
const _maxComponentNodes = 250;
|
||||
const _maxMediaItemsPerComponent = 64;
|
||||
const _maxExternalUrlLength = 2048;
|
||||
|
||||
/// Get messages for a channel
|
||||
/// Query params: channelId (required), limit, before, after
|
||||
Future<Response> getMessagesHandler(Request req) async {
|
||||
@@ -33,8 +41,7 @@ Future<Response> getMessagesHandler(Request req) async {
|
||||
}
|
||||
|
||||
final channelId = int.tryParse(channelIdStr);
|
||||
final parsedLimit =
|
||||
limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr);
|
||||
final parsedLimit = limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr);
|
||||
int? before;
|
||||
int? after;
|
||||
|
||||
@@ -70,7 +77,6 @@ Future<Response> getMessagesHandler(Request req) async {
|
||||
}
|
||||
}
|
||||
|
||||
// Check channel exists
|
||||
final channel = channelBox.get(channelId);
|
||||
if (channel == null) {
|
||||
return Response.notFound(
|
||||
@@ -99,8 +105,7 @@ Future<Response> getMessagesHandler(Request req) async {
|
||||
query.close();
|
||||
|
||||
final hasMore = fetchedMessages.length > limit;
|
||||
final messages =
|
||||
hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages;
|
||||
final messages = hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages;
|
||||
|
||||
int? nextBefore;
|
||||
int? nextAfter;
|
||||
@@ -126,7 +131,87 @@ Future<Response> getMessagesHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Send a message
|
||||
/// Get channel media index (for gallery grid view)
|
||||
Future<Response> getChannelMediaHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
if (userId == null) {
|
||||
return Response.unauthorized(
|
||||
body: Body.fromString('Not authenticated'),
|
||||
);
|
||||
}
|
||||
|
||||
final channelIdStr = req.rawPathParameters[#channelId];
|
||||
final limitStr = req.url.queryParameters['limit'];
|
||||
final beforeStr = req.url.queryParameters['before'];
|
||||
final kindFilter = req.url.queryParameters['kind']?.trim();
|
||||
|
||||
if (channelIdStr == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('channelId required'),
|
||||
);
|
||||
}
|
||||
final channelId = int.tryParse(channelIdStr);
|
||||
if (channelId == null || channelId <= 0) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid channelId'),
|
||||
);
|
||||
}
|
||||
|
||||
final channel = channelBox.get(channelId);
|
||||
if (channel == null) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Channel not found'),
|
||||
);
|
||||
}
|
||||
|
||||
final parsedLimit = limitStr == null ? _defaultMediaLimit : int.tryParse(limitStr);
|
||||
if (parsedLimit == null || parsedLimit <= 0) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid limit'),
|
||||
);
|
||||
}
|
||||
final limit = parsedLimit > _maxMediaLimit ? _maxMediaLimit : parsedLimit;
|
||||
|
||||
int? before;
|
||||
if (beforeStr != null) {
|
||||
before = int.tryParse(beforeStr);
|
||||
if (before == null || before <= 0) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid `before` cursor'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var condition = ChatMessageMedia_.channelId.equals(channelId);
|
||||
if (before != null) {
|
||||
condition = condition & ChatMessageMedia_.id.lessThan(before);
|
||||
}
|
||||
if (kindFilter != null && kindFilter.isNotEmpty) {
|
||||
condition = condition & ChatMessageMedia_.kind.equals(kindFilter);
|
||||
}
|
||||
|
||||
final queryBuilder = messageMediaBox.query(condition)..order(ChatMessageMedia_.id, flags: Order.descending);
|
||||
final query = queryBuilder.build();
|
||||
query.limit = limit + 1;
|
||||
|
||||
final rows = query.find();
|
||||
query.close();
|
||||
|
||||
final hasMore = rows.length > limit;
|
||||
final page = hasMore ? rows.sublist(0, limit) : rows;
|
||||
final nextBefore = page.isNotEmpty ? page.last.id : null;
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'channelId': channelId,
|
||||
'media': page.map(_mediaToMap).toList(),
|
||||
'limit': limit,
|
||||
'hasMore': hasMore,
|
||||
'nextBefore': nextBefore,
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> sendMessageHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
|
||||
@@ -147,13 +232,19 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
final validationError = _validateComponents(components);
|
||||
if (validationError != null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString(validationError),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -161,7 +252,6 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Get user info for caching
|
||||
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = userQuery.findFirst();
|
||||
userQuery.close();
|
||||
@@ -172,7 +262,8 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Create message with cached author info
|
||||
final normalizedComponents = _normalizeComponentsForStorage(components);
|
||||
|
||||
final message = ChatMessage(
|
||||
channelId: channelId,
|
||||
authorLocalId: user.id,
|
||||
@@ -180,13 +271,14 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
authorUsername: user.username,
|
||||
authorAvatarUrl: user.avatarUrl,
|
||||
authorHomeServer: user.homeServer,
|
||||
content: _buildSearchContent(components, contentOverride),
|
||||
componentsJson: jsonEncode(components),
|
||||
content: _buildSearchContent(normalizedComponents, contentOverride),
|
||||
componentsJson: jsonEncode(normalizedComponents),
|
||||
);
|
||||
|
||||
final id = messageBox.put(message);
|
||||
message.id = id;
|
||||
_attachReferencedFiles(components);
|
||||
_attachReferencedFiles(normalizedComponents);
|
||||
_syncMessageMediaIndex(message, normalizedComponents);
|
||||
|
||||
broadcastMessageCreated(
|
||||
channelId: channelId,
|
||||
@@ -207,7 +299,6 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Edit a message
|
||||
Future<Response> editMessageHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
|
||||
@@ -238,7 +329,6 @@ Future<Response> editMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Get user to verify ownership
|
||||
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = userQuery.findFirst();
|
||||
userQuery.close();
|
||||
@@ -258,12 +348,21 @@ Future<Response> editMessageHandler(Request req) async {
|
||||
body: Body.fromString('content or components/componentsJson required'),
|
||||
);
|
||||
}
|
||||
final validationError = _validateComponents(components);
|
||||
if (validationError != null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString(validationError),
|
||||
);
|
||||
}
|
||||
|
||||
message.content = _buildSearchContent(components, contentOverride);
|
||||
message.componentsJson = jsonEncode(components);
|
||||
final normalizedComponents = _normalizeComponentsForStorage(components);
|
||||
|
||||
message.content = _buildSearchContent(normalizedComponents, contentOverride);
|
||||
message.componentsJson = jsonEncode(normalizedComponents);
|
||||
message.editedAt = DateTime.now();
|
||||
messageBox.put(message);
|
||||
_attachReferencedFiles(components);
|
||||
_attachReferencedFiles(normalizedComponents);
|
||||
_syncMessageMediaIndex(message, normalizedComponents);
|
||||
|
||||
broadcastMessageUpdated(
|
||||
channelId: message.channelId,
|
||||
@@ -280,7 +379,6 @@ Future<Response> editMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete a message
|
||||
Future<Response> deleteMessageHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
|
||||
@@ -311,7 +409,6 @@ Future<Response> deleteMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Get user to verify ownership
|
||||
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = userQuery.findFirst();
|
||||
userQuery.close();
|
||||
@@ -324,6 +421,7 @@ Future<Response> deleteMessageHandler(Request req) async {
|
||||
|
||||
final channelId = message.channelId;
|
||||
messageBox.remove(messageId);
|
||||
_removeMessageMediaIndex(messageId);
|
||||
|
||||
broadcastMessageDeleted(
|
||||
channelId: channelId,
|
||||
@@ -353,6 +451,27 @@ Map<String, dynamic> _messageToMap(ChatMessage message) {
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mediaToMap(ChatMessageMedia media) {
|
||||
final url = media.attachmentId != null && media.attachmentId!.isNotEmpty ? '/attachments/${media.attachmentId}' : media.externalUrl;
|
||||
return {
|
||||
'id': media.id,
|
||||
'messageId': media.messageId,
|
||||
'channelId': media.channelId,
|
||||
'authorUserId': media.authorUserId,
|
||||
'kind': media.kind,
|
||||
'url': url,
|
||||
'attachmentId': media.attachmentId,
|
||||
'externalUrl': media.externalUrl,
|
||||
'mimeType': media.mimeType,
|
||||
'originalFileName': media.originalFileName,
|
||||
'sizeBytes': media.sizeBytes,
|
||||
'width': media.width,
|
||||
'height': media.height,
|
||||
'thumbHash': media.thumbHash,
|
||||
'createdAt': media.createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
|
||||
final rawComponents = body['components'];
|
||||
if (rawComponents is List) {
|
||||
@@ -400,7 +519,9 @@ List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
|
||||
|
||||
return <Map<String, dynamic>>[
|
||||
{
|
||||
'id': 'text_0',
|
||||
'type': 'text',
|
||||
'typeVersion': 1,
|
||||
'modifiers': <String>[],
|
||||
'fallback': content,
|
||||
'data': {
|
||||
@@ -410,6 +531,151 @@ List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
|
||||
];
|
||||
}
|
||||
|
||||
String? _validateComponents(List<Map<String, dynamic>> components) {
|
||||
final counter = _ComponentCounter();
|
||||
for (var i = 0; i < components.length; i++) {
|
||||
final err = _validateComponentNode(
|
||||
components[i],
|
||||
depth: 1,
|
||||
path: 'components[$i]',
|
||||
counter: counter,
|
||||
);
|
||||
if (err != null) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateComponentNode(
|
||||
Map<String, dynamic> node, {
|
||||
required int depth,
|
||||
required String path,
|
||||
required _ComponentCounter counter,
|
||||
}) {
|
||||
counter.count++;
|
||||
if (counter.count > _maxComponentNodes) {
|
||||
return 'Too many components (max $_maxComponentNodes)';
|
||||
}
|
||||
if (depth > _maxComponentDepth) {
|
||||
return 'Component nesting too deep at $path';
|
||||
}
|
||||
|
||||
final id = node['id'];
|
||||
if (id is! String || id.trim().isEmpty) {
|
||||
return 'Component id is required at $path';
|
||||
}
|
||||
final type = node['type'];
|
||||
if (type is! String || type.trim().isEmpty) {
|
||||
return 'Component type is required at $path';
|
||||
}
|
||||
final typeVersion = node['typeVersion'];
|
||||
if (typeVersion is! int || typeVersion <= 0) {
|
||||
return 'Component typeVersion must be a positive int at $path';
|
||||
}
|
||||
|
||||
final data = node['data'];
|
||||
if (data is! Map) {
|
||||
return 'Component data must be an object at $path';
|
||||
}
|
||||
|
||||
final children = data['children'];
|
||||
if (children is List) {
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
final child = children[i];
|
||||
if (child is! Map) {
|
||||
return 'children[$i] must be an object at $path';
|
||||
}
|
||||
final childMap = Map<String, dynamic>.from(
|
||||
child.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
final err = _validateComponentNode(
|
||||
childMap,
|
||||
depth: depth + 1,
|
||||
path: '$path.children[$i]',
|
||||
counter: counter,
|
||||
);
|
||||
if (err != null) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final items = data['items'];
|
||||
if (items is List) {
|
||||
if (items.length > _maxMediaItemsPerComponent) {
|
||||
return 'Too many media items at $path (max $_maxMediaItemsPerComponent)';
|
||||
}
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
final item = items[i];
|
||||
if (item is! Map) {
|
||||
return 'media item must be an object at $path.items[$i]';
|
||||
}
|
||||
final itemMap = Map<String, dynamic>.from(
|
||||
item.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
final err = _validateMediaRef(itemMap, '$path.items[$i]');
|
||||
if (err != null) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final thumbnail = data['thumbnail'];
|
||||
if (thumbnail is Map) {
|
||||
final err = _validateMediaRef(
|
||||
Map<String, dynamic>.from(
|
||||
thumbnail.map((k, v) => MapEntry(k.toString(), v)),
|
||||
),
|
||||
'$path.thumbnail',
|
||||
);
|
||||
if (err != null) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateMediaRef(Map<String, dynamic> media, String path) {
|
||||
final source = (media['source'] as String?)?.trim();
|
||||
final attachmentId = ((media['attachmentId'] ?? media['uploadId']) as String?)?.trim();
|
||||
final url = (media['url'] as String?)?.trim();
|
||||
|
||||
final resolvedSource = source ??
|
||||
(attachmentId != null && attachmentId.isNotEmpty ? 'local_upload' : (url != null && url.isNotEmpty ? 'external_url' : null));
|
||||
|
||||
if (resolvedSource == null) {
|
||||
return 'Media source is required at $path';
|
||||
}
|
||||
|
||||
if (resolvedSource == 'local_upload') {
|
||||
if (attachmentId == null || attachmentId.isEmpty) {
|
||||
return 'attachmentId is required for local_upload at $path';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resolvedSource == 'external_url') {
|
||||
if (url == null || url.isEmpty) {
|
||||
return 'url is required for external_url at $path';
|
||||
}
|
||||
if (url.length > _maxExternalUrlLength) {
|
||||
return 'url is too long at $path';
|
||||
}
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !uri.hasAuthority) {
|
||||
return 'url is invalid at $path';
|
||||
}
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
return 'url must use http/https at $path';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Unsupported media source `$resolvedSource` at $path';
|
||||
}
|
||||
|
||||
String _buildSearchContent(
|
||||
List<Map<String, dynamic>> components,
|
||||
String? contentOverride,
|
||||
@@ -420,68 +686,461 @@ String _buildSearchContent(
|
||||
|
||||
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');
|
||||
_collectSearchText(component, parts);
|
||||
}
|
||||
|
||||
final content = parts.join(' ').trim();
|
||||
final content = parts.map((s) => s.trim()).where((s) => s.isNotEmpty).join(' ').replaceAll(RegExp(r'\s+'), ' ').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;
|
||||
}
|
||||
void _collectSearchText(Map<String, dynamic> component, List<String> out) {
|
||||
final fallback = component['fallback'];
|
||||
if (fallback is String && fallback.trim().isNotEmpty) {
|
||||
out.add(fallback);
|
||||
}
|
||||
|
||||
final items = data['items'];
|
||||
if (items is! List) {
|
||||
continue;
|
||||
final data = component['data'];
|
||||
if (data is! Map) {
|
||||
return;
|
||||
}
|
||||
final map = Map<String, dynamic>.from(
|
||||
data.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
const keys = <String>[
|
||||
'text',
|
||||
'title',
|
||||
'description',
|
||||
'caption',
|
||||
'siteName',
|
||||
'author',
|
||||
'originalFileName',
|
||||
];
|
||||
for (final key in keys) {
|
||||
final value = map[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
out.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
final url = map['url'];
|
||||
if (url is String && url.trim().isNotEmpty) {
|
||||
out.add(url);
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && uri.host.isNotEmpty) {
|
||||
out.add(uri.host);
|
||||
}
|
||||
}
|
||||
|
||||
final children = map['children'];
|
||||
if (children is List) {
|
||||
for (final child in children) {
|
||||
if (child is Map<String, dynamic>) {
|
||||
_collectSearchText(child, out);
|
||||
} else if (child is Map) {
|
||||
_collectSearchText(
|
||||
child.map((k, v) => MapEntry(k.toString(), v)),
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final fileId in referencedFileIds) {
|
||||
final file = fileBox.get(fileId);
|
||||
if (file == null || file.isAttached) {
|
||||
final items = map['items'];
|
||||
if (items is List) {
|
||||
for (final item in items) {
|
||||
if (item is Map) {
|
||||
final itemMap = Map<String, dynamic>.from(
|
||||
item.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
for (final key in keys) {
|
||||
final value = itemMap[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
out.add(value);
|
||||
}
|
||||
}
|
||||
final itemUrl = itemMap['url'];
|
||||
if (itemUrl is String && itemUrl.trim().isNotEmpty) {
|
||||
out.add(itemUrl);
|
||||
final uri = Uri.tryParse(itemUrl);
|
||||
if (uri != null && uri.host.isNotEmpty) {
|
||||
out.add(uri.host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _attachReferencedFiles(List<Map<String, dynamic>> components) {
|
||||
final referencedAttachmentIds = <String>{};
|
||||
_walkMediaRefs(components, (item) {
|
||||
final source = (item['source'] as String?)?.trim();
|
||||
if (source != null && source != 'local_upload') {
|
||||
return;
|
||||
}
|
||||
final attachmentId = item['attachmentId'] ?? item['uploadId'];
|
||||
if (attachmentId is String && attachmentId.trim().isNotEmpty) {
|
||||
referencedAttachmentIds.add(attachmentId.trim());
|
||||
}
|
||||
});
|
||||
|
||||
for (final attachmentId in referencedAttachmentIds) {
|
||||
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
|
||||
final file = query.findFirst();
|
||||
query.close();
|
||||
if (file == null || file.isAttached || file.status != 'ready') {
|
||||
continue;
|
||||
}
|
||||
file.isAttached = true;
|
||||
fileBox.put(file);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _normalizeComponentsForStorage(
|
||||
List<Map<String, dynamic>> components,
|
||||
) {
|
||||
final normalized = <Map<String, dynamic>>[];
|
||||
for (final rawComponent in components) {
|
||||
normalized.add(_normalizeComponentNode(rawComponent));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _normalizeComponentNode(Map<String, dynamic> rawNode) {
|
||||
final node = Map<String, dynamic>.from(rawNode);
|
||||
final dataRaw = node['data'];
|
||||
if (dataRaw is! Map) {
|
||||
return node;
|
||||
}
|
||||
|
||||
final data = Map<String, dynamic>.from(
|
||||
dataRaw.map((key, value) => MapEntry(key.toString(), value)),
|
||||
);
|
||||
|
||||
final itemsRaw = data['items'];
|
||||
if (itemsRaw is List) {
|
||||
final normalizedItems = <Map<String, dynamic>>[];
|
||||
for (final itemRaw in itemsRaw) {
|
||||
if (itemRaw is! Map) {
|
||||
continue;
|
||||
}
|
||||
final item = Map<String, dynamic>.from(
|
||||
itemRaw.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
normalizedItems.add(_normalizeMediaItem(item));
|
||||
}
|
||||
data['items'] = normalizedItems;
|
||||
}
|
||||
|
||||
final thumbnailRaw = data['thumbnail'];
|
||||
if (thumbnailRaw is Map) {
|
||||
final thumb = Map<String, dynamic>.from(
|
||||
thumbnailRaw.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
data['thumbnail'] = _normalizeMediaItem(thumb);
|
||||
}
|
||||
|
||||
final childrenRaw = data['children'];
|
||||
if (childrenRaw is List) {
|
||||
final normalizedChildren = <Map<String, dynamic>>[];
|
||||
for (final child in childrenRaw) {
|
||||
if (child is Map<String, dynamic>) {
|
||||
normalizedChildren.add(_normalizeComponentNode(child));
|
||||
} else if (child is Map) {
|
||||
normalizedChildren.add(
|
||||
_normalizeComponentNode(
|
||||
child.map((k, v) => MapEntry(k.toString(), v)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
data['children'] = normalizedChildren;
|
||||
}
|
||||
|
||||
node['data'] = data;
|
||||
return node;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _normalizeMediaItem(Map<String, dynamic> item) {
|
||||
final source = (item['source'] as String?)?.trim();
|
||||
final rawAttachmentId = item['attachmentId'] ?? item['uploadId'];
|
||||
final rawUrl = item['url'];
|
||||
|
||||
final attachmentId = rawAttachmentId is String ? rawAttachmentId.trim() : null;
|
||||
final externalUrl = rawUrl is String ? rawUrl.trim() : null;
|
||||
|
||||
final resolvedSource = source ??
|
||||
(attachmentId != null && attachmentId.isNotEmpty
|
||||
? 'local_upload'
|
||||
: (externalUrl != null && externalUrl.isNotEmpty ? 'external_url' : null));
|
||||
|
||||
if (resolvedSource == 'external_url') {
|
||||
item['source'] = 'external_url';
|
||||
item['status'] = 'ready';
|
||||
if (externalUrl != null && externalUrl.isNotEmpty) {
|
||||
item['url'] = externalUrl;
|
||||
}
|
||||
item.remove('attachmentId');
|
||||
item.remove('uploadId');
|
||||
return item;
|
||||
}
|
||||
|
||||
if (resolvedSource != 'local_upload' || attachmentId == null || attachmentId.isEmpty) {
|
||||
return item;
|
||||
}
|
||||
|
||||
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
|
||||
final file = query.findFirst();
|
||||
query.close();
|
||||
|
||||
item['source'] = 'local_upload';
|
||||
item['attachmentId'] = attachmentId;
|
||||
item['uploadId'] = attachmentId;
|
||||
|
||||
if (file == null) {
|
||||
item['status'] = 'failed';
|
||||
item['errorCode'] = 'upload_not_found';
|
||||
return item;
|
||||
}
|
||||
|
||||
item['originalFileName'] = file.originalFileName;
|
||||
item['sizeBytes'] = file.sizeBytes;
|
||||
if (file.width > 0) {
|
||||
item['width'] = file.width;
|
||||
}
|
||||
if (file.height > 0) {
|
||||
item['height'] = file.height;
|
||||
}
|
||||
item['mimeType'] = file.mimeType;
|
||||
|
||||
if (file.status == 'ready') {
|
||||
item['status'] = 'ready';
|
||||
item['url'] = '/attachments/${file.attachmentId}';
|
||||
item.remove('errorCode');
|
||||
} else if (file.status == 'failed') {
|
||||
item['status'] = 'failed';
|
||||
if (file.errorCode != null && file.errorCode!.isNotEmpty) {
|
||||
item['errorCode'] = file.errorCode;
|
||||
}
|
||||
} else {
|
||||
item['status'] = 'uploading';
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
void _syncMessageMediaIndex(
|
||||
ChatMessage message,
|
||||
List<Map<String, dynamic>> components,
|
||||
) {
|
||||
_removeMessageMediaIndex(message.id);
|
||||
|
||||
final entries = <ChatMessageMedia>[];
|
||||
_walkMediaRefs(components, (item) {
|
||||
final status = (item['status'] as String?)?.trim();
|
||||
if (status != null && status != 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
final source = (item['source'] as String?)?.trim();
|
||||
final mimeType = (item['mimeType'] as String?)?.trim();
|
||||
final originalFileName = (item['originalFileName'] as String?)?.trim();
|
||||
final thumbHash = (item['thumbHash'] as String?)?.trim();
|
||||
final sizeBytes = _asInt(item['sizeBytes']);
|
||||
final width = _asInt(item['width']);
|
||||
final height = _asInt(item['height']);
|
||||
|
||||
if (source == 'external_url') {
|
||||
final url = (item['url'] as String?)?.trim();
|
||||
if (url == null || url.isEmpty) {
|
||||
return;
|
||||
}
|
||||
entries.add(
|
||||
ChatMessageMedia(
|
||||
messageId: message.id,
|
||||
channelId: message.channelId,
|
||||
authorUserId: message.authorUserId,
|
||||
externalUrl: url,
|
||||
kind: _inferKind(mimeType: mimeType, url: url),
|
||||
mimeType: mimeType,
|
||||
originalFileName: originalFileName,
|
||||
sizeBytes: sizeBytes,
|
||||
width: width,
|
||||
height: height,
|
||||
thumbHash: thumbHash,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final attachmentId = ((item['attachmentId'] ?? item['uploadId']) as String?)?.trim();
|
||||
if (attachmentId == null || attachmentId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
entries.add(
|
||||
ChatMessageMedia(
|
||||
messageId: message.id,
|
||||
channelId: message.channelId,
|
||||
authorUserId: message.authorUserId,
|
||||
attachmentId: attachmentId,
|
||||
kind: _inferKind(
|
||||
mimeType: mimeType,
|
||||
url: '/attachments/$attachmentId',
|
||||
),
|
||||
mimeType: mimeType,
|
||||
originalFileName: originalFileName,
|
||||
sizeBytes: sizeBytes,
|
||||
width: width,
|
||||
height: height,
|
||||
thumbHash: thumbHash,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
for (final row in entries) {
|
||||
messageMediaBox.put(row);
|
||||
}
|
||||
}
|
||||
|
||||
void _removeMessageMediaIndex(int messageId) {
|
||||
final query = messageMediaBox.query(ChatMessageMedia_.messageId.equals(messageId)).build();
|
||||
query.remove();
|
||||
query.close();
|
||||
}
|
||||
|
||||
void _walkMediaRefs(
|
||||
List<Map<String, dynamic>> components,
|
||||
void Function(Map<String, dynamic> item) visitor,
|
||||
) {
|
||||
for (final component in components) {
|
||||
final dataRaw = component['data'];
|
||||
if (dataRaw is! Map) {
|
||||
continue;
|
||||
}
|
||||
final data = Map<String, dynamic>.from(
|
||||
dataRaw.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
|
||||
final items = data['items'];
|
||||
if (items is List) {
|
||||
for (final item in items) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
visitor(
|
||||
Map<String, dynamic>.from(
|
||||
item.map((k, v) => MapEntry(k.toString(), v)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final thumbnail = data['thumbnail'];
|
||||
if (thumbnail is Map) {
|
||||
visitor(
|
||||
Map<String, dynamic>.from(
|
||||
thumbnail.map((k, v) => MapEntry(k.toString(), v)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final children = data['children'];
|
||||
if (children is List) {
|
||||
final childComponents = <Map<String, dynamic>>[];
|
||||
for (final child in children) {
|
||||
if (child is Map<String, dynamic>) {
|
||||
childComponents.add(child);
|
||||
} else if (child is Map) {
|
||||
childComponents.add(child.map((k, v) => MapEntry(k.toString(), v)));
|
||||
}
|
||||
}
|
||||
if (childComponents.isNotEmpty) {
|
||||
_walkMediaRefs(childComponents, visitor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _inferKind({String? mimeType, String? url}) {
|
||||
final lowerMime = (mimeType ?? '').toLowerCase();
|
||||
if (lowerMime.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
if (lowerMime.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
final lowerUrl = (url ?? '').toLowerCase();
|
||||
if (lowerUrl.endsWith('.png') ||
|
||||
lowerUrl.endsWith('.jpg') ||
|
||||
lowerUrl.endsWith('.jpeg') ||
|
||||
lowerUrl.endsWith('.gif') ||
|
||||
lowerUrl.endsWith('.webp') ||
|
||||
lowerUrl.endsWith('.avif')) {
|
||||
return 'image';
|
||||
}
|
||||
if (lowerUrl.endsWith('.mp4') || lowerUrl.endsWith('.webm') || lowerUrl.endsWith('.mov')) {
|
||||
return 'video';
|
||||
}
|
||||
return 'file';
|
||||
}
|
||||
|
||||
int? _asInt(Object? value) {
|
||||
return switch (value) {
|
||||
int v => v,
|
||||
double v => v.round(),
|
||||
String v => int.tryParse(v),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
class _ComponentCounter {
|
||||
int count = 0;
|
||||
}
|
||||
|
||||
Future<void> resolvePendingUploadsInMessages(String attachmentId) async {
|
||||
final messages = messageBox.getAll();
|
||||
for (final message in messages) {
|
||||
if (!message.componentsJson.contains(attachmentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(message.componentsJson);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (decoded is! List) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
if (components.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final normalized = _normalizeComponentsForStorage(components);
|
||||
final nextJson = jsonEncode(normalized);
|
||||
if (nextJson == message.componentsJson) {
|
||||
continue;
|
||||
}
|
||||
|
||||
message.componentsJson = nextJson;
|
||||
message.content = _buildSearchContent(normalized, null);
|
||||
messageBox.put(message);
|
||||
_attachReferencedFiles(normalized);
|
||||
_syncMessageMediaIndex(message, normalized);
|
||||
|
||||
broadcastMessageUpdated(
|
||||
channelId: message.channelId,
|
||||
message: _messageToMap(message),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:chat_server/objectbox.g.dart';
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import 'auth_handlers.dart';
|
||||
import '../state/app_state.dart';
|
||||
import '../utils/extensions.dart';
|
||||
|
||||
@@ -38,10 +39,11 @@ Future<Response> publicUserHandler(Request req) async {
|
||||
'userId': user.userId,
|
||||
'username': user.username,
|
||||
'displayName': user.displayName,
|
||||
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
|
||||
'avatarUrl': user.avatarUrl,
|
||||
'bio': user.bio,
|
||||
'publicKey': user.publicKey,
|
||||
'homeServer': Config.serverHost,
|
||||
'homeServer': user.homeServer ?? Config.publicBaseUrl,
|
||||
'createdAt': user.createdAt.toIso8601String(),
|
||||
}.toBody,
|
||||
);
|
||||
@@ -84,6 +86,7 @@ Future<Response> publicVerifyHandler(Request req) async {
|
||||
'userId': user.userId,
|
||||
'publicKey': user.publicKey,
|
||||
'username': user.username,
|
||||
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
@@ -93,11 +96,18 @@ Future<Response> publicVerifyHandler(Request req) async {
|
||||
/// GET /.well-known/server
|
||||
/// Returns: { serverHost, serverName, version }
|
||||
Future<Response> publicServerInfoHandler(Request req) async {
|
||||
final settingsQuery = serverSettingsBox.query(ServerSettings_.singletonKey.equals('default')).build();
|
||||
final settings = settingsQuery.findFirst();
|
||||
settingsQuery.close();
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'serverHost': Config.serverHost,
|
||||
'serverName': Config.serverName,
|
||||
'serverHost': Config.publicHost,
|
||||
'publicBaseUrl': Config.publicBaseUrl,
|
||||
'serverName': settings?.serverName ?? Config.serverName,
|
||||
'version': '0.1.0',
|
||||
'iconUrl': settings?.iconAttachmentId == null ? null : '/icons/${Uri.encodeComponent(Config.publicHost)}',
|
||||
'bannerUrl': settings?.bannerAttachmentId == null ? null : '/banners/${Uri.encodeComponent(Config.publicHost)}',
|
||||
'features': ['identity', 'chat', 'sticker-packs'],
|
||||
}.toBody,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chat_server/models.dart';
|
||||
import 'package:chat_server/objectbox.g.dart';
|
||||
import 'package:relic/relic.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../auth/auth_middleware.dart';
|
||||
import '../state/app_state.dart';
|
||||
import '../utils/extensions.dart';
|
||||
|
||||
Future<Response> getServerSettingsHandler(Request req) async {
|
||||
final auth = _requireCurrentUser(req);
|
||||
if (auth.$2 != null) {
|
||||
return auth.$2!;
|
||||
}
|
||||
|
||||
final settings = _ensureServerSettings();
|
||||
return Response.ok(
|
||||
body: _serverSettingsToMap(settings).toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> patchServerSettingsHandler(Request req) async {
|
||||
final auth = _requireCurrentUser(req);
|
||||
if (auth.$2 != null) {
|
||||
return auth.$2!;
|
||||
}
|
||||
final user = auth.$1!;
|
||||
if (!_isAdmin(user)) {
|
||||
return Response.forbidden(
|
||||
body: Body.fromString('Admin role required'),
|
||||
);
|
||||
}
|
||||
|
||||
final body = await req.asJson();
|
||||
final settings = _ensureServerSettings();
|
||||
|
||||
final nextServerName = (body['serverName'] as String?)?.trim();
|
||||
final nextDescription = (body['description'] as String?)?.trim();
|
||||
|
||||
if (nextServerName != null && nextServerName.isNotEmpty) {
|
||||
settings.serverName = nextServerName;
|
||||
}
|
||||
if (body.containsKey('description')) {
|
||||
settings.description = nextDescription != null && nextDescription.isNotEmpty ? nextDescription : null;
|
||||
}
|
||||
|
||||
settings.updatedByUserId = user.userId;
|
||||
settings.updatedAt = DateTime.now();
|
||||
serverSettingsBox.put(settings);
|
||||
|
||||
return Response.ok(
|
||||
body: _serverSettingsToMap(settings).toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> updateProfileHandler(Request req) async {
|
||||
final auth = _requireCurrentUser(req);
|
||||
if (auth.$2 != null) {
|
||||
return auth.$2!;
|
||||
}
|
||||
final user = auth.$1!;
|
||||
|
||||
final body = await req.asJson();
|
||||
final displayName = (body['displayName'] as String?)?.trim();
|
||||
final bio = (body['bio'] as String?)?.trim();
|
||||
|
||||
if (body.containsKey('displayName')) {
|
||||
user.displayName = displayName != null && displayName.isNotEmpty ? displayName : null;
|
||||
}
|
||||
if (body.containsKey('bio')) {
|
||||
user.bio = bio != null && bio.isNotEmpty ? bio : null;
|
||||
}
|
||||
|
||||
userBox.put(user);
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'userId': user.userId,
|
||||
'username': user.username,
|
||||
'displayName': user.displayName,
|
||||
'avatarUrl': user.avatarUrl,
|
||||
'bio': user.bio,
|
||||
'roles': user.roles,
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> setAvatarHandler(Request req) async {
|
||||
final auth = _requireCurrentUser(req);
|
||||
if (auth.$2 != null) {
|
||||
return auth.$2!;
|
||||
}
|
||||
final user = auth.$1!;
|
||||
|
||||
final body = await req.asJson();
|
||||
final attachmentId = (body['attachmentId'] as String?)?.trim();
|
||||
if (attachmentId == null || attachmentId.isEmpty) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('attachmentId is required'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
|
||||
final file = query.findFirst();
|
||||
query.close();
|
||||
if (file == null || file.status != 'ready') {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Attachment not found or not ready'),
|
||||
);
|
||||
}
|
||||
if (file.uploaderId != user.id) {
|
||||
return Response.forbidden(
|
||||
body: Body.fromString('Attachment must be uploaded by current user'),
|
||||
);
|
||||
}
|
||||
|
||||
file.usageType = 'avatar';
|
||||
fileBox.put(file);
|
||||
|
||||
user.avatarUrl = '/avatars/${Uri.encodeComponent(user.userId)}';
|
||||
userBox.put(user);
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'userId': user.userId,
|
||||
'avatarAttachmentId': file.attachmentId,
|
||||
'avatarUrl': user.avatarUrl,
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> setServerIconHandler(Request req) async {
|
||||
return _setServerMedia(req, usageType: 'server_icon', target: 'icon');
|
||||
}
|
||||
|
||||
Future<Response> setServerBannerHandler(Request req) async {
|
||||
return _setServerMedia(req, usageType: 'server_banner', target: 'banner');
|
||||
}
|
||||
|
||||
Future<Response> getAvatarAliasHandler(Request req) async {
|
||||
final userId = req.rawPathParameters[#userId];
|
||||
if (userId == null || userId.isEmpty) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('userId is required'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
if (user == null || user.avatarUrl == null) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Avatar not found'),
|
||||
);
|
||||
}
|
||||
|
||||
final avatarQuery = fileBox
|
||||
.query(
|
||||
ChatFile_.uploaderId.equals(user.id) & ChatFile_.usageType.equals('avatar') & ChatFile_.status.equals('ready'),
|
||||
)
|
||||
.order(ChatFile_.id, flags: Order.descending)
|
||||
.build();
|
||||
final avatar = avatarQuery.findFirst();
|
||||
avatarQuery.close();
|
||||
if (avatar == null) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Avatar not found'),
|
||||
);
|
||||
}
|
||||
return _serveAttachment(avatar.attachmentId, mutableAlias: true);
|
||||
}
|
||||
|
||||
Future<Response> getServerIconAliasHandler(Request req) async {
|
||||
final serverId = req.rawPathParameters[#serverId];
|
||||
if (!_matchesCurrentServer(serverId)) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Server not found'),
|
||||
);
|
||||
}
|
||||
final settings = _ensureServerSettings();
|
||||
final attachmentId = settings.iconAttachmentId;
|
||||
if (attachmentId == null || attachmentId.isEmpty) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Server icon not found'),
|
||||
);
|
||||
}
|
||||
return _serveAttachment(attachmentId, mutableAlias: true);
|
||||
}
|
||||
|
||||
Future<Response> getServerBannerAliasHandler(Request req) async {
|
||||
final serverId = req.rawPathParameters[#serverId];
|
||||
if (!_matchesCurrentServer(serverId)) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Server not found'),
|
||||
);
|
||||
}
|
||||
final settings = _ensureServerSettings();
|
||||
final attachmentId = settings.bannerAttachmentId;
|
||||
if (attachmentId == null || attachmentId.isEmpty) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Server banner not found'),
|
||||
);
|
||||
}
|
||||
return _serveAttachment(attachmentId, mutableAlias: true);
|
||||
}
|
||||
|
||||
Future<Response> _setServerMedia(
|
||||
Request req, {
|
||||
required String usageType,
|
||||
required String target,
|
||||
}) async {
|
||||
final auth = _requireCurrentUser(req);
|
||||
if (auth.$2 != null) {
|
||||
return auth.$2!;
|
||||
}
|
||||
final user = auth.$1!;
|
||||
if (!_isAdmin(user)) {
|
||||
return Response.forbidden(
|
||||
body: Body.fromString('Admin role required'),
|
||||
);
|
||||
}
|
||||
|
||||
final body = await req.asJson();
|
||||
final attachmentId = (body['attachmentId'] as String?)?.trim();
|
||||
if (attachmentId == null || attachmentId.isEmpty) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('attachmentId is required'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
|
||||
final file = query.findFirst();
|
||||
query.close();
|
||||
if (file == null || file.status != 'ready') {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Attachment not found or not ready'),
|
||||
);
|
||||
}
|
||||
|
||||
file.usageType = usageType;
|
||||
fileBox.put(file);
|
||||
|
||||
final settings = _ensureServerSettings();
|
||||
if (target == 'icon') {
|
||||
settings.iconAttachmentId = attachmentId;
|
||||
} else {
|
||||
settings.bannerAttachmentId = attachmentId;
|
||||
}
|
||||
settings.updatedByUserId = user.userId;
|
||||
settings.updatedAt = DateTime.now();
|
||||
serverSettingsBox.put(settings);
|
||||
|
||||
return Response.ok(
|
||||
body: _serverSettingsToMap(settings).toBody,
|
||||
);
|
||||
}
|
||||
|
||||
(ChatUser?, Response?) _requireCurrentUser(Request req) {
|
||||
final userId = userIdProperty[req];
|
||||
if (userId == null) {
|
||||
return (null, Response.unauthorized(body: Body.fromString('Not authenticated')));
|
||||
}
|
||||
|
||||
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
if (user == null) {
|
||||
return (null, Response.unauthorized(body: Body.fromString('User not found')));
|
||||
}
|
||||
|
||||
return (user, null);
|
||||
}
|
||||
|
||||
bool _isAdmin(ChatUser user) => user.roles.contains('admin');
|
||||
|
||||
ServerSettings _ensureServerSettings() {
|
||||
final query = serverSettingsBox.query(ServerSettings_.singletonKey.equals('default')).build();
|
||||
final existing = query.findFirst();
|
||||
query.close();
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
final settings = ServerSettings(serverName: Config.serverName);
|
||||
settings.id = serverSettingsBox.put(settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
bool _matchesCurrentServer(String? serverId) {
|
||||
if (serverId == null || serverId.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (serverId == 'current') {
|
||||
return true;
|
||||
}
|
||||
return serverId == Config.publicHost;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _serverSettingsToMap(ServerSettings settings) {
|
||||
return {
|
||||
'serverId': Config.publicHost,
|
||||
'publicBaseUrl': Config.publicBaseUrl,
|
||||
'serverName': settings.serverName,
|
||||
'description': settings.description,
|
||||
'iconUrl': settings.iconAttachmentId == null ? null : '/icons/${Uri.encodeComponent(Config.publicHost)}',
|
||||
'bannerUrl': settings.bannerAttachmentId == null ? null : '/banners/${Uri.encodeComponent(Config.publicHost)}',
|
||||
'iconAttachmentId': settings.iconAttachmentId,
|
||||
'bannerAttachmentId': settings.bannerAttachmentId,
|
||||
'updatedAt': settings.updatedAt.toIso8601String(),
|
||||
'updatedByUserId': settings.updatedByUserId,
|
||||
};
|
||||
}
|
||||
|
||||
Future<Response> _serveAttachment(
|
||||
String attachmentId, {
|
||||
required bool mutableAlias,
|
||||
}) async {
|
||||
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
|
||||
final file = query.findFirst();
|
||||
query.close();
|
||||
if (file == null || file.status != 'ready' || file.storedFileName.isEmpty) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Attachment not found'),
|
||||
);
|
||||
}
|
||||
|
||||
final diskPath = 'uploads${Platform.pathSeparator}${file.storedFileName}';
|
||||
final diskFile = File(diskPath);
|
||||
if (!diskFile.existsSync()) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Attachment file missing on disk'),
|
||||
);
|
||||
}
|
||||
|
||||
final cacheControl = mutableAlias ? 'public, max-age=0, must-revalidate' : 'public, max-age=31536000, immutable';
|
||||
|
||||
return Response.ok(
|
||||
headers: Headers.fromMap(
|
||||
{
|
||||
Headers.contentTypeHeader: [file.mimeType],
|
||||
Headers.cacheControlHeader: [cacheControl],
|
||||
},
|
||||
),
|
||||
body: Body.fromData(diskFile.readAsBytesSync()),
|
||||
);
|
||||
}
|
||||
@@ -1,108 +1,217 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:chat_server/models.dart';
|
||||
import 'package:chat_server/objectbox.g.dart';
|
||||
import 'package:crypto/crypto.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';
|
||||
import 'message_handlers.dart';
|
||||
|
||||
const _uploadsDir = 'uploads';
|
||||
const _maxUploadBytes = 25 * 1024 * 1024;
|
||||
const _maxUploadBytes = 50 * 1024 * 1024;
|
||||
|
||||
Future<Response> uploadFileHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
if (userId == null) {
|
||||
return Response.unauthorized(
|
||||
body: Body.fromString('Not authenticated'),
|
||||
);
|
||||
Future<Response> initUploadHandler(Request req) async {
|
||||
final currentUser = _requireCurrentUser(req);
|
||||
if (currentUser.$2 != null) {
|
||||
return currentUser.$2!;
|
||||
}
|
||||
final user = currentUser.$1!;
|
||||
|
||||
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 body = await req.asJson();
|
||||
final usageType = (body['usageType'] as String?)?.trim() ?? 'message';
|
||||
final originalFileName = (body['originalFileName'] as String?)?.trim() ?? '';
|
||||
final declaredMimeType = (body['mimeType'] as String?)?.trim() ?? 'application/octet-stream';
|
||||
final declaredWidth = _asInt(body['width']) ?? 0;
|
||||
final declaredHeight = _asInt(body['height']) ?? 0;
|
||||
final declaredSizeBytes = _asInt(body['sizeBytes']) ?? 0;
|
||||
|
||||
final contentTypeHeader = req.headers[Headers.contentTypeHeader]?.first;
|
||||
final boundary = _extractBoundary(contentTypeHeader);
|
||||
if (boundary == null) {
|
||||
if (!_isAllowedUsageType(usageType)) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Expected multipart/form-data with boundary'),
|
||||
body: Body.fromString('Invalid usageType'),
|
||||
);
|
||||
}
|
||||
|
||||
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 attachmentId = _generateAttachmentId();
|
||||
final entity = ChatFile(
|
||||
attachmentId: attachmentId,
|
||||
uploaderId: user.id,
|
||||
path: '',
|
||||
originalFileName: originalFileName.isEmpty ? 'upload-${attachmentId.substring(0, 8)}' : originalFileName,
|
||||
storedFileName: '',
|
||||
sha256: '',
|
||||
mimeType: declaredMimeType,
|
||||
width: declaredWidth,
|
||||
height: declaredHeight,
|
||||
sizeBytes: declaredSizeBytes,
|
||||
usageType: usageType,
|
||||
status: 'pending',
|
||||
);
|
||||
|
||||
final bytesBuilder = BytesBuilder(copy: false);
|
||||
await for (final chunk in uploadPart) {
|
||||
bytesBuilder.add(chunk);
|
||||
final id = fileBox.put(entity);
|
||||
entity.id = id;
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'uploadId': entity.attachmentId,
|
||||
'attachmentId': entity.attachmentId,
|
||||
'status': entity.status,
|
||||
'usageType': entity.usageType,
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> completeUploadHandler(Request req) async {
|
||||
final currentUser = _requireCurrentUser(req);
|
||||
if (currentUser.$2 != null) {
|
||||
return currentUser.$2!;
|
||||
}
|
||||
final bytes = bytesBuilder.toBytes();
|
||||
final user = currentUser.$1!;
|
||||
|
||||
final uploadId = req.rawPathParameters[#uploadId];
|
||||
if (uploadId == null || uploadId.isEmpty) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('uploadId is required'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = fileBox.query(ChatFile_.attachmentId.equals(uploadId)).build();
|
||||
final file = query.findFirst();
|
||||
query.close();
|
||||
|
||||
if (file == null) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Upload not found'),
|
||||
);
|
||||
}
|
||||
|
||||
if (file.uploaderId != user.id) {
|
||||
return Response.forbidden(
|
||||
body: Body.fromString('You cannot complete another user\'s upload'),
|
||||
);
|
||||
}
|
||||
|
||||
if (file.status == 'ready') {
|
||||
return Response.ok(body: _fileResponseMap(file).toBody);
|
||||
}
|
||||
|
||||
final uploadData = await _readUploadData(req);
|
||||
if (uploadData.$2 != null) {
|
||||
file.status = 'failed';
|
||||
file.errorCode = 'invalid_upload_payload';
|
||||
fileBox.put(file);
|
||||
return uploadData.$2!;
|
||||
}
|
||||
final bytes = uploadData.$1!;
|
||||
final payloadMimeType = uploadData.$3;
|
||||
final payloadFileName = uploadData.$4;
|
||||
|
||||
if (bytes.isEmpty) {
|
||||
file.status = 'failed';
|
||||
file.errorCode = 'empty_file';
|
||||
fileBox.put(file);
|
||||
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,
|
||||
_finalizeUpload(
|
||||
file: file,
|
||||
bytes: bytes,
|
||||
payloadMimeType: payloadMimeType,
|
||||
payloadFileName: payloadFileName,
|
||||
);
|
||||
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);
|
||||
await resolvePendingUploadsInMessages(file.attachmentId);
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'id': id,
|
||||
'url': entity.path,
|
||||
'width': entity.width,
|
||||
'height': entity.height,
|
||||
'mimeType': entity.mimeType,
|
||||
}.toBody,
|
||||
body: _fileResponseMap(file).toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> uploadFileHandler(Request req) async {
|
||||
final currentUser = _requireCurrentUser(req);
|
||||
if (currentUser.$2 != null) {
|
||||
return currentUser.$2!;
|
||||
}
|
||||
final user = currentUser.$1!;
|
||||
|
||||
final uploadData = await _readUploadData(req);
|
||||
if (uploadData.$2 != null) {
|
||||
return uploadData.$2!;
|
||||
}
|
||||
final bytes = uploadData.$1!;
|
||||
final payloadMimeType = uploadData.$3;
|
||||
final payloadFileName = uploadData.$4;
|
||||
|
||||
final attachmentId = _generateAttachmentId();
|
||||
final file = ChatFile(
|
||||
attachmentId: attachmentId,
|
||||
uploaderId: user.id,
|
||||
path: '',
|
||||
originalFileName: payloadFileName ?? 'upload-${attachmentId.substring(0, 8)}',
|
||||
storedFileName: '',
|
||||
sha256: '',
|
||||
mimeType: _normalizeMimeType(payloadMimeType),
|
||||
width: 0,
|
||||
height: 0,
|
||||
sizeBytes: bytes.length,
|
||||
usageType: 'message',
|
||||
status: 'pending',
|
||||
);
|
||||
file.id = fileBox.put(file);
|
||||
|
||||
_finalizeUpload(
|
||||
file: file,
|
||||
bytes: bytes,
|
||||
payloadMimeType: payloadMimeType,
|
||||
payloadFileName: payloadFileName,
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
body: _fileResponseMap(file).toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> getAttachmentHandler(Request req) async {
|
||||
final attachmentId = req.rawPathParameters[#attachmentId];
|
||||
if (attachmentId == null || attachmentId.isEmpty) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid attachment ID'),
|
||||
);
|
||||
}
|
||||
|
||||
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
|
||||
final file = query.findFirst();
|
||||
query.close();
|
||||
|
||||
if (file == null || file.status != 'ready') {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Attachment not found'),
|
||||
);
|
||||
}
|
||||
|
||||
final diskPath = _resolveDiskPath(file);
|
||||
final diskFile = File(diskPath);
|
||||
if (!diskFile.existsSync()) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Attachment file missing on disk'),
|
||||
);
|
||||
}
|
||||
|
||||
return Response.ok(
|
||||
headers: Headers.fromMap(
|
||||
{
|
||||
Headers.contentTypeHeader: [file.mimeType],
|
||||
Headers.cacheControlHeader: ['public, max-age=31536000, immutable'],
|
||||
},
|
||||
),
|
||||
body: Body.fromData(diskFile.readAsBytesSync()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,20 +223,193 @@ Future<Response> getUploadFileHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
final filePath = '$_uploadsDir${Platform.pathSeparator}$fileName';
|
||||
final file = File(filePath);
|
||||
final diskPath = '$_uploadsDir${Platform.pathSeparator}$fileName';
|
||||
final file = File(diskPath);
|
||||
if (!file.existsSync()) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('File not found'),
|
||||
);
|
||||
}
|
||||
|
||||
final data = file.readAsBytesSync();
|
||||
return Response.ok(
|
||||
body: Body.fromData(data),
|
||||
body: Body.fromData(file.readAsBytesSync()),
|
||||
);
|
||||
}
|
||||
|
||||
(ChatUser?, Response?) _requireCurrentUser(Request req) {
|
||||
final userId = userIdProperty[req];
|
||||
if (userId == null) {
|
||||
return (
|
||||
null,
|
||||
Response.unauthorized(
|
||||
body: Body.fromString('Not authenticated'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = query.findFirst();
|
||||
query.close();
|
||||
if (user == null) {
|
||||
return (
|
||||
null,
|
||||
Response.unauthorized(
|
||||
body: Body.fromString('User not found'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (user, null);
|
||||
}
|
||||
|
||||
Future<(Uint8List?, Response?, String?, String?)> _readUploadData(Request req) async {
|
||||
final contentTypeHeader = req.headers[Headers.contentTypeHeader]?.first;
|
||||
final boundary = _extractBoundary(contentTypeHeader);
|
||||
|
||||
if (boundary != null) {
|
||||
final multipartParts =
|
||||
await req.read(maxLength: _maxUploadBytes).cast<List<int>>().transform(MimeMultipartTransformer(boundary)).toList();
|
||||
if (multipartParts.isEmpty) {
|
||||
return (null, Response.badRequest(body: Body.fromString('No files in request')), null, null);
|
||||
}
|
||||
|
||||
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();
|
||||
final mimeType = uploadPart.headers['content-type'];
|
||||
final filename = _extractFilename(uploadPart.headers['content-disposition'] ?? '');
|
||||
|
||||
return (bytes, null, mimeType, filename);
|
||||
}
|
||||
|
||||
final bytesBuilder = BytesBuilder(copy: false);
|
||||
await for (final chunk in req.read(maxLength: _maxUploadBytes)) {
|
||||
bytesBuilder.add(chunk);
|
||||
}
|
||||
|
||||
final mimeType = contentTypeHeader;
|
||||
return (bytesBuilder.toBytes(), null, mimeType, null);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _fileResponseMap(ChatFile file) {
|
||||
return {
|
||||
'uploadId': file.attachmentId,
|
||||
'attachmentId': file.attachmentId,
|
||||
'id': file.id,
|
||||
'url': file.path,
|
||||
'status': file.status,
|
||||
'usageType': file.usageType,
|
||||
'originalFileName': file.originalFileName,
|
||||
'storedFileName': file.storedFileName,
|
||||
'mimeType': file.mimeType,
|
||||
'sizeBytes': file.sizeBytes,
|
||||
'width': file.width,
|
||||
'height': file.height,
|
||||
'sha256': file.sha256,
|
||||
};
|
||||
}
|
||||
|
||||
void _finalizeUpload({
|
||||
required ChatFile file,
|
||||
required Uint8List bytes,
|
||||
String? payloadMimeType,
|
||||
String? payloadFileName,
|
||||
}) {
|
||||
final detectedMime = _normalizeMimeType(
|
||||
payloadMimeType ?? file.mimeType,
|
||||
);
|
||||
final dimensions = _extractImageDimensions(bytes, detectedMime);
|
||||
final extension = _resolveExtension(
|
||||
payloadFileName ?? file.originalFileName,
|
||||
detectedMime,
|
||||
);
|
||||
final storedFileName = '${file.attachmentId}$extension';
|
||||
|
||||
final directory = Directory(_uploadsDir);
|
||||
if (!directory.existsSync()) {
|
||||
directory.createSync(recursive: true);
|
||||
}
|
||||
|
||||
final diskPath = '${directory.path}${Platform.pathSeparator}$storedFileName';
|
||||
File(diskPath).writeAsBytesSync(bytes, flush: true);
|
||||
|
||||
file.path = '/attachments/${file.attachmentId}';
|
||||
file.storedFileName = storedFileName;
|
||||
file.sha256 = sha256.convert(bytes).toString();
|
||||
file.mimeType = detectedMime;
|
||||
file.width = dimensions.$1;
|
||||
file.height = dimensions.$2;
|
||||
file.sizeBytes = bytes.length;
|
||||
file.status = 'ready';
|
||||
file.errorCode = null;
|
||||
fileBox.put(file);
|
||||
}
|
||||
|
||||
String _generateAttachmentId() {
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
final random = Random.secure();
|
||||
String next() {
|
||||
final bytes = List<int>.generate(20, (_) => random.nextInt(256));
|
||||
final sb = StringBuffer();
|
||||
for (final byte in bytes) {
|
||||
sb.write(alphabet[byte % alphabet.length]);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
while (true) {
|
||||
final id = next();
|
||||
final exists = fileBox.query(ChatFile_.attachmentId.equals(id)).build().findFirst();
|
||||
if (exists == null) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isAllowedUsageType(String usageType) {
|
||||
return usageType == 'message' || usageType == 'avatar' || usageType == 'server_icon' || usageType == 'server_banner';
|
||||
}
|
||||
|
||||
int? _asInt(Object? value) {
|
||||
return switch (value) {
|
||||
int v => v,
|
||||
double v => v.round(),
|
||||
String v => int.tryParse(v),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
String _resolveDiskPath(ChatFile file) {
|
||||
final storedFileName = file.storedFileName.trim();
|
||||
if (storedFileName.isNotEmpty) {
|
||||
return '$_uploadsDir${Platform.pathSeparator}$storedFileName';
|
||||
}
|
||||
|
||||
final path = file.path;
|
||||
if (path.startsWith('/uploads/')) {
|
||||
final fileName = path.substring('/uploads/'.length);
|
||||
return '$_uploadsDir${Platform.pathSeparator}$fileName';
|
||||
}
|
||||
|
||||
final attachmentPath = path.startsWith('/attachments/') ? path.substring('/attachments/'.length) : path;
|
||||
return '$_uploadsDir${Platform.pathSeparator}$attachmentPath';
|
||||
}
|
||||
|
||||
String _normalizeMimeType(String? mimeType) {
|
||||
final cleaned = (mimeType ?? '').split(';').first.trim().toLowerCase();
|
||||
if (cleaned.isEmpty) {
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
String? _extractBoundary(String? contentTypeHeader) {
|
||||
if (contentTypeHeader == null) {
|
||||
return null;
|
||||
@@ -168,6 +450,7 @@ String _resolveExtension(String? fileName, String mimeType) {
|
||||
'image/jpeg' || 'image/jpg' => '.jpg',
|
||||
'image/gif' => '.gif',
|
||||
'image/webp' => '.webp',
|
||||
'image/avif' => '.avif',
|
||||
'video/mp4' => '.mp4',
|
||||
_ => '.bin',
|
||||
};
|
||||
@@ -182,8 +465,7 @@ String _resolveExtension(String? fileName, String mimeType) {
|
||||
return (width, height);
|
||||
}
|
||||
|
||||
if ((lowerMime == 'image/jpeg' || lowerMime == 'image/jpg') &&
|
||||
bytes.length > 4) {
|
||||
if ((lowerMime == 'image/jpeg' || lowerMime == 'image/jpg') && bytes.length > 4) {
|
||||
final jpeg = _extractJpegDimensions(bytes);
|
||||
if (jpeg != null) {
|
||||
return jpeg;
|
||||
@@ -200,10 +482,7 @@ String _resolveExtension(String? fileName, String mimeType) {
|
||||
}
|
||||
|
||||
int _readUint32BigEndian(Uint8List bytes, int offset) {
|
||||
return (bytes[offset] << 24) |
|
||||
(bytes[offset + 1] << 16) |
|
||||
(bytes[offset + 2] << 8) |
|
||||
bytes[offset + 3];
|
||||
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
}
|
||||
|
||||
(int, int)? _extractJpegDimensions(Uint8List bytes) {
|
||||
|
||||
@@ -5,12 +5,16 @@ late final Store store;
|
||||
late final Box<ChatUser> userBox;
|
||||
late final Box<ChatChannel> channelBox;
|
||||
late final Box<ChatMessage> messageBox;
|
||||
late final Box<ChatMessageMedia> messageMediaBox;
|
||||
late final Box<ChatFile> fileBox;
|
||||
late final Box<ServerSettings> serverSettingsBox;
|
||||
|
||||
void initializeStore() {
|
||||
store = openStore();
|
||||
userBox = store.box<ChatUser>();
|
||||
channelBox = store.box<ChatChannel>();
|
||||
messageBox = store.box<ChatMessage>();
|
||||
messageMediaBox = store.box<ChatMessageMedia>();
|
||||
fileBox = store.box<ChatFile>();
|
||||
serverSettingsBox = store.box<ServerSettings>();
|
||||
}
|
||||
Reference in new issue
Block a user