Compare commits

...
10 Commits
28 changed files with 4940 additions and 431 deletions

No files matched your search

+9
View File
@@ -21,3 +21,12 @@ build/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Conversation exports (local only)
conversation_export/
uploads/
objectbox/
objectbox.dll
# AI
.claude
+135 -221
View File
@@ -1,248 +1,162 @@
# Chat Server Architecture - Complete Reference # Reliquary Server Architecture
## Project Overview ## 1. Overview
Reliquary is a self-hosted chat server with:
- JWT auth
- ObjectBox persistence
- WebSocket gateway fanout
- Component-based messages
- Decoupled upload pipeline (upload first, message commit anytime)
Self-hosted Discord alternative with decentralized identity. Each user has a "home server" where their identity lives, and can join other "guest servers" seamlessly using cryptographic signatures. ## 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.
## Core Architecture Decisions ### Runtime Config Split
- `server.bind_host` + `server.bind_port`: where Relic listens locally.
- `server.public_base_url`: canonical external URL used for identity/handles/cross-server metadata.
- This avoids conflating local bind settings with public identity/routing.
### Home Server Model ### First Admin Bootstrap
- **NO identity server** - just use one regular server as "home" - Config key: `server.first_setup` in `config.yaml`.
- **NO federation protocol** - servers don't talk to each other - When `first_setup: true`, the first registered local user gets `admin`.
- **Client does everything** - syncs, cross-server requests, profile updates - Later registrations remain `member`.
### Identity System ## 3. Storage Model
- Ed25519 keypair generated on registration
- Private key = proof of identity
- Public key stored on home server at `/.well-known/users/:username`
- userId format: `username@serverhost`
### Single Sign-On Flow ### 3.1 Entities
1. User registers/logs in ONCE on home server - `ChatUser`: identity/profile/auth data.
2. Gets JWT token + encrypted keypair - `ChatChannel`: channel metadata.
3. To join other servers: sign a message with private key - `ChatMessage`: channel message, search text, and `componentsJson`.
4. Other server verifies signature against home server's public key - `ChatFile`: canonical media object for:
5. No password ever sent to guest servers - message attachments
- user avatar media
- server icon/banner media
- `ServerSettings`: singleton row for mutable server presentation settings.
--- ### 3.2 ChatFile Fields (Important)
- `attachmentId` (unique external ID; API-facing)
- `originalFileName` (preserved filename metadata)
- `storedFileName` (physical disk key)
- `sha256`
- `mimeType`, `sizeBytes`, `width`, `height`
- `usageType`: `message | avatar | server_icon | server_banner`
- `status`: `pending | ready | failed`
## What's Implemented ## 4. Upload and Message Flow
### ✅ Done ### 4.1 Recommended Client Flow (Implemented Server Contract)
- User model with keypair, home server reference 1. Client stages attachment in composer/component editor.
- Channel and Message models 2. Client calls `POST /api/uploads/init` to create pending upload.
- Crypto service (keypair generation, signing, verification) 3. Client uploads bytes to `PUT /api/uploads/:uploadId`.
- JWT authentication middleware 4. Client sends `POST /api/messages` with components referencing `uploadId`/`attachmentId`.
- Register handler (creates keypair) 5. If upload completes after message create, server emits `message.updated`.
- Login handler (returns encrypted keypair for device sync)
- Join server handler (signature-based verification - stub)
- Public endpoints for cross-server identity verification
- Channel CRUD
- Message CRUD with author caching
### ❌ Not Implemented ### 4.2 Why This Flow
- HTTP client for cross-server public key fetching - No chronological jumping for text.
- WebSocket for real-time messages - Immediate local optimistic UI.
- Profile sync (push updates to all servers) - Progressive media readiness via gateway updates.
- Sticker packs (future feature) - Server keeps authoritative metadata and storage consistency.
- File uploads
- Permissions system (roles exist but not enforced)
- Message editing/deletion permissions
--- ## 5. API Surface
## Database Models ### 5.0 Identity Contract
- Registration returns:
- `userId` (opaque stable ID)
- `username`
- `handle` (computed, mutable as home host changes)
- Cross-server join request now expects:
- `userId`
- `username`
- `homeServer`
- `timestamp`
- `signature` over `userId:timestamp`
```dart ### 5.1 Uploads and Attachments
// User - works for both HOME and GUEST servers - `POST /api/uploads/init` (auth)
@Entity() - `PUT /api/uploads/:uploadId` (auth)
class User { - `POST /api/uploads` (auth, legacy single-step compatibility)
int id; - `GET /attachments/:attachmentId` (public immutable media URL)
String userId; // "alice@homeserver.com" - `GET /api/channels/:channelId/media` (auth, gallery index feed)
String username;
String? displayName, avatarUrl, bio;
String publicKey; // Ed25519, always stored ### 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).
// HOME SERVER ONLY: ### 5.3 Server Settings
String? passwordHash; - `GET /api/server/settings` (auth)
String? encryptedPrivateKey; // For device sync - `PATCH /api/server/settings` (admin)
String? keyPairSalt; - `PUT /api/server/icon` (admin)
- `PUT /api/server/banner` (admin)
bool isLocal; // true = this is home server ## 6. Gateway Behavior
String? homeServer; // if guest, where is home? - Existing events:
List<String> roles; // Server-specific - `message.created`
DateTime createdAt, lastSyncedAt; - `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`.
@Entity() ## 7. Component Media Item Contract (Client)
class Channel { Each media item in a component should carry stable IDs and upload state:
int id;
String name;
String? description;
DateTime createdAt;
}
@Entity() ```json
class Message { {
int id; "itemId": "local_or_stable_id",
int channelId; "source": "local_upload|external_url",
"uploadId": "pending_or_ready_attachment_id",
// Author info (includes cross-server fetch capability) "attachmentId": "same_as_uploadId_when_known",
int authorLocalId; "status": "uploading|ready|failed",
String authorUserId; // "alice@home.server" "url": "/attachments/<attachmentId>",
String? authorHomeServer; "originalFileName": "image.png",
"mimeType": "image/webp",
// Cached snapshot at message time "sizeBytes": 12345,
String authorUsername; "width": 1280,
String? authorAvatarUrl; "height": 720,
"thumbHash": "optional"
String content;
DateTime createdAt, editedAt;
} }
``` ```
--- ### 7.1 Validation Rules (Server)
- Every component node must include:
- `id` (string)
- `type` (string)
- `typeVersion` (positive int)
- `data` (object)
- Nested components are supported under `data.children` with depth/size limits.
- Media item rules:
- `source=local_upload` => `attachmentId` required.
- `source=external_url` => valid `http/https` `url` required.
- Invalid component payloads are rejected server-side.
## API Endpoints ## 8. Client Implementation Notes
### Public (No Auth) ### 8.1 Staging and Transform Choice
``` On file pick/drop, choose transform mode before upload starts:
POST /auth/register → Create account on HOME server (generates keypair) - `Compressed WebP` (recommended default)
POST /auth/login → Login to HOME server (returns keypair backup) - `WebP`
POST /auth/join → Join THIS server with signature (no password) - `Original`
GET /.well-known/users/:username → Public profile + public key If user changes mode for a staged file:
GET /.well-known/verify/:userId → Verify user exists - cancel in-flight upload
GET /.well-known/server → Server info - reprocess
``` - re-upload
### Protected (JWT Required) ### 8.2 Metadata Trust
``` Client may send metadata hints, but server metadata is canonical.
GET /api/me → Current user profile Client should refresh with upload response values.
GET /api/channels → List channels ### 8.3 Caching
POST /api/channels → Create channel - `/attachments/:attachmentId`: immutable and long-cache.
GET /api/channels/:id → Get channel - `/avatars`, `/icons`, `/banners`: mutable aliases; revalidate frequently.
GET /api/messages?channelId=X → Get messages ## 9. AVIF and JPEG XL Policy
POST /api/messages → Send message - MVP default: WebP (best compatibility/perf tradeoff).
PUT /api/messages/:id → Edit message - AVIF: optional client mode if target platform decode support is verified.
DELETE /api/messages/:id → Delete message - JPEG XL: defer for now due to uneven ecosystem support.
```
---
## Key Files
```
lib/
├── server.dart # Entry point, routes, ObjectBox init
├── models.dart # User, Channel, Message entities
├── objectbox.g.dart # Generated
│
├── config/config.dart # Server host, JWT secret, port
│
└── src/
├── auth/
│ ├── crypto_service.dart # Keypair, signing, encryption
│ └── auth_middleware.dart # JWT verification
│
├── handlers/
│ ├── auth_handlers.dart # register, login, join, me
│ ├── public_handlers.dart # .well-known/*
│ ├── channel_handlers.dart
│ └── message_handlers.dart
│
└── utils/extensions.dart # Request/Response helpers
```
---
## Join Server Flow (Needs Implementation)
```
CLIENT GUEST SERVER HOME SERVER
│ │ │
│ POST /auth/join │ │
│ {userId, homeServer, timestamp, │ │
│ signature} │ │
│─────────────────────────────────────►│ │
│ │ │
│ │ GET /.well-known/users/ │
│ │ alice │
│ │────────────────────────►│
│ │ │
│ │ {publicKey, ...} │
│ │◄────────────────────────│
│ │ │
│ │ verify signature │
│ │ create shadow account │
│ │ │
│ {token, localId} │ │
│◄─────────────────────────────────────│ │
```
The `fetchPublicKeyFromHomeServer()` function in `auth_handlers.dart` needs actual HTTP implementation.
---
## Tech Stack
- **Framework**: Relic 1.0 (Dart web server)
- **Database**: ObjectBox
- **Auth**: JWT + Ed25519 signatures
- **Crypto**: crypton package for Ed25519
- **Password**: bcrypt
---
## Design Decisions Rationale
| Decision | Why |
|----------|-----|
| No identity server | Avoids hosting another service, one server = home |
| No server-to-server | Eliminates federation complexity, client handles everything |
| Keypair identity | Allows passwordless joins, proves identity cryptographically |
| Message author caching | Messages readable even if home server is down |
| userId format `user@server` | Simple, familiar, self-documenting |
---
## Constraints
- User CANNOT quit home server (that's where identity lives)
- User CANNOT change home server easily (migration = future feature)
- Home server MUST be online to join NEW servers
- Home server offline = can still use already-joined servers (cached keys)
---
## Future Features (Out of Scope for MVP)
1. **Sticker Packs** - Telegram-style, live on community servers, clients cache
2. **Home Server Migration** - Sign migration certificate, chain of trust
3. **File Uploads** - With cross-server URLs
4. **Message Forwarding** - Embed with link to original
5. **Permissions** - Role-based access to channels
6. **WebSocket** - Real-time message delivery
---
## Config to Change
```dart
// lib/config/config.dart
static const String serverHost = 'your-actual-domain.com'; // CHANGE THIS
static const String jwtSecret = 'use-env-variable'; // CHANGE THIS
```
---
## Next Steps
1. Implement HTTP client in `fetchPublicKeyFromHomeServer()`
2. Add WebSocket for real-time
3. Add profile sync (client pushes to all servers on change)
4. Test cross-server join flow
5. Add proper error handling and logging
+140
View File
@@ -0,0 +1,140 @@
# Reliquary Protocol (MVP v1)
This document defines the current wire contract used by the server.
## 1. Identity
- `userId`: immutable opaque ID.
- `username`: home-server unique, mutable.
- `handle`: derived string `@username@home`.
- `homeServer`: canonical public base URL.
## 2. Auth
### `POST /auth/register`
Request:
```json
{ "username": "alice", "password": "password123" }
```
Response fields include:
- `token`
- `userId`
- `username`
- `handle`
- `publicKey`
- encrypted key backup fields
### `POST /auth/login`
Request:
```json
{ "username": "alice", "password": "password123" }
```
Response includes token + profile/key fields.
### `POST /auth/join`
Request:
```json
{
"userId": "opaque_id",
"username": "alice",
"homeServer": "https://chat.example.com",
"timestamp": 1730000000,
"signature": "base64..."
}
```
Signature is over `userId:timestamp`.
## 3. Message Components
Each component node must include:
- `id` (string)
- `type` (string)
- `typeVersion` (positive int)
- `data` (object)
Optional:
- `fallback`, `modifiers`
Nested components are supported under `data.children`.
## 4. Media Item Sources
Media references support two sources:
1. Local upload:
```json
{
"source": "local_upload",
"attachmentId": "att_...",
"status": "uploading|ready|failed"
}
```
2. External URL:
```json
{
"source": "external_url",
"url": "https://example.com/image.jpg",
"status": "ready"
}
```
Optional metadata snapshot:
- `mimeType`, `sizeBytes`, `width`, `height`, `thumbHash`, `originalFileName`, `caption`.
## 5. Uploads
### `POST /api/uploads/init` (auth)
Creates pending upload and returns `uploadId` (same ID namespace as attachment ID).
### `PUT /api/uploads/:uploadId` (auth)
Completes upload with file bytes and returns canonical metadata:
- `attachmentId`, `url`, `status`, `mimeType`, `sizeBytes`, dimensions, hash.
### `POST /api/uploads` (auth)
Legacy single-step compatibility endpoint.
### `GET /attachments/:attachmentId`
Immutable media URL.
## 6. Messages
### `POST /api/messages` (auth)
Request includes:
- `channelId`
- `components` or `componentsJson`
- optional `content`
Server behavior:
- validates structure and media refs
- normalizes media items
- computes searchable `content`
- persists `componentsJson`
### `GET /api/messages?channelId=...`
Returns message list with `componentsJson`.
## 7. Gallery Index
### `GET /api/channels/:channelId/media` (auth)
Returns indexed media rows for grid views.
Query params:
- `limit` (default 50, max 100)
- `before` cursor
- `kind` (`image|video|file`)
Each row includes:
- linkage (`messageId`, `channelId`, `authorUserId`)
- source (`attachmentId` or `externalUrl`)
- display metadata (`mimeType`, dimensions, filename, etc.)
## 8. Gateway
WebSocket path: `/gateway`
Message events:
- `message.created`
- `message.updated`
- `message.deleted`
Upload resolution can cause `message.updated`.
+2 -1
View File
@@ -11,6 +11,7 @@ Each user has a **home server** where their identity lives (keypair), and can se
- **Client does everything** - syncs, cross-server requests, profile updates - **Client does everything** - syncs, cross-server requests, profile updates
See [ARCHITECTURE.md](./ARCHITECTURE.md) for full details. See [ARCHITECTURE.md](./ARCHITECTURE.md) for full details.
See [CLIENT_INTEGRATION.md](./CLIENT_INTEGRATION.md) for client protocol/API integration details.
## Quick Start ## Quick Start
@@ -43,4 +44,4 @@ lib/
- **Framework**: Relic 1.0 - **Framework**: Relic 1.0
- **Database**: ObjectBox - **Database**: ObjectBox
- **Auth**: JWT + Ed25519 signatures - **Auth**: JWT + ECDSA signatures
+79
View File
@@ -0,0 +1,79 @@
{
"code": 200,
"data": {
"description": "Instead of SQL, you define your data model by annotating your persistent object types on the programming language level.",
"external": {
"alternate": {
"https://docs.objectbox.io/entity-annotations.md": {
"type": "text/markdown"
},
"https://docs.objectbox.io/entity-annotations/rss.xml": {
"title": "RSS Feed",
"type": "application/rss+xml"
}
},
"apple-touch-icon": {
"https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&width=180&height=180&sign=96b28d45&sv=2": {
"media": "(prefers-color-scheme: dark)",
"type": "image/png"
}
},
"canonical": {
"https://docs.objectbox.io/entity-annotations": {}
},
"icon": {
"https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&width=48&height=48&sign=96b28d45&sv=2": {
"media": "(prefers-color-scheme: dark)",
"type": "image/png"
}
},
"preconnect": {
"https://api.gitbook.com/cache/": {},
"https://ka-p.fontawesome.com/releases/v7.1.0/": {},
"https://static-2v.gitbook.com/": {
"crossorigin": ""
}
},
"preload": {
"https://static-2v.gitbook.com/_next/static/chunks/webpack-3f8d6b18f6ab2c58.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b": {
"as": "script",
"fetchpriority": "low"
}
},
"privacy-policy": {
"https://objectbox.io/wordpress/wp-content/uploads/2022/08/Object-Box-Privacy-Notice.pdf": {}
}
},
"html": "<html lang=\"en\" class=\"rounded-corners theme-clean no-tint sidebar-default sidebar-list-default links-default depth-subtle __variable_a32233 __variable_80f980 __variable_37a5dc font-Roboto sheet-open:gutter-stable hydrated light\" style=\"color-scheme: light; --toc-height: 1216px; --toc-top-offset: 64px; --outline-height: 1216px; --outline-top-offset: 64px;\"><head><meta charset=\"utf-8\"><link rel=\"preconnect\" href=\"https://static-2v.gitbook.com\" crossorigin=\"\"><link rel=\"preconnect\" href=\"https://api.gitbook.com/cache/\"><link rel=\"preconnect\" href=\"https://ka-p.fontawesome.com/releases/v7.1.0/\"><link rel=\"preconnect\" href=\"https://static-2v.gitbook.com\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover\"><link rel=\"preload\" as=\"image\" fetchpriority=\"high\" imagesrcset=\"https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&amp;width=32&amp;dpr=1&amp;quality=100&amp;sign=96b28d45&amp;sv=2 32w, https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&amp;width=32&amp;dpr=2&amp;quality=100&amp;sign=96b28d45&amp;sv=2 64w, https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&amp;width=32&amp;dpr=3&amp;quality=100&amp;sign=96b28d45&amp;sv=2 96w\" imagesizes=\"32px\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/8f95264edb3f86f8.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/919cadf6c2ad1dbc.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/e4670420fc569cb1.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/f758e1ab4ddcc0e6.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/4f8983272bb87ee9.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/c36dde9599bfc781.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/f15a462944baacd8.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/e3d65466d47b91eb.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"preload\" as=\"script\" fetchpriority=\"low\" href=\"https://static-2v.gitbook.com/_next/static/chunks/webpack-3f8d6b18f6ab2c58.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\"><script async=\"\" src=\"https://www.googletagmanager.com/gtag/js?id=G-JJBQJCVSYB\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/87c73c54-3c195070c5cbb22b.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/1902-bb2221cdf4f53ce3.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/main-app-a0ac55901a15e772.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/app/global-error-99197ad4868e95f4.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/f5718501-2b19465055d23f42.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/9071f66d-390fafe3303b2acb.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/6500-df22b4917e9f7eea.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/1026-eb8137b53d68a1b4.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/7165-ceef130264185f86.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/9330-42e224e858196d3b.js?dpl=59ffb9Line truncated
"metadata": {
"apple-mobile-web-app-status-bar-style": "default",
"apple-mobile-web-app-title": "ObjectBox Docs",
"color-scheme": "light",
"description": "Instead of SQL, you define your data model by annotating your persistent object types on the programming language level.",
"generator": "GitBook (59ffb95)",
"lang": "en",
"mobile-web-app-capable": "yes",
"next-size-adjust": "",
"og:description": "Instead of SQL, you define your data model by annotating your persistent object types on the programming language level.",
"og:image": "https://docs.objectbox.io/~gitbook/ogimage/-LEUYsjP1BaBoJ_OPUD3",
"og:title": "Entity Annotations | ObjectBox Docs",
"robots": "index, follow",
"twitter:card": "summary_large_image",
"twitter:description": "Instead of SQL, you define your data model by annotating your persistent object types on the programming language level.",
"twitter:image": "https://docs.objectbox.io/~gitbook/ogimage/-LEUYsjP1BaBoJ_OPUD3",
"twitter:title": "Entity Annotations | ObjectBox Docs",
"viewport": "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
},
"title": "Entity Annotations | ObjectBox Docs",
"url": "https://docs.objectbox.io/entity-annotations",
"usage": {
"tokens": 221469
}
},
"meta": {
"usage": {
"tokens": 221469
}
},
"status": 20000
}
+79
View File
@@ -0,0 +1,79 @@
{
"code": 200,
"data": {
"description": "ObjectBox supports a variety of data types inside the stored objects including various list types and even special \"flex\" types for dynamic and schema-less data.",
"external": {
"alternate": {
"https://docs.objectbox.io/property-types.md": {
"type": "text/markdown"
},
"https://docs.objectbox.io/property-types/rss.xml": {
"title": "RSS Feed",
"type": "application/rss+xml"
}
},
"apple-touch-icon": {
"https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&width=180&height=180&sign=96b28d45&sv=2": {
"media": "(prefers-color-scheme: dark)",
"type": "image/png"
}
},
"canonical": {
"https://docs.objectbox.io/property-types": {}
},
"icon": {
"https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&width=48&height=48&sign=96b28d45&sv=2": {
"media": "(prefers-color-scheme: dark)",
"type": "image/png"
}
},
"preconnect": {
"https://api.gitbook.com/cache/": {},
"https://ka-p.fontawesome.com/releases/v7.1.0/": {},
"https://static-2v.gitbook.com/": {
"crossorigin": ""
}
},
"preload": {
"https://static-2v.gitbook.com/_next/static/chunks/webpack-3f8d6b18f6ab2c58.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b": {
"as": "script",
"fetchpriority": "low"
}
},
"privacy-policy": {
"https://objectbox.io/wordpress/wp-content/uploads/2022/08/Object-Box-Privacy-Notice.pdf": {}
}
},
"html": "<html lang=\"en\" class=\"rounded-corners theme-clean no-tint sidebar-default sidebar-list-default links-default depth-subtle __variable_a32233 __variable_80f980 __variable_37a5dc font-Roboto sheet-open:gutter-stable hydrated light\" style=\"color-scheme: light; --toc-height: 1216px; --toc-top-offset: 64px; --outline-height: 1216px; --outline-top-offset: 64px;\"><head><meta charset=\"utf-8\"><link rel=\"preconnect\" href=\"https://static-2v.gitbook.com\" crossorigin=\"\"><link rel=\"preconnect\" href=\"https://api.gitbook.com/cache/\"><link rel=\"preconnect\" href=\"https://ka-p.fontawesome.com/releases/v7.1.0/\"><link rel=\"preconnect\" href=\"https://static-2v.gitbook.com\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover\"><link rel=\"preload\" as=\"image\" fetchpriority=\"high\" imagesrcset=\"https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&amp;width=32&amp;dpr=1&amp;quality=100&amp;sign=96b28d45&amp;sv=2 32w, https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&amp;width=32&amp;dpr=2&amp;quality=100&amp;sign=96b28d45&amp;sv=2 64w, https://docs.objectbox.io/~gitbook/image?url=https%3A%2F%2F4174656640-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fspaces%252F-LETufmyus5LFkviJjr4%252Favatar.png%3Fgeneration%3D1528453316288221%26alt%3Dmedia&amp;width=32&amp;dpr=3&amp;quality=100&amp;sign=96b28d45&amp;sv=2 96w\" imagesizes=\"32px\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/8f95264edb3f86f8.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/919cadf6c2ad1dbc.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/e4670420fc569cb1.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/f758e1ab4ddcc0e6.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/4f8983272bb87ee9.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/c36dde9599bfc781.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/f15a462944baacd8.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"stylesheet\" href=\"https://static-2v.gitbook.com/_next/static/css/e3d65466d47b91eb.css?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" data-precedence=\"next\"><link rel=\"preload\" as=\"script\" fetchpriority=\"low\" href=\"https://static-2v.gitbook.com/_next/static/chunks/webpack-3f8d6b18f6ab2c58.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\"><script async=\"\" src=\"https://www.googletagmanager.com/gtag/js?id=G-JJBQJCVSYB\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/87c73c54-3c195070c5cbb22b.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/1902-bb2221cdf4f53ce3.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/main-app-a0ac55901a15e772.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script src=\"https://static-2v.gitbook.com/_next/static/chunks/app/global-error-99197ad4868e95f4.js?dpl=59ffb95417795c0d96c480da62fd9440881efa4b\" async=\"\"></script><script async=\"\" src=\"https://integrations.gitbook.com/v1/integrations/googleanalytics/installations/e4636d43a329d3ed8798c3f00605331dda696afe585efda0dc3f650f08b2f0d6/sites/site_1YbDR/script.js?version=151.0\"></script><meta name=\"next-size-adjust\" content=\"\"><meta name=\"color-scheme\" content=\"light\"><link rel=\"privacy-policy\" href=\"https://objectbox.io/wordpress/wp-content/uploads/2022/08/Object-Box-Privacy-Notice.pdf\"><title>Property Types | ObjectBox Docs</title><meta name=\"description\" content=\"ObjectBox supports a variety of data types inside the stored objects including various list types and even special &quot;flex&quot; types for dynamic and schema-less data.\"><meta name=\"generator\" content=\"GitBook (59ffb95)\"><meta name=\"robots\" content=\"index, follow\"><link rel=\"canonical\" href=\"https://docs.objectbox.io/property-types\"><link rel=\"altLine truncated
"metadata": {
"apple-mobile-web-app-status-bar-style": "default",
"apple-mobile-web-app-title": "ObjectBox Docs",
"color-scheme": "light",
"description": "ObjectBox supports a variety of data types inside the stored objects including various list types and even special \"flex\" types for dynamic and schema-less data.",
"generator": "GitBook (59ffb95)",
"lang": "en",
"mobile-web-app-capable": "yes",
"next-size-adjust": "",
"og:description": "ObjectBox supports a variety of data types inside the stored objects including various list types and even special \"flex\" types for dynamic and schema-less data.",
"og:image": "https://docs.objectbox.io/~gitbook/ogimage/kSqRmDrNicE3QmU2oyBf",
"og:title": "Property Types | ObjectBox Docs",
"robots": "index, follow",
"twitter:card": "summary_large_image",
"twitter:description": "ObjectBox supports a variety of data types inside the stored objects including various list types and even special \"flex\" types for dynamic and schema-less data.",
"twitter:image": "https://docs.objectbox.io/~gitbook/ogimage/kSqRmDrNicE3QmU2oyBf",
"twitter:title": "Property Types | ObjectBox Docs",
"viewport": "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
},
"title": "Property Types | ObjectBox Docs",
"url": "https://docs.objectbox.io/property-types",
"usage": {
"tokens": 175515
}
},
"meta": {
"usage": {
"tokens": 175515
}
},
"status": 20000
}
+81
View File
@@ -0,0 +1,81 @@
{
"code": 200,
"data": {
"description": "Relic is a modern, high-performance web server framework inspired by Shelf and built by the Serverpod team to meet the demanding requirements of modern web applications. Relic provides a robust, type-safe, and efficient foundation for building scalable web services.",
"external": {
"alternate": {
"https://docs.dartrelic.dev/": {
"data-rh": "true",
"hreflang": "x-default"
}
},
"canonical": {
"https://docs.dartrelic.dev/": {
"data-rh": "true"
}
},
"icon": {
"https://docs.dartrelic.dev/img/favicon.png": {
"data-rh": "true"
}
},
"preconnect": {
"https://www.google-analytics.com/": {},
"https://www.googletagmanager.com/": {}
},
"prefetch": {
"https://docs.dartrelic.dev/assets/js/0ca634c5.c40e5698.js": {},
"https://docs.dartrelic.dev/assets/js/17896441.35fb8100.js": {},
"https://docs.dartrelic.dev/assets/js/22dd74f7.32b10889.js": {},
"https://docs.dartrelic.dev/assets/js/2c10348c.c38ae9dc.js": {},
"https://docs.dartrelic.dev/assets/js/2dc195cc.c7b109bb.js": {},
"https://docs.dartrelic.dev/assets/js/441e1055.6248ef14.js": {},
"https://docs.dartrelic.dev/assets/js/4edc808e.d18ceb40.js": {},
"https://docs.dartrelic.dev/assets/js/5b00fbf1.0fa3fba9.js": {},
"https://docs.dartrelic.dev/assets/js/5e95c892.753674ac.js": {},
"https://docs.dartrelic.dev/assets/js/6783a3b2.0aa1428a.js": {},
"https://docs.dartrelic.dev/assets/js/9412f430.c708638d.js": {},
"https://docs.dartrelic.dev/assets/js/9576f52c.f029d9c3.js": {},
"https://docs.dartrelic.dev/assets/js/a7bd4aaa.b870ce93.js": {},
"https://docs.dartrelic.dev/assets/js/a94703ab.055f41b9.js": {},
"https://docs.dartrelic.dev/assets/js/a9685393.8fdc4764.js": {},
"https://docs.dartrelic.dev/assets/js/aba21aa0.1d722264.js": {},
"https://docs.dartrelic.dev/assets/js/aca04f59.28672d9f.js": {},
"https://docs.dartrelic.dev/assets/js/b4e2adc5.e5a3b2b2.js": {},
"https://docs.dartrelic.dev/assets/js/b685e31b.f5eb8692.js": {},
"https://docs.dartrelic.dev/assets/js/be2b5817.53d9f078.js": {},
"https://docs.dartrelic.dev/assets/js/c644a7fe.ad3a69e0.js": {},
"https://docs.dartrelic.dev/assets/js/c678908c.c170f033.js": {}
}
},
"html": "<html lang=\"en\" dir=\"ltr\" class=\"docs-wrapper plugin-docs plugin-id-default docs-version-1.0.0 docs-doc-page docs-doc-id-index\" data-has-hydrated=\"true\" data-theme=\"light\" data-theme-choice=\"system\" data-rh=\"lang,dir,class,data-has-hydrated\"><head>\n<meta charset=\"UTF-8\">\n<meta name=\"generator\" content=\"Docusaurus v3.9.2\">\n<title>🎯 Introduction | Relic</title><meta data-rh=\"true\" name=\"twitter:card\" content=\"summary_large_image\"><meta data-rh=\"true\" property=\"og:url\" content=\"https://docs.dartrelic.dev/\"><link data-rh=\"true\" rel=\"icon\" href=\"/img/favicon.png\"><link data-rh=\"true\" rel=\"canonical\" href=\"https://docs.dartrelic.dev/\"><link rel=\"preconnect\" href=\"https://www.google-analytics.com\">\n<link rel=\"preconnect\" href=\"https://www.googletagmanager.com\">\n<script async=\"\" src=\"https://www.googletagmanager.com/gtag/js?id=G-0EYLJMP04H\"></script>\n<script>function gtag(){dataLayer.push(arguments)}window.dataLayer=window.dataLayer||[],gtag(\"js\",new Date),gtag(\"config\",\"G-0EYLJMP04H\",{anonymize_ip:!0})</script><link rel=\"stylesheet\" href=\"/assets/css/styles.5c32dd38.css\">\n<script src=\"/assets/js/runtime~main.75ddcb03.js\" defer=\"defer\"></script>\n<script src=\"/assets/js/main.e1e75f7d.js\" defer=\"defer\"></script>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" data-rh=\"true\"><link rel=\"alternate\" href=\"https://docs.dartrelic.dev/\" hreflang=\"en\" data-rh=\"true\"><link rel=\"alternate\" href=\"https://docs.dartrelic.dev/\" hreflang=\"x-default\" data-rh=\"true\"><meta property=\"og:locale\" content=\"en\" data-rh=\"true\"><meta name=\"docusaurus_locale\" content=\"en\" data-rh=\"true\"><meta name=\"docsearch:language\" content=\"en\" data-rh=\"true\"><meta name=\"docusaurus_version\" content=\"1.0.0\" data-rh=\"true\"><meta name=\"docusaurus_tag\" content=\"docs-default-1.0.0\" data-rh=\"true\"><meta name=\"docsearch:version\" content=\"1.0.0\" data-rh=\"true\"><meta name=\"docsearch:docusaurus_tag\" content=\"docs-default-1.0.0\" data-rh=\"true\"><meta property=\"og:title\" content=\"🎯 Introduction | Relic\" data-rh=\"true\"><meta name=\"description\" content=\"Relic is a modern, high-performance web server framework inspired by Shelf and built by the Serverpod team to meet the demanding requirements of modern web applications. Relic provides a robust, type-safe, and efficient foundation for building scalable web services.\" data-rh=\"true\"><meta property=\"og:description\" content=\"Relic is a modern, high-performance web server framework inspired by Shelf and built by the Serverpod team to meet the demanding requirements of modern web applications. Relic provides a robust, type-safe, and efficient foundation for building scalable web services.\" data-rh=\"true\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7.32b10889.js\"><link rel=\"prefetch\" href=\"/assets/js/a94703ab.055f41b9.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/0ca634c5.c40e5698.js\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7.32b10889.js\"><link rel=\"prefetch\" href=\"/assets/js/a94703ab.055f41b9.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/0ca634c5.c40e5698.js\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7.32b10889.js\"><link rel=\"prefetch\" href=\"/assets/js/a94703ab.055f41b9.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/0ca634c5.c40e5698.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/c644a7fe.ad3a69e0.js\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7.32b10889.js\"><link rel=\"prefetch\" href=\"/assets/js/a94703ab.055f41b9.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/0ca634c5.c40e5698.js\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7Line truncated
"metadata": {
"description": "Relic is a modern, high-performance web server framework inspired by Shelf and built by the Serverpod team to meet the demanding requirements of modern web applications. Relic provides a robust, type-safe, and efficient foundation for building scalable web services.",
"docsearch:docusaurus_tag": "docs-default-1.0.0",
"docsearch:language": "en",
"docsearch:version": "1.0.0",
"docusaurus_locale": "en",
"docusaurus_tag": "docs-default-1.0.0",
"docusaurus_version": "1.0.0",
"generator": "Docusaurus v3.9.2",
"lang": "en",
"og:description": "Relic is a modern, high-performance web server framework inspired by Shelf and built by the Serverpod team to meet the demanding requirements of modern web applications. Relic provides a robust, type-safe, and efficient foundation for building scalable web services.",
"og:locale": "en",
"og:title": "🎯 Introduction | Relic",
"og:url": "https://docs.dartrelic.dev/",
"twitter:card": "summary_large_image",
"viewport": "width=device-width, initial-scale=1.0"
},
"title": "🎯 Introduction | Relic",
"url": "https://docs.dartrelic.dev/",
"usage": {
"tokens": 14117
}
},
"meta": {
"usage": {
"tokens": 14117
}
},
"status": 20000
}
+83
View File
@@ -0,0 +1,83 @@
{
"code": 200,
"data": {
"description": "Once you have Dart installed, it only takes a few lines of code to set up your Relic server. These are the steps you need to take to get a simple Hello world server up and running.",
"external": {
"alternate": {
"https://docs.dartrelic.dev/getting-started/hello-world-example": {
"data-rh": "true",
"hreflang": "x-default"
}
},
"canonical": {
"https://docs.dartrelic.dev/getting-started/hello-world-example": {
"data-rh": "true"
}
},
"icon": {
"https://docs.dartrelic.dev/img/favicon.png": {
"data-rh": "true"
}
},
"preconnect": {
"https://www.google-analytics.com/": {},
"https://www.googletagmanager.com/": {}
},
"prefetch": {
"https://docs.dartrelic.dev/assets/js/0ca634c5.c40e5698.js": {},
"https://docs.dartrelic.dev/assets/js/17896441.35fb8100.js": {},
"https://docs.dartrelic.dev/assets/js/22dd74f7.32b10889.js": {},
"https://docs.dartrelic.dev/assets/js/2c10348c.c38ae9dc.js": {},
"https://docs.dartrelic.dev/assets/js/2dc195cc.c7b109bb.js": {},
"https://docs.dartrelic.dev/assets/js/441e1055.6248ef14.js": {},
"https://docs.dartrelic.dev/assets/js/4edc808e.d18ceb40.js": {},
"https://docs.dartrelic.dev/assets/js/5b00fbf1.0fa3fba9.js": {},
"https://docs.dartrelic.dev/assets/js/5e95c892.753674ac.js": {},
"https://docs.dartrelic.dev/assets/js/6783a3b2.0aa1428a.js": {},
"https://docs.dartrelic.dev/assets/js/9412f430.c708638d.js": {},
"https://docs.dartrelic.dev/assets/js/9576f52c.f029d9c3.js": {},
"https://docs.dartrelic.dev/assets/js/a7bd4aaa.b870ce93.js": {},
"https://docs.dartrelic.dev/assets/js/a94703ab.055f41b9.js": {},
"https://docs.dartrelic.dev/assets/js/a9685393.8fdc4764.js": {},
"https://docs.dartrelic.dev/assets/js/aba21aa0.1d722264.js": {},
"https://docs.dartrelic.dev/assets/js/aca04f59.28672d9f.js": {},
"https://docs.dartrelic.dev/assets/js/b4e2adc5.e5a3b2b2.js": {},
"https://docs.dartrelic.dev/assets/js/b685e31b.f5eb8692.js": {},
"https://docs.dartrelic.dev/assets/js/be2b5817.53d9f078.js": {},
"https://docs.dartrelic.dev/assets/js/c644a7fe.ad3a69e0.js": {},
"https://docs.dartrelic.dev/assets/js/c678908c.c170f033.js": {},
"https://docs.dartrelic.dev/assets/js/f22cf6c3.3fb896a1.js": {}
}
},
"html": "<html lang=\"en\" dir=\"ltr\" class=\"docs-wrapper plugin-docs plugin-id-default docs-version-1.0.0 docs-doc-page docs-doc-id-getting-started/hello-world-example\" data-has-hydrated=\"true\" data-theme=\"light\" data-theme-choice=\"system\" data-rh=\"lang,dir,class,data-has-hydrated\"><head>\n<meta charset=\"UTF-8\">\n<meta name=\"generator\" content=\"Docusaurus v3.9.2\">\n<title>Hello world | Relic</title><meta data-rh=\"true\" name=\"twitter:card\" content=\"summary_large_image\"><meta data-rh=\"true\" property=\"og:url\" content=\"https://docs.dartrelic.dev/getting-started/hello-world-example\"><meta data-rh=\"true\" property=\"og:locale\" content=\"en\"><meta data-rh=\"true\" name=\"docusaurus_locale\" content=\"en\"><meta data-rh=\"true\" name=\"docsearch:language\" content=\"en\"><link data-rh=\"true\" rel=\"icon\" href=\"/img/favicon.png\"><link data-rh=\"true\" rel=\"canonical\" href=\"https://docs.dartrelic.dev/getting-started/hello-world-example\"><link data-rh=\"true\" rel=\"alternate\" href=\"https://docs.dartrelic.dev/getting-started/hello-world-example\" hreflang=\"en\"><link data-rh=\"true\" rel=\"alternate\" href=\"https://docs.dartrelic.dev/getting-started/hello-world-example\" hreflang=\"x-default\"><link rel=\"preconnect\" href=\"https://www.google-analytics.com\">\n<link rel=\"preconnect\" href=\"https://www.googletagmanager.com\">\n<script async=\"\" src=\"https://www.googletagmanager.com/gtag/js?id=G-0EYLJMP04H\"></script>\n<script>function gtag(){dataLayer.push(arguments)}window.dataLayer=window.dataLayer||[],gtag(\"js\",new Date),gtag(\"config\",\"G-0EYLJMP04H\",{anonymize_ip:!0})</script><link rel=\"stylesheet\" href=\"/assets/css/styles.5c32dd38.css\">\n<script src=\"/assets/js/runtime~main.75ddcb03.js\" defer=\"defer\"></script>\n<script src=\"/assets/js/main.e1e75f7d.js\" defer=\"defer\"></script>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" data-rh=\"true\"><meta name=\"docusaurus_version\" content=\"1.0.0\" data-rh=\"true\"><meta name=\"docusaurus_tag\" content=\"docs-default-1.0.0\" data-rh=\"true\"><meta name=\"docsearch:version\" content=\"1.0.0\" data-rh=\"true\"><meta name=\"docsearch:docusaurus_tag\" content=\"docs-default-1.0.0\" data-rh=\"true\"><meta property=\"og:title\" content=\"Hello world | Relic\" data-rh=\"true\"><meta name=\"description\" content=\"Once you have Dart installed, it only takes a few lines of code to set up your Relic server. These are the steps you need to take to get a simple Hello world server up and running.\" data-rh=\"true\"><meta property=\"og:description\" content=\"Once you have Dart installed, it only takes a few lines of code to set up your Relic server. These are the steps you need to take to get a simple Hello world server up and running.\" data-rh=\"true\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7.32b10889.js\"><link rel=\"prefetch\" href=\"/assets/js/a94703ab.055f41b9.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/0ca634c5.c40e5698.js\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7.32b10889.js\"><link rel=\"prefetch\" href=\"/assets/js/a94703ab.055f41b9.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/0ca634c5.c40e5698.js\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7.32b10889.js\"><link rel=\"prefetch\" href=\"/assets/js/a94703ab.055f41b9.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/0ca634c5.c40e5698.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/c678908c.c170f033.js\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7.32b10889.js\"><link rel=\"prefetch\" href=\"/assets/js/a94703ab.055f41b9.js\"><link rel=\"prefetch\" href=\"/assets/js/17896441.35fb8100.js\"><link rel=\"prefetch\" href=\"/assets/js/0ca634c5.c40e5698.js\"><link rel=\"prefetch\" href=\"/assets/js/5e95c892.753674ac.js\"><link rel=\"prefetch\" href=\"/assets/js/aba21aa0.1d722264.js\"><link rel=\"prefetch\" href=\"/assets/js/a7bd4aaa.b870ce93.js\"><link rel=\"prefetch\" href=\"/assets/js/22dd74f7.32b10889.js\"Line truncated
"metadata": {
"description": "Once you have Dart installed, it only takes a few lines of code to set up your Relic server. These are the steps you need to take to get a simple Hello world server up and running.",
"docsearch:docusaurus_tag": "docs-default-1.0.0",
"docsearch:language": "en",
"docsearch:version": "1.0.0",
"docusaurus_locale": "en",
"docusaurus_tag": "docs-default-1.0.0",
"docusaurus_version": "1.0.0",
"generator": "Docusaurus v3.9.2",
"lang": "en",
"og:description": "Once you have Dart installed, it only takes a few lines of code to set up your Relic server. These are the steps you need to take to get a simple Hello world server up and running.",
"og:locale": "en",
"og:title": "Hello world | Relic",
"og:url": "https://docs.dartrelic.dev/getting-started/hello-world-example",
"twitter:card": "summary_large_image",
"viewport": "width=device-width, initial-scale=1.0"
},
"publishedTime": "Wed, 18 Feb 2026 09:05:14 GMT",
"title": "Hello world | Relic",
"url": "https://docs.dartrelic.dev/getting-started/hello-world-example",
"usage": {
"tokens": 21616
}
},
"meta": {
"usage": {
"tokens": 21616
}
},
"status": 20000
}
+158 -10
View File
@@ -1,14 +1,162 @@
import 'dart:io';
import 'dart:convert';
import 'dart:math';
import 'package:yaml/yaml.dart';
class Config { class Config {
// Server settings static late String bindHost;
static const int port = 9870; static late int bindPort;
static late String publicBaseUrl;
static late String serverName;
static late String jwtSecret;
static late bool configured;
static late bool setupMode;
static late int bootstrapAdminLimit;
static late bool devDbTools;
// IMPORTANT: Set this to your actual server host/domain
// Used for generating userId: "username@serverHost"
static const String serverHost = 'localhost:9870';
static const String serverName = 'Chat Server';
// JWT settings
// TODO: Move to environment variable in production
static const String jwtSecret = 'your_super_secret_key_change_in_production';
static const Duration tokenExpiry = Duration(days: 7); static const Duration tokenExpiry = Duration(days: 7);
static void initialize() {
final configFile = File('config.yaml');
if (!configFile.existsSync()) {
final generatedSecret = _generateJwtSecret();
configFile.writeAsStringSync(_defaultConfig(generatedSecret));
print('✅ Generated config.yaml (first run).');
print('⚠️ Review server.public_base_url and security.jwt_secret before production use.');
}
final yamlString = configFile.readAsStringSync();
final yamlMap = loadYaml(yamlString) as YamlMap;
final serverMap = yamlMap['server'] as YamlMap?;
final securityMap = yamlMap['security'] as YamlMap?;
serverName = (serverMap?['name'] as String?)?.trim() ?? '';
bindHost = (serverMap?['bind_host'] as String?)?.trim() ?? (serverMap?['host'] as String?)?.trim() ?? '127.0.0.1';
bindPort = (serverMap?['bind_port'] as int?) ?? (serverMap?['port'] as int?) ?? 9870;
final rawPublicBaseUrl =
(serverMap?['public_base_url'] as String?)?.trim() ?? _legacyPublicBaseUrlFromHost((serverMap?['host'] as String?)?.trim());
publicBaseUrl = _normalizePublicBaseUrl(rawPublicBaseUrl) ?? '';
configured = (serverMap?['configured'] as bool?) ?? false;
setupMode = (serverMap?['first_setup'] as bool?) ?? false;
bootstrapAdminLimit = (serverMap?['bootstrap_admin_limit'] as int?) ?? 1;
devDbTools = (serverMap?['dev_db_tools'] as bool?) ?? false;
jwtSecret = (securityMap?['jwt_secret'] as String?)?.trim() ?? '';
if (serverName.isEmpty) {
throw StateError('config.yaml: server.name is required.');
}
if (bindHost.isEmpty) {
throw StateError('config.yaml: server.bind_host is required.');
}
if (bindPort <= 0 || bindPort > 65535) {
throw StateError('config.yaml: server.bind_port must be between 1 and 65535.');
}
if (publicBaseUrl.isEmpty) {
throw StateError(
'config.yaml: server.public_base_url is required (example: https://chat.example.com).',
);
}
if (jwtSecret.isEmpty) {
throw StateError('config.yaml: security.jwt_secret is required.');
}
if (!configured) {
throw StateError(
'Server setup is not complete.\n'
'Edit config.yaml and set:\n'
' server.configured: true\n'
'After reviewing server.public_base_url and security.jwt_secret.',
);
}
if (bootstrapAdminLimit < 0) {
throw StateError('config.yaml: server.bootstrap_admin_limit must be >= 0.');
}
print(
'🚀 Server Config Loaded: $serverName (bind=$bindHost:$bindPort, public=$publicBaseUrl, configured=$configured, setupMode=$setupMode, bootstrapAdminLimit=$bootstrapAdminLimit, devDbTools=$devDbTools)',
);
}
static String _generateJwtSecret() {
final random = Random.secure();
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
return base64UrlEncode(bytes);
}
static String _defaultConfig(String generatedSecret) => '''
# Reliquary Server Configuration
server:
# Display name shown in clients
name: "Reliquary Server"
# Local bind address for the HTTP server
bind_host: "127.0.0.1"
# Local bind port for the HTTP server
bind_port: 9870
# Public base URL used for identity, handles, and cross-server references
# Dev example: http://localhost:9870
# Prod example: https://chat.example.com
public_base_url: "http://localhost:9870"
# Set to true after reviewing this file; server refuses to start while false
configured: false
# First local registration becomes admin
first_setup: true
# Number of local registrations auto-promoted to admin while first_setup=true
bootstrap_admin_limit: 1
# Testing-only DB admin/debug endpoints
dev_db_tools: true
security:
# Auto-generated on first run; replace for production
jwt_secret: "$generatedSecret"
''';
static String get publicHost {
final uri = Uri.tryParse(publicBaseUrl);
if (uri == null || uri.host.isEmpty) {
return publicBaseUrl;
}
return uri.hasPort ? '${uri.host}:${uri.port}' : uri.host;
}
static String? _normalizePublicBaseUrl(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
final uri = Uri.tryParse(value.trim());
if (uri == null || uri.host.isEmpty) {
return null;
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
return null;
}
return Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
).toString().replaceAll(RegExp(r'/$'), '');
}
static String? _legacyPublicBaseUrlFromHost(String? hostValue) {
if (hostValue == null || hostValue.trim().isEmpty) {
return null;
}
final host = hostValue.trim();
if (host.contains('://')) {
return host;
}
final lower = host.toLowerCase();
final isLocal = lower.startsWith('localhost') || lower.startsWith('127.') || lower.startsWith('[::1]');
final scheme = isLocal ? 'http' : 'https';
return '$scheme://$host';
}
} }
+130 -12
View File
@@ -11,12 +11,12 @@ import 'package:objectbox/objectbox.dart';
/// - isLocal = false /// - isLocal = false
/// - homeServer contains the URL of user's home /// - homeServer contains the URL of user's home
/// - publicKey cached for verification /// - publicKey cached for verification
@Entity() @Entity(uid: 3848231140411393897)
class User { 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;
@@ -28,7 +28,7 @@ class User {
String? bio; String? bio;
// Identity // Identity
String publicKey; // Ed25519 public key, always stored String publicKey; // ECDSA public key, always stored
// HOME SERVER ONLY: Auth & key backup // HOME SERVER ONLY: Auth & key backup
String? passwordHash; String? passwordHash;
@@ -48,7 +48,7 @@ class User {
@Property(type: PropertyType.date) @Property(type: PropertyType.date)
DateTime? lastSyncedAt; // When profile was last refreshed from home DateTime? lastSyncedAt; // When profile was last refreshed from home
User({ ChatUser({
required this.userId, required this.userId,
required this.username, required this.username,
required this.publicKey, required this.publicKey,
@@ -68,8 +68,8 @@ class User {
lastSyncedAt = lastSyncedAt; lastSyncedAt = lastSyncedAt;
} }
@Entity() @Entity(uid: 2663861829934850300)
class Channel { class ChatChannel {
@Id() @Id()
int id = 0; int id = 0;
@@ -81,15 +81,15 @@ class Channel {
@Property(type: PropertyType.date) @Property(type: PropertyType.date)
DateTime createdAt; DateTime createdAt;
Channel({ ChatChannel({
required this.name, required this.name,
this.description, this.description,
DateTime? createdAt, DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now(); }) : createdAt = createdAt ?? DateTime.now();
} }
@Entity() @Entity(uid: 2697867424319168785)
class Message { class ChatMessage {
@Id() @Id()
int id = 0; int id = 0;
@@ -97,7 +97,7 @@ class Message {
// 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)
@@ -105,6 +105,7 @@ class Message {
String? authorAvatarUrl; String? authorAvatarUrl;
String content; String content;
String componentsJson;
@Property(type: PropertyType.date) @Property(type: PropertyType.date)
DateTime createdAt; DateTime createdAt;
@@ -112,15 +113,132 @@ class Message {
@Property(type: PropertyType.date) @Property(type: PropertyType.date)
DateTime? editedAt; DateTime? editedAt;
Message({ ChatMessage({
required this.channelId, required this.channelId,
required this.authorLocalId, required this.authorLocalId,
required this.authorUserId, required this.authorUserId,
required this.authorUsername, required this.authorUsername,
required this.content, required this.content,
required this.componentsJson,
this.authorHomeServer, this.authorHomeServer,
this.authorAvatarUrl, this.authorAvatarUrl,
DateTime? createdAt, DateTime? createdAt,
this.editedAt, this.editedAt,
}) : createdAt = createdAt ?? DateTime.now(); }) : createdAt = createdAt ?? DateTime.now();
} }
@Entity()
class ChatFile {
@Id()
int id = 0;
@Unique()
String attachmentId;
int uploaderId;
String path;
String originalFileName;
String storedFileName;
String sha256;
String mimeType;
int width;
int height;
int sizeBytes;
bool isAttached;
String usageType; // message | avatar | server_icon | server_banner
String status; // pending | ready | failed
String? errorCode;
@Property(type: PropertyType.date)
DateTime createdAt;
ChatFile({
required this.attachmentId,
required this.uploaderId,
required this.path,
required this.originalFileName,
required this.storedFileName,
required this.sha256,
required this.mimeType,
required this.width,
required this.height,
required this.sizeBytes,
this.usageType = 'message',
this.status = 'pending',
this.isAttached = false,
this.errorCode,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
}
@Entity()
class ServerSettings {
@Id()
int id = 0;
@Unique()
String singletonKey;
String serverName;
String? description;
String? iconAttachmentId;
String? bannerAttachmentId;
String? updatedByUserId;
@Property(type: PropertyType.date)
DateTime updatedAt;
ServerSettings({
this.singletonKey = 'default',
required this.serverName,
this.description,
this.iconAttachmentId,
this.bannerAttachmentId,
this.updatedByUserId,
DateTime? updatedAt,
}) : updatedAt = updatedAt ?? DateTime.now();
}
@Entity()
class ChatMessageMedia {
@Id()
int id = 0;
int messageId;
int channelId;
String authorUserId;
// local_upload
String? attachmentId;
// external_url
String? externalUrl;
// image | video | file
String kind;
String? mimeType;
String? originalFileName;
int? sizeBytes;
int? width;
int? height;
String? thumbHash;
@Property(type: PropertyType.date)
DateTime createdAt;
ChatMessageMedia({
required this.messageId,
required this.channelId,
required this.authorUserId,
required this.kind,
this.attachmentId,
this.externalUrl,
this.mimeType,
this.originalFileName,
this.sizeBytes,
this.width,
this.height,
this.thumbHash,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
}
+52 -33
View File
@@ -1,21 +1,18 @@
import 'package:chat_server/models.dart'; import 'dart:io';
import 'package:chat_server/objectbox.g.dart';
import 'package:hotreloader/hotreloader.dart';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import 'config/config.dart'; import 'config/config.dart';
import 'src/auth/auth_middleware.dart'; import 'src/auth/auth_middleware.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/utils/extensions.dart'; import 'src/handlers/settings_handlers.dart';
import 'src/handlers/upload_handlers.dart';
// Global ObjectBox store and boxes import 'src/state/app_state.dart';
late final Store store;
late final Box<User> userBox;
late final Box<Channel> channelBox;
late final Box<Message> messageBox;
RelicApp? _currentApp; RelicApp? _currentApp;
@@ -31,56 +28,78 @@ Future<void> startServer() async {
..post('/auth/join', joinServerHandler) ..post('/auth/join', joinServerHandler)
// Public identity verification (for cross-server) // Public identity verification (for cross-server)
..get('/.well-known/users/<username>', publicUserHandler) ..get('/.well-known/users/:username', publicUserHandler)
..get('/.well-known/verify/<userId>', publicVerifyHandler) ..get('/.well-known/verify/:userId', publicVerifyHandler)
..get('/.well-known/server', publicServerInfoHandler) ..get('/.well-known/server', publicServerInfoHandler)
..get('/uploads/:fileName', getUploadFileHandler)
..get('/attachments/:attachmentId', getAttachmentHandler)
..get('/avatars/:userId', getAvatarAliasHandler)
..get('/icons/:serverId', getServerIconAliasHandler)
..get('/banners/:serverId', getServerBannerAliasHandler)
..get('/gateway', gatewayHandler)
// ============ PROTECTED ENDPOINTS (requires auth) ============ // ============ PROTECTED ENDPOINTS (requires auth) ============
..use('/api', jwtMiddleware) ..use('/api', jwtMiddleware)
// 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)
..post('/api/channels', createChannelHandler) ..post('/api/channels', createChannelHandler)
..get('/api/channels/<channelId>', getChannelHandler) ..get('/api/channels/:channelId', getChannelHandler)
// 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/messages', sendMessageHandler) ..post('/api/messages', sendMessageHandler)
..put('/api/messages/<messageId>', editMessageHandler) ..put('/api/messages/:messageId', editMessageHandler)
..delete('/api/messages/<messageId>', deleteMessageHandler) ..delete('/api/messages/:messageId', deleteMessageHandler)
// ============ LOGGING & FALLBACK ============ // ============ LOGGING & FALLBACK ============
..use('/', logRequests()) ..use('/', logRequests())
..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 {
// Initialize ObjectBox Config.initialize();
store = openStore();
userBox = store.box<User>();
channelBox = store.box<Channel>();
messageBox = store.box<Message>();
// Enable hot reload in development initializeStore();
await HotReloader.create(
onAfterReload: (ctx) async {
print('🔄 Changes detected! Restarting server...');
await startServer();
},
);
await startServer(); await startServer();
} }
-2
View File
@@ -1,7 +1,5 @@
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import '../../config/config.dart';
import '../utils/extensions.dart';
import 'crypto_service.dart'; import 'crypto_service.dart';
final userIdProperty = ContextProperty<String>('userId'); final userIdProperty = ContextProperty<String>('userId');
+24 -20
View File
@@ -1,13 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:bcrypt/bcrypt.dart'; import 'package:bcrypt/bcrypt.dart';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:crypton/crypton.dart'; import 'package:crypton/crypton.dart' as crypton;
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart' as jwt;
import '../config/config.dart'; import '../../config/config.dart';
class KeyPair { class KeyPair {
final String publicKey; final String publicKey;
@@ -29,9 +29,9 @@ class KeyPair {
} }
class CryptoService { class CryptoService {
/// Generate a new Ed25519 key pair /// Generate a new ECDSA key pair
static KeyPair generateKeyPair() { static KeyPair generateKeyPair() {
final keyPair = ECKeypair.fromRandom(); final keyPair = crypton.ECKeypair.fromRandom();
return KeyPair( return KeyPair(
publicKey: keyPair.publicKey.toString(), publicKey: keyPair.publicKey.toString(),
privateKey: keyPair.privateKey.toString(), privateKey: keyPair.privateKey.toString(),
@@ -40,17 +40,19 @@ class CryptoService {
/// Sign data with private key /// Sign data with private key
static String sign(String data, String privateKey) { static String sign(String data, String privateKey) {
final privKey = ECPrivateKey.fromString(privateKey); final privKey = crypton.ECPrivateKey.fromString(privateKey);
final signature = privKey.createSignature(data); final message = Uint8List.fromList(utf8.encode(data));
return signature.toString(); final signature = privKey.createSHA256Signature(message);
return base64Encode(signature);
} }
/// Verify signature with public key /// Verify signature with public key
static bool verify(String data, String signature, String publicKey) { static bool verify(String data, String signature, String publicKey) {
try { try {
final pubKey = ECPublicKey.fromString(publicKey); final pubKey = crypton.ECPublicKey.fromString(publicKey);
final sig = ECSignature.fromString(signature); final message = Uint8List.fromList(utf8.encode(data));
return pubKey.verifySignature(data, sig); final signatureBytes = Uint8List.fromList(base64Decode(signature));
return pubKey.verifySHA256Signature(message, signatureBytes);
} catch (_) { } catch (_) {
return false; return false;
} }
@@ -118,6 +120,8 @@ class EncryptedKey {
} }
class AuthService { class AuthService {
static final _nonceRandom = Random.secure();
/// Generate JWT token for user /// Generate JWT token for user
static String generateToken(String userId, String username) { static String generateToken(String userId, String username) {
final payload = { final payload = {
@@ -125,9 +129,9 @@ class AuthService {
'username': username, 'username': username,
}; };
final jwt = JWT(payload); final token = jwt.JWT(payload);
return jwt.sign( return token.sign(
SecretKey(Config.jwtSecret), jwt.SecretKey(Config.jwtSecret),
expiresIn: Config.tokenExpiry, expiresIn: Config.tokenExpiry,
); );
} }
@@ -135,8 +139,8 @@ class AuthService {
/// Verify JWT token and return payload /// Verify JWT token and return payload
static Map<String, dynamic>? verifyToken(String token) { static Map<String, dynamic>? verifyToken(String token) {
try { try {
final jwt = JWT.verify(token, SecretKey(Config.jwtSecret)); final verified = jwt.JWT.verify(token, jwt.SecretKey(Config.jwtSecret));
return Map<String, dynamic>.from(jwt.payload as Map); return Map<String, dynamic>.from(verified.payload as Map);
} catch (_) { } catch (_) {
return null; return null;
} }
@@ -154,8 +158,8 @@ class AuthService {
/// Generate a nonce for signature verification /// Generate a nonce for signature verification
static String generateNonce() { static String generateNonce() {
final bytes = List<int>.generate(32, (_) => DateTime.now().microsecondsSinceEpoch % 256); final bytes = List<int>.generate(32, (_) => _nonceRandom.nextInt(256));
final randomBytes = sha256.convert(bytes as Uint8List); final hash = sha256.convert(bytes);
return randomBytes.toString().substring(0, 32); return hash.toString().substring(0, 32);
} }
} }
+442
View File
@@ -0,0 +1,442 @@
import 'dart:async';
import 'dart:convert';
import 'package:relic/relic.dart';
import 'package:web_socket/web_socket.dart';
import '../auth/crypto_service.dart';
import '../state/app_state.dart';
final _gatewayRegistry = _GatewayRegistry();
/// WebSocket gateway with JWT auth and channel subscriptions.
Result gatewayHandler(Request req) {
final token = _extractGatewayToken(req);
if (token == null) {
return Response.unauthorized(
body: Body.fromString('Bearer token required'),
);
}
final payload = AuthService.verifyToken(token);
if (payload == null) {
return Response.forbidden(
body: Body.fromString('Invalid or expired token'),
);
}
final userId = payload['sub'] as String?;
final username = payload['username'] as String?;
if (userId == null || userId.isEmpty) {
return Response.forbidden(
body: Body.fromString('Invalid token payload'),
);
}
return WebSocketUpgrade((webSocket) {
webSocket.pingInterval = const Duration(seconds: 30);
final connection = _gatewayRegistry.register(
socket: webSocket,
userId: userId,
username: username,
);
_sendGatewayPayload(
connection.id,
{
'op': 'ready',
'connectionId': connection.id,
'userId': connection.userId,
if (connection.username != null) 'username': connection.username,
},
);
webSocket.events.listen(
(event) {
switch (event) {
case TextDataReceived(text: final text):
_handleGatewayTextMessage(connection.id, text);
case BinaryDataReceived():
_sendProtocolError(
connection.id,
'Binary payloads are not supported',
);
case CloseReceived():
_gatewayRegistry.unregister(connection.id);
}
},
onError: (_, __) {
_gatewayRegistry.unregister(connection.id);
},
onDone: () {
_gatewayRegistry.unregister(connection.id);
},
);
});
}
void broadcastMessageCreated({
required int channelId,
required Map<String, dynamic> message,
}) {
_gatewayRegistry.broadcastToChannel(channelId, {
'op': 'event',
'event': 'message.created',
'channelId': channelId,
'message': message,
'emittedAt': DateTime.now().toUtc().toIso8601String(),
});
}
void broadcastMessageUpdated({
required int channelId,
required Map<String, dynamic> message,
}) {
_gatewayRegistry.broadcastToChannel(channelId, {
'op': 'event',
'event': 'message.updated',
'channelId': channelId,
'message': message,
'emittedAt': DateTime.now().toUtc().toIso8601String(),
});
}
void broadcastMessageDeleted({
required int channelId,
required int messageId,
}) {
_gatewayRegistry.broadcastToChannel(channelId, {
'op': 'event',
'event': 'message.deleted',
'channelId': channelId,
'messageId': messageId,
'emittedAt': DateTime.now().toUtc().toIso8601String(),
});
}
String? _extractGatewayToken(Request req) {
final authHeader = req.headers.authorization;
if (authHeader is BearerAuthorizationHeader) {
final token = authHeader.token.trim();
if (token.isNotEmpty) {
return token;
}
}
final rawAuthorization = req.headers[Headers.authorizationHeader];
if (rawAuthorization != null && rawAuthorization.isNotEmpty) {
final value = rawAuthorization.first.trim();
if (value.toLowerCase().startsWith('bearer ')) {
final token = value.substring(7).trim();
if (token.isNotEmpty) {
return token;
}
}
}
final queryToken = req.url.queryParameters['token']?.trim();
if (queryToken != null && queryToken.isNotEmpty) {
return queryToken;
}
final queryAccessToken = req.url.queryParameters['access_token']?.trim();
if (queryAccessToken != null && queryAccessToken.isNotEmpty) {
return queryAccessToken;
}
return null;
}
void _handleGatewayTextMessage(String connectionId, String text) {
final connection = _gatewayRegistry.getConnection(connectionId);
if (connection == null) {
return;
}
final payload = _decodePayload(text);
if (payload == null) {
_sendProtocolError(connectionId, 'Invalid JSON payload');
return;
}
final rawOp = payload['op'];
if (rawOp is! String || rawOp.trim().isEmpty) {
_sendProtocolError(connectionId, '`op` is required');
return;
}
switch (rawOp.trim().toLowerCase()) {
case 'subscribe':
_handleSubscribe(connectionId, payload);
case 'unsubscribe':
_handleUnsubscribe(connectionId, payload);
case 'ping':
case 'heartbeat':
_sendGatewayPayload(connectionId, {
'op': 'pong',
'ts': DateTime.now().toUtc().toIso8601String(),
});
case 'pong':
return;
default:
_sendProtocolError(connectionId, 'Unsupported op `$rawOp`');
}
}
void _handleSubscribe(String connectionId, Map<String, dynamic> payload) {
final channelIds = _parseChannelIds(payload);
if (channelIds.isEmpty) {
_sendProtocolError(
connectionId,
'subscribe requires `channelId` or `channelIds`',
);
return;
}
final knownChannelIds = <int>[];
final unknownChannelIds = <int>[];
for (final channelId in channelIds) {
if (channelBox.get(channelId) == null) {
unknownChannelIds.add(channelId);
} else {
knownChannelIds.add(channelId);
}
}
final subscribed = _gatewayRegistry.subscribe(connectionId, knownChannelIds);
_sendGatewayPayload(connectionId, {
'op': 'subscribed',
'channelIds': subscribed,
'subscriptions': _gatewayRegistry.subscriptions(connectionId),
if (unknownChannelIds.isNotEmpty) 'unknownChannelIds': unknownChannelIds,
});
}
void _handleUnsubscribe(String connectionId, Map<String, dynamic> payload) {
final channelIds = _parseChannelIds(payload);
if (channelIds.isEmpty) {
_sendProtocolError(
connectionId,
'unsubscribe requires `channelId` or `channelIds`',
);
return;
}
final unsubscribed = _gatewayRegistry.unsubscribe(connectionId, channelIds);
_sendGatewayPayload(connectionId, {
'op': 'unsubscribed',
'channelIds': unsubscribed,
'subscriptions': _gatewayRegistry.subscriptions(connectionId),
});
}
Map<String, dynamic>? _decodePayload(String text) {
try {
final decoded = jsonDecode(text);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return decoded.map(
(key, value) => MapEntry(key.toString(), value),
);
}
return null;
} catch (_) {
return null;
}
}
Set<int> _parseChannelIds(Map<String, dynamic> payload) {
final channelIds = <int>{};
void parseAndAdd(Object? value) {
final parsed = switch (value) {
int id when id > 0 => id,
String id => int.tryParse(id),
_ => null,
};
if (parsed != null && parsed > 0) {
channelIds.add(parsed);
}
}
parseAndAdd(payload['channelId']);
final channelIdList = payload['channelIds'];
if (channelIdList is List) {
for (final value in channelIdList) {
parseAndAdd(value);
}
}
return channelIds;
}
void _sendProtocolError(String connectionId, String message) {
_sendGatewayPayload(connectionId, {
'op': 'error',
'message': message,
});
}
void _sendGatewayPayload(String connectionId, Map<String, dynamic> payload) {
final connection = _gatewayRegistry.getConnection(connectionId);
if (connection == null) {
return;
}
final sent = _safeSend(connection, payload);
if (!sent) {
_gatewayRegistry.unregister(connectionId);
}
}
bool _safeSend(_GatewayConnection connection, Map<String, dynamic> payload) {
try {
return connection.socket.trySendText(jsonEncode(payload));
} catch (_) {
return false;
}
}
class _GatewayRegistry {
final Map<String, _GatewayConnection> _connections = {};
final Map<int, Set<String>> _subscribersByChannel = {};
int _nextConnectionId = 1;
_GatewayConnection register({
required RelicWebSocket socket,
required String userId,
String? username,
}) {
final id = 'gw_${_nextConnectionId++}';
final connection = _GatewayConnection(
id: id,
socket: socket,
userId: userId,
username: username,
);
_connections[id] = connection;
return connection;
}
_GatewayConnection? getConnection(String connectionId) {
return _connections[connectionId];
}
List<int> subscriptions(String connectionId) {
final connection = _connections[connectionId];
if (connection == null) {
return const <int>[];
}
return connection.subscriptions.toList()..sort();
}
List<int> subscribe(String connectionId, Iterable<int> channelIds) {
final connection = _connections[connectionId];
if (connection == null) {
return const <int>[];
}
for (final channelId in channelIds) {
connection.subscriptions.add(channelId);
_subscribersByChannel.putIfAbsent(channelId, () => <String>{}).add(
connectionId,
);
}
return channelIds.toSet().toList()..sort();
}
List<int> unsubscribe(String connectionId, Iterable<int> channelIds) {
final connection = _connections[connectionId];
if (connection == null) {
return const <int>[];
}
final removed = <int>[];
for (final channelId in channelIds) {
final didRemove = connection.subscriptions.remove(channelId);
if (didRemove) {
removed.add(channelId);
}
final subscribers = _subscribersByChannel[channelId];
if (subscribers != null) {
subscribers.remove(connectionId);
if (subscribers.isEmpty) {
_subscribersByChannel.remove(channelId);
}
}
}
removed.sort();
return removed;
}
void unregister(String connectionId) {
final connection = _connections.remove(connectionId);
if (connection == null) {
return;
}
for (final channelId in connection.subscriptions) {
final subscribers = _subscribersByChannel[channelId];
if (subscribers == null) {
continue;
}
subscribers.remove(connectionId);
if (subscribers.isEmpty) {
_subscribersByChannel.remove(channelId);
}
}
if (!connection.socket.isClosed) {
unawaited(connection.socket.tryClose(1000, 'Disconnected'));
}
}
void broadcastToChannel(int channelId, Map<String, dynamic> payload) {
final subscribers = _subscribersByChannel[channelId];
if (subscribers == null || subscribers.isEmpty) {
return;
}
final deadConnections = <String>[];
final subscriberIds = subscribers.toList(growable: false);
for (final connectionId in subscriberIds) {
final connection = _connections[connectionId];
if (connection == null) {
deadConnections.add(connectionId);
continue;
}
final sent = _safeSend(connection, payload);
if (!sent) {
deadConnections.add(connectionId);
}
}
for (final connectionId in deadConnections) {
unregister(connectionId);
}
}
}
class _GatewayConnection {
final String id;
final RelicWebSocket socket;
final String userId;
final String? username;
final Set<int> subscriptions = <int>{};
_GatewayConnection({
required this.id,
required this.socket,
required this.userId,
required this.username,
});
}
+229 -57
View File
@@ -1,11 +1,35 @@
import 'dart:convert';
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';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import '../../config/config.dart';
import '../auth/auth_middleware.dart'; import '../auth/auth_middleware.dart';
import '../auth/crypto_service.dart'; import '../auth/crypto_service.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart'; import '../utils/extensions.dart';
class RemoteIdentity {
final String userId;
final String username;
final String publicKey;
final String? displayName;
final String? avatarUrl;
final String? bio;
RemoteIdentity({
required this.userId,
required this.username,
required this.publicKey,
this.displayName,
this.avatarUrl,
this.bio,
});
}
/// 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 {
@@ -25,8 +49,12 @@ Future<Response> registerHandler(Request req) async {
); );
} }
// Check if username exists // Check if username exists on this home server
final query = userBox.query(User_.username.equals(username)).build(); final query = userBox
.query(
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
)
.build();
final existing = query.findFirst(); final existing = query.findFirst();
query.close(); query.close();
@@ -48,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 user = User( final isBootstrapAdmin = Config.setupMode && _adminCount() < Config.bootstrapAdminLimit;
final roles = isBootstrapAdmin ? <String>['admin'] : <String>['member'];
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);
@@ -71,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,
); );
} }
@@ -93,7 +132,11 @@ Future<Response> loginHandler(Request req) async {
); );
} }
final query = userBox.query(User_.username.equals(username)).build(); final query = userBox
.query(
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
)
.build();
final user = query.findFirst(); final user = query.findFirst();
query.close(); query.close();
@@ -125,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,
@@ -139,16 +183,31 @@ 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?;
final username = body['username'] as String?;
// Validate required fields // Validate required fields
if (userId == null || homeServer == 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('userId, homeServer, timestamp, and signature required'), body: Body.fromString('userId, username, homeServer, timestamp, and signature required'),
);
}
final resolvedHomeServer = normalizeHomeServer(homeServer);
if (resolvedHomeServer == null) {
return Response.badRequest(
body: Body.fromString('Invalid homeServer URL'),
); );
} }
@@ -160,13 +219,51 @@ Future<Response> joinServerHandler(Request req) async {
); );
} }
// Fetch current identity from home server
final identity = await fetchIdentityFromHomeServer(
resolvedHomeServer,
username,
);
if (identity == null) {
return Response.badRequest(
body: Body.fromString('Could not verify identity with home server'),
);
}
// Ensure fetched identity matches what caller claims
if (identity.userId != userId || identity.username != username) {
return Response.forbidden(
body: Body.fromString('Identity mismatch from home server'),
);
}
// Verify signature
// Signature is over: "{userId}:{timestamp}"
final messageToVerify = '$userId:$timestamp';
final isValid = CryptoService.verify(messageToVerify, signature, identity.publicKey);
if (!isValid) {
return Response.forbidden(
body: Body.fromString('Invalid signature'),
);
}
// Check if user already exists on this server // Check if user already exists on this server
final existingQuery = userBox.query(User_.userId.equals(userId)).build(); final existingQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
final existingUser = existingQuery.findFirst(); final existingUser = existingQuery.findFirst();
existingQuery.close(); existingQuery.close();
if (existingUser != null) { if (existingUser != null) {
// User already joined - just return a new token existingUser.username = identity.username;
existingUser.displayName = identity.displayName;
existingUser.avatarUrl = identity.avatarUrl;
existingUser.bio = identity.bio;
existingUser.publicKey = identity.publicKey;
existingUser.homeServer = resolvedHomeServer;
existingUser.lastSyncedAt = DateTime.now();
userBox.put(existingUser);
final token = AuthService.generateToken(existingUser.userId, existingUser.username); final token = AuthService.generateToken(existingUser.userId, existingUser.username);
userLocalIdProperty[req] = existingUser.id; userLocalIdProperty[req] = existingUser.id;
@@ -175,40 +272,25 @@ 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,
); );
} }
// Fetch public key from home server
final publicKey = await fetchPublicKeyFromHomeServer(homeServer, username ?? userId.split('@').first);
if (publicKey == null) {
return Response.badRequest(
body: Body.fromString('Could not verify identity with home server'),
);
}
// Verify signature
// Signature is over: "{userId}:{timestamp}"
final messageToVerify = '$userId:$timestamp';
final isValid = CryptoService.verify(messageToVerify, signature, publicKey);
if (!isValid) {
return Response.forbidden(
body: Body.fromString('Invalid signature'),
);
}
// Create local shadow account // Create local shadow account
final user = User( final user = ChatUser(
userId: userId, userId: userId,
username: username ?? userId.split('@').first, username: identity.username,
publicKey: publicKey, displayName: identity.displayName,
avatarUrl: identity.avatarUrl,
bio: identity.bio,
publicKey: identity.publicKey,
isLocal: false, isLocal: false,
homeServer: homeServer, homeServer: resolvedHomeServer,
roles: ['member'], roles: ['member'],
lastSyncedAt: DateTime.now(),
); );
final localId = userBox.put(user); final localId = userBox.put(user);
@@ -221,43 +303,108 @@ 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,
); );
} }
/// Fetch public key from user's home server /// Fetch public identity from user's home server
Future<String?> fetchPublicKeyFromHomeServer( Future<RemoteIdentity?> fetchIdentityFromHomeServer(
String homeServer, String homeServer,
String username, String username,
) async { ) async {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
try { try {
// Remove trailing slash
final baseUrl = homeServer.replaceAll(RegExp(r'/$'), ''); final baseUrl = homeServer.replaceAll(RegExp(r'/$'), '');
final endpoint = Uri.parse('$baseUrl/.well-known/users/${Uri.encodeComponent(username)}');
// Use http client to fetch final request = await client.getUrl(endpoint);
// Endpoint: GET /.well-known/users/{username} request.headers.set(HttpHeaders.acceptHeader, 'application/json');
// Returns: { publicKey, userId, ... } final response = await request.close();
// For now, return null to indicate we need http client
// This will be implemented with actual HTTP client
print('Would fetch: $baseUrl/.well-known/users/$username');
// TODO: Implement actual HTTP request
// final response = await http.get(Uri.parse('$baseUrl/.well-known/users/$username'));
// if (response.statusCode == 200) {
// final data = jsonDecode(response.body);
// return data['publicKey'] as String;
// }
if (response.statusCode != HttpStatus.ok) {
return null; return null;
}
final responseBody = await utf8.decodeStream(response);
final dynamic decoded = jsonDecode(responseBody);
if (decoded is! Map) {
return null;
}
final data = Map<String, dynamic>.from(decoded);
final userId = data['userId'] as String?;
final fetchedUsername = data['username'] as String?;
final publicKey = data['publicKey'] as String?;
if (userId == null || fetchedUsername == null || publicKey == null) {
return null;
}
return RemoteIdentity(
userId: userId,
username: fetchedUsername,
publicKey: publicKey,
displayName: data['displayName'] as String?,
avatarUrl: data['avatarUrl'] as String?,
bio: data['bio'] as String?,
);
} catch (e) { } catch (e) {
print('Error fetching public key: $e'); print('Error fetching public key: $e');
return null; return null;
} finally {
client.close(force: true);
} }
} }
String? normalizeHomeServer(String homeServerOrHost) {
final trimmed = homeServerOrHost.trim();
if (trimmed.isEmpty) {
return null;
}
final hasScheme = trimmed.contains('://');
final candidate = hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.isEmpty) {
return null;
}
final normalized = Uri(
scheme: uri.scheme,
userInfo: uri.userInfo,
host: uri.host,
port: uri.hasPort ? uri.port : null,
).toString();
return normalized.replaceAll(RegExp(r'/$'), '');
}
String defaultSchemeForHost(String host) {
final lowerHost = host.toLowerCase();
if (lowerHost.startsWith('localhost') || lowerHost.startsWith('127.') || lowerHost.startsWith('[::1]')) {
return 'http';
}
return 'https';
}
int _adminCount() {
var count = 0;
final users = userBox.getAll();
for (final user in users) {
if (user.roles.contains('admin')) {
count++;
}
}
return count;
}
/// Get current user's profile /// Get current user's profile
Future<Response> getMeHandler(Request req) async { Future<Response> getMeHandler(Request req) async {
final userId = userIdProperty[req]; final userId = userIdProperty[req];
@@ -268,7 +415,7 @@ Future<Response> getMeHandler(Request req) async {
); );
} }
final query = userBox.query(User_.userId.equals(userId)).build(); final query = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = query.findFirst(); final user = query.findFirst();
query.close(); query.close();
@@ -282,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,
@@ -292,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()}';
}
+14 -4
View File
@@ -2,6 +2,7 @@ import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart'; import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart'; import '../utils/extensions.dart';
/// Get all channels /// Get all channels
@@ -25,7 +26,8 @@ Future<Response> getChannelsHandler(Request req) async {
/// Create a channel (requires 'admin' role) /// Create a channel (requires 'admin' role)
Future<Response> createChannelHandler(Request req) async { Future<Response> createChannelHandler(Request req) async {
final body = await req.asJson(); final body = await req.asJson();
final name = (body['name'] as String?)?.trim().toLowerCase().replaceAll(' ', '-'); final name =
(body['name'] as String?)?.trim().toLowerCase().replaceAll(' ', '-');
final description = body['description'] as String?; final description = body['description'] as String?;
if (name == null || name.isEmpty) { if (name == null || name.isEmpty) {
@@ -35,14 +37,15 @@ Future<Response> createChannelHandler(Request req) async {
} }
// Check if channel exists // Check if channel exists
final existing = channelBox.query(Channel_.name.equals(name)).build().findFirst(); final existing =
channelBox.query(ChatChannel_.name.equals(name)).build().findFirst();
if (existing != null) { if (existing != null) {
return Response.badRequest( return Response.badRequest(
body: Body.fromString('Channel already exists'), body: Body.fromString('Channel already exists'),
); );
} }
final channel = Channel(name: name, description: description); final channel = ChatChannel(name: name, description: description);
final id = channelBox.put(channel); final id = channelBox.put(channel);
return Response.ok( return Response.ok(
@@ -56,7 +59,14 @@ Future<Response> createChannelHandler(Request req) async {
} }
/// Get channel by ID /// Get channel by ID
Future<Response> getChannelHandler(Request req, String channelIdStr) async { Future<Response> getChannelHandler(Request req) async {
final channelIdStr = req.rawPathParameters[#channelId];
if (channelIdStr == null) {
return Response.badRequest(
body: Body.fromString('Invalid channel ID'),
);
}
final channelId = int.tryParse(channelIdStr); final channelId = int.tryParse(channelIdStr);
if (channelId == null) { if (channelId == null) {
return Response.badRequest( return Response.badRequest(
+280
View File
@@ -0,0 +1,280 @@
import 'dart:io';
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../../config/config.dart';
import '../auth/auth_middleware.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart';
const _maxSnapshotLimit = 500;
Future<Response> getDevDbSnapshotHandler(Request req) async {
final guard = _guardDevDbTools(req);
if (guard != null) {
return guard;
}
final rawLimit = req.url.queryParameters['limit'];
final parsedLimit = rawLimit == null ? 100 : int.tryParse(rawLimit);
if (parsedLimit == null || parsedLimit <= 0) {
return Response.badRequest(
body: Body.fromString('Invalid limit'),
);
}
final limit = parsedLimit > _maxSnapshotLimit ? _maxSnapshotLimit : parsedLimit;
final users = userBox.getAll().take(limit).map(_userToMap).toList();
final channels = channelBox.getAll().take(limit).map(_channelToMap).toList();
final messages = messageBox.getAll().take(limit).map(_messageToMap).toList();
final files = fileBox.getAll().take(limit).map(_fileToMap).toList();
final messageMedia = messageMediaBox.getAll().take(limit).map(_messageMediaToMap).toList();
final settings = serverSettingsBox.getAll().take(limit).map(_settingsToMap).toList();
return Response.ok(
body: {
'limit': limit,
'counts': {
'users': userBox.count(),
'channels': channelBox.count(),
'messages': messageBox.count(),
'files': fileBox.count(),
'messageMedia': messageMediaBox.count(),
'serverSettings': serverSettingsBox.count(),
},
'users': users,
'channels': channels,
'messages': messages,
'files': files,
'messageMedia': messageMedia,
'serverSettings': settings,
}.toBody,
);
}
Future<Response> resetDevDbHandler(Request req) async {
final guard = _guardDevDbTools(req);
if (guard != null) {
return guard;
}
final body = await req.asJson();
if ((body['confirm'] as String?) != 'RESET') {
return Response.badRequest(
body: Body.fromString('Body must include {"confirm":"RESET"}'),
);
}
messageBox.removeAll();
channelBox.removeAll();
fileBox.removeAll();
messageMediaBox.removeAll();
serverSettingsBox.removeAll();
userBox.removeAll();
final uploadsDir = Directory('uploads');
if (uploadsDir.existsSync()) {
for (final entity in uploadsDir.listSync()) {
if (entity is File) {
entity.deleteSync();
}
}
}
return Response.ok(
body: {
'reset': true,
'counts': {
'users': userBox.count(),
'channels': channelBox.count(),
'messages': messageBox.count(),
'files': fileBox.count(),
'messageMedia': messageMediaBox.count(),
'serverSettings': serverSettingsBox.count(),
},
}.toBody,
);
}
Future<Response> promoteUserToAdminHandler(Request req) async {
final guard = _guardDevDbTools(req);
if (guard != null) {
return guard;
}
final body = await req.asJson();
final userId = (body['userId'] as String?)?.trim();
if (userId == null || userId.isEmpty) {
return Response.badRequest(
body: Body.fromString('userId is required'),
);
}
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = query.findFirst();
query.close();
if (user == null) {
return Response.notFound(
body: Body.fromString('User not found'),
);
}
if (!user.roles.contains('admin')) {
user.roles = [...user.roles, 'admin'];
userBox.put(user);
}
return Response.ok(
body: {
'updated': true,
'userId': user.userId,
'roles': user.roles,
}.toBody,
);
}
Future<Response> demoteUserFromAdminHandler(Request req) async {
final guard = _guardDevDbTools(req);
if (guard != null) {
return guard;
}
final body = await req.asJson();
final userId = (body['userId'] as String?)?.trim();
if (userId == null || userId.isEmpty) {
return Response.badRequest(
body: Body.fromString('userId is required'),
);
}
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = query.findFirst();
query.close();
if (user == null) {
return Response.notFound(
body: Body.fromString('User not found'),
);
}
user.roles = user.roles.where((role) => role != 'admin').toList();
userBox.put(user);
return Response.ok(
body: {
'updated': true,
'userId': user.userId,
'roles': user.roles,
}.toBody,
);
}
Response? _guardDevDbTools(Request req) {
if (!Config.devDbTools) {
return Response.forbidden(
body: Body.fromString('Dev DB tools are disabled in config'),
);
}
final authUserId = userIdProperty[req];
if (authUserId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final query = userBox.query(ChatUser_.userId.equals(authUserId)).build();
final user = query.findFirst();
query.close();
if (user == null) {
return Response.unauthorized(
body: Body.fromString('User not found'),
);
}
if (!user.roles.contains('admin')) {
return Response.forbidden(
body: Body.fromString('Admin role required'),
);
}
return null;
}
Map<String, dynamic> _userToMap(ChatUser user) => {
'id': user.id,
'userId': user.userId,
'username': user.username,
'displayName': user.displayName,
'avatarUrl': user.avatarUrl,
'bio': user.bio,
'isLocal': user.isLocal,
'homeServer': user.homeServer,
'roles': user.roles,
'createdAt': user.createdAt.toIso8601String(),
'lastSyncedAt': user.lastSyncedAt?.toIso8601String(),
};
Map<String, dynamic> _channelToMap(ChatChannel channel) => {
'id': channel.id,
'name': channel.name,
'description': channel.description,
'createdAt': channel.createdAt.toIso8601String(),
};
Map<String, dynamic> _messageToMap(ChatMessage message) => {
'id': message.id,
'channelId': message.channelId,
'authorLocalId': message.authorLocalId,
'authorUserId': message.authorUserId,
'authorUsername': message.authorUsername,
'content': message.content,
'componentsJson': message.componentsJson,
'createdAt': message.createdAt.toIso8601String(),
'editedAt': message.editedAt?.toIso8601String(),
};
Map<String, dynamic> _fileToMap(ChatFile file) => {
'id': file.id,
'attachmentId': file.attachmentId,
'uploaderId': file.uploaderId,
'path': file.path,
'originalFileName': file.originalFileName,
'storedFileName': file.storedFileName,
'mimeType': file.mimeType,
'sizeBytes': file.sizeBytes,
'width': file.width,
'height': file.height,
'usageType': file.usageType,
'status': file.status,
'isAttached': file.isAttached,
'createdAt': file.createdAt.toIso8601String(),
};
Map<String, dynamic> _settingsToMap(ServerSettings settings) => {
'id': settings.id,
'singletonKey': settings.singletonKey,
'serverName': settings.serverName,
'description': settings.description,
'iconAttachmentId': settings.iconAttachmentId,
'bannerAttachmentId': settings.bannerAttachmentId,
'updatedByUserId': settings.updatedByUserId,
'updatedAt': settings.updatedAt.toIso8601String(),
};
Map<String, dynamic> _messageMediaToMap(ChatMessageMedia media) => {
'id': media.id,
'messageId': media.messageId,
'channelId': media.channelId,
'authorUserId': media.authorUserId,
'attachmentId': media.attachmentId,
'externalUrl': media.externalUrl,
'kind': media.kind,
'mimeType': media.mimeType,
'originalFileName': media.originalFileName,
'sizeBytes': media.sizeBytes,
'width': media.width,
'height': media.height,
'thumbHash': media.thumbHash,
'createdAt': media.createdAt.toIso8601String(),
};
File diff suppressed because it is too large. Load diff
+41 -12
View File
@@ -1,7 +1,9 @@
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart'; import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import '../../config/config.dart';
import 'auth_handlers.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart'; import '../utils/extensions.dart';
/// Public endpoint for cross-server identity verification /// Public endpoint for cross-server identity verification
@@ -9,10 +11,19 @@ import '../utils/extensions.dart';
/// ///
/// GET /.well-known/users/{username} /// GET /.well-known/users/{username}
/// Returns: { userId, publicKey, username, displayName, avatarUrl } /// Returns: { userId, publicKey, username, displayName, avatarUrl }
Future<Response> publicUserHandler(Request req, String username) async { Future<Response> publicUserHandler(Request req) async {
final query = userBox.query( final username = req.rawPathParameters[#username];
User_.username.equals(username) & User_.isLocal.equals(true), if (username == null) {
).build(); return Response.badRequest(
body: Body.fromString('Username required'),
);
}
final query = userBox
.query(
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
)
.build();
final user = query.findFirst(); final user = query.findFirst();
query.close(); query.close();
@@ -28,10 +39,11 @@ Future<Response> publicUserHandler(Request req, String username) 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,
); );
@@ -42,10 +54,19 @@ Future<Response> publicUserHandler(Request req, String username) async {
/// ///
/// GET /.well-known/verify/{userId} /// GET /.well-known/verify/{userId}
/// Returns: { exists, userId, publicKey } /// Returns: { exists, userId, publicKey }
Future<Response> publicVerifyHandler(Request req, String userId) async { Future<Response> publicVerifyHandler(Request req) async {
final query = userBox.query( final userId = req.rawPathParameters[#userId];
User_.userId.equals(userId) & User_.isLocal.equals(true), if (userId == null) {
).build(); return Response.badRequest(
body: Body.fromString('userId required'),
);
}
final query = userBox
.query(
ChatUser_.userId.equals(userId) & ChatUser_.isLocal.equals(true),
)
.build();
final user = query.findFirst(); final user = query.findFirst();
query.close(); query.close();
@@ -65,6 +86,7 @@ Future<Response> publicVerifyHandler(Request req, String userId) 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,
); );
} }
@@ -74,11 +96,18 @@ Future<Response> publicVerifyHandler(Request req, String userId) async {
/// GET /.well-known/server /// GET /.well-known/server
/// Returns: { serverHost, serverName, version } /// Returns: { serverHost, serverName, version }
Future<Response> publicServerInfoHandler(Request req) async { Future<Response> publicServerInfoHandler(Request req) async {
final settingsQuery = serverSettingsBox.query(ServerSettings_.singletonKey.equals('default')).build();
final settings = settingsQuery.findFirst();
settingsQuery.close();
return Response.ok( return Response.ok(
body: { body: {
'serverHost': Config.serverHost, 'serverHost': Config.publicHost,
'serverName': Config.serverName, 'publicBaseUrl': Config.publicBaseUrl,
'serverName': settings?.serverName ?? Config.serverName,
'version': '0.1.0', 'version': '0.1.0',
'iconUrl': settings?.iconAttachmentId == null ? null : '/icons/${Uri.encodeComponent(Config.publicHost)}',
'bannerUrl': settings?.bannerAttachmentId == null ? null : '/banners/${Uri.encodeComponent(Config.publicHost)}',
'features': ['identity', 'chat', 'sticker-packs'], 'features': ['identity', 'chat', 'sticker-packs'],
}.toBody, }.toBody,
); );
+348
View File
@@ -0,0 +1,348 @@
import 'dart:io';
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../../config/config.dart';
import '../auth/auth_middleware.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart';
Future<Response> getServerSettingsHandler(Request req) async {
final auth = _requireCurrentUser(req);
if (auth.$2 != null) {
return auth.$2!;
}
final settings = _ensureServerSettings();
return Response.ok(
body: _serverSettingsToMap(settings).toBody,
);
}
Future<Response> patchServerSettingsHandler(Request req) async {
final auth = _requireCurrentUser(req);
if (auth.$2 != null) {
return auth.$2!;
}
final user = auth.$1!;
if (!_isAdmin(user)) {
return Response.forbidden(
body: Body.fromString('Admin role required'),
);
}
final body = await req.asJson();
final settings = _ensureServerSettings();
final nextServerName = (body['serverName'] as String?)?.trim();
final nextDescription = (body['description'] as String?)?.trim();
if (nextServerName != null && nextServerName.isNotEmpty) {
settings.serverName = nextServerName;
}
if (body.containsKey('description')) {
settings.description = nextDescription != null && nextDescription.isNotEmpty ? nextDescription : null;
}
settings.updatedByUserId = user.userId;
settings.updatedAt = DateTime.now();
serverSettingsBox.put(settings);
return Response.ok(
body: _serverSettingsToMap(settings).toBody,
);
}
Future<Response> updateProfileHandler(Request req) async {
final auth = _requireCurrentUser(req);
if (auth.$2 != null) {
return auth.$2!;
}
final user = auth.$1!;
final body = await req.asJson();
final displayName = (body['displayName'] as String?)?.trim();
final bio = (body['bio'] as String?)?.trim();
if (body.containsKey('displayName')) {
user.displayName = displayName != null && displayName.isNotEmpty ? displayName : null;
}
if (body.containsKey('bio')) {
user.bio = bio != null && bio.isNotEmpty ? bio : null;
}
userBox.put(user);
return Response.ok(
body: {
'userId': user.userId,
'username': user.username,
'displayName': user.displayName,
'avatarUrl': user.avatarUrl,
'bio': user.bio,
'roles': user.roles,
}.toBody,
);
}
Future<Response> setAvatarHandler(Request req) async {
final auth = _requireCurrentUser(req);
if (auth.$2 != null) {
return auth.$2!;
}
final user = auth.$1!;
final body = await req.asJson();
final attachmentId = (body['attachmentId'] as String?)?.trim();
if (attachmentId == null || attachmentId.isEmpty) {
return Response.badRequest(
body: Body.fromString('attachmentId is required'),
);
}
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
final file = query.findFirst();
query.close();
if (file == null || file.status != 'ready') {
return Response.notFound(
body: Body.fromString('Attachment not found or not ready'),
);
}
if (file.uploaderId != user.id) {
return Response.forbidden(
body: Body.fromString('Attachment must be uploaded by current user'),
);
}
file.usageType = 'avatar';
fileBox.put(file);
user.avatarUrl = '/avatars/${Uri.encodeComponent(user.userId)}';
userBox.put(user);
return Response.ok(
body: {
'userId': user.userId,
'avatarAttachmentId': file.attachmentId,
'avatarUrl': user.avatarUrl,
}.toBody,
);
}
Future<Response> setServerIconHandler(Request req) async {
return _setServerMedia(req, usageType: 'server_icon', target: 'icon');
}
Future<Response> setServerBannerHandler(Request req) async {
return _setServerMedia(req, usageType: 'server_banner', target: 'banner');
}
Future<Response> getAvatarAliasHandler(Request req) async {
final userId = req.rawPathParameters[#userId];
if (userId == null || userId.isEmpty) {
return Response.badRequest(
body: Body.fromString('userId is required'),
);
}
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = query.findFirst();
query.close();
if (user == null || user.avatarUrl == null) {
return Response.notFound(
body: Body.fromString('Avatar not found'),
);
}
final avatarQuery = fileBox
.query(
ChatFile_.uploaderId.equals(user.id) & ChatFile_.usageType.equals('avatar') & ChatFile_.status.equals('ready'),
)
.order(ChatFile_.id, flags: Order.descending)
.build();
final avatar = avatarQuery.findFirst();
avatarQuery.close();
if (avatar == null) {
return Response.notFound(
body: Body.fromString('Avatar not found'),
);
}
return _serveAttachment(avatar.attachmentId, mutableAlias: true);
}
Future<Response> getServerIconAliasHandler(Request req) async {
final serverId = req.rawPathParameters[#serverId];
if (!_matchesCurrentServer(serverId)) {
return Response.notFound(
body: Body.fromString('Server not found'),
);
}
final settings = _ensureServerSettings();
final attachmentId = settings.iconAttachmentId;
if (attachmentId == null || attachmentId.isEmpty) {
return Response.notFound(
body: Body.fromString('Server icon not found'),
);
}
return _serveAttachment(attachmentId, mutableAlias: true);
}
Future<Response> getServerBannerAliasHandler(Request req) async {
final serverId = req.rawPathParameters[#serverId];
if (!_matchesCurrentServer(serverId)) {
return Response.notFound(
body: Body.fromString('Server not found'),
);
}
final settings = _ensureServerSettings();
final attachmentId = settings.bannerAttachmentId;
if (attachmentId == null || attachmentId.isEmpty) {
return Response.notFound(
body: Body.fromString('Server banner not found'),
);
}
return _serveAttachment(attachmentId, mutableAlias: true);
}
Future<Response> _setServerMedia(
Request req, {
required String usageType,
required String target,
}) async {
final auth = _requireCurrentUser(req);
if (auth.$2 != null) {
return auth.$2!;
}
final user = auth.$1!;
if (!_isAdmin(user)) {
return Response.forbidden(
body: Body.fromString('Admin role required'),
);
}
final body = await req.asJson();
final attachmentId = (body['attachmentId'] as String?)?.trim();
if (attachmentId == null || attachmentId.isEmpty) {
return Response.badRequest(
body: Body.fromString('attachmentId is required'),
);
}
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
final file = query.findFirst();
query.close();
if (file == null || file.status != 'ready') {
return Response.notFound(
body: Body.fromString('Attachment not found or not ready'),
);
}
file.usageType = usageType;
fileBox.put(file);
final settings = _ensureServerSettings();
if (target == 'icon') {
settings.iconAttachmentId = attachmentId;
} else {
settings.bannerAttachmentId = attachmentId;
}
settings.updatedByUserId = user.userId;
settings.updatedAt = DateTime.now();
serverSettingsBox.put(settings);
return Response.ok(
body: _serverSettingsToMap(settings).toBody,
);
}
(ChatUser?, Response?) _requireCurrentUser(Request req) {
final userId = userIdProperty[req];
if (userId == null) {
return (null, Response.unauthorized(body: Body.fromString('Not authenticated')));
}
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = query.findFirst();
query.close();
if (user == null) {
return (null, Response.unauthorized(body: Body.fromString('User not found')));
}
return (user, null);
}
bool _isAdmin(ChatUser user) => user.roles.contains('admin');
ServerSettings _ensureServerSettings() {
final query = serverSettingsBox.query(ServerSettings_.singletonKey.equals('default')).build();
final existing = query.findFirst();
query.close();
if (existing != null) {
return existing;
}
final settings = ServerSettings(serverName: Config.serverName);
settings.id = serverSettingsBox.put(settings);
return settings;
}
bool _matchesCurrentServer(String? serverId) {
if (serverId == null || serverId.isEmpty) {
return false;
}
if (serverId == 'current') {
return true;
}
return serverId == Config.publicHost;
}
Map<String, dynamic> _serverSettingsToMap(ServerSettings settings) {
return {
'serverId': Config.publicHost,
'publicBaseUrl': Config.publicBaseUrl,
'serverName': settings.serverName,
'description': settings.description,
'iconUrl': settings.iconAttachmentId == null ? null : '/icons/${Uri.encodeComponent(Config.publicHost)}',
'bannerUrl': settings.bannerAttachmentId == null ? null : '/banners/${Uri.encodeComponent(Config.publicHost)}',
'iconAttachmentId': settings.iconAttachmentId,
'bannerAttachmentId': settings.bannerAttachmentId,
'updatedAt': settings.updatedAt.toIso8601String(),
'updatedByUserId': settings.updatedByUserId,
};
}
Future<Response> _serveAttachment(
String attachmentId, {
required bool mutableAlias,
}) async {
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
final file = query.findFirst();
query.close();
if (file == null || file.status != 'ready' || file.storedFileName.isEmpty) {
return Response.notFound(
body: Body.fromString('Attachment not found'),
);
}
final diskPath = 'uploads${Platform.pathSeparator}${file.storedFileName}';
final diskFile = File(diskPath);
if (!diskFile.existsSync()) {
return Response.notFound(
body: Body.fromString('Attachment file missing on disk'),
);
}
final cacheControl = mutableAlias ? 'public, max-age=0, must-revalidate' : 'public, max-age=31536000, immutable';
return Response.ok(
headers: Headers.fromMap(
{
Headers.contentTypeHeader: [file.mimeType],
Headers.cacheControlHeader: [cacheControl],
},
),
body: Body.fromData(diskFile.readAsBytesSync()),
);
}
+529
View File
@@ -0,0 +1,529 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:crypto/crypto.dart';
import 'package:mime/mime.dart';
import 'package:relic/relic.dart';
import '../auth/auth_middleware.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart';
import 'message_handlers.dart';
const _uploadsDir = 'uploads';
const _maxUploadBytes = 50 * 1024 * 1024;
Future<Response> initUploadHandler(Request req) async {
final currentUser = _requireCurrentUser(req);
if (currentUser.$2 != null) {
return currentUser.$2!;
}
final user = currentUser.$1!;
final body = await req.asJson();
final usageType = (body['usageType'] as String?)?.trim() ?? 'message';
final originalFileName = (body['originalFileName'] as String?)?.trim() ?? '';
final declaredMimeType = (body['mimeType'] as String?)?.trim() ?? 'application/octet-stream';
final declaredWidth = _asInt(body['width']) ?? 0;
final declaredHeight = _asInt(body['height']) ?? 0;
final declaredSizeBytes = _asInt(body['sizeBytes']) ?? 0;
if (!_isAllowedUsageType(usageType)) {
return Response.badRequest(
body: Body.fromString('Invalid usageType'),
);
}
final attachmentId = _generateAttachmentId();
final entity = ChatFile(
attachmentId: attachmentId,
uploaderId: user.id,
path: '',
originalFileName: originalFileName.isEmpty ? 'upload-${attachmentId.substring(0, 8)}' : originalFileName,
storedFileName: '',
sha256: '',
mimeType: declaredMimeType,
width: declaredWidth,
height: declaredHeight,
sizeBytes: declaredSizeBytes,
usageType: usageType,
status: 'pending',
);
final id = fileBox.put(entity);
entity.id = id;
return Response.ok(
body: {
'uploadId': entity.attachmentId,
'attachmentId': entity.attachmentId,
'status': entity.status,
'usageType': entity.usageType,
}.toBody,
);
}
Future<Response> completeUploadHandler(Request req) async {
final currentUser = _requireCurrentUser(req);
if (currentUser.$2 != null) {
return currentUser.$2!;
}
final user = currentUser.$1!;
final uploadId = req.rawPathParameters[#uploadId];
if (uploadId == null || uploadId.isEmpty) {
return Response.badRequest(
body: Body.fromString('uploadId is required'),
);
}
final query = fileBox.query(ChatFile_.attachmentId.equals(uploadId)).build();
final file = query.findFirst();
query.close();
if (file == null) {
return Response.notFound(
body: Body.fromString('Upload not found'),
);
}
if (file.uploaderId != user.id) {
return Response.forbidden(
body: Body.fromString('You cannot complete another user\'s upload'),
);
}
if (file.status == 'ready') {
return Response.ok(body: _fileResponseMap(file).toBody);
}
final uploadData = await _readUploadData(req);
if (uploadData.$2 != null) {
file.status = 'failed';
file.errorCode = 'invalid_upload_payload';
fileBox.put(file);
return uploadData.$2!;
}
final bytes = uploadData.$1!;
final payloadMimeType = uploadData.$3;
final payloadFileName = uploadData.$4;
if (bytes.isEmpty) {
file.status = 'failed';
file.errorCode = 'empty_file';
fileBox.put(file);
return Response.badRequest(
body: Body.fromString('Uploaded file is empty'),
);
}
_finalizeUpload(
file: file,
bytes: bytes,
payloadMimeType: payloadMimeType,
payloadFileName: payloadFileName,
);
await resolvePendingUploadsInMessages(file.attachmentId);
return Response.ok(
body: _fileResponseMap(file).toBody,
);
}
Future<Response> uploadFileHandler(Request req) async {
final currentUser = _requireCurrentUser(req);
if (currentUser.$2 != null) {
return currentUser.$2!;
}
final user = currentUser.$1!;
final uploadData = await _readUploadData(req);
if (uploadData.$2 != null) {
return uploadData.$2!;
}
final bytes = uploadData.$1!;
final payloadMimeType = uploadData.$3;
final payloadFileName = uploadData.$4;
final attachmentId = _generateAttachmentId();
final file = ChatFile(
attachmentId: attachmentId,
uploaderId: user.id,
path: '',
originalFileName: payloadFileName ?? 'upload-${attachmentId.substring(0, 8)}',
storedFileName: '',
sha256: '',
mimeType: _normalizeMimeType(payloadMimeType),
width: 0,
height: 0,
sizeBytes: bytes.length,
usageType: 'message',
status: 'pending',
);
file.id = fileBox.put(file);
_finalizeUpload(
file: file,
bytes: bytes,
payloadMimeType: payloadMimeType,
payloadFileName: payloadFileName,
);
return Response.ok(
body: _fileResponseMap(file).toBody,
);
}
Future<Response> getAttachmentHandler(Request req) async {
final attachmentId = req.rawPathParameters[#attachmentId];
if (attachmentId == null || attachmentId.isEmpty) {
return Response.badRequest(
body: Body.fromString('Invalid attachment ID'),
);
}
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
final file = query.findFirst();
query.close();
if (file == null || file.status != 'ready') {
return Response.notFound(
body: Body.fromString('Attachment not found'),
);
}
final diskPath = _resolveDiskPath(file);
final diskFile = File(diskPath);
if (!diskFile.existsSync()) {
return Response.notFound(
body: Body.fromString('Attachment file missing on disk'),
);
}
return Response.ok(
headers: Headers.fromMap(
{
Headers.contentTypeHeader: [file.mimeType],
Headers.cacheControlHeader: ['public, max-age=31536000, immutable'],
},
),
body: Body.fromData(diskFile.readAsBytesSync()),
);
}
Future<Response> getUploadFileHandler(Request req) async {
final fileName = req.rawPathParameters[#fileName];
if (fileName == null || fileName.isEmpty || fileName.contains('..')) {
return Response.badRequest(
body: Body.fromString('Invalid file name'),
);
}
final diskPath = '$_uploadsDir${Platform.pathSeparator}$fileName';
final file = File(diskPath);
if (!file.existsSync()) {
return Response.notFound(
body: Body.fromString('File not found'),
);
}
return Response.ok(
body: Body.fromData(file.readAsBytesSync()),
);
}
(ChatUser?, Response?) _requireCurrentUser(Request req) {
final userId = userIdProperty[req];
if (userId == null) {
return (
null,
Response.unauthorized(
body: Body.fromString('Not authenticated'),
)
);
}
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = query.findFirst();
query.close();
if (user == null) {
return (
null,
Response.unauthorized(
body: Body.fromString('User not found'),
)
);
}
return (user, null);
}
Future<(Uint8List?, Response?, String?, String?)> _readUploadData(Request req) async {
final contentTypeHeader = req.headers[Headers.contentTypeHeader]?.first;
final boundary = _extractBoundary(contentTypeHeader);
if (boundary != null) {
final multipartParts =
await req.read(maxLength: _maxUploadBytes).cast<List<int>>().transform(MimeMultipartTransformer(boundary)).toList();
if (multipartParts.isEmpty) {
return (null, Response.badRequest(body: Body.fromString('No files in request')), null, null);
}
final uploadPart = multipartParts.firstWhere(
(part) => (part.headers['content-disposition'] ?? '').contains('filename='),
orElse: () => multipartParts.first,
);
final bytesBuilder = BytesBuilder(copy: false);
await for (final chunk in uploadPart) {
bytesBuilder.add(chunk);
}
final bytes = bytesBuilder.toBytes();
final mimeType = uploadPart.headers['content-type'];
final filename = _extractFilename(uploadPart.headers['content-disposition'] ?? '');
return (bytes, null, mimeType, filename);
}
final bytesBuilder = BytesBuilder(copy: false);
await for (final chunk in req.read(maxLength: _maxUploadBytes)) {
bytesBuilder.add(chunk);
}
final mimeType = contentTypeHeader;
return (bytesBuilder.toBytes(), null, mimeType, null);
}
Map<String, dynamic> _fileResponseMap(ChatFile file) {
return {
'uploadId': file.attachmentId,
'attachmentId': file.attachmentId,
'id': file.id,
'url': file.path,
'status': file.status,
'usageType': file.usageType,
'originalFileName': file.originalFileName,
'storedFileName': file.storedFileName,
'mimeType': file.mimeType,
'sizeBytes': file.sizeBytes,
'width': file.width,
'height': file.height,
'sha256': file.sha256,
};
}
void _finalizeUpload({
required ChatFile file,
required Uint8List bytes,
String? payloadMimeType,
String? payloadFileName,
}) {
final detectedMime = _normalizeMimeType(
payloadMimeType ?? file.mimeType,
);
final dimensions = _extractImageDimensions(bytes, detectedMime);
final extension = _resolveExtension(
payloadFileName ?? file.originalFileName,
detectedMime,
);
final storedFileName = '${file.attachmentId}$extension';
final directory = Directory(_uploadsDir);
if (!directory.existsSync()) {
directory.createSync(recursive: true);
}
final diskPath = '${directory.path}${Platform.pathSeparator}$storedFileName';
File(diskPath).writeAsBytesSync(bytes, flush: true);
file.path = '/attachments/${file.attachmentId}';
file.storedFileName = storedFileName;
file.sha256 = sha256.convert(bytes).toString();
file.mimeType = detectedMime;
file.width = dimensions.$1;
file.height = dimensions.$2;
file.sizeBytes = bytes.length;
file.status = 'ready';
file.errorCode = null;
fileBox.put(file);
}
String _generateAttachmentId() {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
final random = Random.secure();
String next() {
final bytes = List<int>.generate(20, (_) => random.nextInt(256));
final sb = StringBuffer();
for (final byte in bytes) {
sb.write(alphabet[byte % alphabet.length]);
}
return sb.toString();
}
while (true) {
final id = next();
final exists = fileBox.query(ChatFile_.attachmentId.equals(id)).build().findFirst();
if (exists == null) {
return id;
}
}
}
bool _isAllowedUsageType(String usageType) {
return usageType == 'message' || usageType == 'avatar' || usageType == 'server_icon' || usageType == 'server_banner';
}
int? _asInt(Object? value) {
return switch (value) {
int v => v,
double v => v.round(),
String v => int.tryParse(v),
_ => null,
};
}
String _resolveDiskPath(ChatFile file) {
final storedFileName = file.storedFileName.trim();
if (storedFileName.isNotEmpty) {
return '$_uploadsDir${Platform.pathSeparator}$storedFileName';
}
final path = file.path;
if (path.startsWith('/uploads/')) {
final fileName = path.substring('/uploads/'.length);
return '$_uploadsDir${Platform.pathSeparator}$fileName';
}
final attachmentPath = path.startsWith('/attachments/') ? path.substring('/attachments/'.length) : path;
return '$_uploadsDir${Platform.pathSeparator}$attachmentPath';
}
String _normalizeMimeType(String? mimeType) {
final cleaned = (mimeType ?? '').split(';').first.trim().toLowerCase();
if (cleaned.isEmpty) {
return 'application/octet-stream';
}
return cleaned;
}
String? _extractBoundary(String? contentTypeHeader) {
if (contentTypeHeader == null) {
return null;
}
if (!contentTypeHeader.toLowerCase().startsWith('multipart/form-data')) {
return null;
}
final match = RegExp(
r'boundary="?([^\";]+)"?',
caseSensitive: false,
).firstMatch(contentTypeHeader);
return match?.group(1);
}
String? _extractFilename(String contentDisposition) {
final match = RegExp(
r'filename="([^"]+)"',
caseSensitive: false,
).firstMatch(contentDisposition);
if (match == null) {
return null;
}
final decoded = Uri.decodeFull(match.group(1)!);
return decoded.split(RegExp(r'[\\/]')).last;
}
String _resolveExtension(String? fileName, String mimeType) {
final lowerName = fileName?.toLowerCase();
if (lowerName != null) {
final dotIndex = lowerName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < lowerName.length - 1) {
return lowerName.substring(dotIndex);
}
}
return switch (mimeType.toLowerCase()) {
'image/png' => '.png',
'image/jpeg' || 'image/jpg' => '.jpg',
'image/gif' => '.gif',
'image/webp' => '.webp',
'image/avif' => '.avif',
'video/mp4' => '.mp4',
_ => '.bin',
};
}
(int, int) _extractImageDimensions(Uint8List bytes, String mimeType) {
final lowerMime = mimeType.toLowerCase();
if (lowerMime == 'image/png' && bytes.length >= 24) {
final width = _readUint32BigEndian(bytes, 16);
final height = _readUint32BigEndian(bytes, 20);
return (width, height);
}
if ((lowerMime == 'image/jpeg' || lowerMime == 'image/jpg') && bytes.length > 4) {
final jpeg = _extractJpegDimensions(bytes);
if (jpeg != null) {
return jpeg;
}
}
if (lowerMime == 'image/gif' && bytes.length >= 10) {
final width = bytes[6] | (bytes[7] << 8);
final height = bytes[8] | (bytes[9] << 8);
return (width, height);
}
return (0, 0);
}
int _readUint32BigEndian(Uint8List bytes, int offset) {
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
}
(int, int)? _extractJpegDimensions(Uint8List bytes) {
if (bytes[0] != 0xFF || bytes[1] != 0xD8) {
return null;
}
var offset = 2;
while (offset + 9 < bytes.length) {
if (bytes[offset] != 0xFF) {
offset++;
continue;
}
final marker = bytes[offset + 1];
final hasSize = marker != 0xD8 && marker != 0xD9;
if (!hasSize) {
offset += 2;
continue;
}
if (offset + 4 >= bytes.length) {
return null;
}
final length = (bytes[offset + 2] << 8) | bytes[offset + 3];
if (length < 2 || offset + 2 + length > bytes.length) {
return null;
}
final isSofMarker = (marker >= 0xC0 && marker <= 0xC3) ||
(marker >= 0xC5 && marker <= 0xC7) ||
(marker >= 0xC9 && marker <= 0xCB) ||
(marker >= 0xCD && marker <= 0xCF);
if (isSofMarker && offset + 9 < bytes.length) {
final height = (bytes[offset + 5] << 8) | bytes[offset + 6];
final width = (bytes[offset + 7] << 8) | bytes[offset + 8];
return (width, height);
}
offset += 2 + length;
}
return null;
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
late final Store store;
late final Box<ChatUser> userBox;
late final Box<ChatChannel> channelBox;
late final Box<ChatMessage> messageBox;
late final Box<ChatMessageMedia> messageMediaBox;
late final Box<ChatFile> fileBox;
late final Box<ServerSettings> serverSettingsBox;
void initializeStore() {
store = openStore();
userBox = store.box<ChatUser>();
channelBox = store.box<ChatChannel>();
messageBox = store.box<ChatMessage>();
messageMediaBox = store.box<ChatMessageMedia>();
fileBox = store.box<ChatFile>();
serverSettingsBox = store.box<ServerSettings>();
}
-2
View File
@@ -2,8 +2,6 @@ import 'dart:convert';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import '../config/config.dart';
extension RequestHelper on Request { extension RequestHelper on Request {
/// Parses the request body as JSON /// Parses the request body as JSON
Future<Map<String, dynamic>> asJson() async { Future<Map<String, dynamic>> asJson() async {
+669
View File
@@ -0,0 +1,669 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
url: "https://pub.dev"
source: hosted
version: "67.0.0"
adaptive_number:
dependency: transitive
description:
name: adaptive_number
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
asn1lib:
dependency: transitive
description:
name: asn1lib
sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024"
url: "https://pub.dev"
source: hosted
version: "1.6.5"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
bcrypt:
dependency: "direct main"
description:
name: bcrypt
sha256: "6073a700cbbc59f1d4ab27cd532755e3de5e676c4941f535f351374df849270b"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
url: "https://pub.dev"
source: hosted
version: "2.4.13"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
url: "https://pub.dev"
source: hosted
version: "7.3.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8"
url: "https://pub.dev"
source: hosted
version: "8.12.3"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection:
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
crypton:
dependency: "direct main"
description:
name: crypton
sha256: "17b6631fbf89e389d421b46629132287ed37d601b2ad1357445826ab85022271"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
dart_jsonwebtoken:
dependency: "direct main"
description:
name: dart_jsonwebtoken
sha256: b268ff9bb87a16360a397f1accb50e7c781384e1119010b34f6d7c25820c44d8
url: "https://pub.dev"
source: hosted
version: "3.1.1"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
url: "https://pub.dev"
source: hosted
version: "2.3.6"
ed25519_edwards:
dependency: transitive
description:
name: ed25519_edwards
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flat_buffers:
dependency: transitive
description:
name: flat_buffers
sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3"
url: "https://pub.dev"
source: hosted
version: "23.5.26"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
lints:
dependency: "direct dev"
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
meta:
dependency: transitive
description:
name: meta
sha256: "9f29b9bcc8ee287b1a31e0d01be0eae99a930dbffdaecf04b3f3d82a969f296f"
url: "https://pub.dev"
source: hosted
version: "1.18.1"
mime:
dependency: "direct main"
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
objectbox:
dependency: "direct main"
description:
name: objectbox
sha256: "3d1cb5f9aa564f95c76ba251299f6cb1591c3dd8ff05fd76fa0549d899d9fe31"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
objectbox_generator:
dependency: "direct dev"
description:
name: objectbox_generator
sha256: "777924e14daf18603a023b346333acac1c4fce9fe4a715b85d98cfcc723d9a1a"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "3.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
relic:
dependency: "direct main"
description:
name: relic
sha256: d87e88a2dd758b67a03bfbedd7f62ef81f44734a4851ec51547b51502cd2f114
url: "https://pub.dev"
source: hosted
version: "1.0.0"
relic_core:
dependency: transitive
description:
name: relic_core
sha256: caefeef3a85580357cf2d6f70c4a3f9de3e2375ec22196aa983b84c8f463c960
url: "https://pub.dev"
source: hosted
version: "1.0.0"
relic_io:
dependency: transitive
description:
name: relic_io
sha256: "9f2148ae0b8ca60e241c980e37216eb416e1342d51992266fcd5d18b8092b820"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev"
source: hosted
version: "2.0.1"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.12"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: "direct main"
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
+14 -12
View File
@@ -3,19 +3,21 @@ description: A self-hosted chat server with home server identity model.
version: 0.1.0 version: 0.1.0
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: ">=3.0.0 <4.0.0"
dependencies: dependencies:
relic: ^1.0.0 relic:
objectbox: ^4.0.0 objectbox:
dart_jsonwebtoken: ^2.14.0 dart_jsonwebtoken:
bcrypt: ^1.1.3 bcrypt:
crypton: ^2.2.1 crypton:
crypto: ^3.0.3 crypto:
collection: ^1.18.0 collection:
hotreloader: ^1.0.6 yaml:
mime:
dev_dependencies: dev_dependencies:
objectbox_generator: ^4.0.0 test:
build_runner: ^2.4.0 objectbox_generator:
lints: ^4.0.0 build_runner:
lints:
+61
View File
@@ -0,0 +1,61 @@
import 'package:chat_server/src/handlers/auth_handlers.dart';
import 'package:test/test.dart';
void main() {
group('normalizeHomeServer', () {
test('normalizes host without scheme', () {
expect(
normalizeHomeServer('chat.example.com'),
'https://chat.example.com',
);
expect(
normalizeHomeServer('localhost:9870'),
'http://localhost:9870',
);
});
test('strips trailing slash/path/query/fragment', () {
expect(
normalizeHomeServer('https://example.com/a/b?x=1#y'),
'https://example.com',
);
expect(
normalizeHomeServer('https://example.com/'),
'https://example.com',
);
});
test('returns null for invalid input', () {
expect(normalizeHomeServer(''), isNull);
expect(normalizeHomeServer('://bad'), 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('toUserHandle', () {
test('uses URL host and port', () {
expect(
toUserHandle('alice', 'https://chat.example.com:8443'),
'@alice@chat.example.com:8443',
);
});
test('falls back to raw home server string', () {
expect(
toUserHandle('alice', 'chat.example.com'),
'@alice@chat.example.com',
);
});
});
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:chat_server/src/auth/crypto_service.dart';
import 'package:test/test.dart';
void main() {
group('AuthService password hashing', () {
test('hash + verify works', () {
const password = 'pass1234';
final hash = AuthService.hashPassword(password);
expect(hash, isNotEmpty);
expect(AuthService.verifyPassword(password, hash), isTrue);
expect(AuthService.verifyPassword('wrong-password', hash), isFalse);
});
});
group('CryptoService signing', () {
test('signatures verify with matching keypair', () {
final keyPair = CryptoService.generateKeyPair();
const payload = 'alice@localhost:9870:1700000000';
final signature = CryptoService.sign(payload, keyPair.privateKey);
final ok = CryptoService.verify(payload, signature, keyPair.publicKey);
expect(ok, isTrue);
});
test('signature fails for modified payload', () {
final keyPair = CryptoService.generateKeyPair();
const payload = 'payload-a';
const tamperedPayload = 'payload-b';
final signature = CryptoService.sign(payload, keyPair.privateKey);
final ok = CryptoService.verify(
tamperedPayload,
signature,
keyPair.publicKey,
);
expect(ok, isFalse);
});
});
}
+326
View File
@@ -0,0 +1,326 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:chat_server/config/config.dart';
import 'package:chat_server/server.dart';
import 'package:chat_server/src/state/app_state.dart';
import 'package:test/test.dart';
void main() {
final repoDir = Directory.current.path;
final configPath = '$repoDir${Platform.pathSeparator}config.yaml';
final objectboxDir = '$repoDir${Platform.pathSeparator}objectbox';
final uploadsDir = '$repoDir${Platform.pathSeparator}uploads';
late String originalConfig;
late Uri baseUri;
final random = Random();
Future<void> waitUntilServerReady(Uri uri) async {
final client = HttpClient();
try {
for (var i = 0; i < 120; i++) {
try {
final req = await client.getUrl(uri.resolve('/.well-known/server'));
final res = await req.close();
if (res.statusCode == 200) {
await res.drain();
return;
}
await res.drain();
} catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 250));
}
fail('Server did not become ready in time.');
} finally {
client.close(force: true);
}
}
setUpAll(() async {
originalConfig = File(configPath).existsSync() ? File(configPath).readAsStringSync() : '';
final bindPort = 12000 + random.nextInt(2000);
final testConfig = '''
server:
name: "Reliquary Test Server"
bind_host: "127.0.0.1"
bind_port: $bindPort
public_base_url: "http://127.0.0.1:$bindPort"
configured: true
first_setup: true
bootstrap_admin_limit: 2
dev_db_tools: true
security:
jwt_secret: "integration_test_secret_1234567890"
''';
File(configPath).writeAsStringSync(testConfig);
baseUri = Uri.parse('http://127.0.0.1:$bindPort');
final dataMdb = File('$objectboxDir${Platform.pathSeparator}data.mdb');
final lockMdb = File('$objectboxDir${Platform.pathSeparator}lock.mdb');
if (dataMdb.existsSync()) {
dataMdb.deleteSync();
}
if (lockMdb.existsSync()) {
lockMdb.deleteSync();
}
final uploads = Directory(uploadsDir);
if (uploads.existsSync()) {
for (final entity in uploads.listSync()) {
if (entity is File) {
entity.deleteSync();
}
}
}
Config.initialize();
initializeStore();
unawaited(startServer());
await waitUntilServerReady(baseUri);
});
tearDownAll(() async {
await stopServer();
if (originalConfig.isEmpty) {
File(configPath).deleteSync();
} else {
File(configPath).writeAsStringSync(originalConfig);
}
});
test('auth + upload + component messages + gallery index', () async {
final client = HttpClient();
try {
Future<Map<String, dynamic>> postJson(
String path,
Map<String, dynamic> body, {
String? token,
}) async {
final req = await client.postUrl(baseUri.resolve(path));
req.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
if (token != null) {
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
}
req.write(jsonEncode(body));
final res = await req.close();
final text = await utf8.decodeStream(res);
expect(res.statusCode, anyOf(200, 201), reason: '$path -> $text');
return jsonDecode(text) as Map<String, dynamic>;
}
Future<Map<String, dynamic>> getJson(
String path, {
String? token,
}) async {
final req = await client.getUrl(baseUri.resolve(path));
if (token != null) {
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
}
final res = await req.close();
final text = await utf8.decodeStream(res);
expect(res.statusCode, 200, reason: '$path -> $text');
return jsonDecode(text) as Map<String, dynamic>;
}
Future<Map<String, dynamic>> uploadFile(
String filePath, {
required String token,
}) async {
final file = File(filePath);
expect(file.existsSync(), isTrue, reason: 'Missing test asset: $filePath');
final bytes = file.readAsBytesSync();
final rawFileName = file.uri.pathSegments.last;
final safeFileName = rawFileName.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_');
final lower = rawFileName.toLowerCase();
final contentType = lower.endsWith('.mp4')
? 'video/mp4'
: lower.endsWith('.png')
? 'image/png'
: lower.endsWith('.jpg') || lower.endsWith('.jpeg')
? 'image/jpeg'
: lower.endsWith('.gif')
? 'image/gif'
: 'application/octet-stream';
final boundary = '----reliquary-test-boundary';
final req = await client.postUrl(baseUri.resolve('/api/uploads'));
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
req.headers.set(
HttpHeaders.contentTypeHeader,
'multipart/form-data; boundary=$boundary',
);
req.write('--$boundary\r\n');
req.write(
'Content-Disposition: form-data; name="file"; filename="$safeFileName"\r\n',
);
req.write('Content-Type: $contentType\r\n\r\n');
req.add(bytes);
req.write('\r\n--$boundary--\r\n');
final res = await req.close();
final text = await utf8.decodeStream(res);
expect(res.statusCode, 200, reason: '/api/uploads -> $text');
return jsonDecode(text) as Map<String, dynamic>;
}
final register = await postJson('/auth/register', {
'username': 'alice',
'password': 'password123',
});
final token = register['token'] as String;
final channel = await postJson(
'/api/channels',
{'name': 'integration-channel', 'description': 'integration'},
token: token,
);
final channelId = channel['id'] as int;
final uploadedPng = await uploadFile(
'$repoDir${Platform.pathSeparator}scripts${Platform.pathSeparator}media${Platform.pathSeparator}Untitled-1.png',
token: token,
);
final pngAttachmentId = uploadedPng['attachmentId'] as String;
final uploadedPsd = await uploadFile(
'$repoDir${Platform.pathSeparator}scripts${Platform.pathSeparator}media${Platform.pathSeparator}ftnr-symbol.psd',
token: token,
);
final psdAttachmentId = uploadedPsd['attachmentId'] as String;
expect(psdAttachmentId, isNotEmpty);
final uploadedMp4 = await uploadFile(
'$repoDir${Platform.pathSeparator}scripts${Platform.pathSeparator}media${Platform.pathSeparator}Zelsus: Enemy Within [jUKjVrDFUug].mp4',
token: token,
);
final mp4AttachmentId = uploadedMp4['attachmentId'] as String;
expect(mp4AttachmentId, isNotEmpty);
final localMessage = await postJson(
'/api/messages',
{
'channelId': channelId,
'components': [
{
'id': 'cmp_local_gallery',
'type': 'gallery',
'typeVersion': 1,
'fallback': 'Local upload gallery',
'data': {
'items': [
{
'id': 'itm_local_1',
'source': 'local_upload',
'attachmentId': pngAttachmentId,
}
]
}
}
]
},
token: token,
);
expect(localMessage['componentsJson'], contains('"source":"local_upload"'));
expect(localMessage['componentsJson'], contains('"status":"ready"'));
final fileAndVideoMessage = await postJson(
'/api/messages',
{
'channelId': channelId,
'components': [
{
'id': 'cmp_files',
'type': 'gallery',
'typeVersion': 1,
'fallback': 'Local file + local video + external file link',
'data': {
'items': [
{
'id': 'itm_psd',
'source': 'local_upload',
'attachmentId': psdAttachmentId,
'caption': 'PSD unsupported preview candidate'
},
{'id': 'itm_mp4', 'source': 'local_upload', 'attachmentId': mp4AttachmentId, 'caption': 'MP4 playback candidate'},
{
'id': 'itm_ext_file',
'source': 'external_url',
'url': 'https://example.com/assets/archive.zip',
'mimeType': 'application/zip',
'originalFileName': 'archive.zip'
}
]
}
}
]
},
token: token,
);
expect(fileAndVideoMessage['componentsJson'], contains('"itm_psd"'));
expect(fileAndVideoMessage['componentsJson'], contains('"itm_mp4"'));
expect(fileAndVideoMessage['componentsJson'], contains('"external_url"'));
final externalMessage = await postJson(
'/api/messages',
{
'channelId': channelId,
'components': [
{
'id': 'cmp_external_gallery',
'type': 'gallery',
'typeVersion': 1,
'fallback': 'External gallery',
'data': {
'items': [
{
'id': 'itm_ext_1',
'source': 'external_url',
'url': 'https://pbs.twimg.com/media/HCI8WPKaYAAKhkj?format=jpg&name=medium',
'mimeType': 'image/jpeg',
'originalFileName': 'HCI8WPKaYAAKhkj.jpg',
},
{
'id': 'itm_ext_2',
'source': 'external_url',
'url': 'https://pbs.twimg.com/media/HCKAMLxaUAAhYJS?format=jpg&name=medium',
'mimeType': 'image/jpeg',
'originalFileName': 'HCKAMLxaUAAhYJS.jpg',
}
]
}
}
]
},
token: token,
);
expect(externalMessage['componentsJson'], contains('"source":"external_url"'));
expect(externalMessage['componentsJson'], contains('"status":"ready"'));
final messages = await getJson('/api/messages?channelId=$channelId&limit=10', token: token);
final messageList = (messages['messages'] as List).cast<Map<String, dynamic>>();
expect(messageList.length, greaterThanOrEqualTo(2));
final media = await getJson('/api/channels/$channelId/media?limit=10', token: token);
final mediaItems = (media['media'] as List).cast<Map<String, dynamic>>();
expect(mediaItems.length, greaterThanOrEqualTo(6));
final hasExternal = mediaItems.any((m) => (m['externalUrl'] as String?)?.contains('pbs.twimg.com/media/HCI8WPKaYAAKhkj') == true);
final hasPngLocal = mediaItems.any((m) => (m['attachmentId'] as String?) == pngAttachmentId);
final hasPsdLocal = mediaItems.any((m) => (m['attachmentId'] as String?) == psdAttachmentId);
final hasMp4Local = mediaItems.any((m) => (m['attachmentId'] as String?) == mp4AttachmentId);
final hasExternalFile = mediaItems.any((m) => (m['externalUrl'] as String?) == 'https://example.com/assets/archive.zip');
final hasVideoKind = mediaItems.any((m) => (m['attachmentId'] as String?) == mp4AttachmentId && m['kind'] == 'video');
expect(hasExternal, isTrue);
expect(hasPngLocal, isTrue);
expect(hasPsdLocal, isTrue);
expect(hasMp4Local, isTrue);
expect(hasExternalFile, isTrue);
expect(hasVideoKind, isTrue);
} finally {
client.close(force: true);
}
});
}