Compare commits

..
11 Commits
Author SHA1 Message Date
Foxyz 01c110bbe1 Major architecture update: stable identity, component validation, media indexing, and protocol docs 2026-02-27 19:33:35 +03:00
Foxyz ce7c97d13a Add baseline unit tests and fix home server normalization 2026-02-20 17:45:10 +03:00
Foxyz a93d619b4c Implement components + uploads flow and gateway-aligned payloads 2026-02-20 17:41:08 +03:00
Foxyz 458be6f991 Revised ARCHITECTURE.md based on CLIENT_INTEGRATION.md, to match the
current implementation
2026-02-20 16:37:30 +03:00
Foxyz 2222e2af4a Updated ARCHITECTURE.md with new and refined ideas 2026-02-20 15:01:55 +03:00
Foxyz ccf966ffc1 Add gateway realtime fanout and cursor pagination docs 2026-02-20 04:24:27 +03:00
Foxyz 4399eafc90 Align architecture doc with current crypto implementation 2026-02-20 03:50:56 +03:00
Foxyz 3f7d50f6a2 Document realtime gateway and notifications/push architecture 2026-02-20 03:50:31 +03:00
Foxyz f6b27565cd Implement chat server auth, models, handlers, and docs context 2026-02-20 03:44:02 +03:00
Foxyz 23602efd5f Harden join auth flow and sync project baseline 2026-02-20 03:43:50 +03:00
Z User 6a8977c4c3 feat: Initial MVP with home server identity model
- User model with Ed25519 keypair identity
- Register/login on home server (creates keypair)
- Join guest servers with signature verification
- Public endpoints for cross-server identity
- Channel and message CRUD
- JWT authentication middleware

Architecture: Home server model, no federation, client-driven sync
2026-02-19 23:29:52 +00:00
42 changed files with 5128 additions and 1001 deletions

No files matched your search

