From 01c110bbe12d6ee2438be83a21733a2df1f0a99f Mon Sep 17 00:00:00 2001 From: Foxyz Date: Fri, 27 Feb 2026 19:33:35 +0300 Subject: [PATCH] Major architecture update: stable identity, component validation, media indexing, and protocol docs --- ARCHITECTURE.md | 284 ++++----- PROTOCOL.md | 140 +++++ lib/config/config.dart | 168 ++++- lib/models.dart | 95 ++- lib/server.dart | 56 +- lib/src/handlers/auth_handlers.dart | 156 +++-- lib/src/handlers/dev_db_handlers.dart | 280 +++++++++ lib/src/handlers/message_handlers.dart | 791 ++++++++++++++++++++++-- lib/src/handlers/public_handlers.dart | 16 +- lib/src/handlers/settings_handlers.dart | 348 +++++++++++ lib/src/handlers/upload_handlers.dart | 441 ++++++++++--- lib/src/state/app_state.dart | 4 + pubspec.lock | 12 +- pubspec.yaml | 3 +- test/auth_helpers_test.dart | 66 +- test/integration_api_test.dart | 326 ++++++++++ 16 files changed, 2718 insertions(+), 468 deletions(-) create mode 100644 PROTOCOL.md create mode 100644 lib/src/handlers/dev_db_handlers.dart create mode 100644 lib/src/handlers/settings_handlers.dart create mode 100644 test/integration_api_test.dart diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f4dab6b..9768346 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,172 +1,162 @@ -# Reliquary Architecture & Protocol Reference +# Reliquary Server Architecture -## 1. Project Overview -**Reliquary** is a self-hosted, high-performance chat platform operating on the **Reliquary Protocol**. -* **Philosophy:** Desktop-first, native performance, decentralized identity. -* **Routing:** No server-to-server federation. The Client handles all routing. -* **Identity:** Cryptographic keypairs (secp256k1). User ID: `user@home.server`. -* **Push Notifications:** Explicitly out of scope for MVP. Desktop relies on background WebSockets; Android relies on Foreground Services. +## 1. Overview +Reliquary is a self-hosted chat server with: +- JWT auth +- ObjectBox persistence +- WebSocket gateway fanout +- 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 -*The following features are implemented or have a fixed specification.* +### Runtime Config Split +- `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 -* **Auth:** JWT-based. Register/Login returns encrypted keypair. -* **Join Flow:** Cryptographic signature verification. -* **REST API:** Basic CRUD for Channels and Text-only Messages. -* **Gateway:** WebSocket server for event fanout (Specification defined in Section 4). +### First Admin Bootstrap +- Config key: `server.first_setup` in `config.yaml`. +- When `first_setup: true`, the first registered local user gets `admin`. +- Later registrations remain `member`. -### ๐Ÿ’พ Database Models (ObjectBox) -* **User:** `userId`, `publicKey`, `passwordHash` (home only). -* **Channel:** `id`, `name`, `createdAt`. -* **Message:** `id`, `channelId`, `authorUserId`, `content`, `createdAt`. +## 3. Storage Model ---- +### 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 -*โš ๏ธ THIS SECTION DESCRIBES NEW FEATURES TO BE CODED โš ๏ธ* +### 3.2 ChatFile Fields (Important) +- `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) -To ensure instant UI feedback and save server bandwidth: -1. **Stage (Client):** User picks files. Client shows local previews. User reorders/adds spoilers. *No network request yet.* -2. **Commit (Client):** User hits "Send". Client adds a "Dummy Message" to the UI with a progress bar. -3. **Upload (Network):** Client POSTs files to `/api/uploads`. -4. **Publish (Network):** Client gets `FileId`s, builds the `componentsJson`, and POSTs to `/api/messages`. -5. **Resolve (Network):** Server broadcasts the new message via Gateway. Client swaps "Dummy" for "Real". +### 4.1 Recommended Client Flow (Implemented Server Contract) +1. Client stages attachment in composer/component editor. +2. Client calls `POST /api/uploads/init` to create pending upload. +3. Client uploads bytes to `PUT /api/uploads/:uploadId`. +4. Client sends `POST /api/messages` with components referencing `uploadId`/`attachmentId`. +5. If upload completes after message create, server emits `message.updated`. -### 3.2 New/Modified Database Models -```dart -@Entity() -class Message { - // ... existing fields ... - - // Search Index (Server concatenates text from all components) - String content; +### 4.2 Why This Flow +- No chronological jumping for text. +- Immediate local optimistic UI. +- Progressive media readiness via gateway updates. +- Server keeps authoritative metadata and storage consistency. - // Source of Truth (JSON List) - String componentsJson; -} +## 5. API Surface -@Entity() -class File { - int id; - int uploaderId; - String path; // /uploads/uuid.jpg - String mimeType; - int width; // CRITICAL for layout - int height; - int sizeBytes; - - bool isAttached; // False until linked to a Message - DateTime createdAt; -} -``` +### 5.0 Identity Contract +- 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` -### 3.3 Component JSON Schema -All items in `componentsJson` must follow this structure. +### 5.1 Uploads and Attachments +- `POST /api/uploads/init` (auth) +- `PUT /api/uploads/:uploadId` (auth) +- `POST /api/uploads` (auth, legacy single-step compatibility) +- `GET /attachments/:attachmentId` (public immutable media URL) +- `GET /api/channels/:channelId/media` (auth, gallery index feed) + +### 5.2 Profile and Server Media +- `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). + +### 5.3 Server Settings +- `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 { - "type": "text | gallery | collapsible | embed", - "modifiers":, // "spoiler" applies blur - "fallback": "String for push notifs", - "data": { ... } + "itemId": "local_or_stable_id", + "source": "local_upload|external_url", + "uploadId": "pending_or_ready_attachment_id", + "attachmentId": "same_as_uploadId_when_known", + "status": "uploading|ready|failed", + "url": "/attachments/", + "originalFileName": "image.png", + "mimeType": "image/webp", + "sizeBytes": 12345, + "width": 1280, + "height": 720, + "thumbHash": "optional" } ``` -**Gallery Component (Images/Video):** -```json -{ - "type": "gallery", - "modifiers": [], - "fallback": "Image", - "data": { - "items": - } -} -``` +### 7.1 Validation Rules (Server) +- Every component node must include: + - `id` (string) + - `type` (string) + - `typeVersion` (positive int) + - `data` (object) +- Nested components are supported under `data.children` with depth/size limits. +- 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) -*The server implements a WebSocket Gateway at `/gateway`.* +### 8.1 Staging and Transform Choice +On file pick/drop, choose transform mode before upload starts: +- `Compressed WebP` (recommended default) +- `WebP` +- `Original` -### Connection & Auth -* **URL:** `ws://host:port/gateway` -* **Auth:** `?token=` (Query param) or `Authorization: Bearer ` (Header). -* **OpCode: Ready** (Server -> Client on connect): - ```json - { "op": "ready", "connectionId": "gw_1", "userId": "alice@home" } - ``` +If user changes mode for a staged file: +- cancel in-flight upload +- reprocess +- re-upload -### Client -> Server Ops -**Subscribe:** -```json -{ "op": "subscribe", "channelIds": [12, 13] } -``` -**Unsubscribe:** -```json -{ "op": "unsubscribe", "channelIds": [12] } -``` -**Heartbeat:** -```json -{ "op": "ping" } -``` +### 8.2 Metadata Trust +Client may send metadata hints, but server metadata is canonical. +Client should refresh with upload response values. -### Server -> Client Ops (Events) -**Message Created:** -```json -{ - "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`)* +### 8.3 Caching +- `/attachments/:attachmentId`: immutable and long-cache. +- `/avatars`, `/icons`, `/banners`: mutable aliases; revalidate frequently. ---- - -## 5. API Reference: Pagination & Uploads - -### 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" - } - ``` +## 9. AVIF and JPEG XL Policy +- MVP default: WebP (best compatibility/perf tradeoff). +- AVIF: optional client mode if target platform decode support is verified. +- JPEG XL: defer for now due to uneven ecosystem support. diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..9bf8478 --- /dev/null +++ b/PROTOCOL.md @@ -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`. diff --git a/lib/config/config.dart b/lib/config/config.dart index 4e71f19..c8175b0 100644 --- a/lib/config/config.dart +++ b/lib/config/config.dart @@ -1,14 +1,162 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:yaml/yaml.dart'; + class Config { - // Server settings - static const int port = 9870; + static late String bindHost; + static late int bindPort; + static late String publicBaseUrl; + static late String serverName; + static late String jwtSecret; + static late bool configured; + static late bool setupMode; + static late int bootstrapAdminLimit; + static late bool devDbTools; - // IMPORTANT: Set this to your actual server host/domain - // Used for generating userId: "username@serverHost" - static const String serverHost = 'localhost:9870'; - static const String serverName = 'Chat Server'; - - // JWT settings - // TODO: Move to environment variable in production - static const String jwtSecret = 'your_super_secret_key_change_in_production'; static const Duration tokenExpiry = Duration(days: 7); + + static void initialize() { + final configFile = File('config.yaml'); + + if (!configFile.existsSync()) { + final generatedSecret = _generateJwtSecret(); + configFile.writeAsStringSync(_defaultConfig(generatedSecret)); + print('โœ… Generated config.yaml (first run).'); + print('โš ๏ธ Review server.public_base_url and security.jwt_secret before production use.'); + } + + final yamlString = configFile.readAsStringSync(); + final yamlMap = loadYaml(yamlString) as YamlMap; + + final serverMap = yamlMap['server'] as YamlMap?; + final securityMap = yamlMap['security'] as YamlMap?; + + serverName = (serverMap?['name'] as String?)?.trim() ?? ''; + bindHost = (serverMap?['bind_host'] as String?)?.trim() ?? (serverMap?['host'] as String?)?.trim() ?? '127.0.0.1'; + bindPort = (serverMap?['bind_port'] as int?) ?? (serverMap?['port'] as int?) ?? 9870; + final rawPublicBaseUrl = + (serverMap?['public_base_url'] as String?)?.trim() ?? _legacyPublicBaseUrlFromHost((serverMap?['host'] as String?)?.trim()); + publicBaseUrl = _normalizePublicBaseUrl(rawPublicBaseUrl) ?? ''; + configured = (serverMap?['configured'] as bool?) ?? false; + setupMode = (serverMap?['first_setup'] as bool?) ?? false; + bootstrapAdminLimit = (serverMap?['bootstrap_admin_limit'] as int?) ?? 1; + devDbTools = (serverMap?['dev_db_tools'] as bool?) ?? false; + + jwtSecret = (securityMap?['jwt_secret'] as String?)?.trim() ?? ''; + + if (serverName.isEmpty) { + throw StateError('config.yaml: server.name is required.'); + } + if (bindHost.isEmpty) { + throw StateError('config.yaml: server.bind_host is required.'); + } + if (bindPort <= 0 || bindPort > 65535) { + throw StateError('config.yaml: server.bind_port must be between 1 and 65535.'); + } + if (publicBaseUrl.isEmpty) { + throw StateError( + 'config.yaml: server.public_base_url is required (example: https://chat.example.com).', + ); + } + if (jwtSecret.isEmpty) { + throw StateError('config.yaml: security.jwt_secret is required.'); + } + if (!configured) { + throw StateError( + 'Server setup is not complete.\n' + 'Edit config.yaml and set:\n' + ' server.configured: true\n' + 'After reviewing server.public_base_url and security.jwt_secret.', + ); + } + if (bootstrapAdminLimit < 0) { + throw StateError('config.yaml: server.bootstrap_admin_limit must be >= 0.'); + } + + print( + '๐Ÿš€ Server Config Loaded: $serverName (bind=$bindHost:$bindPort, public=$publicBaseUrl, configured=$configured, setupMode=$setupMode, bootstrapAdminLimit=$bootstrapAdminLimit, devDbTools=$devDbTools)', + ); + } + + static String _generateJwtSecret() { + final random = Random.secure(); + final bytes = List.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'; + } } diff --git a/lib/models.dart b/lib/models.dart index de61940..783b727 100644 --- a/lib/models.dart +++ b/lib/models.dart @@ -16,7 +16,7 @@ class ChatUser { @Id() int id = 0; - // Global identifier: "username@homeserver.com" + // Global immutable identifier (opaque, host-independent). @Unique() String userId; @@ -37,8 +37,7 @@ class ChatUser { // Server relationship bool isLocal; // true = this is their home server - String? - homeServer; // if guest, where is their home? (e.g., "https://my.server.com") + String? homeServer; // if guest, where is their home? (e.g., "https://my.server.com") // Server-specific List roles; // ["admin", "moderator", "member"] @@ -98,7 +97,7 @@ class ChatMessage { // Author info - includes enough for cross-server profile fetch int authorLocalId; - String authorUserId; // "alice@homeserver.com" + String authorUserId; // Immutable userId String? authorHomeServer; // for profile fetching // Cached for display (snapshot at message time) @@ -133,25 +132,113 @@ class ChatFile { @Id() int id = 0; + @Unique() + String attachmentId; + int uploaderId; String path; + String originalFileName; + String storedFileName; + String sha256; String mimeType; int width; int height; int sizeBytes; bool isAttached; + String usageType; // message | avatar | server_icon | server_banner + String status; // pending | ready | failed + String? errorCode; @Property(type: PropertyType.date) DateTime createdAt; ChatFile({ + required this.attachmentId, required this.uploaderId, required this.path, + required this.originalFileName, + required this.storedFileName, + required this.sha256, required this.mimeType, required this.width, required this.height, required this.sizeBytes, + this.usageType = 'message', + this.status = 'pending', this.isAttached = false, + this.errorCode, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); +} + +@Entity() +class ServerSettings { + @Id() + int id = 0; + + @Unique() + String singletonKey; + + String serverName; + String? description; + String? iconAttachmentId; + String? bannerAttachmentId; + String? updatedByUserId; + + @Property(type: PropertyType.date) + DateTime updatedAt; + + ServerSettings({ + this.singletonKey = 'default', + required this.serverName, + this.description, + this.iconAttachmentId, + this.bannerAttachmentId, + this.updatedByUserId, + DateTime? updatedAt, + }) : updatedAt = updatedAt ?? DateTime.now(); +} + +@Entity() +class ChatMessageMedia { + @Id() + int id = 0; + + int messageId; + int channelId; + String authorUserId; + + // local_upload + String? attachmentId; + + // external_url + String? externalUrl; + + // image | video | file + String kind; + String? mimeType; + String? originalFileName; + int? sizeBytes; + int? width; + int? height; + String? thumbHash; + + @Property(type: PropertyType.date) + DateTime createdAt; + + ChatMessageMedia({ + required this.messageId, + required this.channelId, + required this.authorUserId, + required this.kind, + this.attachmentId, + this.externalUrl, + this.mimeType, + this.originalFileName, + this.sizeBytes, + this.width, + this.height, + this.thumbHash, DateTime? createdAt, }) : createdAt = createdAt ?? DateTime.now(); } diff --git a/lib/server.dart b/lib/server.dart index 44742ec..22c80d6 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,4 +1,5 @@ -import 'package:hotreloader/hotreloader.dart'; +import 'dart:io'; + import 'package:relic/relic.dart'; import 'config/config.dart'; @@ -6,8 +7,10 @@ import 'src/auth/auth_middleware.dart'; import 'src/gateway/gateway_handlers.dart'; import 'src/handlers/auth_handlers.dart'; import 'src/handlers/channel_handlers.dart'; +import 'src/handlers/dev_db_handlers.dart'; import 'src/handlers/message_handlers.dart'; import 'src/handlers/public_handlers.dart'; +import 'src/handlers/settings_handlers.dart'; import 'src/handlers/upload_handlers.dart'; import 'src/state/app_state.dart'; @@ -29,6 +32,10 @@ Future startServer() async { ..get('/.well-known/verify/:userId', publicVerifyHandler) ..get('/.well-known/server', publicServerInfoHandler) ..get('/uploads/:fileName', getUploadFileHandler) + ..get('/attachments/:attachmentId', getAttachmentHandler) + ..get('/avatars/:userId', getAvatarAliasHandler) + ..get('/icons/:serverId', getServerIconAliasHandler) + ..get('/banners/:serverId', getServerBannerAliasHandler) ..get('/gateway', gatewayHandler) // ============ PROTECTED ENDPOINTS (requires auth) ============ @@ -36,6 +43,20 @@ Future startServer() async { // Current user ..get('/api/me', getMeHandler) + ..patch('/api/me', updateProfileHandler) + ..put('/api/me/avatar', setAvatarHandler) + + // Server settings + ..get('/api/server/settings', getServerSettingsHandler) + ..patch('/api/server/settings', patchServerSettingsHandler) + ..put('/api/server/icon', setServerIconHandler) + ..put('/api/server/banner', setServerBannerHandler) + + // Dev DB tools (testing only, admin only, requires config flag) + ..get('/api/dev/db/snapshot', getDevDbSnapshotHandler) + ..post('/api/dev/db/reset', resetDevDbHandler) + ..post('/api/dev/users/promote-admin', promoteUserToAdminHandler) + ..post('/api/dev/users/demote-admin', demoteUserFromAdminHandler) // Channels ..get('/api/channels', getChannelsHandler) @@ -44,6 +65,9 @@ Future startServer() async { // Messages ..get('/api/messages', getMessagesHandler) + ..get('/api/channels/:channelId/media', getChannelMediaHandler) + ..post('/api/uploads/init', initUploadHandler) + ..put('/api/uploads/:uploadId', completeUploadHandler) ..post('/api/uploads', uploadFileHandler) ..post('/api/messages', sendMessageHandler) ..put('/api/messages/:messageId', editMessageHandler) @@ -54,26 +78,28 @@ Future startServer() async { ..fallback = respondWith((_) => Response.notFound()); print(''); - print('โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); - print('โ•‘ ๐Ÿš€ Server starting... โ•‘'); - print('โ•‘ Host: ${Config.serverHost.padRight(34)}โ•‘'); - print('โ•‘ URL: http://localhost:${Config.port.toString().padRight(26)}โ•‘'); - print('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + print('๐Ÿš€ Server starting...'); + print('Bind: ${'${Config.bindHost}:${Config.bindPort}'.padRight(34)}'); + print('Public URL: ${Config.publicBaseUrl.padRight(26)}'); print(''); - await _currentApp!.serve(port: Config.port); + final bindAddress = InternetAddress.tryParse(Config.bindHost) ?? (await InternetAddress.lookup(Config.bindHost)).first; + + await _currentApp!.serve( + address: bindAddress, + port: Config.bindPort, + ); +} + +Future stopServer() async { + await _currentApp?.close(); + _currentApp = null; } void main() async { - initializeStore(); + Config.initialize(); - // Enable hot reload in development - await HotReloader.create( - onAfterReload: (ctx) async { - print('๐Ÿ”„ Changes detected! Restarting server...'); - await startServer(); - }, - ); + initializeStore(); await startServer(); } diff --git a/lib/src/handlers/auth_handlers.dart b/lib/src/handlers/auth_handlers.dart index 2ffd7fe..2e4e629 100644 --- a/lib/src/handlers/auth_handlers.dart +++ b/lib/src/handlers/auth_handlers.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:chat_server/models.dart'; import 'package:chat_server/objectbox.g.dart'; @@ -29,16 +30,6 @@ class RemoteIdentity { }); } -class ParsedUserId { - final String username; - final String host; - - ParsedUserId({ - required this.username, - required this.host, - }); -} - /// Register on HOME server /// Creates user with keypair for identity Future registerHandler(Request req) async { @@ -85,22 +76,30 @@ Future registerHandler(Request req) async { // Hash password final passwordHash = AuthService.hashPassword(password); - // Create user with userId format: "username@this.server" - final userId = '${username}@${Config.serverHost}'; + // Create immutable opaque identity ID. + final userId = generateUserId(); + + final isBootstrapAdmin = Config.setupMode && _adminCount() < Config.bootstrapAdminLimit; + final roles = isBootstrapAdmin ? ['admin'] : ['member']; final user = ChatUser( userId: userId, username: username, publicKey: keyPair.publicKey, isLocal: true, + homeServer: Config.publicBaseUrl, passwordHash: passwordHash, encryptedPrivateKey: encryptedKey.encryptedData, keyPairSalt: encryptedKey.salt, - roles: ['member'], + roles: roles, ); final localId = userBox.put(user); + if (isBootstrapAdmin) { + print('๐Ÿ” Setup mode: granted initial admin to $userId'); + } + // Generate token final token = AuthService.generateToken(userId, username); @@ -108,11 +107,14 @@ Future registerHandler(Request req) async { body: { 'token': token, 'userId': userId, - 'username': username, + 'username': user.username, + 'handle': toUserHandle(user.username, Config.publicBaseUrl), 'publicKey': keyPair.publicKey, 'encryptedPrivateKey': encryptedKey.encryptedData, 'keyPairSalt': encryptedKey.salt, 'localId': localId, + 'roles': roles, + 'isBootstrapAdmin': isBootstrapAdmin, }.toBody, ); } @@ -147,8 +149,7 @@ Future loginHandler(Request req) async { // Check if this is actually their home server if (!user.isLocal) { return Response.badRequest( - body: - Body.fromString('This is not your home server. Login there instead.'), + body: Body.fromString('This is not your home server. Login there instead.'), ); } @@ -167,6 +168,7 @@ Future loginHandler(Request req) async { 'token': token, 'userId': user.userId, 'username': user.username, + 'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl), 'displayName': user.displayName, 'avatarUrl': user.avatarUrl, 'publicKey': user.publicKey, @@ -181,27 +183,27 @@ Future loginHandler(Request req) async { /// No password required - proves identity via private key signature Future joinServerHandler(Request req) async { final body = await req.asJson(); - final userId = body['userId'] as String?; // "alice@homeserver.com" - final homeServer = body['homeServer'] as String?; // "https://homeserver.com" + final userId = body['userId'] as String?; + final username = (body['username'] as String?)?.trim(); + final homeServer = body['homeServer'] as String?; final timestamp = body['timestamp'] as int?; final signature = body['signature'] as String?; // Validate required fields - if (userId == null || timestamp == null || signature == null) { + if (userId == null || + userId.isEmpty || + username == null || + username.isEmpty || + homeServer == null || + homeServer.trim().isEmpty || + timestamp == null || + signature == null) { return Response.badRequest( - body: Body.fromString( - 'userId, timestamp, and signature required (homeServer optional)'), + body: Body.fromString('userId, username, homeServer, timestamp, and signature required'), ); } - final parsedUser = parseUserId(userId); - if (parsedUser == null) { - return Response.badRequest( - body: Body.fromString('Invalid userId format. Expected username@host'), - ); - } - - final resolvedHomeServer = normalizeHomeServer(homeServer ?? parsedUser.host); + final resolvedHomeServer = normalizeHomeServer(homeServer); if (resolvedHomeServer == null) { return Response.badRequest( @@ -209,12 +211,6 @@ Future joinServerHandler(Request req) async { ); } - if (!homeServerMatchesUserIdHost(resolvedHomeServer, parsedUser.host)) { - return Response.badRequest( - body: Body.fromString('homeServer does not match userId host'), - ); - } - // Check timestamp is recent (within 5 minutes) final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; if ((now - timestamp).abs() > 300) { @@ -226,7 +222,7 @@ Future joinServerHandler(Request req) async { // Fetch current identity from home server final identity = await fetchIdentityFromHomeServer( resolvedHomeServer, - parsedUser.username, + username, ); if (identity == null) { @@ -236,7 +232,7 @@ Future joinServerHandler(Request req) async { } // Ensure fetched identity matches what caller claims - if (identity.userId != userId || identity.username != parsedUser.username) { + if (identity.userId != userId || identity.username != username) { return Response.forbidden( body: Body.fromString('Identity mismatch from home server'), ); @@ -245,8 +241,7 @@ Future joinServerHandler(Request req) async { // Verify signature // Signature is over: "{userId}:{timestamp}" final messageToVerify = '$userId:$timestamp'; - final isValid = - CryptoService.verify(messageToVerify, signature, identity.publicKey); + final isValid = CryptoService.verify(messageToVerify, signature, identity.publicKey); if (!isValid) { return Response.forbidden( @@ -269,8 +264,7 @@ Future joinServerHandler(Request req) async { existingUser.lastSyncedAt = DateTime.now(); userBox.put(existingUser); - final token = - AuthService.generateToken(existingUser.userId, existingUser.username); + final token = AuthService.generateToken(existingUser.userId, existingUser.username); userLocalIdProperty[req] = existingUser.id; return Response.ok( @@ -278,6 +272,7 @@ Future joinServerHandler(Request req) async { 'token': token, 'userId': existingUser.userId, 'username': existingUser.username, + 'handle': toUserHandle(existingUser.username, existingUser.homeServer ?? Config.publicBaseUrl), 'localId': existingUser.id, 'alreadyJoined': true, }.toBody, @@ -308,6 +303,7 @@ Future joinServerHandler(Request req) async { 'token': token, 'userId': userId, 'username': user.username, + 'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl), 'localId': localId, 'alreadyJoined': false, }.toBody, @@ -324,8 +320,7 @@ Future fetchIdentityFromHomeServer( try { final baseUrl = homeServer.replaceAll(RegExp(r'/$'), ''); - final endpoint = Uri.parse( - '$baseUrl/.well-known/users/${Uri.encodeComponent(username)}'); + final endpoint = Uri.parse('$baseUrl/.well-known/users/${Uri.encodeComponent(username)}'); final request = await client.getUrl(endpoint); request.headers.set(HttpHeaders.acceptHeader, 'application/json'); @@ -366,22 +361,6 @@ Future fetchIdentityFromHomeServer( } } -ParsedUserId? parseUserId(String userId) { - final parts = userId.split('@'); - if (parts.length != 2) { - return null; - } - - final username = parts[0].trim(); - final host = parts[1].trim(); - - if (username.isEmpty || host.isEmpty) { - return null; - } - - return ParsedUserId(username: username, host: host); -} - String? normalizeHomeServer(String homeServerOrHost) { final trimmed = homeServerOrHost.trim(); if (trimmed.isEmpty) { @@ -389,8 +368,7 @@ String? normalizeHomeServer(String homeServerOrHost) { } final hasScheme = trimmed.contains('://'); - final candidate = - hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed'; + final candidate = hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed'; final uri = Uri.tryParse(candidate); if (uri == null || uri.host.isEmpty) { @@ -409,37 +387,22 @@ String? normalizeHomeServer(String homeServerOrHost) { String defaultSchemeForHost(String host) { final lowerHost = host.toLowerCase(); - if (lowerHost.startsWith('localhost') || - lowerHost.startsWith('127.') || - lowerHost.startsWith('[::1]')) { + if (lowerHost.startsWith('localhost') || lowerHost.startsWith('127.') || lowerHost.startsWith('[::1]')) { return 'http'; } return 'https'; } -bool homeServerMatchesUserIdHost(String homeServerUrl, String userIdHost) { - final uri = Uri.tryParse(homeServerUrl); - if (uri == null || uri.host.isEmpty) { - return false; - } - - final normalizedUserIdHost = userIdHost.trim().toLowerCase(); - final urlHost = uri.host.toLowerCase(); - final urlHostWithPort = uri.hasPort ? '$urlHost:${uri.port}' : urlHost; - - if (normalizedUserIdHost == urlHostWithPort) { - return true; - } - - if (!uri.hasPort) { - final defaultPort = uri.scheme == 'http' ? 80 : 443; - if (normalizedUserIdHost == '$urlHost:$defaultPort') { - return true; +int _adminCount() { + var count = 0; + final users = userBox.getAll(); + for (final user in users) { + if (user.roles.contains('admin')) { + count++; } } - - return false; + return count; } /// Get current user's profile @@ -466,6 +429,7 @@ Future getMeHandler(Request req) async { body: { 'userId': user.userId, 'username': user.username, + 'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl), 'displayName': user.displayName, 'avatarUrl': user.avatarUrl, 'bio': user.bio, @@ -476,3 +440,27 @@ Future getMeHandler(Request req) async { }.toBody, ); } + +String generateUserId() { + const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; + final random = Random.secure(); + while (true) { + final bytes = List.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()}'; +} diff --git a/lib/src/handlers/dev_db_handlers.dart b/lib/src/handlers/dev_db_handlers.dart new file mode 100644 index 0000000..8715694 --- /dev/null +++ b/lib/src/handlers/dev_db_handlers.dart @@ -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 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 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 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 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 _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 _channelToMap(ChatChannel channel) => { + 'id': channel.id, + 'name': channel.name, + 'description': channel.description, + 'createdAt': channel.createdAt.toIso8601String(), + }; + +Map _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 _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 _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 _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(), + }; diff --git a/lib/src/handlers/message_handlers.dart b/lib/src/handlers/message_handlers.dart index 327a10a..5d0548b 100644 --- a/lib/src/handlers/message_handlers.dart +++ b/lib/src/handlers/message_handlers.dart @@ -12,6 +12,14 @@ import '../utils/extensions.dart'; const _defaultMessageLimit = 50; const _maxMessageLimit = 100; +const _defaultMediaLimit = 50; +const _maxMediaLimit = 100; + +const _maxComponentDepth = 6; +const _maxComponentNodes = 250; +const _maxMediaItemsPerComponent = 64; +const _maxExternalUrlLength = 2048; + /// Get messages for a channel /// Query params: channelId (required), limit, before, after Future getMessagesHandler(Request req) async { @@ -33,8 +41,7 @@ Future getMessagesHandler(Request req) async { } final channelId = int.tryParse(channelIdStr); - final parsedLimit = - limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr); + final parsedLimit = limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr); int? before; int? after; @@ -70,7 +77,6 @@ Future getMessagesHandler(Request req) async { } } - // Check channel exists final channel = channelBox.get(channelId); if (channel == null) { return Response.notFound( @@ -99,8 +105,7 @@ Future getMessagesHandler(Request req) async { query.close(); final hasMore = fetchedMessages.length > limit; - final messages = - hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages; + final messages = hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages; int? nextBefore; int? nextAfter; @@ -126,7 +131,87 @@ Future getMessagesHandler(Request req) async { ); } -/// Send a message +/// Get channel media index (for gallery grid view) +Future 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 sendMessageHandler(Request req) async { final userId = userIdProperty[req]; @@ -147,13 +232,19 @@ Future sendMessageHandler(Request req) async { ); } + final validationError = _validateComponents(components); + if (validationError != null) { + return Response.badRequest( + body: Body.fromString(validationError), + ); + } + if (channelId == null) { return Response.badRequest( body: Body.fromString('channelId required'), ); } - // Check channel exists final channel = channelBox.get(channelId); if (channel == null) { return Response.notFound( @@ -161,7 +252,6 @@ Future sendMessageHandler(Request req) async { ); } - // Get user info for caching final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build(); final user = userQuery.findFirst(); userQuery.close(); @@ -172,7 +262,8 @@ Future sendMessageHandler(Request req) async { ); } - // Create message with cached author info + final normalizedComponents = _normalizeComponentsForStorage(components); + final message = ChatMessage( channelId: channelId, authorLocalId: user.id, @@ -180,13 +271,14 @@ Future sendMessageHandler(Request req) async { authorUsername: user.username, authorAvatarUrl: user.avatarUrl, authorHomeServer: user.homeServer, - content: _buildSearchContent(components, contentOverride), - componentsJson: jsonEncode(components), + content: _buildSearchContent(normalizedComponents, contentOverride), + componentsJson: jsonEncode(normalizedComponents), ); final id = messageBox.put(message); message.id = id; - _attachReferencedFiles(components); + _attachReferencedFiles(normalizedComponents); + _syncMessageMediaIndex(message, normalizedComponents); broadcastMessageCreated( channelId: channelId, @@ -207,7 +299,6 @@ Future sendMessageHandler(Request req) async { ); } -/// Edit a message Future editMessageHandler(Request req) async { final userId = userIdProperty[req]; @@ -238,7 +329,6 @@ Future editMessageHandler(Request req) async { ); } - // Get user to verify ownership final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build(); final user = userQuery.findFirst(); userQuery.close(); @@ -258,12 +348,21 @@ Future editMessageHandler(Request req) async { body: Body.fromString('content or components/componentsJson required'), ); } + final validationError = _validateComponents(components); + if (validationError != null) { + return Response.badRequest( + body: Body.fromString(validationError), + ); + } - message.content = _buildSearchContent(components, contentOverride); - message.componentsJson = jsonEncode(components); + final normalizedComponents = _normalizeComponentsForStorage(components); + + message.content = _buildSearchContent(normalizedComponents, contentOverride); + message.componentsJson = jsonEncode(normalizedComponents); message.editedAt = DateTime.now(); messageBox.put(message); - _attachReferencedFiles(components); + _attachReferencedFiles(normalizedComponents); + _syncMessageMediaIndex(message, normalizedComponents); broadcastMessageUpdated( channelId: message.channelId, @@ -280,7 +379,6 @@ Future editMessageHandler(Request req) async { ); } -/// Delete a message Future deleteMessageHandler(Request req) async { final userId = userIdProperty[req]; @@ -311,7 +409,6 @@ Future deleteMessageHandler(Request req) async { ); } - // Get user to verify ownership final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build(); final user = userQuery.findFirst(); userQuery.close(); @@ -324,6 +421,7 @@ Future deleteMessageHandler(Request req) async { final channelId = message.channelId; messageBox.remove(messageId); + _removeMessageMediaIndex(messageId); broadcastMessageDeleted( channelId: channelId, @@ -353,6 +451,27 @@ Map _messageToMap(ChatMessage message) { }; } +Map _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>? _parseComponents(Map body) { final rawComponents = body['components']; if (rawComponents is List) { @@ -400,7 +519,9 @@ List>? _parseComponents(Map body) { return >[ { + 'id': 'text_0', 'type': 'text', + 'typeVersion': 1, 'modifiers': [], 'fallback': content, 'data': { @@ -410,6 +531,151 @@ List>? _parseComponents(Map body) { ]; } +String? _validateComponents(List> 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 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.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.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.from( + thumbnail.map((k, v) => MapEntry(k.toString(), v)), + ), + '$path.thumbnail', + ); + if (err != null) { + return err; + } + } + + return null; +} + +String? _validateMediaRef(Map media, String path) { + final source = (media['source'] as String?)?.trim(); + final attachmentId = ((media['attachmentId'] ?? media['uploadId']) as String?)?.trim(); + final url = (media['url'] as String?)?.trim(); + + final resolvedSource = source ?? + (attachmentId != null && attachmentId.isNotEmpty ? 'local_upload' : (url != null && url.isNotEmpty ? 'external_url' : null)); + + if (resolvedSource == null) { + return 'Media source is required at $path'; + } + + if (resolvedSource == 'local_upload') { + if (attachmentId == null || attachmentId.isEmpty) { + return 'attachmentId is required for local_upload at $path'; + } + return null; + } + + if (resolvedSource == 'external_url') { + if (url == null || url.isEmpty) { + return 'url is required for external_url at $path'; + } + if (url.length > _maxExternalUrlLength) { + return 'url is too long at $path'; + } + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasAuthority) { + return 'url is invalid at $path'; + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + return 'url must use http/https at $path'; + } + return null; + } + + return 'Unsupported media source `$resolvedSource` at $path'; +} + String _buildSearchContent( List> components, String? contentOverride, @@ -420,68 +686,461 @@ String _buildSearchContent( final parts = []; for (final component in components) { - final fallback = component['fallback']; - if (fallback is String && fallback.trim().isNotEmpty) { - parts.add(fallback.trim()); - } - - final data = component['data']; - if (data is! Map) { - continue; - } - - void addDataString(String key) { - final value = data[key]; - if (value is String && value.trim().isNotEmpty) { - parts.add(value.trim()); - } - } - - addDataString('text'); - addDataString('title'); - addDataString('description'); - addDataString('caption'); + _collectSearchText(component, parts); } - final content = parts.join(' ').trim(); + final content = parts.map((s) => s.trim()).where((s) => s.isNotEmpty).join(' ').replaceAll(RegExp(r'\s+'), ' ').trim(); return content.isEmpty ? '[component]' : content; } -void _attachReferencedFiles(List> components) { - final referencedFileIds = {}; - for (final component in components) { - final data = component['data']; - if (data is! Map) { - continue; - } +void _collectSearchText(Map component, List out) { + final fallback = component['fallback']; + if (fallback is String && fallback.trim().isNotEmpty) { + out.add(fallback); + } - final items = data['items']; - if (items is! List) { - continue; + final data = component['data']; + if (data is! Map) { + return; + } + final map = Map.from( + data.map((k, v) => MapEntry(k.toString(), v)), + ); + const keys = [ + 'text', + 'title', + 'description', + 'caption', + 'siteName', + 'author', + 'originalFileName', + ]; + for (final key in keys) { + final value = map[key]; + if (value is String && value.trim().isNotEmpty) { + out.add(value); } + } - for (final item in items) { - if (item is! Map) { - continue; - } - final fileId = item['fileId']; - final parsedId = switch (fileId) { - int id when id > 0 => id, - String value => int.tryParse(value), - _ => null, - }; - if (parsedId != null && parsedId > 0) { - referencedFileIds.add(parsedId); + final url = map['url']; + if (url is String && url.trim().isNotEmpty) { + out.add(url); + final uri = Uri.tryParse(url); + if (uri != null && uri.host.isNotEmpty) { + out.add(uri.host); + } + } + + final children = map['children']; + if (children is List) { + for (final child in children) { + if (child is Map) { + _collectSearchText(child, out); + } else if (child is Map) { + _collectSearchText( + child.map((k, v) => MapEntry(k.toString(), v)), + out, + ); } } } - for (final fileId in referencedFileIds) { - final file = fileBox.get(fileId); - if (file == null || file.isAttached) { + final items = map['items']; + if (items is List) { + for (final item in items) { + if (item is Map) { + final itemMap = Map.from( + item.map((k, v) => MapEntry(k.toString(), v)), + ); + for (final key in keys) { + final value = itemMap[key]; + if (value is String && value.trim().isNotEmpty) { + out.add(value); + } + } + final itemUrl = itemMap['url']; + if (itemUrl is String && itemUrl.trim().isNotEmpty) { + out.add(itemUrl); + final uri = Uri.tryParse(itemUrl); + if (uri != null && uri.host.isNotEmpty) { + out.add(uri.host); + } + } + } + } + } +} + +void _attachReferencedFiles(List> components) { + final referencedAttachmentIds = {}; + _walkMediaRefs(components, (item) { + final source = (item['source'] as String?)?.trim(); + if (source != null && source != 'local_upload') { + return; + } + final attachmentId = item['attachmentId'] ?? item['uploadId']; + if (attachmentId is String && attachmentId.trim().isNotEmpty) { + referencedAttachmentIds.add(attachmentId.trim()); + } + }); + + for (final attachmentId in referencedAttachmentIds) { + final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build(); + final file = query.findFirst(); + query.close(); + if (file == null || file.isAttached || file.status != 'ready') { continue; } file.isAttached = true; fileBox.put(file); } } + +List> _normalizeComponentsForStorage( + List> components, +) { + final normalized = >[]; + for (final rawComponent in components) { + normalized.add(_normalizeComponentNode(rawComponent)); + } + return normalized; +} + +Map _normalizeComponentNode(Map rawNode) { + final node = Map.from(rawNode); + final dataRaw = node['data']; + if (dataRaw is! Map) { + return node; + } + + final data = Map.from( + dataRaw.map((key, value) => MapEntry(key.toString(), value)), + ); + + final itemsRaw = data['items']; + if (itemsRaw is List) { + final normalizedItems = >[]; + for (final itemRaw in itemsRaw) { + if (itemRaw is! Map) { + continue; + } + final item = Map.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.from( + thumbnailRaw.map((k, v) => MapEntry(k.toString(), v)), + ); + data['thumbnail'] = _normalizeMediaItem(thumb); + } + + final childrenRaw = data['children']; + if (childrenRaw is List) { + final normalizedChildren = >[]; + for (final child in childrenRaw) { + if (child is Map) { + 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 _normalizeMediaItem(Map 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> components, +) { + _removeMessageMediaIndex(message.id); + + final entries = []; + _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> components, + void Function(Map item) visitor, +) { + for (final component in components) { + final dataRaw = component['data']; + if (dataRaw is! Map) { + continue; + } + final data = Map.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.from( + item.map((k, v) => MapEntry(k.toString(), v)), + ), + ); + } + } + + final thumbnail = data['thumbnail']; + if (thumbnail is Map) { + visitor( + Map.from( + thumbnail.map((k, v) => MapEntry(k.toString(), v)), + ), + ); + } + + final children = data['children']; + if (children is List) { + final childComponents = >[]; + for (final child in children) { + if (child is Map) { + 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 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 = >[]; + for (final component in decoded) { + if (component is Map) { + components.add(component); + } else if (component is Map) { + components.add(component.map((k, v) => MapEntry(k.toString(), v))); + } + } + 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), + ); + } +} diff --git a/lib/src/handlers/public_handlers.dart b/lib/src/handlers/public_handlers.dart index 2609d01..5268028 100644 --- a/lib/src/handlers/public_handlers.dart +++ b/lib/src/handlers/public_handlers.dart @@ -2,6 +2,7 @@ import 'package:chat_server/objectbox.g.dart'; import 'package:relic/relic.dart'; import '../../config/config.dart'; +import 'auth_handlers.dart'; import '../state/app_state.dart'; import '../utils/extensions.dart'; @@ -38,10 +39,11 @@ Future publicUserHandler(Request req) async { 'userId': user.userId, 'username': user.username, 'displayName': user.displayName, + 'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl), 'avatarUrl': user.avatarUrl, 'bio': user.bio, 'publicKey': user.publicKey, - 'homeServer': Config.serverHost, + 'homeServer': user.homeServer ?? Config.publicBaseUrl, 'createdAt': user.createdAt.toIso8601String(), }.toBody, ); @@ -84,6 +86,7 @@ Future publicVerifyHandler(Request req) async { 'userId': user.userId, 'publicKey': user.publicKey, 'username': user.username, + 'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl), }.toBody, ); } @@ -93,11 +96,18 @@ Future publicVerifyHandler(Request req) async { /// GET /.well-known/server /// Returns: { serverHost, serverName, version } Future publicServerInfoHandler(Request req) async { + final settingsQuery = serverSettingsBox.query(ServerSettings_.singletonKey.equals('default')).build(); + final settings = settingsQuery.findFirst(); + settingsQuery.close(); + return Response.ok( body: { - 'serverHost': Config.serverHost, - 'serverName': Config.serverName, + 'serverHost': Config.publicHost, + 'publicBaseUrl': Config.publicBaseUrl, + 'serverName': settings?.serverName ?? Config.serverName, 'version': '0.1.0', + 'iconUrl': settings?.iconAttachmentId == null ? null : '/icons/${Uri.encodeComponent(Config.publicHost)}', + 'bannerUrl': settings?.bannerAttachmentId == null ? null : '/banners/${Uri.encodeComponent(Config.publicHost)}', 'features': ['identity', 'chat', 'sticker-packs'], }.toBody, ); diff --git a/lib/src/handlers/settings_handlers.dart b/lib/src/handlers/settings_handlers.dart new file mode 100644 index 0000000..a8c6d7b --- /dev/null +++ b/lib/src/handlers/settings_handlers.dart @@ -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 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 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 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 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 setServerIconHandler(Request req) async { + return _setServerMedia(req, usageType: 'server_icon', target: 'icon'); +} + +Future setServerBannerHandler(Request req) async { + return _setServerMedia(req, usageType: 'server_banner', target: 'banner'); +} + +Future 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 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 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 _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 _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 _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()), + ); +} diff --git a/lib/src/handlers/upload_handlers.dart b/lib/src/handlers/upload_handlers.dart index dbc8e9c..51a43c6 100644 --- a/lib/src/handlers/upload_handlers.dart +++ b/lib/src/handlers/upload_handlers.dart @@ -1,108 +1,217 @@ import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; import 'package:chat_server/models.dart'; import 'package:chat_server/objectbox.g.dart'; +import 'package:crypto/crypto.dart'; import 'package:mime/mime.dart'; import 'package:relic/relic.dart'; import '../auth/auth_middleware.dart'; import '../state/app_state.dart'; import '../utils/extensions.dart'; +import 'message_handlers.dart'; const _uploadsDir = 'uploads'; -const _maxUploadBytes = 25 * 1024 * 1024; +const _maxUploadBytes = 50 * 1024 * 1024; -Future uploadFileHandler(Request req) async { - final userId = userIdProperty[req]; - if (userId == null) { - return Response.unauthorized( - body: Body.fromString('Not authenticated'), - ); +Future initUploadHandler(Request req) async { + final currentUser = _requireCurrentUser(req); + if (currentUser.$2 != null) { + return currentUser.$2!; } + final user = currentUser.$1!; - final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build(); - final user = userQuery.findFirst(); - userQuery.close(); - if (user == null) { - return Response.unauthorized( - body: Body.fromString('User not found'), - ); - } + final body = await req.asJson(); + final usageType = (body['usageType'] as String?)?.trim() ?? 'message'; + final originalFileName = (body['originalFileName'] as String?)?.trim() ?? ''; + final declaredMimeType = (body['mimeType'] as String?)?.trim() ?? 'application/octet-stream'; + final declaredWidth = _asInt(body['width']) ?? 0; + final declaredHeight = _asInt(body['height']) ?? 0; + final declaredSizeBytes = _asInt(body['sizeBytes']) ?? 0; - final contentTypeHeader = req.headers[Headers.contentTypeHeader]?.first; - final boundary = _extractBoundary(contentTypeHeader); - if (boundary == null) { + if (!_isAllowedUsageType(usageType)) { return Response.badRequest( - body: Body.fromString('Expected multipart/form-data with boundary'), + body: Body.fromString('Invalid usageType'), ); } - final multipartParts = await req - .read(maxLength: _maxUploadBytes) - .cast>() - .transform(MimeMultipartTransformer(boundary)) - .toList(); - if (multipartParts.isEmpty) { - return Response.badRequest( - body: Body.fromString('No files in request'), - ); - } - - final uploadPart = multipartParts.firstWhere( - (part) => (part.headers['content-disposition'] ?? '').contains('filename='), - orElse: () => multipartParts.first, + final attachmentId = _generateAttachmentId(); + final entity = ChatFile( + attachmentId: attachmentId, + uploaderId: user.id, + path: '', + originalFileName: originalFileName.isEmpty ? 'upload-${attachmentId.substring(0, 8)}' : originalFileName, + storedFileName: '', + sha256: '', + mimeType: declaredMimeType, + width: declaredWidth, + height: declaredHeight, + sizeBytes: declaredSizeBytes, + usageType: usageType, + status: 'pending', ); - final bytesBuilder = BytesBuilder(copy: false); - await for (final chunk in uploadPart) { - bytesBuilder.add(chunk); + final id = fileBox.put(entity); + entity.id = id; + + return Response.ok( + body: { + 'uploadId': entity.attachmentId, + 'attachmentId': entity.attachmentId, + 'status': entity.status, + 'usageType': entity.usageType, + }.toBody, + ); +} + +Future completeUploadHandler(Request req) async { + final currentUser = _requireCurrentUser(req); + if (currentUser.$2 != null) { + return currentUser.$2!; } - final bytes = bytesBuilder.toBytes(); + final user = currentUser.$1!; + + final uploadId = req.rawPathParameters[#uploadId]; + if (uploadId == null || uploadId.isEmpty) { + return Response.badRequest( + body: Body.fromString('uploadId is required'), + ); + } + + final query = fileBox.query(ChatFile_.attachmentId.equals(uploadId)).build(); + final file = query.findFirst(); + query.close(); + + if (file == null) { + return Response.notFound( + body: Body.fromString('Upload not found'), + ); + } + + if (file.uploaderId != user.id) { + return Response.forbidden( + body: Body.fromString('You cannot complete another user\'s upload'), + ); + } + + if (file.status == 'ready') { + return Response.ok(body: _fileResponseMap(file).toBody); + } + + final uploadData = await _readUploadData(req); + if (uploadData.$2 != null) { + file.status = 'failed'; + file.errorCode = 'invalid_upload_payload'; + fileBox.put(file); + return uploadData.$2!; + } + final bytes = uploadData.$1!; + final payloadMimeType = uploadData.$3; + final payloadFileName = uploadData.$4; + if (bytes.isEmpty) { + file.status = 'failed'; + file.errorCode = 'empty_file'; + fileBox.put(file); return Response.badRequest( body: Body.fromString('Uploaded file is empty'), ); } - final contentDisposition = uploadPart.headers['content-disposition'] ?? ''; - final fileName = _extractFilename(contentDisposition); - final mimeType = - (uploadPart.headers['content-type'] ?? 'application/octet-stream').trim(); - final ext = _resolveExtension(fileName, mimeType); - final dimensions = _extractImageDimensions(bytes, mimeType); - - final entity = ChatFile( - uploaderId: user.id, - path: '/uploads/pending$ext', - mimeType: mimeType, - width: dimensions.$1, - height: dimensions.$2, - sizeBytes: bytes.length, + _finalizeUpload( + file: file, + bytes: bytes, + payloadMimeType: payloadMimeType, + payloadFileName: payloadFileName, ); - final id = fileBox.put(entity); - final directory = Directory(_uploadsDir); - if (!directory.existsSync()) { - directory.createSync(recursive: true); - } - - final storedName = '$id$ext'; - final diskPath = '${directory.path}${Platform.pathSeparator}$storedName'; - File(diskPath).writeAsBytesSync(bytes, flush: true); - - entity.id = id; - entity.path = '/uploads/$storedName'; - fileBox.put(entity); + await resolvePendingUploadsInMessages(file.attachmentId); return Response.ok( - body: { - 'id': id, - 'url': entity.path, - 'width': entity.width, - 'height': entity.height, - 'mimeType': entity.mimeType, - }.toBody, + body: _fileResponseMap(file).toBody, + ); +} + +Future 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 getAttachmentHandler(Request req) async { + final attachmentId = req.rawPathParameters[#attachmentId]; + if (attachmentId == null || attachmentId.isEmpty) { + return Response.badRequest( + body: Body.fromString('Invalid attachment ID'), + ); + } + + final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build(); + final file = query.findFirst(); + query.close(); + + if (file == null || file.status != 'ready') { + return Response.notFound( + body: Body.fromString('Attachment not found'), + ); + } + + final diskPath = _resolveDiskPath(file); + final diskFile = File(diskPath); + if (!diskFile.existsSync()) { + return Response.notFound( + body: Body.fromString('Attachment file missing on disk'), + ); + } + + return Response.ok( + headers: Headers.fromMap( + { + Headers.contentTypeHeader: [file.mimeType], + Headers.cacheControlHeader: ['public, max-age=31536000, immutable'], + }, + ), + body: Body.fromData(diskFile.readAsBytesSync()), ); } @@ -114,20 +223,193 @@ Future getUploadFileHandler(Request req) async { ); } - final filePath = '$_uploadsDir${Platform.pathSeparator}$fileName'; - final file = File(filePath); + final diskPath = '$_uploadsDir${Platform.pathSeparator}$fileName'; + final file = File(diskPath); if (!file.existsSync()) { return Response.notFound( body: Body.fromString('File not found'), ); } - final data = file.readAsBytesSync(); return Response.ok( - body: Body.fromData(data), + body: Body.fromData(file.readAsBytesSync()), ); } +(ChatUser?, Response?) _requireCurrentUser(Request req) { + final userId = userIdProperty[req]; + if (userId == null) { + return ( + null, + Response.unauthorized( + body: Body.fromString('Not authenticated'), + ) + ); + } + + final query = userBox.query(ChatUser_.userId.equals(userId)).build(); + final user = query.findFirst(); + query.close(); + if (user == null) { + return ( + null, + Response.unauthorized( + body: Body.fromString('User not found'), + ) + ); + } + + return (user, null); +} + +Future<(Uint8List?, Response?, String?, String?)> _readUploadData(Request req) async { + final contentTypeHeader = req.headers[Headers.contentTypeHeader]?.first; + final boundary = _extractBoundary(contentTypeHeader); + + if (boundary != null) { + final multipartParts = + await req.read(maxLength: _maxUploadBytes).cast>().transform(MimeMultipartTransformer(boundary)).toList(); + if (multipartParts.isEmpty) { + return (null, Response.badRequest(body: Body.fromString('No files in request')), null, null); + } + + final uploadPart = multipartParts.firstWhere( + (part) => (part.headers['content-disposition'] ?? '').contains('filename='), + orElse: () => multipartParts.first, + ); + + final bytesBuilder = BytesBuilder(copy: false); + await for (final chunk in uploadPart) { + bytesBuilder.add(chunk); + } + final bytes = bytesBuilder.toBytes(); + final mimeType = uploadPart.headers['content-type']; + final filename = _extractFilename(uploadPart.headers['content-disposition'] ?? ''); + + return (bytes, null, mimeType, filename); + } + + final bytesBuilder = BytesBuilder(copy: false); + await for (final chunk in req.read(maxLength: _maxUploadBytes)) { + bytesBuilder.add(chunk); + } + + final mimeType = contentTypeHeader; + return (bytesBuilder.toBytes(), null, mimeType, null); +} + +Map _fileResponseMap(ChatFile file) { + return { + 'uploadId': file.attachmentId, + 'attachmentId': file.attachmentId, + 'id': file.id, + 'url': file.path, + 'status': file.status, + 'usageType': file.usageType, + 'originalFileName': file.originalFileName, + 'storedFileName': file.storedFileName, + 'mimeType': file.mimeType, + 'sizeBytes': file.sizeBytes, + 'width': file.width, + 'height': file.height, + 'sha256': file.sha256, + }; +} + +void _finalizeUpload({ + required ChatFile file, + required Uint8List bytes, + String? payloadMimeType, + String? payloadFileName, +}) { + final detectedMime = _normalizeMimeType( + payloadMimeType ?? file.mimeType, + ); + final dimensions = _extractImageDimensions(bytes, detectedMime); + final extension = _resolveExtension( + payloadFileName ?? file.originalFileName, + detectedMime, + ); + final storedFileName = '${file.attachmentId}$extension'; + + final directory = Directory(_uploadsDir); + if (!directory.existsSync()) { + directory.createSync(recursive: true); + } + + final diskPath = '${directory.path}${Platform.pathSeparator}$storedFileName'; + File(diskPath).writeAsBytesSync(bytes, flush: true); + + file.path = '/attachments/${file.attachmentId}'; + file.storedFileName = storedFileName; + file.sha256 = sha256.convert(bytes).toString(); + file.mimeType = detectedMime; + file.width = dimensions.$1; + file.height = dimensions.$2; + file.sizeBytes = bytes.length; + file.status = 'ready'; + file.errorCode = null; + fileBox.put(file); +} + +String _generateAttachmentId() { + const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; + final random = Random.secure(); + String next() { + final bytes = List.generate(20, (_) => random.nextInt(256)); + final sb = StringBuffer(); + for (final byte in bytes) { + sb.write(alphabet[byte % alphabet.length]); + } + return sb.toString(); + } + + while (true) { + final id = next(); + final exists = fileBox.query(ChatFile_.attachmentId.equals(id)).build().findFirst(); + if (exists == null) { + return id; + } + } +} + +bool _isAllowedUsageType(String usageType) { + return usageType == 'message' || usageType == 'avatar' || usageType == 'server_icon' || usageType == 'server_banner'; +} + +int? _asInt(Object? value) { + return switch (value) { + int v => v, + double v => v.round(), + String v => int.tryParse(v), + _ => null, + }; +} + +String _resolveDiskPath(ChatFile file) { + final storedFileName = file.storedFileName.trim(); + if (storedFileName.isNotEmpty) { + return '$_uploadsDir${Platform.pathSeparator}$storedFileName'; + } + + final path = file.path; + if (path.startsWith('/uploads/')) { + final fileName = path.substring('/uploads/'.length); + return '$_uploadsDir${Platform.pathSeparator}$fileName'; + } + + final attachmentPath = path.startsWith('/attachments/') ? path.substring('/attachments/'.length) : path; + return '$_uploadsDir${Platform.pathSeparator}$attachmentPath'; +} + +String _normalizeMimeType(String? mimeType) { + final cleaned = (mimeType ?? '').split(';').first.trim().toLowerCase(); + if (cleaned.isEmpty) { + return 'application/octet-stream'; + } + return cleaned; +} + String? _extractBoundary(String? contentTypeHeader) { if (contentTypeHeader == null) { return null; @@ -168,6 +450,7 @@ String _resolveExtension(String? fileName, String mimeType) { 'image/jpeg' || 'image/jpg' => '.jpg', 'image/gif' => '.gif', 'image/webp' => '.webp', + 'image/avif' => '.avif', 'video/mp4' => '.mp4', _ => '.bin', }; @@ -182,8 +465,7 @@ String _resolveExtension(String? fileName, String mimeType) { return (width, height); } - if ((lowerMime == 'image/jpeg' || lowerMime == 'image/jpg') && - bytes.length > 4) { + if ((lowerMime == 'image/jpeg' || lowerMime == 'image/jpg') && bytes.length > 4) { final jpeg = _extractJpegDimensions(bytes); if (jpeg != null) { return jpeg; @@ -200,10 +482,7 @@ String _resolveExtension(String? fileName, String mimeType) { } int _readUint32BigEndian(Uint8List bytes, int offset) { - return (bytes[offset] << 24) | - (bytes[offset + 1] << 16) | - (bytes[offset + 2] << 8) | - bytes[offset + 3]; + return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]; } (int, int)? _extractJpegDimensions(Uint8List bytes) { diff --git a/lib/src/state/app_state.dart b/lib/src/state/app_state.dart index 0bd15f2..41234ac 100644 --- a/lib/src/state/app_state.dart +++ b/lib/src/state/app_state.dart @@ -5,12 +5,16 @@ late final Store store; late final Box userBox; late final Box channelBox; late final Box messageBox; +late final Box messageMediaBox; late final Box fileBox; +late final Box serverSettingsBox; void initializeStore() { store = openStore(); userBox = store.box(); channelBox = store.box(); messageBox = store.box(); + messageMediaBox = store.box(); fileBox = store.box(); + serverSettingsBox = store.box(); } diff --git a/pubspec.lock b/pubspec.lock index 95b39fa..ab6ba64 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -281,14 +281,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - hotreloader: - dependency: "direct main" - description: - name: hotreloader - sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b - url: "https://pub.dev" - source: hosted - version: "4.3.0" http: dependency: transitive description: @@ -370,7 +362,7 @@ packages: source: hosted version: "1.18.1" mime: - dependency: transitive + dependency: "direct main" description: name: mime sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" @@ -666,7 +658,7 @@ packages: source: hosted version: "1.2.1" yaml: - dependency: transitive + dependency: "direct main" description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce diff --git a/pubspec.yaml b/pubspec.yaml index 06743a8..776f8fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,8 @@ dependencies: crypton: crypto: collection: - hotreloader: + yaml: + mime: dev_dependencies: test: diff --git a/test/auth_helpers_test.dart b/test/auth_helpers_test.dart index 3047955..d50948e 100644 --- a/test/auth_helpers_test.dart +++ b/test/auth_helpers_test.dart @@ -2,34 +2,6 @@ import 'package:chat_server/src/handlers/auth_handlers.dart'; import 'package:test/test.dart'; 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', () { test('normalizes host without scheme', () { expect( @@ -59,30 +31,30 @@ void main() { }); }); - group('homeServerMatchesUserIdHost', () { - test('matches host and explicit port', () { + 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('toUserHandle', () { + test('uses URL host and port', () { expect( - homeServerMatchesUserIdHost( - 'https://chat.example.com:8443', 'chat.example.com:8443'), - isTrue, - ); - expect( - homeServerMatchesUserIdHost( - 'https://chat.example.com:8443', 'chat.example.com'), - isFalse, + toUserHandle('alice', 'https://chat.example.com:8443'), + '@alice@chat.example.com:8443', ); }); - test('matches default ports when omitted', () { + test('falls back to raw home server string', () { expect( - homeServerMatchesUserIdHost( - 'https://chat.example.com', 'chat.example.com:443'), - isTrue, - ); - expect( - homeServerMatchesUserIdHost( - 'http://chat.example.com', 'chat.example.com:80'), - isTrue, + toUserHandle('alice', 'chat.example.com'), + '@alice@chat.example.com', ); }); }); diff --git a/test/integration_api_test.dart b/test/integration_api_test.dart new file mode 100644 index 0000000..c791d4f --- /dev/null +++ b/test/integration_api_test.dart @@ -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 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.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> postJson( + String path, + Map 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; + } + + Future> 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; + } + + Future> 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; + } + + 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>(); + expect(messageList.length, greaterThanOrEqualTo(2)); + + final media = await getJson('/api/channels/$channelId/media?limit=10', token: token); + final mediaItems = (media['media'] as List).cast>(); + 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); + } + }); +}