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