-9
View File
@@ -1,9 +0,0 @@
.dockerignore
Dockerfile
build/
.dart_tool/
.git/
.github/
.gitignore
.idea/
.packages
+31 -2
View File
@@ -1,3 +1,32 @@
# https://dart.dev/guides/libraries/private-files # Dart
# Created by `dart pub`
.dart_tool/ .dart_tool/
.packages
# ObjectBox generated
*.g.dart
objectbox-model.json
# Build
build/
# Environment
.env
.env.local
# IDE
.idea/
.vscode/
*.iml
# OS
.DS_Store
Thumbs.db
# Conversation exports (local only)
conversation_export/
uploads/
objectbox/
objectbox.dll
# AI
.claude
+162
View File
@@ -0,0 +1,162 @@
# Reliquary Server Architecture
## 1. Overview
Reliquary is a self-hosted chat server with:
- JWT auth
- ObjectBox persistence
- WebSocket gateway fanout
- Component-based messages
- Decoupled upload pipeline (upload first, message commit anytime)
## 2. Identity and Roles (Current)
- `userId` is an immutable opaque identifier (host-independent).
- `handle` is derived metadata: `@username@home`.
- `ChatUser.roles` controls authorization (`admin`, `member`, etc.).
- `GET /api/me` returns `roles` so the client can gate admin UI.
### 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.
### First Admin Bootstrap
- Config key: `server.first_setup` in `config.yaml`.
- When `first_setup: true`, the first registered local user gets `admin`.
- Later registrations remain `member`.
## 3. Storage Model
### 3.1 Entities
- `ChatUser`: identity/profile/auth data.
- `ChatChannel`: channel metadata.
- `ChatMessage`: channel message, search text, and `componentsJson`.
- `ChatFile`: canonical media object for:
- message attachments
- user avatar media
- server icon/banner media
- `ServerSettings`: singleton row for mutable server presentation settings.
### 3.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`
## 4. Upload and Message Flow
### 4.1 Recommended Client Flow (Implemented Server Contract)
1. Client stages attachment in composer/component editor.
2. Client calls `POST /api/uploads/init` to create pending upload.
3. Client uploads bytes to `PUT /api/uploads/:uploadId`.
4. Client sends `POST /api/messages` with components referencing `uploadId`/`attachmentId`.
5. If upload completes after message create, server emits `message.updated`.
### 4.2 Why This Flow
- No chronological jumping for text.
- Immediate local optimistic UI.
- Progressive media readiness via gateway updates.
- Server keeps authoritative metadata and storage consistency.
## 5. API Surface
### 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`
### 5.1 Uploads and Attachments
- `POST /api/uploads/init` (auth)
- `PUT /api/uploads/:uploadId` (auth)
- `POST /api/uploads` (auth, legacy single-step compatibility)
- `GET /attachments/:attachmentId` (public immutable media URL)
- `GET /api/channels/:channelId/media` (auth, gallery index feed)
### 5.2 Profile and Server Media
- `PATCH /api/me` (auth): update profile text fields.
- `PUT /api/me/avatar` (auth): bind uploaded media as avatar.
- `GET /avatars/:userId` (public alias for current avatar).
- `GET /icons/:serverId` (public alias for current server icon).
- `GET /banners/:serverId` (public alias for current server banner).
### 5.3 Server Settings
- `GET /api/server/settings` (auth)
- `PATCH /api/server/settings` (admin)
- `PUT /api/server/icon` (admin)
- `PUT /api/server/banner` (admin)
## 6. Gateway Behavior
- Existing events:
- `message.created`
- `message.updated`
- `message.deleted`
- For upload-driven updates:
- A message can be created with media items in `uploading`.
- When upload becomes ready, server rewrites affected components and emits `message.updated`.
## 7. Component Media Item Contract (Client)
Each media item in a component should carry stable IDs and upload state:
```json
{
"itemId": "local_or_stable_id",
"source": "local_upload|external_url",
"uploadId": "pending_or_ready_attachment_id",
"attachmentId": "same_as_uploadId_when_known",
"status": "uploading|ready|failed",
"url": "/attachments/<attachmentId>",
"originalFileName": "image.png",
"mimeType": "image/webp",
"sizeBytes": 12345,
"width": 1280,
"height": 720,
"thumbHash": "optional"
}
```
### 7.1 Validation Rules (Server)
- Every component node must include:
- `id` (string)
- `type` (string)
- `typeVersion` (positive int)
- `data` (object)
- Nested components are supported under `data.children` with depth/size limits.
- Media item rules:
- `source=local_upload` => `attachmentId` required.
- `source=external_url` => valid `http/https` `url` required.
- Invalid component payloads are rejected server-side.
## 8. Client Implementation Notes
### 8.1 Staging and Transform Choice
On file pick/drop, choose transform mode before upload starts:
- `Compressed WebP` (recommended default)
- `WebP`
- `Original`
If user changes mode for a staged file:
- cancel in-flight upload
- reprocess
- re-upload
### 8.2 Metadata Trust
Client may send metadata hints, but server metadata is canonical.
Client should refresh with upload response values.
### 8.3 Caching
- `/attachments/:attachmentId`: immutable and long-cache.
- `/avatars`, `/icons`, `/banners`: mutable aliases; revalidate frequently.
## 9. AVIF and JPEG XL Policy
- MVP default: WebP (best compatibility/perf tradeoff).
- AVIF: optional client mode if target platform decode support is verified.
- JPEG XL: defer for now due to uneven ecosystem support.
-3
View File
@@ -1,3 +0,0 @@
## 1.0.0
- Initial version.
-21
View File
@@ -1,21 +0,0 @@
# Use latest stable channel SDK.
FROM dart:stable AS build
# Resolve app dependencies.
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
# Copy app source code (except anything in .dockerignore) and AOT compile app.
COPY . .
RUN dart build cli --target bin/server.dart -o output
# Build minimal serving image from AOT-compiled `/server`
# and the pre-built AOT-runtime in the `/runtime/` directory of the base image.
FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/output/bundle/ /app/
# Start server.
EXPOSE 8080
CMD ["/app/bin/server"]
+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`.
+35 -37
View File
@@ -1,49 +1,47 @@
A server app built using [Shelf](https://pub.dev/packages/shelf), # Chat Server
configured to enable running with [Docker](https://www.docker.com/).
This sample code handles HTTP GET requests to `/` and `/echo/<message>` A self-hosted Discord alternative with decentralized identity using the Home Server model.
# Running the sample ## Architecture
## Running with the Dart SDK Each user has a **home server** where their identity lives (keypair), and can seamlessly join other **guest servers** using cryptographic signatures.
You can run the example with the [Dart SDK](https://dart.dev/get-dart) - **NO identity server** - just use one regular server as "home"
like this: - **NO federation protocol** - servers don't talk to each other
- **Client does everything** - syncs, cross-server requests, profile updates
``` See [ARCHITECTURE.md](./ARCHITECTURE.md) for full details.
$ dart run bin/server.dart See [CLIENT_INTEGRATION.md](./CLIENT_INTEGRATION.md) for client protocol/API integration details.
Server listening on port 8080
## Quick Start
```bash
# Install dependencies
dart pub get
# Generate ObjectBox code
dart run build_runner build
# Run server
dart run lib/server.dart
``` ```
And then from a second terminal: ## Project Structure
```
$ curl http://0.0.0.0:8080
Hello, World!
$ curl http://0.0.0.0:8080/echo/I_love_Dart
I_love_Dart
```
## Running with Docker
If you have [Docker Desktop](https://www.docker.com/get-started) installed, you
can build and run with the `docker` command:
``` ```
$ docker build . -t myserver lib/
$ docker run -it -p 8080:8080 myserver ├── server.dart # Entry point, routes
Server listening on port 8080 ├── models.dart # User, Channel, Message entities
├── objectbox.g.dart # Generated
├── config/config.dart # Server config
└── src/
├── auth/ # Keypair, JWT, middleware
├── handlers/ # API endpoints
└── utils/ # Helpers
``` ```
And then from a second terminal: ## Tech Stack
```
$ curl http://0.0.0.0:8080
Hello, World!
$ curl http://0.0.0.0:8080/echo/I_love_Dart
I_love_Dart
```
You should see the logging printed in the first terminal: - **Framework**: Relic 1.0
``` - **Database**: ObjectBox
2021-05-06T15:47:04.620417 0:00:00.000158 GET [200] / - **Auth**: JWT + ECDSA signatures
2021-05-06T15:47:08.392928 0:00:00.001216 GET [200] /echo/I_love_Dart
```
-30
View File
@@ -1,30 +0,0 @@
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
# linter:
# rules:
# - camel_case_types
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options
-59
View File
@@ -1,59 +0,0 @@
import 'package:chat_server/config/config.dart';
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:chat_server/src/auth/auth_middleware.dart';
import 'package:chat_server/src/handlers/auth_handlers.dart';
import 'package:chat_server/src/handlers/channel_handlers.dart';
import 'package:chat_server/src/handlers/debug_handlers.dart';
import 'package:chat_server/src/handlers/message_handlers.dart';
import 'package:chat_server/src/handlers/user_handlers.dart';
import 'package:hotreloader/hotreloader.dart';
import 'package:relic/relic.dart';
// Global ObjectBox boxes
final Store store = openStore();
final Box<ChatUser> userBox = store.box<ChatUser>();
final Box<ChatChannel> channelBox = store.box<ChatChannel>();
final Box<ChatMessage> messageBox = store.box<ChatMessage>();
RelicApp? _currentApp;
Future<void> startServer() async {
await _currentApp?.close();
_currentApp = RelicApp()
// Public routes
..post('/login', loginHandler)
..post('/register', registerHandler)
// Debug routes
..get('/debug/users', debugUsersHandler)
..get('/debug/messages', debugMessagesHandler)
..get('/debug/clear', debugClearHandler)
// Protected routes
..use('/api', jwtMiddleware)
// Channels
..get('/api/channels', getChannelsHandler)
..post('/api/channels', createChannelHandler)
// Messages
..get('/api/messages', getMessagesHandler)
..post('/api/messages', sendMessageHandler)
// Users
..get('/api/users', getUsersHandler)
// Logging & fallback
..use('/', logRequests())
..fallback = respondWith((_) => Response.notFound());
print('🚀 Server starting on http://localhost:${Config.port}');
await _currentApp!.serve(port: Config.port);
}
void main() async {
await HotReloader.create(
onAfterReload: (ctx) async {
print('🔄 Changes detected! Restarting server...');
await startServer();
},
);
await startServer();
}
+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 -5
View File
@@ -1,9 +1,162 @@
import 'dart:io';
import 'dart:convert';
import 'dart:math';
import 'package:yaml/yaml.dart';
class Config { class Config {
static const int port = 9870; static late String bindHost;
static late int bindPort;
static late String publicBaseUrl;
static late String serverName;
static late String jwtSecret;
static late bool configured;
static late bool setupMode;
static late int bootstrapAdminLimit;
static late bool devDbTools;
// TODO: Move to environment variable in production
static const String jwtSecret = 'your_super_secret_key';
// Token expiration
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';
}
} }
+134 -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,11 +81,15 @@ class Channel {
@Property(type: PropertyType.date) @Property(type: PropertyType.date)
DateTime createdAt; DateTime createdAt;
Channel({required this.name, this.description, DateTime? createdAt}) : createdAt = createdAt ?? DateTime.now(); ChatChannel({
required this.name,
this.description,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
} }
@Entity() @Entity(uid: 2697867424319168785)
class Message { class ChatMessage {
@Id() @Id()
int id = 0; int id = 0;
@@ -93,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)
@@ -101,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;
@@ -108,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();
}
-136
View File
@@ -1,136 +0,0 @@
{
"_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.",
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "4:8789103914473960036",
"lastPropertyId": "4:1292940478834995143",
"name": "ChatChannel",
"properties": [
{
"id": "1:8326949064500635093",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:4261805573466490477",
"name": "name",
"indexId": "5:7489629953476729908",
"type": 9,
"flags": 2080
},
{
"id": "3:3159087232030070914",
"name": "description",
"type": 9
},
{
"id": "4:1292940478834995143",
"name": "createdAt",
"type": 10
}
],
"relations": []
},
{
"id": "5:3737221321740284134",
"lastPropertyId": "5:9202752608552942529",
"name": "ChatMessage",
"properties": [
{
"id": "1:6937877991903739354",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:2320935452587221036",
"name": "content",
"type": 9
},
{
"id": "3:4252783758825304742",
"name": "senderId",
"indexId": "6:1080757755541990100",
"type": 6,
"flags": 8
},
{
"id": "4:3240604049002701987",
"name": "channelId",
"indexId": "7:2720778762517392200",
"type": 6,
"flags": 8
},
{
"id": "5:9202752608552942529",
"name": "createdAt",
"type": 10
}
],
"relations": []
},
{
"id": "6:6808595317639142314",
"lastPropertyId": "4:3756250635467596289",
"name": "ChatUser",
"properties": [
{
"id": "1:5564900411312083069",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:3278062150453672411",
"name": "username",
"indexId": "8:3068871702763915304",
"type": 9,
"flags": 2048
},
{
"id": "3:7002102783671264440",
"name": "passwordHash",
"type": 9
},
{
"id": "4:3756250635467596289",
"name": "createdAt",
"type": 10
}
],
"relations": []
}
],
"lastEntityId": "6:6808595317639142314",
"lastIndexId": "8:3068871702763915304",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
"modelVersionParserMinimum": 5,
"retiredEntityUids": [
240536022189472816,
8077011728318081304,
4705468874025958717
],
"retiredIndexUids": [],
"retiredPropertyUids": [
8544617182402792569,
6117431287454466914,
5437786674528658195,
6072852454810870218,
2542181165384894966,
864989412971989074,
8028710060941408150,
1587682278007620331,
4383284664306084614,
8714147817359246506,
7393508199779059949,
3791306853268708842,
6999720138118727677
],
"retiredRelationUids": [],
"version": 1
}
-418
View File
@@ -1,418 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// This code was generated by ObjectBox. To update it run the generator again
// with `dart run build_runner build`.
// See also https://docs.objectbox.io/getting-started#generate-objectbox-code
// ignore_for_file: camel_case_types, depend_on_referenced_packages
// coverage:ignore-file
import 'dart:typed_data';
import 'package:flat_buffers/flat_buffers.dart' as fb;
import 'package:objectbox/internal.dart'
as obx_int; // generated code can access "internal" functionality
import 'package:objectbox/objectbox.dart' as obx;
import 'models.dart';
export 'package:objectbox/objectbox.dart'; // so that callers only have to import this file
final _entities = <obx_int.ModelEntity>[
obx_int.ModelEntity(
id: const obx_int.IdUid(4, 8789103914473960036),
name: 'ChatChannel',
lastPropertyId: const obx_int.IdUid(4, 1292940478834995143),
flags: 0,
properties: <obx_int.ModelProperty>[
obx_int.ModelProperty(
id: const obx_int.IdUid(1, 8326949064500635093),
name: 'id',
type: 6,
flags: 1,
),
obx_int.ModelProperty(
id: const obx_int.IdUid(2, 4261805573466490477),
name: 'name',
type: 9,
flags: 2080,
indexId: const obx_int.IdUid(5, 7489629953476729908),
),
obx_int.ModelProperty(
id: const obx_int.IdUid(3, 3159087232030070914),
name: 'description',
type: 9,
flags: 0,
),
obx_int.ModelProperty(
id: const obx_int.IdUid(4, 1292940478834995143),
name: 'createdAt',
type: 10,
flags: 0,
),
],
relations: <obx_int.ModelRelation>[],
backlinks: <obx_int.ModelBacklink>[],
),
obx_int.ModelEntity(
id: const obx_int.IdUid(5, 3737221321740284134),
name: 'ChatMessage',
lastPropertyId: const obx_int.IdUid(5, 9202752608552942529),
flags: 0,
properties: <obx_int.ModelProperty>[
obx_int.ModelProperty(
id: const obx_int.IdUid(1, 6937877991903739354),
name: 'id',
type: 6,
flags: 1,
),
obx_int.ModelProperty(
id: const obx_int.IdUid(2, 2320935452587221036),
name: 'content',
type: 9,
flags: 0,
),
obx_int.ModelProperty(
id: const obx_int.IdUid(3, 4252783758825304742),
name: 'senderId',
type: 6,
flags: 8,
indexId: const obx_int.IdUid(6, 1080757755541990100),
),
obx_int.ModelProperty(
id: const obx_int.IdUid(4, 3240604049002701987),
name: 'channelId',
type: 6,
flags: 8,
indexId: const obx_int.IdUid(7, 2720778762517392200),
),
obx_int.ModelProperty(
id: const obx_int.IdUid(5, 9202752608552942529),
name: 'createdAt',
type: 10,
flags: 0,
),
],
relations: <obx_int.ModelRelation>[],
backlinks: <obx_int.ModelBacklink>[],
),
obx_int.ModelEntity(
id: const obx_int.IdUid(6, 6808595317639142314),
name: 'ChatUser',
lastPropertyId: const obx_int.IdUid(4, 3756250635467596289),
flags: 0,
properties: <obx_int.ModelProperty>[
obx_int.ModelProperty(
id: const obx_int.IdUid(1, 5564900411312083069),
name: 'id',
type: 6,
flags: 1,
),
obx_int.ModelProperty(
id: const obx_int.IdUid(2, 3278062150453672411),
name: 'username',
type: 9,
flags: 2048,
indexId: const obx_int.IdUid(8, 3068871702763915304),
),
obx_int.ModelProperty(
id: const obx_int.IdUid(3, 7002102783671264440),
name: 'passwordHash',
type: 9,
flags: 0,
),
obx_int.ModelProperty(
id: const obx_int.IdUid(4, 3756250635467596289),
name: 'createdAt',
type: 10,
flags: 0,
),
],
relations: <obx_int.ModelRelation>[],
backlinks: <obx_int.ModelBacklink>[],
),
];
/// Shortcut for [obx.Store.new] that passes [getObjectBoxModel] and for Flutter
/// apps by default a [directory] using `defaultStoreDirectory()` from the
/// ObjectBox Flutter library.
///
/// Note: for desktop apps it is recommended to specify a unique [directory].
///
/// See [obx.Store.new] for an explanation of all parameters.
///
/// For Flutter apps, also calls `loadObjectBoxLibraryAndroidCompat()` from
/// the ObjectBox Flutter library to fix loading the native ObjectBox library
/// on Android 6 and older.
obx.Store openStore({
String? directory,
int? maxDBSizeInKB,
int? maxDataSizeInKB,
int? fileMode,
int? maxReaders,
bool queriesCaseSensitiveDefault = true,
String? macosApplicationGroup,
}) {
return obx.Store(
getObjectBoxModel(),
directory: directory,
maxDBSizeInKB: maxDBSizeInKB,
maxDataSizeInKB: maxDataSizeInKB,
fileMode: fileMode,
maxReaders: maxReaders,
queriesCaseSensitiveDefault: queriesCaseSensitiveDefault,
macosApplicationGroup: macosApplicationGroup,
);
}
/// Returns the ObjectBox model definition for this project for use with
/// [obx.Store.new].
obx_int.ModelDefinition getObjectBoxModel() {
final model = obx_int.ModelInfo(
// If this version is not found, it means that this file was generated
// with an older version of the ObjectBox Dart generator.
// Please regenerate this file with the current generator version.
// Typically, this is done with `dart run build_runner build`.
generatorVersion: obx_int.GeneratorVersion.v2025_12_16,
entities: _entities,
lastEntityId: const obx_int.IdUid(6, 6808595317639142314),
lastIndexId: const obx_int.IdUid(8, 3068871702763915304),
lastRelationId: const obx_int.IdUid(0, 0),
lastSequenceId: const obx_int.IdUid(0, 0),
retiredEntityUids: const [
240536022189472816,
8077011728318081304,
4705468874025958717,
],
retiredIndexUids: const [],
retiredPropertyUids: const [
8544617182402792569,
6117431287454466914,
5437786674528658195,
6072852454810870218,
2542181165384894966,
864989412971989074,
8028710060941408150,
1587682278007620331,
4383284664306084614,
8714147817359246506,
7393508199779059949,
3791306853268708842,
6999720138118727677,
],
retiredRelationUids: const [],
modelVersion: 5,
modelVersionParserMinimum: 5,
version: 1,
);
final bindings = <Type, obx_int.EntityDefinition>{
ChatChannel: obx_int.EntityDefinition<ChatChannel>(
model: _entities[0],
toOneRelations: (ChatChannel object) => [],
toManyRelations: (ChatChannel object) => {},
getId: (ChatChannel object) => object.id,
setId: (ChatChannel object, int id) {
object.id = id;
},
objectToFB: (ChatChannel object, fb.Builder fbb) {
final nameOffset = fbb.writeString(object.name);
final descriptionOffset = object.description == null
? null
: fbb.writeString(object.description!);
fbb.startTable(5);
fbb.addInt64(0, object.id);
fbb.addOffset(1, nameOffset);
fbb.addOffset(2, descriptionOffset);
fbb.addInt64(3, object.createdAt.millisecondsSinceEpoch);
fbb.finish(fbb.endTable());
return object.id;
},
objectFromFB: (obx.Store store, ByteData fbData) {
final buffer = fb.BufferContext(fbData);
final rootOffset = buffer.derefObject(0);
final nameParam = const fb.StringReader(
asciiOptimization: true,
).vTableGet(buffer, rootOffset, 6, '');
final descriptionParam = const fb.StringReader(
asciiOptimization: true,
).vTableGetNullable(buffer, rootOffset, 8);
final createdAtParam = DateTime.fromMillisecondsSinceEpoch(
const fb.Int64Reader().vTableGet(buffer, rootOffset, 10, 0),
);
final object = ChatChannel(
name: nameParam,
description: descriptionParam,
createdAt: createdAtParam,
)..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0);
return object;
},
),
ChatMessage: obx_int.EntityDefinition<ChatMessage>(
model: _entities[1],
toOneRelations: (ChatMessage object) => [],
toManyRelations: (ChatMessage object) => {},
getId: (ChatMessage object) => object.id,
setId: (ChatMessage object, int id) {
object.id = id;
},
objectToFB: (ChatMessage object, fb.Builder fbb) {
final contentOffset = fbb.writeString(object.content);
fbb.startTable(6);
fbb.addInt64(0, object.id);
fbb.addOffset(1, contentOffset);
fbb.addInt64(2, object.senderId);
fbb.addInt64(3, object.channelId);
fbb.addInt64(4, object.createdAt.millisecondsSinceEpoch);
fbb.finish(fbb.endTable());
return object.id;
},
objectFromFB: (obx.Store store, ByteData fbData) {
final buffer = fb.BufferContext(fbData);
final rootOffset = buffer.derefObject(0);
final contentParam = const fb.StringReader(
asciiOptimization: true,
).vTableGet(buffer, rootOffset, 6, '');
final senderIdParam = const fb.Int64Reader().vTableGet(
buffer,
rootOffset,
8,
0,
);
final channelIdParam = const fb.Int64Reader().vTableGet(
buffer,
rootOffset,
10,
0,
);
final createdAtParam = DateTime.fromMillisecondsSinceEpoch(
const fb.Int64Reader().vTableGet(buffer, rootOffset, 12, 0),
);
final object = ChatMessage(
content: contentParam,
senderId: senderIdParam,
channelId: channelIdParam,
createdAt: createdAtParam,
)..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0);
return object;
},
),
ChatUser: obx_int.EntityDefinition<ChatUser>(
model: _entities[2],
toOneRelations: (ChatUser object) => [],
toManyRelations: (ChatUser object) => {},
getId: (ChatUser object) => object.id,
setId: (ChatUser object, int id) {
object.id = id;
},
objectToFB: (ChatUser object, fb.Builder fbb) {
final usernameOffset = fbb.writeString(object.username);
final passwordHashOffset = fbb.writeString(object.passwordHash);
fbb.startTable(5);
fbb.addInt64(0, object.id);
fbb.addOffset(1, usernameOffset);
fbb.addOffset(2, passwordHashOffset);
fbb.addInt64(3, object.createdAt.millisecondsSinceEpoch);
fbb.finish(fbb.endTable());
return object.id;
},
objectFromFB: (obx.Store store, ByteData fbData) {
final buffer = fb.BufferContext(fbData);
final rootOffset = buffer.derefObject(0);
final usernameParam = const fb.StringReader(
asciiOptimization: true,
).vTableGet(buffer, rootOffset, 6, '');
final passwordHashParam = const fb.StringReader(
asciiOptimization: true,
).vTableGet(buffer, rootOffset, 8, '');
final createdAtParam = DateTime.fromMillisecondsSinceEpoch(
const fb.Int64Reader().vTableGet(buffer, rootOffset, 10, 0),
);
final object = ChatUser(
username: usernameParam,
passwordHash: passwordHashParam,
createdAt: createdAtParam,
)..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0);
return object;
},
),
};
return obx_int.ModelDefinition(model, bindings);
}
/// [ChatChannel] entity fields to define ObjectBox queries.
class ChatChannel_ {
/// See [ChatChannel.id].
static final id = obx.QueryIntegerProperty<ChatChannel>(
_entities[0].properties[0],
);
/// See [ChatChannel.name].
static final name = obx.QueryStringProperty<ChatChannel>(
_entities[0].properties[1],
);
/// See [ChatChannel.description].
static final description = obx.QueryStringProperty<ChatChannel>(
_entities[0].properties[2],
);
/// See [ChatChannel.createdAt].
static final createdAt = obx.QueryDateProperty<ChatChannel>(
_entities[0].properties[3],
);
}
/// [ChatMessage] entity fields to define ObjectBox queries.
class ChatMessage_ {
/// See [ChatMessage.id].
static final id = obx.QueryIntegerProperty<ChatMessage>(
_entities[1].properties[0],
);
/// See [ChatMessage.content].
static final content = obx.QueryStringProperty<ChatMessage>(
_entities[1].properties[1],
);
/// See [ChatMessage.senderId].
static final senderId = obx.QueryIntegerProperty<ChatMessage>(
_entities[1].properties[2],
);
/// See [ChatMessage.channelId].
static final channelId = obx.QueryIntegerProperty<ChatMessage>(
_entities[1].properties[3],
);
/// See [ChatMessage.createdAt].
static final createdAt = obx.QueryDateProperty<ChatMessage>(
_entities[1].properties[4],
);
}
/// [ChatUser] entity fields to define ObjectBox queries.
class ChatUser_ {
/// See [ChatUser.id].
static final id = obx.QueryIntegerProperty<ChatUser>(
_entities[2].properties[0],
);
/// See [ChatUser.username].
static final username = obx.QueryStringProperty<ChatUser>(
_entities[2].properties[1],
);
/// See [ChatUser.passwordHash].
static final passwordHash = obx.QueryStringProperty<ChatUser>(
_entities[2].properties[2],
);
/// See [ChatUser.createdAt].
static final createdAt = obx.QueryDateProperty<ChatUser>(
_entities[2].properties[3],
);
}
+105
View File
@@ -0,0 +1,105 @@
import 'dart:io';
import 'package:relic/relic.dart';
import 'config/config.dart';
import 'src/auth/auth_middleware.dart';
import 'src/gateway/gateway_handlers.dart';
import 'src/handlers/auth_handlers.dart';
import 'src/handlers/channel_handlers.dart';
import 'src/handlers/dev_db_handlers.dart';
import 'src/handlers/message_handlers.dart';
import 'src/handlers/public_handlers.dart';
import 'src/handlers/settings_handlers.dart';
import 'src/handlers/upload_handlers.dart';
import 'src/state/app_state.dart';
RelicApp? _currentApp;
Future<void> startServer() async {
await _currentApp?.close();
_currentApp = RelicApp()
// ============ PUBLIC ENDPOINTS (no auth) ============
// Auth
..post('/auth/register', registerHandler)
..post('/auth/login', loginHandler)
..post('/auth/join', joinServerHandler)
// Public identity verification (for cross-server)
..get('/.well-known/users/:username', publicUserHandler)
..get('/.well-known/verify/:userId', publicVerifyHandler)
..get('/.well-known/server', publicServerInfoHandler)
..get('/uploads/:fileName', getUploadFileHandler)
..get('/attachments/:attachmentId', getAttachmentHandler)
..get('/avatars/:userId', getAvatarAliasHandler)
..get('/icons/:serverId', getServerIconAliasHandler)
..get('/banners/:serverId', getServerBannerAliasHandler)
..get('/gateway', gatewayHandler)
// ============ PROTECTED ENDPOINTS (requires auth) ============
..use('/api', jwtMiddleware)
// Current user
..get('/api/me', getMeHandler)
..patch('/api/me', updateProfileHandler)
..put('/api/me/avatar', setAvatarHandler)
// Server settings
..get('/api/server/settings', getServerSettingsHandler)
..patch('/api/server/settings', patchServerSettingsHandler)
..put('/api/server/icon', setServerIconHandler)
..put('/api/server/banner', setServerBannerHandler)
// Dev DB tools (testing only, admin only, requires config flag)
..get('/api/dev/db/snapshot', getDevDbSnapshotHandler)
..post('/api/dev/db/reset', resetDevDbHandler)
..post('/api/dev/users/promote-admin', promoteUserToAdminHandler)
..post('/api/dev/users/demote-admin', demoteUserFromAdminHandler)
// Channels
..get('/api/channels', getChannelsHandler)
..post('/api/channels', createChannelHandler)
..get('/api/channels/:channelId', getChannelHandler)
// Messages
..get('/api/messages', getMessagesHandler)
..get('/api/channels/:channelId/media', getChannelMediaHandler)
..post('/api/uploads/init', initUploadHandler)
..put('/api/uploads/:uploadId', completeUploadHandler)
..post('/api/uploads', uploadFileHandler)
..post('/api/messages', sendMessageHandler)
..put('/api/messages/:messageId', editMessageHandler)
..delete('/api/messages/:messageId', deleteMessageHandler)
// ============ LOGGING & FALLBACK ============
..use('/', logRequests())
..fallback = respondWith((_) => Response.notFound());
print('');
print('🚀 Server starting...');
print('Bind: ${'${Config.bindHost}:${Config.bindPort}'.padRight(34)}');
print('Public URL: ${Config.publicBaseUrl.padRight(26)}');
print('');
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 {
Config.initialize();
initializeStore();
await startServer();
}
+20 -11
View File
@@ -1,10 +1,10 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import '../../config/config.dart'; import 'crypto_service.dart';
final userIdProperty = ContextProperty<String>('userId'); final userIdProperty = ContextProperty<String>('userId');
final usernameProperty = ContextProperty<String>('username'); final usernameProperty = ContextProperty<String>('username');
final userLocalIdProperty = ContextProperty<int>('userLocalId');
/// JWT authentication middleware /// JWT authentication middleware
final jwtMiddleware = createMiddleware( final jwtMiddleware = createMiddleware(
@@ -12,23 +12,32 @@ final jwtMiddleware = createMiddleware(
final authHeader = request.headers.authorization; final authHeader = request.headers.authorization;
if (authHeader is! BearerAuthorizationHeader) { if (authHeader is! BearerAuthorizationHeader) {
return Response.unauthorized(body: Body.fromString('Bearer token required')); return Response.unauthorized(
body: Body.fromString('Bearer token required'),
);
} }
try { final payload = AuthService.verifyToken(authHeader.token);
final jwt = JWT.verify(authHeader.token, SecretKey(Config.jwtSecret)); if (payload == null) {
return Response.forbidden(
body: Body.fromString('Invalid or expired token'),
);
}
final userId = jwt.payload['sub'] as String? ?? jwt.payload['id'] as String; final userId = payload['sub'] as String?;
final username = jwt.payload['username'] as String?; final username = payload['username'] as String?;
if (userId == null) {
return Response.forbidden(
body: Body.fromString('Invalid token payload'),
);
}
userIdProperty[request] = userId; userIdProperty[request] = userId;
if (username != null) { if (username != null) {
usernameProperty[request] = username; usernameProperty[request] = username;
} }
return null; return null; // Continue to handler
} catch (e) {
return Response.forbidden(body: Body.fromString('Invalid or expired token'));
}
}, },
); );
-34
View File
@@ -1,34 +0,0 @@
import 'package:bcrypt/bcrypt.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import '../../config/config.dart';
class AuthService {
/// Generates a JWT token for a user
static String generateToken(String userId, {String? username}) {
final payload = {'sub': userId, 'username': ?username};
final jwt = JWT(payload);
return jwt.sign(SecretKey(Config.jwtSecret), expiresIn: Config.tokenExpiry);
}
/// Verifies a password against a bcrypt hash
static bool verifyPassword(String password, String hash) {
return BCrypt.checkpw(password, hash);
}
/// Hashes a password using bcrypt
static String hashPassword(String password) {
return BCrypt.hashpw(password, BCrypt.gensalt());
}
/// Verifies a JWT token and returns the payload
static Map<String, dynamic>? verifyToken(String token) {
try {
final jwt = JWT.verify(token, SecretKey(Config.jwtSecret));
return Map<String, dynamic>.from(jwt.payload as Map);
} catch (_) {
return null;
}
}
}
+165
View File
@@ -0,0 +1,165 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:bcrypt/bcrypt.dart';
import 'package:crypto/crypto.dart';
import 'package:crypton/crypton.dart' as crypton;
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart' as jwt;
import '../../config/config.dart';
class KeyPair {
final String publicKey;
final String privateKey;
KeyPair({required this.publicKey, required this.privateKey});
Map<String, dynamic> toJson() => {
'publicKey': publicKey,
'privateKey': privateKey,
};
factory KeyPair.fromJson(Map<String, dynamic> json) {
return KeyPair(
publicKey: json['publicKey'] as String,
privateKey: json['privateKey'] as String,
);
}
}
class CryptoService {
/// Generate a new ECDSA key pair
static KeyPair generateKeyPair() {
final keyPair = crypton.ECKeypair.fromRandom();
return KeyPair(
publicKey: keyPair.publicKey.toString(),
privateKey: keyPair.privateKey.toString(),
);
}
/// Sign data with private key
static String sign(String data, String privateKey) {
final privKey = crypton.ECPrivateKey.fromString(privateKey);
final message = Uint8List.fromList(utf8.encode(data));
final signature = privKey.createSHA256Signature(message);
return base64Encode(signature);
}
/// Verify signature with public key
static bool verify(String data, String signature, String publicKey) {
try {
final pubKey = crypton.ECPublicKey.fromString(publicKey);
final message = Uint8List.fromList(utf8.encode(data));
final signatureBytes = Uint8List.fromList(base64Decode(signature));
return pubKey.verifySHA256Signature(message, signatureBytes);
} catch (_) {
return false;
}
}
/// Encrypt private key with password (for storage/backup)
static EncryptedKey encryptPrivateKey(String privateKey, String password) {
final salt = BCrypt.gensalt();
final key = _deriveKey(password, salt);
final encrypted = _xorEncrypt(privateKey, key);
return EncryptedKey(
encryptedData: encrypted,
salt: salt,
);
}
/// Decrypt private key with password
static String? decryptPrivateKey(
String encryptedData, String salt, String password) {
try {
final key = _deriveKey(password, salt);
return _xorDecrypt(encryptedData, key);
} catch (_) {
return null;
}
}
static String _deriveKey(String password, String salt) {
final bytes = utf8.encode(password + salt);
final hash = sha256.convert(bytes);
return hash.toString();
}
static String _xorEncrypt(String data, String key) {
final dataBytes = utf8.encode(data);
final keyBytes = utf8.encode(key);
final encrypted = <int>[];
for (var i = 0; i < dataBytes.length; i++) {
encrypted.add(dataBytes[i] ^ keyBytes[i % keyBytes.length]);
}
return base64Encode(encrypted);
}
static String _xorDecrypt(String encrypted, String key) {
final encryptedBytes = base64Decode(encrypted);
final keyBytes = utf8.encode(key);
final decrypted = <int>[];
for (var i = 0; i < encryptedBytes.length; i++) {
decrypted.add(encryptedBytes[i] ^ keyBytes[i % keyBytes.length]);
}
return utf8.decode(decrypted);
}
}
class EncryptedKey {
final String encryptedData;
final String salt;
EncryptedKey({required this.encryptedData, required this.salt});
Map<String, dynamic> toJson() => {
'encryptedData': encryptedData,
'salt': salt,
};
}
class AuthService {
static final _nonceRandom = Random.secure();
/// Generate JWT token for user
static String generateToken(String userId, String username) {
final payload = {
'sub': userId,
'username': username,
};
final token = jwt.JWT(payload);
return token.sign(
jwt.SecretKey(Config.jwtSecret),
expiresIn: Config.tokenExpiry,
);
}
/// Verify JWT token and return payload
static Map<String, dynamic>? verifyToken(String token) {
try {
final verified = jwt.JWT.verify(token, jwt.SecretKey(Config.jwtSecret));
return Map<String, dynamic>.from(verified.payload as Map);
} catch (_) {
return null;
}
}
/// Hash password with bcrypt
static String hashPassword(String password) {
return BCrypt.hashpw(password, BCrypt.gensalt());
}
/// Verify password against hash
static bool verifyPassword(String password, String hash) {
return BCrypt.checkpw(password, hash);
}
/// Generate a nonce for signature verification
static String generateNonce() {
final bytes = List<int>.generate(32, (_) => _nonceRandom.nextInt(256));
final hash = sha256.convert(bytes);
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,
});
}
+436 -24
View File
@@ -1,54 +1,466 @@
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 '../auth/auth_service.dart'; import '../../config/config.dart';
import '../auth/auth_middleware.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
/// Creates user with keypair for identity
Future<Response> registerHandler(Request req) async {
final body = await req.asJson();
final username = (body['username'] as String?)?.trim();
final password = body['password'] as String?;
if (username == null || username.isEmpty) {
return Response.badRequest(
body: Body.fromString('Username required'),
);
}
if (password == null || password.length < 6) {
return Response.badRequest(
body: Body.fromString('Password must be at least 6 characters'),
);
}
// Check if username exists on this home server
final query = userBox
.query(
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
)
.build();
final existing = query.findFirst();
query.close();
if (existing != null) {
return Response.badRequest(
body: Body.fromString('Username already taken'),
);
}
// Generate keypair for identity
final keyPair = CryptoService.generateKeyPair();
// Encrypt private key with password
final encryptedKey = CryptoService.encryptPrivateKey(
keyPair.privateKey,
password,
);
// Hash password
final passwordHash = AuthService.hashPassword(password);
// Create immutable opaque identity ID.
final userId = generateUserId();
final isBootstrapAdmin = Config.setupMode && _adminCount() < Config.bootstrapAdminLimit;
final roles = isBootstrapAdmin ? <String>['admin'] : <String>['member'];
final user = ChatUser(
userId: userId,
username: username,
publicKey: keyPair.publicKey,
isLocal: true,
homeServer: Config.publicBaseUrl,
passwordHash: passwordHash,
encryptedPrivateKey: encryptedKey.encryptedData,
keyPairSalt: encryptedKey.salt,
roles: roles,
);
final localId = userBox.put(user);
if (isBootstrapAdmin) {
print('🔐 Setup mode: granted initial admin to $userId');
}
// Generate token
final token = AuthService.generateToken(userId, username);
return Response.ok(
body: {
'token': token,
'userId': userId,
'username': user.username,
'handle': toUserHandle(user.username, Config.publicBaseUrl),
'publicKey': keyPair.publicKey,
'encryptedPrivateKey': encryptedKey.encryptedData,
'keyPairSalt': encryptedKey.salt,
'localId': localId,
'roles': roles,
'isBootstrapAdmin': isBootstrapAdmin,
}.toBody,
);
}
/// Login on HOME server
/// Returns token + encrypted keypair for device sync
Future<Response> loginHandler(Request req) async { Future<Response> loginHandler(Request req) async {
final body = await req.asJson(); final body = await req.asJson();
final username = body['username'] as String?; final username = body['username'] as String?;
final password = body['password'] as String?; final password = body['password'] as String?;
if (username == null || password == null) { if (username == null || password == null) {
return Response.badRequest(body: Body.fromString('Username and password required')); return Response.badRequest(
body: Body.fromString('Username and password required'),
);
} }
final query = userBox.query(ChatUser_.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();
if (user == null || !AuthService.verifyPassword(password, user.passwordHash)) { if (user == null) {
return Response.unauthorized(body: Body.fromString('Invalid credentials')); return Response.unauthorized(
body: Body.fromString('Invalid credentials'),
);
} }
final token = AuthService.generateToken(user.id.toString(), username: user.username); // Check if this is actually their home server
if (!user.isLocal) {
return Response.ok(body: {'token': token, 'userId': user.id, 'username': user.username}.toBody); return Response.badRequest(
body: Body.fromString('This is not your home server. Login there instead.'),
);
} }
Future<Response> registerHandler(Request req) async { // Verify password
if (!AuthService.verifyPassword(password, user.passwordHash!)) {
return Response.unauthorized(
body: Body.fromString('Invalid credentials'),
);
}
// Generate token
final token = AuthService.generateToken(user.userId, user.username);
return Response.ok(
body: {
'token': token,
'userId': user.userId,
'username': user.username,
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
'displayName': user.displayName,
'avatarUrl': user.avatarUrl,
'publicKey': user.publicKey,
'encryptedPrivateKey': user.encryptedPrivateKey,
'keyPairSalt': user.keyPairSalt,
'localId': user.id,
}.toBody,
);
}
/// Join a GUEST server using signature verification
/// No password required - proves identity via private key signature
Future<Response> joinServerHandler(Request req) async {
final body = await req.asJson(); final body = await req.asJson();
final username = body['username'] as String?; final userId = body['userId'] as String?;
final password = body['password'] as String?; final username = (body['username'] as String?)?.trim();
final homeServer = body['homeServer'] as String?;
final timestamp = body['timestamp'] as int?;
final signature = body['signature'] as String?;
if (username == null || password == null) { // Validate required fields
return Response.badRequest(body: Body.fromString('Username and password required')); if (userId == null ||
userId.isEmpty ||
username == null ||
username.isEmpty ||
homeServer == null ||
homeServer.trim().isEmpty ||
timestamp == null ||
signature == null) {
return Response.badRequest(
body: Body.fromString('userId, username, homeServer, timestamp, and signature required'),
);
} }
final query = userBox.query(ChatUser_.username.equals(username)).build(); final resolvedHomeServer = normalizeHomeServer(homeServer);
final existing = query.findFirst();
if (resolvedHomeServer == null) {
return Response.badRequest(
body: Body.fromString('Invalid homeServer URL'),
);
}
// Check timestamp is recent (within 5 minutes)
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if ((now - timestamp).abs() > 300) {
return Response.badRequest(
body: Body.fromString('Timestamp expired. Please sync your clock.'),
);
}
// 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
final existingQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
final existingUser = existingQuery.findFirst();
existingQuery.close();
if (existingUser != null) {
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);
userLocalIdProperty[req] = existingUser.id;
return Response.ok(
body: {
'token': token,
'userId': existingUser.userId,
'username': existingUser.username,
'handle': toUserHandle(existingUser.username, existingUser.homeServer ?? Config.publicBaseUrl),
'localId': existingUser.id,
'alreadyJoined': true,
}.toBody,
);
}
// Create local shadow account
final user = ChatUser(
userId: userId,
username: identity.username,
displayName: identity.displayName,
avatarUrl: identity.avatarUrl,
bio: identity.bio,
publicKey: identity.publicKey,
isLocal: false,
homeServer: resolvedHomeServer,
roles: ['member'],
lastSyncedAt: DateTime.now(),
);
final localId = userBox.put(user);
// Generate local token
final token = AuthService.generateToken(userId, user.username);
return Response.ok(
body: {
'token': token,
'userId': userId,
'username': user.username,
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
'localId': localId,
'alreadyJoined': false,
}.toBody,
);
}
/// Fetch public identity from user's home server
Future<RemoteIdentity?> fetchIdentityFromHomeServer(
String homeServer,
String username,
) async {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
try {
final baseUrl = homeServer.replaceAll(RegExp(r'/$'), '');
final endpoint = Uri.parse('$baseUrl/.well-known/users/${Uri.encodeComponent(username)}');
final request = await client.getUrl(endpoint);
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
final response = await request.close();
if (response.statusCode != HttpStatus.ok) {
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) {
print('Error fetching public key: $e');
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
Future<Response> getMeHandler(Request req) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = query.findFirst();
query.close(); query.close();
if (existing != null) { if (user == null) {
return Response.badRequest(body: Body.fromString('User already exists')); return Response.notFound(
body: Body.fromString('User not found'),
);
} }
final hashedPassword = AuthService.hashPassword(password); return Response.ok(
final user = ChatUser(username: username, passwordHash: hashedPassword); body: {
final userId = userBox.put(user); 'userId': user.userId,
'username': user.username,
final token = AuthService.generateToken(userId.toString(), username: username); 'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
'displayName': user.displayName,
return Response.ok(body: {'token': token, 'userId': userId, 'username': username}.toBody); 'avatarUrl': user.avatarUrl,
'bio': user.bio,
'isLocal': user.isLocal,
'homeServer': user.homeServer,
'roles': user.roles,
'createdAt': user.createdAt.toIso8601String(),
}.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()}';
} }
+61 -6
View File
@@ -2,36 +2,91 @@ 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
Future<Response> getChannelsHandler(Request req) async { Future<Response> getChannelsHandler(Request req) async {
final channels = channelBox.getAll(); final channels = channelBox.getAll();
return Response.ok( return Response.ok(
body: { body: {
'channels': channels 'channels': channels
.map((c) => {'id': c.id, 'name': c.name, 'description': c.description, 'createdAt': c.createdAt.toIso8601String()}) .map((c) => {
'id': c.id,
'name': c.name,
'description': c.description,
'createdAt': c.createdAt.toIso8601String(),
})
.toList(), .toList(),
}.toBody, }.toBody,
); );
} }
/// 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) {
return Response.badRequest(body: Body.fromString('Channel name required')); return Response.badRequest(
body: Body.fromString('Channel name required'),
);
} }
final existing = channelBox.query(ChatChannel_.name.equals(name)).build().findFirst(); // Check if channel exists
final existing =
channelBox.query(ChatChannel_.name.equals(name)).build().findFirst();
if (existing != null) { if (existing != null) {
return Response.badRequest(body: Body.fromString('Channel already exists')); return Response.badRequest(
body: Body.fromString('Channel already exists'),
);
} }
final channel = ChatChannel(name: name, description: description); final channel = ChatChannel(name: name, description: description);
final id = channelBox.put(channel); final id = channelBox.put(channel);
return Response.ok(body: {'id': id, 'name': name, 'description': description, 'createdAt': channel.createdAt.toIso8601String()}.toBody); return Response.ok(
body: {
'id': id,
'name': name,
'description': description,
'createdAt': channel.createdAt.toIso8601String(),
}.toBody,
);
}
/// Get channel by ID
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);
if (channelId == null) {
return Response.badRequest(
body: Body.fromString('Invalid channel ID'),
);
}
final channel = channelBox.get(channelId);
if (channel == null) {
return Response.notFound(
body: Body.fromString('Channel not found'),
);
}
return Response.ok(
body: {
'id': channel.id,
'name': channel.name,
'description': channel.description,
'createdAt': channel.createdAt.toIso8601String(),
}.toBody,
);
} }
-38
View File
@@ -1,38 +0,0 @@
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../utils/extensions.dart';
Future<Response> debugUsersHandler(Request req) async {
return Response.ok(
body: {
'users': userBox.getAll().map((u) => {'id': u.id, 'username': u.username}).toList(),
}.toBody,
);
}
Future<Response> debugMessagesHandler(Request req) async {
return Response.ok(
body: {
'messages': messageBox
.getAll()
.map(
(m) => {
'id': m.id,
'content': m.content,
'senderId': m.senderId,
'channelId': m.channelId,
'createdAt': m.createdAt.toIso8601String(),
},
)
.toList(),
}.toBody,
);
}
Future<Response> debugClearHandler(Request req) async {
messageBox.removeAll();
channelBox.removeAll();
userBox.removeAll();
return Response.ok(body: {'cleared': true}.toBody);
}
+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
+114
View File
@@ -0,0 +1,114 @@
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../../config/config.dart';
import 'auth_handlers.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart';
/// Public endpoint for cross-server identity verification
/// Called by other servers to verify a user's public key
///
/// GET /.well-known/users/{username}
/// Returns: { userId, publicKey, username, displayName, avatarUrl }
Future<Response> publicUserHandler(Request req) async {
final username = req.rawPathParameters[#username];
if (username == null) {
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();
query.close();
if (user == null) {
return Response.notFound(
body: Body.fromString('User not found'),
);
}
return Response.ok(
body: {
'userId': user.userId,
'username': user.username,
'displayName': user.displayName,
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
'avatarUrl': user.avatarUrl,
'bio': user.bio,
'publicKey': user.publicKey,
'homeServer': user.homeServer ?? Config.publicBaseUrl,
'createdAt': user.createdAt.toIso8601String(),
}.toBody,
);
}
/// Public endpoint to verify a user exists
/// Lightweight check without full profile
///
/// GET /.well-known/verify/{userId}
/// Returns: { exists, userId, publicKey }
Future<Response> publicVerifyHandler(Request req) async {
final userId = req.rawPathParameters[#userId];
if (userId == null) {
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();
query.close();
if (user == null) {
return Response.ok(
body: {
'exists': false,
'userId': userId,
}.toBody,
);
}
return Response.ok(
body: {
'exists': true,
'userId': user.userId,
'publicKey': user.publicKey,
'username': user.username,
'handle': toUserHandle(user.username, user.homeServer ?? Config.publicBaseUrl),
}.toBody,
);
}
/// Public server info
///
/// GET /.well-known/server
/// Returns: { serverHost, serverName, version }
Future<Response> publicServerInfoHandler(Request req) async {
final settingsQuery = serverSettingsBox.query(ServerSettings_.singletonKey.equals('default')).build();
final settings = settingsQuery.findFirst();
settingsQuery.close();
return Response.ok(
body: {
'serverHost': Config.publicHost,
'publicBaseUrl': Config.publicBaseUrl,
'serverName': settings?.serverName ?? Config.serverName,
'version': '0.1.0',
'iconUrl': settings?.iconAttachmentId == null ? null : '/icons/${Uri.encodeComponent(Config.publicHost)}',
'bannerUrl': settings?.bannerAttachmentId == null ? null : '/banners/${Uri.encodeComponent(Config.publicHost)}',
'features': ['identity', 'chat', 'sticker-packs'],
}.toBody,
);
}
+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;
}
-14
View File
@@ -1,14 +0,0 @@
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../utils/extensions.dart';
Future<Response> getUsersHandler(Request req) async {
final users = userBox.getAll();
return Response.ok(
body: {
'users': users.map((u) => {'id': u.id, 'username': u.username, 'createdAt': u.createdAt.toIso8601String()}).toList(),
}.toBody,
);
}
+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>();
}
+1 -1
View File
@@ -15,7 +15,7 @@ extension MapToBody on Map<String, dynamic> {
Body get toBody => Body.fromString(jsonEncode(this)); Body get toBody => Body.fromString(jsonEncode(this));
} }
extension JsonBody on Body { extension BodyHelper on Body {
/// Creates a JSON body from a Map /// Creates a JSON body from a Map
static Body fromJson(Map<String, dynamic> json) { static Body fromJson(Map<String, dynamic> json) {
return Body.fromString(jsonEncode(json)); return Body.fromString(jsonEncode(json));
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+90 -57
View File
@@ -5,10 +5,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: "796d97d925add7ffcdf5595f33a2066a6e3cee97971e6dbef09b76b7880fd760" sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "94.0.0" version: "67.0.0"
adaptive_number: adaptive_number:
dependency: transitive dependency: transitive
description: description:
@@ -21,10 +21,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: "9c8ebb304d72c0a0c8764344627529d9503fc83d7d73e43ed727dc532f822e4b" sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.2" version: "6.4.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.0" version: "2.7.0"
asn1lib:
dependency: transitive
description:
name: asn1lib
sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024"
url: "https://pub.dev"
source: hosted
version: "1.6.5"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -61,18 +69,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build name: build
sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.4" version: "2.4.1"
build_config: build_config:
dependency: transitive dependency: transitive
description: description:
name: build_config name: build_config
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.1.2"
build_daemon: build_daemon:
dependency: transitive dependency: transitive
description: description:
@@ -81,14 +89,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.1" 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: build_runner:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: build_runner name: build_runner
sha256: "39ad4ca8a2876779737c60e4228b4bcd35d4352ef7e14e47514093edc012c734" sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.1" 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: built_collection:
dependency: transitive dependency: transitive
description: description:
@@ -138,7 +162,7 @@ packages:
source: hosted source: hosted
version: "4.11.1" version: "4.11.1"
collection: collection:
dependency: transitive dependency: "direct main"
description: description:
name: collection name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
@@ -162,29 +186,37 @@ packages:
source: hosted source: hosted
version: "1.15.0" version: "1.15.0"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" 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: dart_jsonwebtoken:
dependency: "direct main" dependency: "direct main"
description: description:
name: dart_jsonwebtoken name: dart_jsonwebtoken
sha256: "0de65691c1d736e9459f22f654ddd6fd8368a271d4e41aa07e53e6301eff5075" sha256: b268ff9bb87a16360a397f1accb50e7c781384e1119010b34f6d7c25820c44d8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.1" version: "3.1.1"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: dart_style
sha256: "15a7db352c8fc6a4d2bc475ba901c25b39fe7157541da4c16eacce6f8be83e49" sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.5" version: "2.3.6"
ed25519_edwards: ed25519_edwards:
dependency: transitive dependency: transitive
description: description:
@@ -221,17 +253,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flat_buffers name: flat_buffers
sha256: "7c1de2d6eb5f3e61e5c50040841109f509deaaf2b12ec0d57b92456d9ea50345" sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "25.9.23" version: "23.5.26"
fox_utils2:
dependency: "direct main"
description:
path: "../fox_utils2"
relative: true
source: path
version: "1.0.0"
frontend_server_client: frontend_server_client:
dependency: transitive dependency: transitive
description: description:
@@ -256,16 +281,8 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
hotreloader:
dependency: "direct main"
description:
name: hotreloader
sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b
url: "https://pub.dev"
source: hosted
version: "4.3.0"
http: http:
dependency: "direct dev" dependency: transitive
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -296,14 +313,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
name: json_annotation name: json_annotation
sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.10.0" version: "4.11.0"
lints: lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -324,10 +349,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.17"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -337,7 +362,7 @@ packages:
source: hosted source: hosted
version: "1.18.1" version: "1.18.1"
mime: mime:
dependency: transitive dependency: "direct main"
description: description:
name: mime name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
@@ -356,18 +381,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: objectbox name: objectbox
sha256: ac1e1ef852f60ea0af01fddb28811c8aa169f2b1ae9284e91e7b822abe5ed603 sha256: "3d1cb5f9aa564f95c76ba251299f6cb1591c3dd8ff05fd76fa0549d899d9fe31"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.0" version: "4.1.0"
objectbox_generator: objectbox_generator:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: objectbox_generator name: objectbox_generator
sha256: "28628fa65d4fe8ade868cd50aa323ecdfd6d435fcda2cc7484071b84031f8c5e" sha256: "777924e14daf18603a023b346333acac1c4fce9fe4a715b85d98cfcc723d9a1a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.0" version: "4.1.0"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
@@ -388,10 +413,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: pointycastle name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "3.9.1"
pool: pool:
dependency: transitive dependency: transitive
description: description:
@@ -468,18 +493,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shelf_web_socket name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "2.0.1"
source_gen: source_gen:
dependency: transitive dependency: transitive
description: description:
name: source_gen name: source_gen
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" version: "1.5.0"
source_map_stack_trace: source_map_stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -548,26 +573,34 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: test name: test
sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.29.0" version: "1.26.3"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.7"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.15" version: "0.6.12"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -625,7 +658,7 @@ packages:
source: hosted source: hosted
version: "1.2.1" version: "1.2.1"
yaml: yaml:
dependency: transitive dependency: "direct main"
description: description:
name: yaml name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
@@ -633,4 +666,4 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.11.0-296.5.beta <4.0.0" dart: ">=3.9.0 <4.0.0"
+10 -10
View File
@@ -1,23 +1,23 @@
name: chat_server name: chat_server
description: A server app using the shelf package and Docker. description: A self-hosted chat server with home server identity model.
version: 1.0.0 version: 0.1.0
# repository: https://github.com/my_org/my_repo
environment: environment:
sdk: ^3.11.0-296.5.beta sdk: ">=3.0.0 <4.0.0"
dependencies: dependencies:
relic: relic:
hotreloader: objectbox:
dart_jsonwebtoken: dart_jsonwebtoken:
bcrypt: bcrypt:
objectbox: crypton:
fox_utils2: crypto:
path: ../fox_utils2 collection:
yaml:
mime:
dev_dependencies: dev_dependencies:
test:
objectbox_generator: objectbox_generator:
build_runner: build_runner:
http:
lints: lints:
test:
+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);
}
});
}
-39
View File
@@ -1,39 +0,0 @@
import 'dart:io';
import 'package:http/http.dart';
import 'package:test/test.dart';
void main() {
final port = '8080';
final host = 'http://0.0.0.0:$port';
late Process p;
setUp(() async {
p = await Process.start(
'dart',
['run', 'bin/server.dart'],
environment: {'PORT': port},
);
// Wait for server to start and print to stdout.
await p.stdout.first;
});
tearDown(() => p.kill());
test('Root', () async {
final response = await get(Uri.parse('$host/'));
expect(response.statusCode, 200);
expect(response.body, 'Hello, World!\n');
});
test('Echo', () async {
final response = await get(Uri.parse('$host/echo/hello'));
expect(response.statusCode, 200);
expect(response.body, 'hello\n');
});
test('404', () async {
final response = await get(Uri.parse('$host/foobar'));
expect(response.statusCode, 404);
});
}