Major architecture update: stable identity, component validation, media indexing, and protocol docs

This commit is contained in:
Foxyz committed 2026-02-27 19:33:35 +03:00
1 parent ce7c97d13a
commit 01c110bbe1
16 files changed
+2704 -454

No files matched your search

+135 -145
View File
@@ -1,172 +1,162 @@
# Reliquary Architecture & Protocol Reference # Reliquary Server Architecture
## 1. Project Overview ## 1. Overview
**Reliquary** is a self-hosted, high-performance chat platform operating on the **Reliquary Protocol**. Reliquary is a self-hosted chat server with:
* **Philosophy:** Desktop-first, native performance, decentralized identity. - JWT auth
* **Routing:** No server-to-server federation. The Client handles all routing. - ObjectBox persistence
* **Identity:** Cryptographic keypairs (secp256k1). User ID: `user@home.server`. - WebSocket gateway fanout
* **Push Notifications:** Explicitly out of scope for MVP. Desktop relies on background WebSockets; Android relies on Foreground Services. - Component-based messages
- Decoupled upload pipeline (upload first, message commit anytime)
--- ## 2. Identity and Roles (Current)
- `userId` is an immutable opaque identifier (host-independent).
- `handle` is derived metadata: `@username@home`.
- `ChatUser.roles` controls authorization (`admin`, `member`, etc.).
- `GET /api/me` returns `roles` so the client can gate admin UI.
## 2. Current System State ### Runtime Config Split
*The following features are implemented or have a fixed specification.* - `server.bind_host` + `server.bind_port`: where Relic listens locally.
- `server.public_base_url`: canonical external URL used for identity/handles/cross-server metadata.
- This avoids conflating local bind settings with public identity/routing.
### ✅ Core Features ### First Admin Bootstrap
* **Auth:** JWT-based. Register/Login returns encrypted keypair. - Config key: `server.first_setup` in `config.yaml`.
* **Join Flow:** Cryptographic signature verification. - When `first_setup: true`, the first registered local user gets `admin`.
* **REST API:** Basic CRUD for Channels and Text-only Messages. - Later registrations remain `member`.
* **Gateway:** WebSocket server for event fanout (Specification defined in Section 4).
### 💾 Database Models (ObjectBox) ## 3. Storage Model
* **User:** `userId`, `publicKey`, `passwordHash` (home only).
* **Channel:** `id`, `name`, `createdAt`.
* **Message:** `id`, `channelId`, `authorUserId`, `content`, `createdAt`.
--- ### 3.1 Entities
- `ChatUser`: identity/profile/auth data.
- `ChatChannel`: channel metadata.
- `ChatMessage`: channel message, search text, and `componentsJson`.
- `ChatFile`: canonical media object for:
- message attachments
- user avatar media
- server icon/banner media
- `ServerSettings`: singleton row for mutable server presentation settings.
## 3. Active Implementation Target: Component & File System ### 3.2 ChatFile Fields (Important)
*⚠️ THIS SECTION DESCRIBES NEW FEATURES TO BE CODED ⚠️* - `attachmentId` (unique external ID; API-facing)
- `originalFileName` (preserved filename metadata)
- `storedFileName` (physical disk key)
- `sha256`
- `mimeType`, `sizeBytes`, `width`, `height`
- `usageType`: `message | avatar | server_icon | server_banner`
- `status`: `pending | ready | failed`
We are migrating from plain text to **Reliquary Components**. This allows rich media, embeds, and collapsible UIs. ## 4. Upload and Message Flow
### 3.1 UX Flow: Staged Uploads (Discord-Style) ### 4.1 Recommended Client Flow (Implemented Server Contract)
To ensure instant UI feedback and save server bandwidth: 1. Client stages attachment in composer/component editor.
1. **Stage (Client):** User picks files. Client shows local previews. User reorders/adds spoilers. *No network request yet.* 2. Client calls `POST /api/uploads/init` to create pending upload.
2. **Commit (Client):** User hits "Send". Client adds a "Dummy Message" to the UI with a progress bar. 3. Client uploads bytes to `PUT /api/uploads/:uploadId`.
3. **Upload (Network):** Client POSTs files to `/api/uploads`. 4. Client sends `POST /api/messages` with components referencing `uploadId`/`attachmentId`.
4. **Publish (Network):** Client gets `FileId`s, builds the `componentsJson`, and POSTs to `/api/messages`. 5. If upload completes after message create, server emits `message.updated`.
5. **Resolve (Network):** Server broadcasts the new message via Gateway. Client swaps "Dummy" for "Real".
### 3.2 New/Modified Database Models ### 4.2 Why This Flow
```dart - No chronological jumping for text.
@Entity() - Immediate local optimistic UI.
class Message { - Progressive media readiness via gateway updates.
// ... existing fields ... - Server keeps authoritative metadata and storage consistency.
// Search Index (Server concatenates text from all components) ## 5. API Surface
String content;
// Source of Truth (JSON List<Component>) ### 5.0 Identity Contract
String componentsJson; - Registration returns:
} - `userId` (opaque stable ID)
- `username`
- `handle` (computed, mutable as home host changes)
- Cross-server join request now expects:
- `userId`
- `username`
- `homeServer`
- `timestamp`
- `signature` over `userId:timestamp`
@Entity() ### 5.1 Uploads and Attachments
class File { - `POST /api/uploads/init` (auth)
int id; - `PUT /api/uploads/:uploadId` (auth)
int uploaderId; - `POST /api/uploads` (auth, legacy single-step compatibility)
String path; // /uploads/uuid.jpg - `GET /attachments/:attachmentId` (public immutable media URL)
String mimeType; - `GET /api/channels/:channelId/media` (auth, gallery index feed)
int width; // CRITICAL for layout
int height;
int sizeBytes;
bool isAttached; // False until linked to a Message ### 5.2 Profile and Server Media
DateTime createdAt; - `PATCH /api/me` (auth): update profile text fields.
} - `PUT /api/me/avatar` (auth): bind uploaded media as avatar.
``` - `GET /avatars/:userId` (public alias for current avatar).
- `GET /icons/:serverId` (public alias for current server icon).
- `GET /banners/:serverId` (public alias for current server banner).
### 3.3 Component JSON Schema ### 5.3 Server Settings
All items in `componentsJson` must follow this structure. - `GET /api/server/settings` (auth)
- `PATCH /api/server/settings` (admin)
- `PUT /api/server/icon` (admin)
- `PUT /api/server/banner` (admin)
## 6. Gateway Behavior
- Existing events:
- `message.created`
- `message.updated`
- `message.deleted`
- For upload-driven updates:
- A message can be created with media items in `uploading`.
- When upload becomes ready, server rewrites affected components and emits `message.updated`.
## 7. Component Media Item Contract (Client)
Each media item in a component should carry stable IDs and upload state:
**Base Wrapper:**
```json ```json
{ {
"type": "text | gallery | collapsible | embed", "itemId": "local_or_stable_id",
"modifiers":, // "spoiler" applies blur "source": "local_upload|external_url",
"fallback": "String for push notifs", "uploadId": "pending_or_ready_attachment_id",
"data": { ... } "attachmentId": "same_as_uploadId_when_known",
"status": "uploading|ready|failed",
"url": "/attachments/<attachmentId>",
"originalFileName": "image.png",
"mimeType": "image/webp",
"sizeBytes": 12345,
"width": 1280,
"height": 720,
"thumbHash": "optional"
} }
``` ```
**Gallery Component (Images/Video):** ### 7.1 Validation Rules (Server)
```json - Every component node must include:
{ - `id` (string)
"type": "gallery", - `type` (string)
"modifiers": [], - `typeVersion` (positive int)
"fallback": "Image", - `data` (object)
"data": { - Nested components are supported under `data.children` with depth/size limits.
"items": - Media item rules:
} - `source=local_upload` => `attachmentId` required.
} - `source=external_url` => valid `http/https` `url` required.
``` - Invalid component payloads are rejected server-side.
--- ## 8. Client Implementation Notes
## 4. Gateway Protocol Specification (WebSockets) ### 8.1 Staging and Transform Choice
*The server implements a WebSocket Gateway at `/gateway`.* On file pick/drop, choose transform mode before upload starts:
- `Compressed WebP` (recommended default)
- `WebP`
- `Original`
### Connection & Auth If user changes mode for a staged file:
* **URL:** `ws://host:port/gateway` - cancel in-flight upload
* **Auth:** `?token=<jwt>` (Query param) or `Authorization: Bearer <jwt>` (Header). - reprocess
* **OpCode: Ready** (Server -> Client on connect): - re-upload
```json
{ "op": "ready", "connectionId": "gw_1", "userId": "alice@home" }
```
### Client -> Server Ops ### 8.2 Metadata Trust
**Subscribe:** Client may send metadata hints, but server metadata is canonical.
```json Client should refresh with upload response values.
{ "op": "subscribe", "channelIds": [12, 13] }
```
**Unsubscribe:**
```json
{ "op": "unsubscribe", "channelIds": [12] }
```
**Heartbeat:**
```json
{ "op": "ping" }
```
### Server -> Client Ops (Events) ### 8.3 Caching
**Message Created:** - `/attachments/:attachmentId`: immutable and long-cache.
```json - `/avatars`, `/icons`, `/banners`: mutable aliases; revalidate frequently.
{
"op": "event",
"event": "message.created",
"channelId": 12,
"message": {
"id": 101,
"content": "Legacy text or fallback",
"componentsJson": "[...]",
"authorId": "alice@home",
"createdAt": "..."
}
}
```
*(Also supports `message.updated` and `message.deleted`)*
--- ## 9. AVIF and JPEG XL Policy
- MVP default: WebP (best compatibility/perf tradeoff).
## 5. API Reference: Pagination & Uploads - AVIF: optional client mode if target platform decode support is verified.
- JPEG XL: defer for now due to uneven ecosystem support.
### GET /api/messages
Used for initial load and history backfilling.
* **Params:**
* `channelId` (required)
* `limit` (default 50)
* `before` (Message ID) -> Load older messages.
* `after` (Message ID) -> Load newer messages (catch-up).
* **Response:**
```json
{
"messages": [],
"hasMore": true,
"nextBefore": 51, // Cursor for older
"nextAfter": 100 // Cursor for newer
}
```
### POST /api/uploads (New)
* **Format:** `multipart/form-data`
* **Response:**
```json
{
"id": 505,
"url": "/uploads/505.png",
"width": 1920,
"height": 1080,
"mimeType": "image/png"
}
```
+140
View File
@@ -0,0 +1,140 @@
# Reliquary Protocol (MVP v1)
This document defines the current wire contract used by the server.
## 1. Identity
- `userId`: immutable opaque ID.
- `username`: home-server unique, mutable.
- `handle`: derived string `@username@home`.
- `homeServer`: canonical public base URL.
## 2. Auth
### `POST /auth/register`
Request:
```json
{ "username": "alice", "password": "password123" }
```
Response fields include:
- `token`
- `userId`
- `username`
- `handle`
- `publicKey`
- encrypted key backup fields
### `POST /auth/login`
Request:
```json
{ "username": "alice", "password": "password123" }
```
Response includes token + profile/key fields.
### `POST /auth/join`
Request:
```json
{
"userId": "opaque_id",
"username": "alice",
"homeServer": "https://chat.example.com",
"timestamp": 1730000000,
"signature": "base64..."
}
```
Signature is over `userId:timestamp`.
## 3. Message Components
Each component node must include:
- `id` (string)
- `type` (string)
- `typeVersion` (positive int)
- `data` (object)
Optional:
- `fallback`, `modifiers`
Nested components are supported under `data.children`.
## 4. Media Item Sources
Media references support two sources:
1. Local upload:
```json
{
"source": "local_upload",
"attachmentId": "att_...",
"status": "uploading|ready|failed"
}
```
2. External URL:
```json
{
"source": "external_url",
"url": "https://example.com/image.jpg",
"status": "ready"
}
```
Optional metadata snapshot:
- `mimeType`, `sizeBytes`, `width`, `height`, `thumbHash`, `originalFileName`, `caption`.
## 5. Uploads
### `POST /api/uploads/init` (auth)
Creates pending upload and returns `uploadId` (same ID namespace as attachment ID).
### `PUT /api/uploads/:uploadId` (auth)
Completes upload with file bytes and returns canonical metadata:
- `attachmentId`, `url`, `status`, `mimeType`, `sizeBytes`, dimensions, hash.
### `POST /api/uploads` (auth)
Legacy single-step compatibility endpoint.
### `GET /attachments/:attachmentId`
Immutable media URL.
## 6. Messages
### `POST /api/messages` (auth)
Request includes:
- `channelId`
- `components` or `componentsJson`
- optional `content`
Server behavior:
- validates structure and media refs
- normalizes media items
- computes searchable `content`
- persists `componentsJson`
### `GET /api/messages?channelId=...`
Returns message list with `componentsJson`.
## 7. Gallery Index
### `GET /api/channels/:channelId/media` (auth)
Returns indexed media rows for grid views.
Query params:
- `limit` (default 50, max 100)
- `before` cursor
- `kind` (`image|video|file`)
Each row includes:
- linkage (`messageId`, `channelId`, `authorUserId`)
- source (`attachmentId` or `externalUrl`)
- display metadata (`mimeType`, dimensions, filename, etc.)
## 8. Gateway
WebSocket path: `/gateway`
Message events:
- `message.created`
- `message.updated`
- `message.deleted`
Upload resolution can cause `message.updated`.
+158 -10
View File
@@ -1,14 +1,162 @@
import 'dart:io';
import 'dart:convert';
import 'dart:math';
import 'package:yaml/yaml.dart';
class Config { class Config {
// Server settings static late String bindHost;
static const int port = 9870; 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 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
View File
@@ -16,7 +16,7 @@ class ChatUser {
@Id() @Id()
int id = 0; int id = 0;
// Global identifier: "username@homeserver.com" // Global immutable identifier (opaque, host-independent).
@Unique() @Unique()
String userId; String userId;
@@ -37,8 +37,7 @@ class ChatUser {
// Server relationship // Server relationship
bool isLocal; // true = this is their home server bool isLocal; // true = this is their home server
String? String? homeServer; // if guest, where is their home? (e.g., "https://my.server.com")
homeServer; // if guest, where is their home? (e.g., "https://my.server.com")
// Server-specific // Server-specific
List<String> roles; // ["admin", "moderator", "member"] List<String> roles; // ["admin", "moderator", "member"]
@@ -98,7 +97,7 @@ class ChatMessage {
// Author info - includes enough for cross-server profile fetch // Author info - includes enough for cross-server profile fetch
int authorLocalId; int authorLocalId;
String authorUserId; // "alice@homeserver.com" String authorUserId; // Immutable userId
String? authorHomeServer; // for profile fetching String? authorHomeServer; // for profile fetching
// Cached for display (snapshot at message time) // Cached for display (snapshot at message time)
@@ -133,25 +132,113 @@ class ChatFile {
@Id() @Id()
int id = 0; int id = 0;
@Unique()
String attachmentId;
int uploaderId; int uploaderId;
String path; String path;
String originalFileName;
String storedFileName;
String sha256;
String mimeType; String mimeType;
int width; int width;
int height; int height;
int sizeBytes; int sizeBytes;
bool isAttached; bool isAttached;
String usageType; // message | avatar | server_icon | server_banner
String status; // pending | ready | failed
String? errorCode;
@Property(type: PropertyType.date) @Property(type: PropertyType.date)
DateTime createdAt; DateTime createdAt;
ChatFile({ ChatFile({
required this.attachmentId,
required this.uploaderId, required this.uploaderId,
required this.path, required this.path,
required this.originalFileName,
required this.storedFileName,
required this.sha256,
required this.mimeType, required this.mimeType,
required this.width, required this.width,
required this.height, required this.height,
required this.sizeBytes, required this.sizeBytes,
this.usageType = 'message',
this.status = 'pending',
this.isAttached = false, 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, DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now(); }) : createdAt = createdAt ?? DateTime.now();
} }
+41 -15
View File
@@ -1,4 +1,5 @@
import 'package:hotreloader/hotreloader.dart'; import 'dart:io';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import 'config/config.dart'; import 'config/config.dart';
@@ -6,8 +7,10 @@ import 'src/auth/auth_middleware.dart';
import 'src/gateway/gateway_handlers.dart'; import 'src/gateway/gateway_handlers.dart';
import 'src/handlers/auth_handlers.dart'; import 'src/handlers/auth_handlers.dart';
import 'src/handlers/channel_handlers.dart'; import 'src/handlers/channel_handlers.dart';
import 'src/handlers/dev_db_handlers.dart';
import 'src/handlers/message_handlers.dart'; import 'src/handlers/message_handlers.dart';
import 'src/handlers/public_handlers.dart'; import 'src/handlers/public_handlers.dart';
import 'src/handlers/settings_handlers.dart';
import 'src/handlers/upload_handlers.dart'; import 'src/handlers/upload_handlers.dart';
import 'src/state/app_state.dart'; import 'src/state/app_state.dart';
@@ -29,6 +32,10 @@ Future<void> startServer() async {
..get('/.well-known/verify/:userId', publicVerifyHandler) ..get('/.well-known/verify/:userId', publicVerifyHandler)
..get('/.well-known/server', publicServerInfoHandler) ..get('/.well-known/server', publicServerInfoHandler)
..get('/uploads/:fileName', getUploadFileHandler) ..get('/uploads/:fileName', getUploadFileHandler)
..get('/attachments/:attachmentId', getAttachmentHandler)
..get('/avatars/:userId', getAvatarAliasHandler)
..get('/icons/:serverId', getServerIconAliasHandler)
..get('/banners/:serverId', getServerBannerAliasHandler)
..get('/gateway', gatewayHandler) ..get('/gateway', gatewayHandler)
// ============ PROTECTED ENDPOINTS (requires auth) ============ // ============ PROTECTED ENDPOINTS (requires auth) ============
@@ -36,6 +43,20 @@ Future<void> startServer() async {
// Current user // Current user
..get('/api/me', getMeHandler) ..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 // Channels
..get('/api/channels', getChannelsHandler) ..get('/api/channels', getChannelsHandler)
@@ -44,6 +65,9 @@ Future<void> startServer() async {
// Messages // Messages
..get('/api/messages', getMessagesHandler) ..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/uploads', uploadFileHandler)
..post('/api/messages', sendMessageHandler) ..post('/api/messages', sendMessageHandler)
..put('/api/messages/:messageId', editMessageHandler) ..put('/api/messages/:messageId', editMessageHandler)
@@ -54,26 +78,28 @@ Future<void> startServer() async {
..fallback = respondWith((_) => Response.notFound()); ..fallback = respondWith((_) => Response.notFound());
print(''); print('');
print('╔════════════════════════════════════════════╗'); print('🚀 Server starting...');
print('║ 🚀 Server starting... ║'); print('Bind: ${'${Config.bindHost}:${Config.bindPort}'.padRight(34)}');
print('║ Host: ${Config.serverHost.padRight(34)}║'); print('Public URL: ${Config.publicBaseUrl.padRight(26)}');
print('║ URL: http://localhost:${Config.port.toString().padRight(26)}║');
print('╚════════════════════════════════════════════╝');
print(''); 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 { void main() async {
initializeStore(); Config.initialize();
// Enable hot reload in development initializeStore();
await HotReloader.create(
onAfterReload: (ctx) async {
print('🔄 Changes detected! Restarting server...');
await startServer();
},
);
await startServer(); await startServer();
} }
+72 -84
View File
@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:chat_server/models.dart'; import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart'; import 'package:chat_server/objectbox.g.dart';
@@ -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 /// Register on HOME server
/// Creates user with keypair for identity /// Creates user with keypair for identity
Future<Response> registerHandler(Request req) async { Future<Response> registerHandler(Request req) async {
@@ -85,22 +76,30 @@ Future<Response> registerHandler(Request req) async {
// Hash password // Hash password
final passwordHash = AuthService.hashPassword(password); final passwordHash = AuthService.hashPassword(password);
// Create user with userId format: "username@this.server" // Create immutable opaque identity ID.
final userId = '${username}@${Config.serverHost}'; final userId = generateUserId();
final isBootstrapAdmin = Config.setupMode && _adminCount() < Config.bootstrapAdminLimit;
final roles = isBootstrapAdmin ? <String>['admin'] : <String>['member'];
final user = ChatUser( final user = ChatUser(
userId: userId, userId: userId,
username: username, username: username,
publicKey: keyPair.publicKey, publicKey: keyPair.publicKey,
isLocal: true, isLocal: true,
homeServer: Config.publicBaseUrl,
passwordHash: passwordHash, passwordHash: passwordHash,
encryptedPrivateKey: encryptedKey.encryptedData, encryptedPrivateKey: encryptedKey.encryptedData,
keyPairSalt: encryptedKey.salt, keyPairSalt: encryptedKey.salt,
roles: ['member'], roles: roles,
); );
final localId = userBox.put(user); final localId = userBox.put(user);
if (isBootstrapAdmin) {
print('🔐 Setup mode: granted initial admin to $userId');
}
// Generate token // Generate token
final token = AuthService.generateToken(userId, username); final token = AuthService.generateToken(userId, username);
@@ -108,11 +107,14 @@ Future<Response> registerHandler(Request req) async {
body: { body: {
'token': token, 'token': token,
'userId': userId, 'userId': userId,
'username': username, 'username': user.username,
'handle': toUserHandle(user.username, Config.publicBaseUrl),
'publicKey': keyPair.publicKey, 'publicKey': keyPair.publicKey,
'encryptedPrivateKey': encryptedKey.encryptedData, 'encryptedPrivateKey': encryptedKey.encryptedData,
'keyPairSalt': encryptedKey.salt, 'keyPairSalt': encryptedKey.salt,
'localId': localId, 'localId': localId,
'roles': roles,
'isBootstrapAdmin': isBootstrapAdmin,
}.toBody, }.toBody,
); );
} }
@@ -147,8 +149,7 @@ Future<Response> loginHandler(Request req) async {
// Check if this is actually their home server // Check if this is actually their home server
if (!user.isLocal) { if (!user.isLocal) {
return Response.badRequest( return Response.badRequest(
body: body: Body.fromString('This is not your home server. Login there instead.'),
Body.fromString('This is not your home server. Login there instead.'),
); );
} }
@@ -167,6 +168,7 @@ Future<Response> loginHandler(Request req) async {
'token': token, 'token': token,
'userId': user.userId, 'userId': user.userId,
'username': user.username, 'username': user.username,
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
'displayName': user.displayName, 'displayName': user.displayName,
'avatarUrl': user.avatarUrl, 'avatarUrl': user.avatarUrl,
'publicKey': user.publicKey, 'publicKey': user.publicKey,
@@ -181,27 +183,27 @@ Future<Response> loginHandler(Request req) async {
/// No password required - proves identity via private key signature /// No password required - proves identity via private key signature
Future<Response> joinServerHandler(Request req) async { Future<Response> joinServerHandler(Request req) async {
final body = await req.asJson(); final body = await req.asJson();
final userId = body['userId'] as String?; // "alice@homeserver.com" final userId = body['userId'] as String?;
final homeServer = body['homeServer'] as String?; // "https://homeserver.com" final username = (body['username'] as String?)?.trim();
final homeServer = body['homeServer'] as String?;
final timestamp = body['timestamp'] as int?; final timestamp = body['timestamp'] as int?;
final signature = body['signature'] as String?; final signature = body['signature'] as String?;
// Validate required fields // 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( return Response.badRequest(
body: Body.fromString( body: Body.fromString('userId, username, homeServer, timestamp, and signature required'),
'userId, timestamp, and signature required (homeServer optional)'),
); );
} }
final parsedUser = parseUserId(userId); final resolvedHomeServer = normalizeHomeServer(homeServer);
if (parsedUser == null) {
return Response.badRequest(
body: Body.fromString('Invalid userId format. Expected username@host'),
);
}
final resolvedHomeServer = normalizeHomeServer(homeServer ?? parsedUser.host);
if (resolvedHomeServer == null) { if (resolvedHomeServer == null) {
return Response.badRequest( 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) // Check timestamp is recent (within 5 minutes)
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if ((now - timestamp).abs() > 300) { if ((now - timestamp).abs() > 300) {
@@ -226,7 +222,7 @@ Future<Response> joinServerHandler(Request req) async {
// Fetch current identity from home server // Fetch current identity from home server
final identity = await fetchIdentityFromHomeServer( final identity = await fetchIdentityFromHomeServer(
resolvedHomeServer, resolvedHomeServer,
parsedUser.username, username,
); );
if (identity == null) { if (identity == null) {
@@ -236,7 +232,7 @@ Future<Response> joinServerHandler(Request req) async {
} }
// Ensure fetched identity matches what caller claims // 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( return Response.forbidden(
body: Body.fromString('Identity mismatch from home server'), body: Body.fromString('Identity mismatch from home server'),
); );
@@ -245,8 +241,7 @@ Future<Response> joinServerHandler(Request req) async {
// Verify signature // Verify signature
// Signature is over: "{userId}:{timestamp}" // Signature is over: "{userId}:{timestamp}"
final messageToVerify = '$userId:$timestamp'; final messageToVerify = '$userId:$timestamp';
final isValid = final isValid = CryptoService.verify(messageToVerify, signature, identity.publicKey);
CryptoService.verify(messageToVerify, signature, identity.publicKey);
if (!isValid) { if (!isValid) {
return Response.forbidden( return Response.forbidden(
@@ -269,8 +264,7 @@ Future<Response> joinServerHandler(Request req) async {
existingUser.lastSyncedAt = DateTime.now(); existingUser.lastSyncedAt = DateTime.now();
userBox.put(existingUser); userBox.put(existingUser);
final token = final token = AuthService.generateToken(existingUser.userId, existingUser.username);
AuthService.generateToken(existingUser.userId, existingUser.username);
userLocalIdProperty[req] = existingUser.id; userLocalIdProperty[req] = existingUser.id;
return Response.ok( return Response.ok(
@@ -278,6 +272,7 @@ Future<Response> joinServerHandler(Request req) async {
'token': token, 'token': token,
'userId': existingUser.userId, 'userId': existingUser.userId,
'username': existingUser.username, 'username': existingUser.username,
'handle': toUserHandle(existingUser.username, existingUser.homeServer ?? Config.publicBaseUrl),
'localId': existingUser.id, 'localId': existingUser.id,
'alreadyJoined': true, 'alreadyJoined': true,
}.toBody, }.toBody,
@@ -308,6 +303,7 @@ Future<Response> joinServerHandler(Request req) async {
'token': token, 'token': token,
'userId': userId, 'userId': userId,
'username': user.username, 'username': user.username,
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
'localId': localId, 'localId': localId,
'alreadyJoined': false, 'alreadyJoined': false,
}.toBody, }.toBody,
@@ -324,8 +320,7 @@ Future<RemoteIdentity?> fetchIdentityFromHomeServer(
try { try {
final baseUrl = homeServer.replaceAll(RegExp(r'/$'), ''); final baseUrl = homeServer.replaceAll(RegExp(r'/$'), '');
final endpoint = Uri.parse( final endpoint = Uri.parse('$baseUrl/.well-known/users/${Uri.encodeComponent(username)}');
'$baseUrl/.well-known/users/${Uri.encodeComponent(username)}');
final request = await client.getUrl(endpoint); final request = await client.getUrl(endpoint);
request.headers.set(HttpHeaders.acceptHeader, 'application/json'); 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) { String? normalizeHomeServer(String homeServerOrHost) {
final trimmed = homeServerOrHost.trim(); final trimmed = homeServerOrHost.trim();
if (trimmed.isEmpty) { if (trimmed.isEmpty) {
@@ -389,8 +368,7 @@ String? normalizeHomeServer(String homeServerOrHost) {
} }
final hasScheme = trimmed.contains('://'); final hasScheme = trimmed.contains('://');
final candidate = final candidate = hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed';
hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed';
final uri = Uri.tryParse(candidate); final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.isEmpty) { if (uri == null || uri.host.isEmpty) {
@@ -409,37 +387,22 @@ String? normalizeHomeServer(String homeServerOrHost) {
String defaultSchemeForHost(String host) { String defaultSchemeForHost(String host) {
final lowerHost = host.toLowerCase(); final lowerHost = host.toLowerCase();
if (lowerHost.startsWith('localhost') || if (lowerHost.startsWith('localhost') || lowerHost.startsWith('127.') || lowerHost.startsWith('[::1]')) {
lowerHost.startsWith('127.') ||
lowerHost.startsWith('[::1]')) {
return 'http'; return 'http';
} }
return 'https'; return 'https';
} }
bool homeServerMatchesUserIdHost(String homeServerUrl, String userIdHost) { int _adminCount() {
final uri = Uri.tryParse(homeServerUrl); var count = 0;
if (uri == null || uri.host.isEmpty) { final users = userBox.getAll();
return false; for (final user in users) {
} if (user.roles.contains('admin')) {
count++;
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;
} }
} }
return count;
return false;
} }
/// Get current user's profile /// Get current user's profile
@@ -466,6 +429,7 @@ Future<Response> getMeHandler(Request req) async {
body: { body: {
'userId': user.userId, 'userId': user.userId,
'username': user.username, 'username': user.username,
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
'displayName': user.displayName, 'displayName': user.displayName,
'avatarUrl': user.avatarUrl, 'avatarUrl': user.avatarUrl,
'bio': user.bio, 'bio': user.bio,
@@ -476,3 +440,27 @@ Future<Response> getMeHandler(Request req) async {
}.toBody, }.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()}';
}
+280
View File
@@ -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(),
};
+720 -61
View File
@@ -12,6 +12,14 @@ import '../utils/extensions.dart';
const _defaultMessageLimit = 50; const _defaultMessageLimit = 50;
const _maxMessageLimit = 100; 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 /// Get messages for a channel
/// Query params: channelId (required), limit, before, after /// Query params: channelId (required), limit, before, after
Future<Response> getMessagesHandler(Request req) async { Future<Response> getMessagesHandler(Request req) async {
@@ -33,8 +41,7 @@ Future<Response> getMessagesHandler(Request req) async {
} }
final channelId = int.tryParse(channelIdStr); final channelId = int.tryParse(channelIdStr);
final parsedLimit = final parsedLimit = limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr);
limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr);
int? before; int? before;
int? after; int? after;
@@ -70,7 +77,6 @@ Future<Response> getMessagesHandler(Request req) async {
} }
} }
// Check channel exists
final channel = channelBox.get(channelId); final channel = channelBox.get(channelId);
if (channel == null) { if (channel == null) {
return Response.notFound( return Response.notFound(
@@ -99,8 +105,7 @@ Future<Response> getMessagesHandler(Request req) async {
query.close(); query.close();
final hasMore = fetchedMessages.length > limit; final hasMore = fetchedMessages.length > limit;
final messages = final messages = hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages;
hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages;
int? nextBefore; int? nextBefore;
int? nextAfter; 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 { Future<Response> sendMessageHandler(Request req) async {
final userId = userIdProperty[req]; 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) { if (channelId == null) {
return Response.badRequest( return Response.badRequest(
body: Body.fromString('channelId required'), body: Body.fromString('channelId required'),
); );
} }
// Check channel exists
final channel = channelBox.get(channelId); final channel = channelBox.get(channelId);
if (channel == null) { if (channel == null) {
return Response.notFound( 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 userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = userQuery.findFirst(); final user = userQuery.findFirst();
userQuery.close(); 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( final message = ChatMessage(
channelId: channelId, channelId: channelId,
authorLocalId: user.id, authorLocalId: user.id,
@@ -180,13 +271,14 @@ Future<Response> sendMessageHandler(Request req) async {
authorUsername: user.username, authorUsername: user.username,
authorAvatarUrl: user.avatarUrl, authorAvatarUrl: user.avatarUrl,
authorHomeServer: user.homeServer, authorHomeServer: user.homeServer,
content: _buildSearchContent(components, contentOverride), content: _buildSearchContent(normalizedComponents, contentOverride),
componentsJson: jsonEncode(components), componentsJson: jsonEncode(normalizedComponents),
); );
final id = messageBox.put(message); final id = messageBox.put(message);
message.id = id; message.id = id;
_attachReferencedFiles(components); _attachReferencedFiles(normalizedComponents);
_syncMessageMediaIndex(message, normalizedComponents);
broadcastMessageCreated( broadcastMessageCreated(
channelId: channelId, channelId: channelId,
@@ -207,7 +299,6 @@ Future<Response> sendMessageHandler(Request req) async {
); );
} }
/// Edit a message
Future<Response> editMessageHandler(Request req) async { Future<Response> editMessageHandler(Request req) async {
final userId = userIdProperty[req]; 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 userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = userQuery.findFirst(); final user = userQuery.findFirst();
userQuery.close(); userQuery.close();
@@ -258,12 +348,21 @@ Future<Response> editMessageHandler(Request req) async {
body: Body.fromString('content or components/componentsJson required'), 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); final normalizedComponents = _normalizeComponentsForStorage(components);
message.componentsJson = jsonEncode(components);
message.content = _buildSearchContent(normalizedComponents, contentOverride);
message.componentsJson = jsonEncode(normalizedComponents);
message.editedAt = DateTime.now(); message.editedAt = DateTime.now();
messageBox.put(message); messageBox.put(message);
_attachReferencedFiles(components); _attachReferencedFiles(normalizedComponents);
_syncMessageMediaIndex(message, normalizedComponents);
broadcastMessageUpdated( broadcastMessageUpdated(
channelId: message.channelId, channelId: message.channelId,
@@ -280,7 +379,6 @@ Future<Response> editMessageHandler(Request req) async {
); );
} }
/// Delete a message
Future<Response> deleteMessageHandler(Request req) async { Future<Response> deleteMessageHandler(Request req) async {
final userId = userIdProperty[req]; 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 userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = userQuery.findFirst(); final user = userQuery.findFirst();
userQuery.close(); userQuery.close();
@@ -324,6 +421,7 @@ Future<Response> deleteMessageHandler(Request req) async {
final channelId = message.channelId; final channelId = message.channelId;
messageBox.remove(messageId); messageBox.remove(messageId);
_removeMessageMediaIndex(messageId);
broadcastMessageDeleted( broadcastMessageDeleted(
channelId: channelId, 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) { List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
final rawComponents = body['components']; final rawComponents = body['components'];
if (rawComponents is List) { if (rawComponents is List) {
@@ -400,7 +519,9 @@ List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
return <Map<String, dynamic>>[ return <Map<String, dynamic>>[
{ {
'id': 'text_0',
'type': 'text', 'type': 'text',
'typeVersion': 1,
'modifiers': <String>[], 'modifiers': <String>[],
'fallback': content, 'fallback': content,
'data': { '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( String _buildSearchContent(
List<Map<String, dynamic>> components, List<Map<String, dynamic>> components,
String? contentOverride, String? contentOverride,
@@ -420,68 +686,461 @@ String _buildSearchContent(
final parts = <String>[]; final parts = <String>[];
for (final component in components) { for (final component in components) {
final fallback = component['fallback']; _collectSearchText(component, parts);
if (fallback is String && fallback.trim().isNotEmpty) {
parts.add(fallback.trim());
} }
final data = component['data']; final content = parts.map((s) => s.trim()).where((s) => s.isNotEmpty).join(' ').replaceAll(RegExp(r'\s+'), ' ').trim();
if (data is! Map) {
continue;
}
void addDataString(String key) {
final value = data[key];
if (value is String && value.trim().isNotEmpty) {
parts.add(value.trim());
}
}
addDataString('text');
addDataString('title');
addDataString('description');
addDataString('caption');
}
final content = parts.join(' ').trim();
return content.isEmpty ? '[component]' : content; return content.isEmpty ? '[component]' : content;
} }
void _attachReferencedFiles(List<Map<String, dynamic>> components) { void _collectSearchText(Map<String, dynamic> component, List<String> out) {
final referencedFileIds = <int>{}; final fallback = component['fallback'];
for (final component in components) { if (fallback is String && fallback.trim().isNotEmpty) {
out.add(fallback);
}
final data = component['data']; final data = component['data'];
if (data is! Map) { if (data is! Map) {
continue; 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);
}
} }
final items = data['items']; final url = map['url'];
if (items is! List) { if (url is String && url.trim().isNotEmpty) {
continue; 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,
);
}
}
}
final items = map['items'];
if (items is List) {
for (final item in items) { for (final item in items) {
if (item is! Map) { if (item is Map) {
continue; 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);
}
}
} }
final fileId = item['fileId'];
final parsedId = switch (fileId) {
int id when id > 0 => id,
String value => int.tryParse(value),
_ => null,
};
if (parsedId != null && parsedId > 0) {
referencedFileIds.add(parsedId);
} }
} }
} }
for (final fileId in referencedFileIds) { void _attachReferencedFiles(List<Map<String, dynamic>> components) {
final file = fileBox.get(fileId); final referencedAttachmentIds = <String>{};
if (file == null || file.isAttached) { _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; continue;
} }
file.isAttached = true; file.isAttached = true;
fileBox.put(file); 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),
);
}
}
+13 -3
View File
@@ -2,6 +2,7 @@ import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import '../../config/config.dart'; import '../../config/config.dart';
import 'auth_handlers.dart';
import '../state/app_state.dart'; import '../state/app_state.dart';
import '../utils/extensions.dart'; import '../utils/extensions.dart';
@@ -38,10 +39,11 @@ Future<Response> publicUserHandler(Request req) async {
'userId': user.userId, 'userId': user.userId,
'username': user.username, 'username': user.username,
'displayName': user.displayName, 'displayName': user.displayName,
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
'avatarUrl': user.avatarUrl, 'avatarUrl': user.avatarUrl,
'bio': user.bio, 'bio': user.bio,
'publicKey': user.publicKey, 'publicKey': user.publicKey,
'homeServer': Config.serverHost, 'homeServer': user.homeServer ?? Config.publicBaseUrl,
'createdAt': user.createdAt.toIso8601String(), 'createdAt': user.createdAt.toIso8601String(),
}.toBody, }.toBody,
); );
@@ -84,6 +86,7 @@ Future<Response> publicVerifyHandler(Request req) async {
'userId': user.userId, 'userId': user.userId,
'publicKey': user.publicKey, 'publicKey': user.publicKey,
'username': user.username, 'username': user.username,
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
}.toBody, }.toBody,
); );
} }
@@ -93,11 +96,18 @@ Future<Response> publicVerifyHandler(Request req) async {
/// GET /.well-known/server /// GET /.well-known/server
/// Returns: { serverHost, serverName, version } /// Returns: { serverHost, serverName, version }
Future<Response> publicServerInfoHandler(Request req) async { Future<Response> publicServerInfoHandler(Request req) async {
final settingsQuery = serverSettingsBox.query(ServerSettings_.singletonKey.equals('default')).build();
final settings = settingsQuery.findFirst();
settingsQuery.close();
return Response.ok( return Response.ok(
body: { body: {
'serverHost': Config.serverHost, 'serverHost': Config.publicHost,
'serverName': Config.serverName, 'publicBaseUrl': Config.publicBaseUrl,
'serverName': settings?.serverName ?? Config.serverName,
'version': '0.1.0', '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'], 'features': ['identity', 'chat', 'sticker-packs'],
}.toBody, }.toBody,
); );
+348
View File
@@ -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()),
);
}
+353 -74
View File
@@ -1,52 +1,276 @@
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:chat_server/models.dart'; import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart'; import 'package:chat_server/objectbox.g.dart';
import 'package:crypto/crypto.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import '../auth/auth_middleware.dart'; import '../auth/auth_middleware.dart';
import '../state/app_state.dart'; import '../state/app_state.dart';
import '../utils/extensions.dart'; import '../utils/extensions.dart';
import 'message_handlers.dart';
const _uploadsDir = 'uploads'; const _uploadsDir = 'uploads';
const _maxUploadBytes = 25 * 1024 * 1024; const _maxUploadBytes = 50 * 1024 * 1024;
Future<Response> initUploadHandler(Request req) async {
final currentUser = _requireCurrentUser(req);
if (currentUser.$2 != null) {
return currentUser.$2!;
}
final user = currentUser.$1!;
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;
if (!_isAllowedUsageType(usageType)) {
return Response.badRequest(
body: Body.fromString('Invalid usageType'),
);
}
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 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 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'),
);
}
_finalizeUpload(
file: file,
bytes: bytes,
payloadMimeType: payloadMimeType,
payloadFileName: payloadFileName,
);
await resolvePendingUploadsInMessages(file.attachmentId);
return Response.ok(
body: _fileResponseMap(file).toBody,
);
}
Future<Response> uploadFileHandler(Request req) async { 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()),
);
}
Future<Response> getUploadFileHandler(Request req) async {
final fileName = req.rawPathParameters[#fileName];
if (fileName == null || fileName.isEmpty || fileName.contains('..')) {
return Response.badRequest(
body: Body.fromString('Invalid file name'),
);
}
final diskPath = '$_uploadsDir${Platform.pathSeparator}$fileName';
final file = File(diskPath);
if (!file.existsSync()) {
return Response.notFound(
body: Body.fromString('File not found'),
);
}
return Response.ok(
body: Body.fromData(file.readAsBytesSync()),
);
}
(ChatUser?, Response?) _requireCurrentUser(Request req) {
final userId = userIdProperty[req]; final userId = userIdProperty[req];
if (userId == null) { if (userId == null) {
return Response.unauthorized( return (
null,
Response.unauthorized(
body: Body.fromString('Not authenticated'), body: Body.fromString('Not authenticated'),
)
); );
} }
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build(); final query = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = userQuery.findFirst(); final user = query.findFirst();
userQuery.close(); query.close();
if (user == null) { if (user == null) {
return Response.unauthorized( return (
null,
Response.unauthorized(
body: Body.fromString('User not found'), 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 contentTypeHeader = req.headers[Headers.contentTypeHeader]?.first;
final boundary = _extractBoundary(contentTypeHeader); final boundary = _extractBoundary(contentTypeHeader);
if (boundary == null) {
return Response.badRequest(
body: Body.fromString('Expected multipart/form-data with boundary'),
);
}
final multipartParts = await req if (boundary != null) {
.read(maxLength: _maxUploadBytes) final multipartParts =
.cast<List<int>>() await req.read(maxLength: _maxUploadBytes).cast<List<int>>().transform(MimeMultipartTransformer(boundary)).toList();
.transform(MimeMultipartTransformer(boundary))
.toList();
if (multipartParts.isEmpty) { if (multipartParts.isEmpty) {
return Response.badRequest( return (null, Response.badRequest(body: Body.fromString('No files in request')), null, null);
body: Body.fromString('No files in request'),
);
} }
final uploadPart = multipartParts.firstWhere( final uploadPart = multipartParts.firstWhere(
@@ -59,73 +283,131 @@ Future<Response> uploadFileHandler(Request req) async {
bytesBuilder.add(chunk); bytesBuilder.add(chunk);
} }
final bytes = bytesBuilder.toBytes(); final bytes = bytesBuilder.toBytes();
if (bytes.isEmpty) { final mimeType = uploadPart.headers['content-type'];
return Response.badRequest( final filename = _extractFilename(uploadPart.headers['content-disposition'] ?? '');
body: Body.fromString('Uploaded file is empty'),
); return (bytes, null, mimeType, filename);
} }
final contentDisposition = uploadPart.headers['content-disposition'] ?? ''; final bytesBuilder = BytesBuilder(copy: false);
final fileName = _extractFilename(contentDisposition); await for (final chunk in req.read(maxLength: _maxUploadBytes)) {
final mimeType = bytesBuilder.add(chunk);
(uploadPart.headers['content-type'] ?? 'application/octet-stream').trim(); }
final ext = _resolveExtension(fileName, mimeType);
final dimensions = _extractImageDimensions(bytes, mimeType);
final entity = ChatFile( final mimeType = contentTypeHeader;
uploaderId: user.id, return (bytesBuilder.toBytes(), null, mimeType, null);
path: '/uploads/pending$ext', }
mimeType: mimeType,
width: dimensions.$1, Map<String, dynamic> _fileResponseMap(ChatFile file) {
height: dimensions.$2, return {
sizeBytes: bytes.length, '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 id = fileBox.put(entity); final dimensions = _extractImageDimensions(bytes, detectedMime);
final extension = _resolveExtension(
payloadFileName ?? file.originalFileName,
detectedMime,
);
final storedFileName = '${file.attachmentId}$extension';
final directory = Directory(_uploadsDir); final directory = Directory(_uploadsDir);
if (!directory.existsSync()) { if (!directory.existsSync()) {
directory.createSync(recursive: true); directory.createSync(recursive: true);
} }
final storedName = '$id$ext'; final diskPath = '${directory.path}${Platform.pathSeparator}$storedFileName';
final diskPath = '${directory.path}${Platform.pathSeparator}$storedName';
File(diskPath).writeAsBytesSync(bytes, flush: true); File(diskPath).writeAsBytesSync(bytes, flush: true);
entity.id = id; file.path = '/attachments/${file.attachmentId}';
entity.path = '/uploads/$storedName'; file.storedFileName = storedFileName;
fileBox.put(entity); file.sha256 = sha256.convert(bytes).toString();
file.mimeType = detectedMime;
return Response.ok( file.width = dimensions.$1;
body: { file.height = dimensions.$2;
'id': id, file.sizeBytes = bytes.length;
'url': entity.path, file.status = 'ready';
'width': entity.width, file.errorCode = null;
'height': entity.height, fileBox.put(file);
'mimeType': entity.mimeType,
}.toBody,
);
} }
Future<Response> getUploadFileHandler(Request req) async { String _generateAttachmentId() {
final fileName = req.rawPathParameters[#fileName]; const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
if (fileName == null || fileName.isEmpty || fileName.contains('..')) { final random = Random.secure();
return Response.badRequest( String next() {
body: Body.fromString('Invalid file name'), 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();
} }
final filePath = '$_uploadsDir${Platform.pathSeparator}$fileName'; while (true) {
final file = File(filePath); final id = next();
if (!file.existsSync()) { final exists = fileBox.query(ChatFile_.attachmentId.equals(id)).build().findFirst();
return Response.notFound( if (exists == null) {
body: Body.fromString('File not found'), return id;
); }
}
} }
final data = file.readAsBytesSync(); bool _isAllowedUsageType(String usageType) {
return Response.ok( return usageType == 'message' || usageType == 'avatar' || usageType == 'server_icon' || usageType == 'server_banner';
body: Body.fromData(data), }
);
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) { String? _extractBoundary(String? contentTypeHeader) {
@@ -168,6 +450,7 @@ String _resolveExtension(String? fileName, String mimeType) {
'image/jpeg' || 'image/jpg' => '.jpg', 'image/jpeg' || 'image/jpg' => '.jpg',
'image/gif' => '.gif', 'image/gif' => '.gif',
'image/webp' => '.webp', 'image/webp' => '.webp',
'image/avif' => '.avif',
'video/mp4' => '.mp4', 'video/mp4' => '.mp4',
_ => '.bin', _ => '.bin',
}; };
@@ -182,8 +465,7 @@ String _resolveExtension(String? fileName, String mimeType) {
return (width, height); return (width, height);
} }
if ((lowerMime == 'image/jpeg' || lowerMime == 'image/jpg') && if ((lowerMime == 'image/jpeg' || lowerMime == 'image/jpg') && bytes.length > 4) {
bytes.length > 4) {
final jpeg = _extractJpegDimensions(bytes); final jpeg = _extractJpegDimensions(bytes);
if (jpeg != null) { if (jpeg != null) {
return jpeg; return jpeg;
@@ -200,10 +482,7 @@ String _resolveExtension(String? fileName, String mimeType) {
} }
int _readUint32BigEndian(Uint8List bytes, int offset) { int _readUint32BigEndian(Uint8List bytes, int offset) {
return (bytes[offset] << 24) | return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3];
} }
(int, int)? _extractJpegDimensions(Uint8List bytes) { (int, int)? _extractJpegDimensions(Uint8List bytes) {
+4
View File
@@ -5,12 +5,16 @@ late final Store store;
late final Box<ChatUser> userBox; late final Box<ChatUser> userBox;
late final Box<ChatChannel> channelBox; late final Box<ChatChannel> channelBox;
late final Box<ChatMessage> messageBox; late final Box<ChatMessage> messageBox;
late final Box<ChatMessageMedia> messageMediaBox;
late final Box<ChatFile> fileBox; late final Box<ChatFile> fileBox;
late final Box<ServerSettings> serverSettingsBox;
void initializeStore() { void initializeStore() {
store = openStore(); store = openStore();
userBox = store.box<ChatUser>(); userBox = store.box<ChatUser>();
channelBox = store.box<ChatChannel>(); channelBox = store.box<ChatChannel>();
messageBox = store.box<ChatMessage>(); messageBox = store.box<ChatMessage>();
messageMediaBox = store.box<ChatMessageMedia>();
fileBox = store.box<ChatFile>(); fileBox = store.box<ChatFile>();
serverSettingsBox = store.box<ServerSettings>();
} }
+2 -10
View File
@@ -281,14 +281,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
hotreloader:
dependency: "direct main"
description:
name: hotreloader
sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b
url: "https://pub.dev"
source: hosted
version: "4.3.0"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -370,7 +362,7 @@ packages:
source: hosted source: hosted
version: "1.18.1" version: "1.18.1"
mime: mime:
dependency: transitive dependency: "direct main"
description: description:
name: mime name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
@@ -666,7 +658,7 @@ packages:
source: hosted source: hosted
version: "1.2.1" version: "1.2.1"
yaml: yaml:
dependency: transitive dependency: "direct main"
description: description:
name: yaml name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
+2 -1
View File
@@ -13,7 +13,8 @@ dependencies:
crypton: crypton:
crypto: crypto:
collection: collection:
hotreloader: yaml:
mime:
dev_dependencies: dev_dependencies:
test: test:
+19 -47
View File
@@ -2,34 +2,6 @@ import 'package:chat_server/src/handlers/auth_handlers.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {
group('parseUserId', () {
test('parses valid userId', () {
final parsed = parseUserId('alice@example.com');
expect(parsed, isNotNull);
expect(parsed!.username, 'alice');
expect(parsed.host, 'example.com');
});
test('returns null for invalid userId', () {
expect(parseUserId('alice'), isNull);
expect(parseUserId('alice@'), isNull);
expect(parseUserId('@example.com'), isNull);
expect(parseUserId('alice@example.com@x'), isNull);
});
});
group('defaultSchemeForHost', () {
test('returns http for localhost-style hosts', () {
expect(defaultSchemeForHost('localhost:9870'), 'http');
expect(defaultSchemeForHost('127.0.0.1:9870'), 'http');
expect(defaultSchemeForHost('[::1]:9870'), 'http');
});
test('returns https for non-local hosts', () {
expect(defaultSchemeForHost('chat.example.com'), 'https');
});
});
group('normalizeHomeServer', () { group('normalizeHomeServer', () {
test('normalizes host without scheme', () { test('normalizes host without scheme', () {
expect( expect(
@@ -59,30 +31,30 @@ void main() {
}); });
}); });
group('homeServerMatchesUserIdHost', () { group('defaultSchemeForHost', () {
test('matches host and explicit port', () { test('returns http for localhost-style hosts', () {
expect(defaultSchemeForHost('localhost:9870'), 'http');
expect(defaultSchemeForHost('127.0.0.1:9870'), 'http');
expect(defaultSchemeForHost('[::1]:9870'), 'http');
});
test('returns https for non-local hosts', () {
expect(defaultSchemeForHost('chat.example.com'), 'https');
});
});
group('toUserHandle', () {
test('uses URL host and port', () {
expect( expect(
homeServerMatchesUserIdHost( toUserHandle('alice', 'https://chat.example.com:8443'),
'https://chat.example.com:8443', 'chat.example.com:8443'), '@alice@chat.example.com:8443',
isTrue,
);
expect(
homeServerMatchesUserIdHost(
'https://chat.example.com:8443', 'chat.example.com'),
isFalse,
); );
}); });
test('matches default ports when omitted', () { test('falls back to raw home server string', () {
expect( expect(
homeServerMatchesUserIdHost( toUserHandle('alice', 'chat.example.com'),
'https://chat.example.com', 'chat.example.com:443'), '@alice@chat.example.com',
isTrue,
);
expect(
homeServerMatchesUserIdHost(
'http://chat.example.com', 'chat.example.com:80'),
isTrue,
); );
}); });
}); });
+326
View File
@@ -0,0 +1,326 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:chat_server/config/config.dart';
import 'package:chat_server/server.dart';
import 'package:chat_server/src/state/app_state.dart';
import 'package:test/test.dart';
void main() {
final repoDir = Directory.current.path;
final configPath = '$repoDir${Platform.pathSeparator}config.yaml';
final objectboxDir = '$repoDir${Platform.pathSeparator}objectbox';
final uploadsDir = '$repoDir${Platform.pathSeparator}uploads';
late String originalConfig;
late Uri baseUri;
final random = Random();
Future<void> waitUntilServerReady(Uri uri) async {
final client = HttpClient();
try {
for (var i = 0; i < 120; i++) {
try {
final req = await client.getUrl(uri.resolve('/.well-known/server'));
final res = await req.close();
if (res.statusCode == 200) {
await res.drain();
return;
}
await res.drain();
} catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 250));
}
fail('Server did not become ready in time.');
} finally {
client.close(force: true);
}
}
setUpAll(() async {
originalConfig = File(configPath).existsSync() ? File(configPath).readAsStringSync() : '';
final bindPort = 12000 + random.nextInt(2000);
final testConfig = '''
server:
name: "Reliquary Test Server"
bind_host: "127.0.0.1"
bind_port: $bindPort
public_base_url: "http://127.0.0.1:$bindPort"
configured: true
first_setup: true
bootstrap_admin_limit: 2
dev_db_tools: true
security:
jwt_secret: "integration_test_secret_1234567890"
''';
File(configPath).writeAsStringSync(testConfig);
baseUri = Uri.parse('http://127.0.0.1:$bindPort');
final dataMdb = File('$objectboxDir${Platform.pathSeparator}data.mdb');
final lockMdb = File('$objectboxDir${Platform.pathSeparator}lock.mdb');
if (dataMdb.existsSync()) {
dataMdb.deleteSync();
}
if (lockMdb.existsSync()) {
lockMdb.deleteSync();
}
final uploads = Directory(uploadsDir);
if (uploads.existsSync()) {
for (final entity in uploads.listSync()) {
if (entity is File) {
entity.deleteSync();
}
}
}
Config.initialize();
initializeStore();
unawaited(startServer());
await waitUntilServerReady(baseUri);
});
tearDownAll(() async {
await stopServer();
if (originalConfig.isEmpty) {
File(configPath).deleteSync();
} else {
File(configPath).writeAsStringSync(originalConfig);
}
});
test('auth + upload + component messages + gallery index', () async {
final client = HttpClient();
try {
Future<Map<String, dynamic>> postJson(
String path,
Map<String, dynamic> body, {
String? token,
}) async {
final req = await client.postUrl(baseUri.resolve(path));
req.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
if (token != null) {
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
}
req.write(jsonEncode(body));
final res = await req.close();
final text = await utf8.decodeStream(res);
expect(res.statusCode, anyOf(200, 201), reason: '$path -> $text');
return jsonDecode(text) as Map<String, dynamic>;
}
Future<Map<String, dynamic>> getJson(
String path, {
String? token,
}) async {
final req = await client.getUrl(baseUri.resolve(path));
if (token != null) {
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
}
final res = await req.close();
final text = await utf8.decodeStream(res);
expect(res.statusCode, 200, reason: '$path -> $text');
return jsonDecode(text) as Map<String, dynamic>;
}
Future<Map<String, dynamic>> uploadFile(
String filePath, {
required String token,
}) async {
final file = File(filePath);
expect(file.existsSync(), isTrue, reason: 'Missing test asset: $filePath');
final bytes = file.readAsBytesSync();
final rawFileName = file.uri.pathSegments.last;
final safeFileName = rawFileName.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_');
final lower = rawFileName.toLowerCase();
final contentType = lower.endsWith('.mp4')
? 'video/mp4'
: lower.endsWith('.png')
? 'image/png'
: lower.endsWith('.jpg') || lower.endsWith('.jpeg')
? 'image/jpeg'
: lower.endsWith('.gif')
? 'image/gif'
: 'application/octet-stream';
final boundary = '----reliquary-test-boundary';
final req = await client.postUrl(baseUri.resolve('/api/uploads'));
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
req.headers.set(
HttpHeaders.contentTypeHeader,
'multipart/form-data; boundary=$boundary',
);
req.write('--$boundary\r\n');
req.write(
'Content-Disposition: form-data; name="file"; filename="$safeFileName"\r\n',
);
req.write('Content-Type: $contentType\r\n\r\n');
req.add(bytes);
req.write('\r\n--$boundary--\r\n');
final res = await req.close();
final text = await utf8.decodeStream(res);
expect(res.statusCode, 200, reason: '/api/uploads -> $text');
return jsonDecode(text) as Map<String, dynamic>;
}
final register = await postJson('/auth/register', {
'username': 'alice',
'password': 'password123',
});
final token = register['token'] as String;
final channel = await postJson(
'/api/channels',
{'name': 'integration-channel', 'description': 'integration'},
token: token,
);
final channelId = channel['id'] as int;
final uploadedPng = await uploadFile(
'$repoDir${Platform.pathSeparator}scripts${Platform.pathSeparator}media${Platform.pathSeparator}Untitled-1.png',
token: token,
);
final pngAttachmentId = uploadedPng['attachmentId'] as String;
final uploadedPsd = await uploadFile(
'$repoDir${Platform.pathSeparator}scripts${Platform.pathSeparator}media${Platform.pathSeparator}ftnr-symbol.psd',
token: token,
);
final psdAttachmentId = uploadedPsd['attachmentId'] as String;
expect(psdAttachmentId, isNotEmpty);
final uploadedMp4 = await uploadFile(
'$repoDir${Platform.pathSeparator}scripts${Platform.pathSeparator}media${Platform.pathSeparator}Zelsus: Enemy Within [jUKjVrDFUug].mp4',
token: token,
);
final mp4AttachmentId = uploadedMp4['attachmentId'] as String;
expect(mp4AttachmentId, isNotEmpty);
final localMessage = await postJson(
'/api/messages',
{
'channelId': channelId,
'components': [
{
'id': 'cmp_local_gallery',
'type': 'gallery',
'typeVersion': 1,
'fallback': 'Local upload gallery',
'data': {
'items': [
{
'id': 'itm_local_1',
'source': 'local_upload',
'attachmentId': pngAttachmentId,
}
]
}
}
]
},
token: token,
);
expect(localMessage['componentsJson'], contains('"source":"local_upload"'));
expect(localMessage['componentsJson'], contains('"status":"ready"'));
final fileAndVideoMessage = await postJson(
'/api/messages',
{
'channelId': channelId,
'components': [
{
'id': 'cmp_files',
'type': 'gallery',
'typeVersion': 1,
'fallback': 'Local file + local video + external file link',
'data': {
'items': [
{
'id': 'itm_psd',
'source': 'local_upload',
'attachmentId': psdAttachmentId,
'caption': 'PSD unsupported preview candidate'
},
{'id': 'itm_mp4', 'source': 'local_upload', 'attachmentId': mp4AttachmentId, 'caption': 'MP4 playback candidate'},
{
'id': 'itm_ext_file',
'source': 'external_url',
'url': 'https://example.com/assets/archive.zip',
'mimeType': 'application/zip',
'originalFileName': 'archive.zip'
}
]
}
}
]
},
token: token,
);
expect(fileAndVideoMessage['componentsJson'], contains('"itm_psd"'));
expect(fileAndVideoMessage['componentsJson'], contains('"itm_mp4"'));
expect(fileAndVideoMessage['componentsJson'], contains('"external_url"'));
final externalMessage = await postJson(
'/api/messages',
{
'channelId': channelId,
'components': [
{
'id': 'cmp_external_gallery',
'type': 'gallery',
'typeVersion': 1,
'fallback': 'External gallery',
'data': {
'items': [
{
'id': 'itm_ext_1',
'source': 'external_url',
'url': 'https://pbs.twimg.com/media/HCI8WPKaYAAKhkj?format=jpg&name=medium',
'mimeType': 'image/jpeg',
'originalFileName': 'HCI8WPKaYAAKhkj.jpg',
},
{
'id': 'itm_ext_2',
'source': 'external_url',
'url': 'https://pbs.twimg.com/media/HCKAMLxaUAAhYJS?format=jpg&name=medium',
'mimeType': 'image/jpeg',
'originalFileName': 'HCKAMLxaUAAhYJS.jpg',
}
]
}
}
]
},
token: token,
);
expect(externalMessage['componentsJson'], contains('"source":"external_url"'));
expect(externalMessage['componentsJson'], contains('"status":"ready"'));
final messages = await getJson('/api/messages?channelId=$channelId&limit=10', token: token);
final messageList = (messages['messages'] as List).cast<Map<String, dynamic>>();
expect(messageList.length, greaterThanOrEqualTo(2));
final media = await getJson('/api/channels/$channelId/media?limit=10', token: token);
final mediaItems = (media['media'] as List).cast<Map<String, dynamic>>();
expect(mediaItems.length, greaterThanOrEqualTo(6));
final hasExternal = mediaItems.any((m) => (m['externalUrl'] as String?)?.contains('pbs.twimg.com/media/HCI8WPKaYAAKhkj') == true);
final hasPngLocal = mediaItems.any((m) => (m['attachmentId'] as String?) == pngAttachmentId);
final hasPsdLocal = mediaItems.any((m) => (m['attachmentId'] as String?) == psdAttachmentId);
final hasMp4Local = mediaItems.any((m) => (m['attachmentId'] as String?) == mp4AttachmentId);
final hasExternalFile = mediaItems.any((m) => (m['externalUrl'] as String?) == 'https://example.com/assets/archive.zip');
final hasVideoKind = mediaItems.any((m) => (m['attachmentId'] as String?) == mp4AttachmentId && m['kind'] == 'video');
expect(hasExternal, isTrue);
expect(hasPngLocal, isTrue);
expect(hasPsdLocal, isTrue);
expect(hasMp4Local, isTrue);
expect(hasExternalFile, isTrue);
expect(hasVideoKind, isTrue);
} finally {
client.close(force: true);
}
});
}